渲染函数
在绝大多数情况下,Vue 推荐使用模板语法来创建应用。然而在某些使用场景下,我们真的需要用到 JavaScript 完全的编程能力。这时渲染函数就派上用场了。
创建 VNode
Vue 提供了一个 h() 函数用于创建 VNode:
import { h } from 'vue'
const vnode = h(
'div', // type
{ id: 'foo', class: 'bar' }, // props
[
/* children */
],
)一个更准确的名称应该是 createVNode(),但当你需要多次使用渲染函数时,一个简短的名字会更省力。
h(type,props?,childrenOrSlots?)
h(type,props?,childrenOrSlots?)type:标签名 / 组件
- string:HTML 元素名称
props:属性、props、事件等
- Object
childrenOrSlots:子节点
第三个参数是对象时,表示slots;key 是插槽名,value 必须是返回 VNode 的函数。
- string
- VNode
Array<VNode>- Slots
[name: string]: ()=>VNodeh('div')
h('div', { id: 'foo' })
// attribute 和 property 都能在 prop 中书写
// Vue 会自动将它们分配到正确的位置
h('div', { class: 'bar', innerHTML: 'hello' })
// 像 `.prop` 和 `.attr` 这样的属性修饰符
// 可以分别通过 `.` 和 `^` 前缀来添加
h('div', { '.name': 'some-name', '^width': '100' })
// 类与样式可以像在模板中一样
// 用数组或对象的形式书写
h('div', { class: [foo, { bar }], style: { color: 'red' } })
// 事件监听器应以 onXxx 的形式书写
h('div', { onClick: () => {} })
// children 可以是一个字符串
h('div', { id: 'foo' }, 'hello')h(MyCard, null, {
default: () => h('p', '正文内容'),
footer: () => h('button', '确认'),
})h(type,childrenOrSlots)
// 没有 props 时可以省略不写
h('div', 'hello')
h('div', [h('span', 'hello')])
// children 数组可以同时包含 vnodes 与字符串
h('div', ['hello', h('span', 'hello')])声明渲染函数
我们可以在 setup 钩子将渲染函数返回,渲染函数是交给 vue 重复运行的,因此必须返回一个 Function:
import { ref, h } from 'vue'
export default {
props: {
/* ... */
},
setup(props) {
const count = ref(1)
// 返回渲染函数
return () => h('div', props.msg + count.value)
},
}除了返回一个 vnode,你还可以返回字符串或数组:
export default {
setup() {
return () => 'hello world!'
},
}import { h } from 'vue'
export default {
setup() {
// 使用数组返回多个根节点
return () => [h('div'), h('div'), h('div')]
},
}请确保返回的是一个函数而不是一个值
setup() 函数在每个组件中只会被调用一次,如果你直接返回一个值(比如 VNode)而不是函数。那么 Vue 就没有一个“可重复执行的渲染逻辑”,无法根据响应式数据变化重新渲染。
VNode 必须唯一
VNode 必须唯一,每次出现都必须是新的实例。不能重复使用同一个 VNode 对象。
VNode 和实际 DOM 节点是一一对应的,同一个 VNode 实例只能对应一个 DOM 位置,组件树中的 vnode 必须是唯一的。下面是错误示范:
function render() {
const p = h('p', 'hi')
return h('div', [
// 重复的 vnodes 是无效的
p,
p,
])
}Vue 无法把它映射为两个不同的 DOM <p>,因为:
- 它没有两个 VNode
- 没有两个独立的状态
- diff 算法无法区分它们
- 会破坏整棵 VDOM 树的结构一致性
所以复用一个 VNode 实例两次是被禁止的。正确的做法:
function render() {
return h('div', [h('p', 'hi'), h('p', 'hi')])
}JSX/TSX
VNode 还支持 JSX:
const vnode = <div>hello</div>如果你想在 vue 中使用 jsx 则需要进行配置
虽然最早是由 React 引入,但实际上 JSX 语法并没有定义运行时语义,并且能被编译成各种不同的输出形式。如果你之前使用过 JSX 语法,那么请注意 Vue 的 JSX 转换方式与 React 中 JSX 的转换方式不同,因此你不能在 Vue 应用中使用 React 的 JSX 转换。与 React JSX 语法的一些明显区别包括:
- 可以使用 HTML attributes 比如
class和for作为 props - 不需要使用className或htmlFor。 - 传递子元素给组件 (比如 slots) 的方式不同
Vue 的类型定义也提供了 TSX 语法的类型推导支持。当使用 TSX 语法时,确保在 tsconfig.json 中配置了 "jsx": "preserve",这样的 TypeScript 就能保证 Vue JSX 语法转换过程中的完整性。
渲染函数实例
v-if
<div>
<div v-if="ok">yes</div>
<span v-else>no</span>
</div>h('div', [ok.value ? h('div', 'yes') : h('span', 'no')])<div>{ok.value ? <div>yes</div> : <span>no</span>}</div>v-for
<ul>
<li v-for="{ id, text } in items" :key="id">
{{ text }}
</li>
</ul>h(
'ul',
items.value.map(({ id, text }) => {
return h('li', { key: id }, text)
}),
)<ul>
{items.value.map(({ id, text }) => {
return <li key={id}>{text}</li>
})}
</ul>v-on
以 on 开头,并跟着大写字母的 props 会被当作事件监听器。比如,onClick 与模板中的 @click 等价。
h(
'button',
{
onClick(event) {
/* ... */
},
},
'Click Me',
)<button
onClick={(event) => {
/* ... */
}}
>
Click Me
</button>事件修饰符
对于 .passive、.capture 和 .once 事件修饰符,可以使用驼峰写法将他们拼接在事件名后面:
h('input', {
onClickCapture() {
/* 捕捉模式中的监听器 */
},
onKeyupOnce() {
/* 只触发一次 */
},
onMouseoverOnceCapture() {
/* 单次 + 捕捉 */
},
})<input
onClickCapture={() => {}}
onKeyupOnce={() => {}}
onMouseoverOnceCapture={() => {}}
/>对于事件和按键修饰符,可以使用 withModifiers 函数:
import { withModifiers } from 'vue'
h('div', {
onClick: withModifiers(() => {}, ['self']),
})<div onClick={withModifiers(() => {}, ['self'])} />组件
在给组件创建 vnode 时,传递给 h() 函数的第一个参数应当是组件的定义。这意味着使用渲染函数时不再需要注册组件了 —— 可以直接使用导入的组件:
import Foo from './Foo.vue'
import Bar from './Bar.jsx'
function render() {
return h('div', [h(Foo), h(Bar)])
}function render() {
return (
<div>
<Foo />
<Bar />
</div>
)
}如果一个组件是用名字注册的,不能直接导入 (例如,由一个库全局注册),可以使用 resolveComponent() 来解决这个问题。
import TDesign from 'tdesign-vue-next'
const app = createApp(App)
app.use(TDesign) // 内部注册了 t-button、t-table 等组件
app.component('MyComp', MyComp)<!--因为模板编译阶段会按名字自动解析组件-->
<t-button theme="primary">按钮</t-button>使用渲染函数时,我们跳过了模板编译阶段,因此 h 无法直接使用。实际上模板编译时帮助我们调用了resolveComponent
错误示范:
import { h } from 'vue'
export default {
setup() {
return () => h('t-button', { theme: 'primary' }, '按钮')
}
}正确示范:
import { h, resolveComponent } from 'vue'
export default {
setup() {
const TButton = resolveComponent('t-button')
return () => h(TButton, { theme: 'primary' }, '按钮')
},
}内置组件
诸如 <KeepAlive>、<Transition>、<TransitionGroup>、<Teleport> 和 <Suspense> 等内置组件在渲染函数中必须导入才能使用:
import { h, KeepAlive, Teleport, Transition, TransitionGroup } from 'vue'
export default {
setup() {
return () => h(Transition, { mode: 'out-in' } /* ... */)
},
}插槽
理解
提示
插槽的本质不是“内容”,而是一段可以延迟执行的渲染逻辑
给原生元素或普通 vnode 传子节点时,children 就是一个 VNode / VNode 数组:
h('div', null, [h('span', 'A'), h('span', 'B')])组件不接收children,而是接收 插槽(slots)。
// ❌ 这是给元素用的,不是给组件用的
h(MyComp, null, [h('span', '内容')])默认插槽:
h(MyComp, null, () => [h('span', '内容')])多个插槽:
h(MyComp, null, {
default: () => h('span', '默认插槽'),
footer: () => h('div', '底部插槽'),
})定义插槽
在渲染函数中,插槽可以通过 setup() 的上下文来访问。每个 slots 对象中的插槽都是一个返回 vnodes 数组的函数:
export default {
props: ['message'],
setup(props, { slots }) {
return () => [
// 默认插槽:
// <div><slot /></div>
h('div', slots.default()),
// 具名插槽:
// <div><slot name="footer" :text="message" /></div>
h(
'div',
slots.footer({
text: props.message,
}),
),
]
},
}// 默认插槽
<div>{slots.default()}</div>
// 具名插槽
<div>{slots.footer({ text: props.message })}</div>传递插槽
// 单个默认插槽
h(MyComponent, () => 'hello')
// 具名插槽
// 注意 `null` 是必需的
// 以避免 slot 对象被当成 prop 处理
h(MyComponent, null, {
default: () => 'default slot',
foo: () => h('div', 'foo'),
bar: () => [h('span', 'one'), h('span', 'two')],
})// 默认插槽
<MyComponent>{() => 'hello'}</MyComponent>
// 具名插槽
<MyComponent>{{
default: () => 'default slot',
foo: () => <div>foo</div>,
bar: () => [<span>one</span>, <span>two</span>]
}}</MyComponent>// 子组件
export default {
setup(props, { slots }) {
const text = ref('hi')
return () => h('div', null, slots.default({ text: text.value }))
},
}<MyComponent>
{{
default: ({ text }) => <p>{text}</p>,
}}
</MyComponent>v-model
v-model 是语法糖,在渲染函数中我们必须手动实现:
<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
//触发父组件的update:modelValue事件
<SomeComponent
:model-value="modelValue"
@update:model-value="(val) => emit('update:modelValue', val)"
/>
</template>export default {
props: ['modelValue'],
emits: ['update:modelValue'],
setup(props, { emit }) {
return () =>
h(SomeComponent, {
modelValue: props.modelValue,
'onUpdate:modelValue': (value) => emit('update:modelValue', value),
})
},
}自定义指令
可以使用 withDirectives 将自定义指令应用于 vnode:
import { h, withDirectives } from 'vue'
// 自定义指令
const pin = {
mounted() {
/* ... */
},
updated() {
/* ... */
},
}
// <div v-pin:top.animate="200"></div>
const vnode = withDirectives(h('div'), [[pin, 200, 'top', { animate: true }]])当一个指令是以名称注册并且不能被直接导入时,可以使用 resolveDirective 函数来解决这个问题。
模板引用
模板引用是通过将字符串值作为 prop 传递给 vnode 创建的:
import { h, useTemplateRef } from 'vue'
export default {
setup() {
const divEl = useTemplateRef('my-div')
// <div ref="my-div">
return () => h('div', { ref: 'my-div' })
},
}