Vue 的渲染函数、JSX 和 h() 怎样使用?
模板最终会编译成渲染函数,渲染函数调用 h() 创建虚拟节点。h(type, props, children) 里 type 可以是标签或组件,事件写成 onXxx;v-if 换成三元表达式,v-for 换成 map,插槽要传函数。Vue 的 JSX 可以直接写 class,插槽同样传函数对象。
简答
比如要写一个标题组件,传
level: 2就渲染<h2>,传 3 就渲染<h3>。用模板得写一串v-if,用渲染函数一行h('h' + props.level, slots.default())就行。
h()的作用是创建虚拟节点,参数是h(类型, props, 子节点)。类型可以是标签名,也可以直接传导入的组件对象;props 里class、style和模板里一样写,事件写成onClick这种on加大写字母开头的形式;子节点可以是字符串或节点数组。除了类型,其他参数都能省。模板指令在渲染函数里都要换成 JavaScript:
v-if用三元表达式,v-for用map并传key,v-model拆成modelValue和onUpdate:modelValue。给组件传插槽要传函数,具名插槽写成函数对象,前面的 props 位置没有值时要填null。JSX 是同一件事的另一种写法,Vue 的 JSX 能直接写class和for。要注意,在
setup里要返回一个函数,而不是直接返回h()的结果,因为setup只执行一次,返回的渲染函数才会在每次更新时重新执行。另外同一个虚拟节点不能在树里用两次,需要重复时用函数每次新建。
模板和渲染函数是什么关系
模板会先被编译成渲染函数,运行时执行渲染函数,得到一棵虚拟节点树,再交给渲染器更新 DOM。编译的过程见《Vue 模板编译的过程是怎样的?parse、transform、generate 各做什么?》。
所以手写渲染函数就是跳过编译,自己写编译器原本会生成的那部分代码。大部分场景模板更好读,编译器还能做静态提升、补丁标记等优化,见《Vue 模板编译如何减少运行时更新工作?》。渲染函数适合逻辑比结构复杂的场景,比如上面按 level 切换标签。
h() 的几种写法
官方文档列出的参数形式如下,除了第一个参数都可以省略:
import { h } from 'vue';
h('div');
h('div', { id: 'foo' });
h('div', { class: 'bar', innerHTML: 'hello' });
h('div', { onClick: () => {} });
h('div', { id: 'foo' }, 'hello');
h('div', 'hello');
h('div', [h('span', 'hello')]);
第二个参数为什么能同时放 class 和 innerHTML?文档说明,HTML 属性和 DOM 属性都可以写在 props 里,Vue 会自动选择正确的设置方式。确实需要强制时,可以加 . 前缀表示 DOM 属性、^ 前缀表示 HTML 属性。
第一个参数传组件时,直接传导入的组件对象,不需要注册:
import Foo from './Foo.vue';
h('div', [h(Foo, { msg: 'hi' })]);
在 setup 里返回渲染函数
组合式 API 里,setup 返回一个函数,这个函数就是渲染函数:
import { ref, h } from 'vue';
export default {
props: { msg: String },
setup(props) {
const count = ref(1);
return () => h('div', { onClick: () => count.value++ }, props.msg + count.value);
},
};
为什么不能直接 return h('div', ...)?因为 setup 每个组件实例只执行一次,直接返回节点就只渲染这一次的结果。返回函数以后,Vue 每次更新都调用它,函数里读到的 count.value 和 props.msg 是最新值,也会被收集成依赖。
指令在渲染函数里怎么写
模板指令都要换成普通 JavaScript。下面把 v-if、v-for 和 v-model 放在一起看:
h('div', [
ok.value ? h('p', 'yes') : h('p', 'no'),
h(
'ul',
items.value.map(({ id, text }) => h('li', { key: id }, text)),
),
h(SomeInput, {
modelValue: text.value,
'onUpdate:modelValue': (value) => (text.value = value),
}),
]);
v-for 换成 map 以后,key 仍然要写,它放在 props 里。组件上的 v-model 本来就是 modelValue 加 update:modelValue 事件的简写,见《组件 v-model 的本质是什么?》,渲染函数里要自己把这两个都传上。
事件修饰符里,.passive、.capture、.once 可以直接拼进事件名,比如 onClickCapture、onKeyupOnce;.self、.stop 这类要用 withModifiers(handler, ['self']) 包一下。
同一个节点能不能复用?文档要求组件树里的虚拟节点必须唯一。想渲染 20 个相同的段落,不能把同一个 h('p', 'hi') 放进数组 20 次,要用 Array.from({ length: 20 }).map(() => h('p', 'hi')) 每次新建。
插槽为什么要传函数
组件内部渲染插槽时,slots 上的每个插槽都是一个函数,调用后返回节点数组:
setup(props, { slots }) {
return () => [
h('div', slots.default()),
h('div', slots.footer({ text: props.message })),
];
}
父组件给子组件传插槽,也要传函数而不是节点数组:
h(MyComponent, () => 'hello');
h(MyComponent, null, {
default: () => 'default slot',
footer: ({ text }) => h('p', text),
});
第二种写法里的 null 不能省,否则插槽对象会被当成 props。为什么要传函数?文档说明,传函数让子组件调用它,插槽里用到的数据就被子组件追踪成依赖,更新更准确,也更高效。作用域插槽就是子组件调用时传了参数,比如上面的 { text }。插槽本身的概念见《插槽、作用域插槽和 provide/inject 各解决什么问题?》。
Vue 的 JSX 和 React 有什么不同
JSX 是写渲染函数的另一种语法,最后同样编译成创建虚拟节点的调用。项目里通过 create-vue 或 Vue CLI 开启,手动配置用 @vue/babel-plugin-jsx。
const vnode = (
<div class="card" onClick={handleClick}>
{ok.value ? <span>yes</span> : <span>no</span>}
</div>
);
文档强调 Vue 的 JSX 转换和 React 的不同。第一,可以直接写 class 和 for,不用写 className、htmlFor。第二,给组件传插槽时,子节点要写成函数对象:
<MyComponent>
{{
default: () => 'default slot',
foo: () => <div>foo</div>,
}}
</MyComponent>
用 TSX 时,tsconfig.json 要设置 "jsx": "preserve",让 TypeScript 保留 JSX 交给 Vue 的转换处理。Vue 3.4 起不再隐式注册全局 JSX 命名空间,要加 "jsxImportSource": "vue",或者在文件顶部写 /* @jsxImportSource vue */。
常见追问
函数式组件是什么?
就是一个普通函数,接收 props 和上下文,返回虚拟节点,比如
function MyComponent(props, { slots, emit, attrs }) {}。它不创建组件实例,没有this,也没有常规的生命周期钩子。声明 props 要写在函数上,比如MyComponent.props = ['value']。
选项式 API 里怎么写渲染函数?
写
render()选项,在里面用this访问组件数据,比如render() { return h('div', this.message) },插槽通过this.$slots.default()渲染。