Vue 基础体系 · 第 28/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 渲染函数与 JSX:VNode、h、Slots、事件和适用边界
在 Vue 3 中,模板、渲染函数和 JSX 最终都要完成同一件事:根据组件状态生成一棵 VNode 树,再由 Vue 将这棵树与上一次渲染结果比较,最后更新真实 DOM 或子组件。
可以把一次渲染抽象为:
其中:
state_t是当前响应式状态;render是模板编译结果、手写渲染函数或 JSX 编译结果;VNodeTree_t是当前虚拟节点树;patch是 Vue 根据新旧 VNode 的差异执行更新的过程。
渲染函数与 JSX 的核心价值,不是“换一种写 HTML 的语法”,而是直接参与 VNode 的构造。当模板语法难以表达动态组件、递归结构、可配置插槽或复杂节点组合时,它们可以提供更直接的控制能力。
一、VNode 是什么
1. VNode 是对渲染结果的描述
VNode,即 Virtual Node,虚拟节点,是一个普通 JavaScript 对象,用来描述某个节点应当如何被渲染。
例如:
const vnode = h(
'button',
{
class: 'primary',
disabled: false,
onClick: () => {
console.log('clicked')
}
},
'保存'
)
它表达的意图大致是:
<button class="primary">保存</button>
但 vnode 本身不是 HTMLButtonElement,也不是已经插入文档的 DOM 节点。它只是描述:
- 节点类型是
button; - 节点属性中有
class和disabled; - 节点绑定了一个点击事件;
- 子节点是文本
保存。
Vue 后续会读取这个描述,并创建或更新真实 DOM。
2. VNode 与 DOM 的区别
下面三者必须区分:
const vnode = h('div', 'hello')
const element = document.createElement('div')
element.textContent = 'hello'
vnode是 Vue 的中间描述;element是浏览器中的真实 DOM;render(vnode, container)执行后,VNode 才会影响 DOM。
如果需要访问实际元素,应当使用模板引用、ref 或组件实例暴露的 API,而不是把 VNode 当作 DOM:
const vnode = h('input')
// 错误:VNode 不是 HTMLElement
// vnode.focus()
// 正确:在组件中使用模板引用
const input = ref<HTMLInputElement | null>(null)
onMounted(() => {
input.value?.focus()
})
3. VNode 不是可随意复用的 DOM 模板对象
一个 VNode 通常应当只出现在一次渲染结果中。不要把同一个 VNode 对象重复放入多个位置:
const child = h('span', '文本')
// 不应这样做
return h('div', [child, child])
正确做法是每次创建独立的 VNode:
return h('div', [
h('span', '文本'),
h('span', '文本')
])
原因是 Vue 的更新过程会为 VNode 建立与实际节点相关的内部关系。重复使用同一个 VNode 可能导致运行时警告、更新异常或节点移动行为不符合预期。
4. VNode 的常见类型
h 的第一个参数可以是多种类型:
h('div') // 原生元素
h(MyComponent) // Vue 组件
h(resolveComponent('X')) // 运行时解析出的组件
h(Fragment, [...]) // Fragment
h(Text, '文本') // 文本节点
工程代码中最常见的是:
- 字符串:原生 HTML 元素名;
- 组件对象:一个已导入的 Vue 组件;
- 动态组件引用:根据条件选择的组件。
二、渲染函数如何参与组件生命周期
Vue 组件的 setup 可以返回一个渲染函数:
import { ref, h } from 'vue'
export default {
setup() {
const count = ref(0)
return () =>
h('button', {
onClick: () => {
count.value++
}
}, `点击次数:${count.value}`)
}
}
组件首次挂载时,Vue 执行渲染函数:
count.value === 0
│
▼
h('button', ..., '点击次数:0')
│
▼
创建 button,并插入 DOM
点击按钮后:
count.value++
│
▼
触发该组件的响应式渲染副作用
│
▼
重新执行渲染函数
│
▼
得到新的 VNode:'点击次数:1'
│
▼
比较新旧 VNode
│
▼
只更新文本节点
渲染函数不是只执行一次的初始化函数。只要渲染函数读取了响应式数据,该数据发生变化时,Vue 就会重新执行渲染函数。
不要在渲染函数外提前读取响应式值
const count = ref(0)
// 错误示例:这里只读取一次
const text = `点击次数:${count.value}`
return () => h('div', text)
此时 text 是普通字符串。即使 count.value 改变,渲染函数也没有读取新的 count.value,因此不会得到更新后的文本。
应该在渲染函数执行期间读取:
return () => h('div', `点击次数:${count.value}`)
或者使用计算属性:
const label = computed(() => `点击次数:${count.value}`)
return () => h('div', label.value)
三、h 的参数和子节点规则
h 是 Vue 提供的 VNode 创建函数。常见形式如下:
h(type, propsOrChildren?, children?)
其中:
type:节点类型;propsOrChildren:属性对象或子节点;children:子节点。
1. 只传类型
h('div')
等价于创建一个没有属性和子节点的 div。
2. 传属性
h('button', {
id: 'save-button',
class: 'primary',
disabled: true
})
3. 传文本子节点
h('p', '保存成功')
因为第二个参数是字符串,Vue 会把它识别为子节点,而不是属性对象。
4. 同时传属性和子节点
h(
'button',
{
class: 'primary',
type: 'button'
},
'保存'
)
5. 传多个子节点
h('ul', [
h('li', { key: 1 }, '第一项'),
h('li', { key: 2 }, '第二项')
])
数组中的元素可以是:
- 字符串;
- 数字;
- VNode;
- 条件表达式返回的
null; - 由
map创建的 VNode 数组。
例如:
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]
return () =>
h(
'ul',
users.map(user =>
h('li', { key: user.id }, user.name)
)
)
这里 key 不是 HTML 属性,而是 Vue 用于识别同级节点身份的更新提示。
6. 为什么列表需要稳定的 key
假设旧列表为:
[A, B, C]
新列表为:
[B, C, D]
有稳定 key 时,Vue 可以推断:
A 被移除
B 保留并移动
C 保留并移动
D 新增
如果使用数组下标作为 key:
items.map((item, index) =>
h('li', { key: index }, item.name)
)
插入或删除中间项时,节点身份可能被错误复用。尤其当列表项包含输入框、组件内部状态或 DOM 状态时,用户会看到输入内容“跑到另一行”等问题。
稳定 key 的条件是:
- 在同一个列表的同级范围内唯一;
- 在项目生命周期内尽可能稳定;
- 不应随着数组位置变化而变化。
四、属性、DOM Property、组件 Props 和事件
VNode 的第二个参数通常叫 props,但它不仅对应 HTML attribute。Vue 会根据节点类型和属性名称,将它们应用到 DOM 或组件上。
1. 原生元素属性
h('input', {
id: 'username',
class: ['field', { invalid: true }],
placeholder: '请输入用户名',
value: 'Alice'
})
Vue 会对这些值进行适当处理。class 可以是字符串、数组或对象;style 也可以是对象:
h('div', {
style: {
color: 'red',
fontSize: '14px'
}
}, '警告')
2. 布尔属性
h('button', {
disabled: isSaving.value
}, '保存')
当 isSaving.value 为 true 时,按钮被禁用;为 false 时,Vue 会移除或关闭对应状态。
3. 组件 Props
h(UserCard, {
name: 'Alice',
age: 30
})
如果 UserCard 声明了:
const props = defineProps<{
name: string
age: number
}>()
那么这些值会作为组件 props 进入组件。
原生元素的属性和组件 props 的处理路径不同:
h('button', { disabled: true })
│
▼
设置 DOM property / attribute
h(UserCard, { name: 'Alice' })
│
▼
创建组件实例,并填充 props
4. 事件名使用 on 前缀
Vue 3 的渲染函数中,原生 DOM 事件通常写成:
h('button', {
onClick: handleClick
}, '点击')
而不是 Vue 2 时代常见的:
// Vue 3 渲染函数中不要使用这种写法
h('button', {
on: {
click: handleClick
}
})
onClick 的 Click 对应 DOM 事件名 click。常见写法包括:
h('input', {
onInput: (event) => {
const target = event.target as HTMLInputElement
console.log(target.value)
},
onFocus: () => {},
onKeydown: (event: KeyboardEvent) => {
if (event.key === 'Enter') {
// 提交
}
}
})
5. 事件修饰符需要手动表达
模板可以写:
<button @click.stop.prevent="submit">
提交
</button>
渲染函数中没有直接的 .stop.prevent 语法,需要在处理器中明确调用:
h('button', {
onClick: (event: MouseEvent) => {
event.stopPropagation()
event.preventDefault()
submit()
}
}, '提交')
键盘修饰符也需要手动判断:
h('input', {
onKeydown: (event: KeyboardEvent) => {
if (event.key !== 'Enter') return
submit()
}
})
这不是 Vue 运行时缺少事件能力,而是因为修饰符原本由模板编译器翻译为事件处理逻辑;手写渲染函数时,调用者需要自己提供这段逻辑。
五、组件事件与 emits 的边界
原生元素事件和组件事件在表面上都使用 onXxx,但含义不同。
1. 原生元素事件
h('button', {
onClick: () => console.log('DOM click')
}, '按钮')
这里 onClick 最终会被注册为 DOM 点击监听器。
2. 组件自定义事件
子组件:
import { h } from 'vue'
export default {
emits: ['change'],
setup(_, { emit }) {
return () =>
h('button', {
onClick: () => emit('change', 'new-value')
}, '改变值')
}
}
父组件:
h(ChildComponent, {
onChange: (value: string) => {
console.log(value)
}
})
组件发出:
emit('change', value)
父组件接收:
onChange
也就是说,Vue 将事件名 change 映射为组件监听 prop onChange。
3. v-model 对应的事件
Vue 3 中,默认的 v-model 约定是:
- prop:
modelValue - 事件:
update:modelValue
使用 h 时:
h(CustomInput, {
modelValue: value.value,
'onUpdate:modelValue': (nextValue: string) => {
value.value = nextValue
}
})
这里属性名包含 :,因此在对象字面量中必须使用引号。
如果组件有命名模型,例如:
<MyInput v-model:title="title" />
对应的渲染函数形式通常是:
h(MyInput, {
title: title.value,
'onUpdate:title': (nextTitle: string) => {
title.value = nextTitle
}
})
4. 声明 emits 的意义
emits 不只是类型说明,它还影响:
- 组件哪些事件是正式对外接口;
- 事件监听器是否应被当作组件事件处理;
- 未声明事件在属性透传中的行为;
- 开发环境下的校验和警告。
TypeScript 项目中可以使用:
const emit = defineEmits<{
change: [value: string]
}>()
这会让:
emit('change', 'Alice')
获得参数类型检查。
六、Slots:把 VNode 生产逻辑传给子组件
Slot 不是已经生成好的静态 HTML,而通常是一组由父组件提供、由子组件在渲染时调用的函数。
可以将默认插槽抽象为:
type Slot = (...args: any[]) => VNode[]
实际类型由 Vue 定义,但“插槽是延迟执行的渲染函数”是理解其行为的关键。
1. 使用 h 传递默认插槽
子组件:
import { h } from 'vue'
export default {
setup(_, { slots }) {
return () =>
h('section', { class: 'panel' }, [
h('header', '面板标题'),
h('main', slots.default ? slots.default() : [])
])
}
}
父组件:
h(Panel, null, {
default: () => h('p', '这是面板内容')
})
第三个参数是一个插槽对象:
{
default: () => VNode
}
这里使用 null 是为了明确表示:第二个参数没有 props,第三个参数才是 slots。
2. 具名插槽
子组件:
import { h } from 'vue'
export default {
setup(_, { slots }) {
return () =>
h('article', [
h('header', slots.header?.()),
h('main', slots.default?.()),
h('footer', slots.footer?.())
])
}
}
父组件:
h(ArticleLayout, null, {
header: () => h('h1', '文章标题'),
default: () => h('p', '正文'),
footer: () => h('small', '页脚')
})
如果插槽可能不存在,应使用可选调用或默认空数组:
slots.footer?.() ?? []
这样可以避免把 undefined 直接作为不符合预期的子节点传递下去。
3. 作用域插槽
子组件可以在调用插槽时传入参数:
import { h } from 'vue'
export default {
setup(_, { slots }) {
const user = {
name: 'Alice',
role: 'admin'
}
return () =>
h('div', [
slots.default?.(user)
])
}
}
父组件传入的插槽函数接收这个参数:
h(UserPanel, null, {
default: (user: { name: string; role: string }) =>
h('p', `${user.name}:${user.role}`)
})
数据流是:
子组件准备 user
│
▼
调用 slots.default(user)
│
▼
执行父组件提供的渲染函数
│
▼
得到使用 user 的 VNode
这与模板中的:
<template #default="{ name, role }">
<p>{{ name }}:{{ role }}</p>
</template>
表达的是同一类关系。
4. 插槽必须在渲染上下文中调用
插槽函数可能读取父组件的响应式状态,因此应在子组件的渲染函数执行期间调用:
return () => h('div', slots.default?.())
不要在 setup 初始化时提前调用:
// 不推荐
const content = slots.default?.()
return () => h('div', content)
提前调用会使插槽脱离正常的渲染依赖追踪上下文,可能出现更新不及时或开发环境警告。插槽的正确生命周期是:子组件每次需要渲染插槽时调用插槽函数,由父组件的渲染作用域生成最新 VNode。
七、JSX 是如何进入 Vue 的
JSX 是一种 JavaScript 语法扩展:
const vnode = <button>保存</button>
它本身不是浏览器原生语法,也不是 Vue 运行时直接解析的字符串。Vite 会通过 JSX 插件将其编译为创建 VNode 的代码。
在 Vue 3 + Vite + TypeScript 项目中,安装:
npm install vue
npm install -D vite @vitejs/plugin-vue @vitejs/plugin-vue-jsx typescript vue-tsc
vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
export default defineConfig({
plugins: [
vue(),
vueJsx()
]
})
tsconfig.json 至少需要允许 TypeScript 处理 JSX:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"types": ["vite/client"]
}
}
对于 Vue JSX,jsxImportSource: "vue" 让 TypeScript 使用 Vue 的 JSX 类型定义。具体字段可能因项目已有的 Vite 模板和 TypeScript 版本不同而存在差异,但 @vitejs/plugin-vue-jsx 是 Vue 3 JSX 的关键编译插件。
八、JSX 与模板、h 的对应关系
1. 原生元素
JSX:
const vnode = <button class="primary">保存</button>
渲染函数:
const vnode = h(
'button',
{ class: 'primary' },
'保存'
)
模板:
<button class="primary">保存</button>
三者最终都产生类似的 VNode 结构。
2. 动态内容
JSX 中使用花括号插入 JavaScript 表达式:
const count = ref(0)
return () => (
<button onClick={() => count.value++}>
点击次数:{count.value}
</button>
)
不能把模板语法直接写进 JSX:
// 错误
<button v-if={visible}>按钮</button>
Vue JSX 不支持把模板指令当作普通 JSX 属性使用。应该使用 JavaScript 条件表达式:
return () => (
<div>
{visible.value ? <button>按钮</button> : null}
</div>
)
3. class 和 style
return () => (
<div
class={['card', { active: active.value }]}
style={{
color: active.value ? 'green' : 'gray',
marginTop: '8px'
}}
>
内容
</div>
)
这里的写法与 Vue 运行时对 class 和 style 的处理规则一致。
4. 事件
return () => (
<button onClick={handleClick}>
保存
</button>
)
键盘事件:
return () => (
<input
onKeydown={(event) => {
if (event.key === 'Enter') {
submit()
}
}}
/>
)
事件修饰符不能照搬模板:
// 不应把模板修饰符当成 JSX 语法
<button onClick.stop={submit}>提交</button>
应当显式处理:
<button
onClick={(event) => {
event.stopPropagation()
event.preventDefault()
submit()
}}
>
提交
</button>
九、JSX 中的组件和插槽
1. 使用组件
import UserCard from './UserCard.vue'
export default defineComponent({
setup() {
return () => (
<UserCard
name="Alice"
age={30}
/>
)
}
})
组件名必须是 JavaScript 作用域中可访问的变量:
import UserCard from './UserCard.vue'
不能像模板那样依赖字符串形式的自动解析:
// 这不是动态组件的通用写法
<UserCard />
如果组件是动态的,可以直接使用变量:
const currentComponent = computed(() =>
activeTab.value === 'users' ? UserList : SettingsPanel
)
return () => h(currentComponent.value)
或在 JSX 中:
return () => {
const Component = currentComponent.value
return <Component />
}
2. 默认插槽
import Panel from './Panel.vue'
return () => (
<Panel>
<p>面板内容</p>
</Panel>
)
在 Vue JSX 中,更复杂的插槽可以使用 v-slots:
return () => (
<Panel
v-slots={{
default: () => <p>正文</p>,
header: () => <h2>标题</h2>,
footer: () => <small>页脚</small>
}}
/>
)
v-slots 是 Vue JSX 插件提供的 JSX 形式,用于明确传递插槽对象。
也可以直接使用函数子节点表达默认插槽,但在包含具名插槽、作用域插槽或复杂条件时,v-slots 更清晰:
return () => (
<UserList
v-slots={{
item: (user: User) => (
<strong>{user.name}</strong>
)
}}
/>
)
对应子组件可能这样调用:
import { defineComponent } from 'vue'
export default defineComponent({
props: {
users: {
type: Array,
required: true
}
},
setup(props, { slots }) {
return () => (
<ul>
{(props.users as User[]).map(user => (
<li key={user.id}>
{slots.item?.(user) ?? user.name}
</li>
))}
</ul>
)
}
})
十、一个可运行的 TypeScript JSX 示例
下面实现一个支持:
- 响应式搜索;
- 列表渲染;
- 稳定 key;
- 作用域插槽;
- 原生输入事件;
- 组件自定义事件;
的最小示例。
1. UserList.tsx
import {
defineComponent,
PropType,
type VNodeChild
} from 'vue'
export interface User {
id: number
name: string
role: string
}
export default defineComponent({
name: 'UserList',
props: {
users: {
type: Array as PropType<User[]>,
required: true
}
},
emits: {
select: (user: User) => Boolean(user && user.id > 0)
},
setup(props, { emit, slots }) {
return () => (
<ul>
{props.users.map(user => (
<li key={user.id}>
<button
type="button"
onClick={() => emit('select', user)}
>
{slots.item?.(user) ?? (
<span>
{user.name}({user.role})
</span>
)}
</button>
</li>
))}
</ul>
)
}
})
这里有几个关键点:
PropType<User[]>用于让运行时 props 声明与 TypeScript 类型结合;key={user.id}保持列表项身份稳定;slots.item?.(user)调用作用域插槽;emit('select', user)触发组件事件;- 父组件对应监听器名称是
onSelect。
VNodeChild 在某些复杂插槽类型声明中有用,但本示例依赖 Vue 对 JSX 返回值的推断,不需要显式使用它。
2. App.tsx
import {
defineComponent,
computed,
ref
} from 'vue'
import UserList, { type User } from './UserList'
export default defineComponent({
name: 'App',
setup() {
const keyword = ref('')
const selectedUser = ref<User | null>(null)
const users = ref<User[]>([
{ id: 1, name: 'Alice', role: '管理员' },
{ id: 2, name: 'Bob', role: '编辑' },
{ id: 3, name: 'Carol', role: '访客' }
])
const filteredUsers = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
if (!normalized) {
return users.value
}
return users.value.filter(user =>
user.name.toLowerCase().includes(normalized) ||
user.role.toLowerCase().includes(normalized)
)
})
const handleSelect = (user: User) => {
selectedUser.value = user
}
return () => (
<main>
<h1>用户列表</h1>
<input
value={keyword.value}
placeholder="搜索姓名或角色"
onInput={(event) => {
const target = event.target as HTMLInputElement
keyword.value = target.value
}}
/>
<UserList
users={filteredUsers.value}
onSelect={handleSelect}
v-slots={{
item: (user: User) => (
<span>
<strong>{user.name}</strong>
<small> · {user.role}</small>
</span>
)
}}
/>
{selectedUser.value ? (
<p>
当前选择:{selectedUser.value.name}
</p>
) : (
<p>尚未选择用户</p>
)}
</main>
)
}
})
运行过程如下:
keyword初始为空,filteredUsers返回全部用户;- 输入框触发
onInput; - 事件处理器从
event.target读取新值并写入keyword.value; - 计算属性重新计算筛选结果;
App的渲染函数重新执行;UserList接收新的users;- 列表根据
id对比新旧 VNode; - 点击某个用户时,子组件发出
select; - 父组件的
onSelect更新selectedUser; - 页面底部只需更新当前选择文本。
3. 运行方式
标准 Vite Vue 项目中,确保入口正常挂载:
// main.ts
import { createApp } from 'vue'
import App from './App'
createApp(App).mount('#app')
然后执行:
npm run dev
预期行为:
- 页面显示三个用户;
- 输入
管理员后只显示 Alice; - 点击用户后,底部显示当前选择的用户;
- 通过
v-slots可以改变列表项的显示结构,而不修改UserList的遍历逻辑。
十一、模板、渲染函数和 JSX 的关系
1. 模板不是另一套运行机制
模板:
<button
:class="{ active }"
@click="count++"
>
{{ count }}
</button>
编译器会把它转换为与下面类似的渲染逻辑:
return () =>
h('button', {
class: { active: active.value },
onClick: () => count.value++
}, String(count.value))
真实编译产物会包含块树、静态提升、patch flag 等优化信息,不能简单等同于上面的手写代码,但两者的基本目标一致:生成 VNode 并让 Vue 执行更新。
2. JSX 也不是字符串模板
JSX:
return () => (
<button onClick={increment}>
{count.value}
</button>
)
通过 JSX 转换后,大致会变成:
return () =>
createVNode(
'button',
{ onClick: increment },
String(count.value)
)
具体生成函数名和辅助代码由 JSX 编译器决定,开发者不应依赖未公开的编译产物细节。
3. 三种方式的抽象层次
| 方式 | 主要抽象 | 优势 | 主要代价 |
|---|---|---|---|
| 模板 | 声明式视图语言 | 可读性好,编译器优化充分 | 动态结构和 JavaScript 控制能力受模板语法约束 |
| 渲染函数 | 直接构造 VNode | 控制精确,依赖少 | 代码冗长,容易手写错误 |
| JSX | JavaScript 中的 VNode 表达式 | 组件组合和条件逻辑自然 | 需要构建配置,模板用户不熟悉时可读性下降 |
“JSX 更快”或“渲染函数更高级”都不是普遍成立的结论。性能取决于节点数量、更新频率、key、静态内容、组件边界以及编译器能否做出优化。
十二、常见失败表现与诊断方法
1. 把 VNode 当成 DOM
错误:
const vnode = h('input')
vnode.focus()
失败原因是 vnode 没有浏览器 DOM API。
诊断方式:
console.log(vnode)
console.log(vnode.el)
在挂载完成前,vnode.el 通常不存在;即使某些内部阶段可能出现,也不应把它当作稳定公共 API 使用。应该使用 ref 获取元素。
2. 渲染函数没有返回 VNode
错误:
setup() {
const count = ref(0)
// 没有 return 渲染函数
}
或者:
return () => {
if (!visible.value) {
return
}
return h('div', '内容')
}
组件渲染函数必须返回合法渲染结果。条件为空时可以返回 null:
return () => {
if (!visible.value) {
return null
}
return h('div', '内容')
}
3. 把 slots 当成已经生成的 VNode
错误:
return () => h('div', slots.default)
slots.default 是函数,不是 VNode 数组。应该调用:
return () => h('div', slots.default?.())
4. 把组件事件写成 DOM 事件配置
父组件:
h(Child, {
onClick: handleClick
})
这是否能工作,取决于 Child 是否:
- 声明并发出
click事件; - 或者该监听器被作为未声明属性透传到了根元素。
如果希望建立明确的组件接口,应声明:
emits: ['select']
并使用:
h(Child, {
onSelect: handleSelect
})
不要依赖根元素透传来模拟组件事件,因为子组件根节点结构变化后,行为可能改变。
5. 事件处理器没有正确读取输入值
错误:
<input onInput={(event) => {
keyword.value = event.target.value
}} />
TypeScript 通常会提示 event.target 类型可能是 EventTarget,没有 value 属性。
正确做法:
<input onInput={(event) => {
const target = event.target as HTMLInputElement
keyword.value = target.value
}} />
更严格的项目也可以通过事件类型和运行时检查避免不安全断言。
6. JSX 中使用了错误的属性大小写
常见写法:
<button onClick={handleClick} />
不要写成模板字符串式的:
<button @click={handleClick} />
对于组件事件:
<MyComponent onSelect={handleSelect} />
对于 update:modelValue,在 JSX 中应根据 JSX 编译器支持的属性语法使用对应写法;使用 h 时最明确、最不容易产生解析歧义:
h(MyComponent, {
modelValue: value.value,
'onUpdate:modelValue': updateValue
})
项目升级或更换 JSX 转换器时,应通过实际类型检查和构建验证这类带冒号的事件属性。
7. 在 map 中忘记返回
错误:
<ul>
{users.value.map(user => {
<li key={user.id}>{user.name}</li>
})}
</ul>
花括号函数体没有 return,结果是每一项都返回 undefined。
正确写法:
<ul>
{users.value.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
或:
<ul>
{users.value.map(user => {
return <li key={user.id}>{user.name}</li>
})}
</ul>
十三、渲染函数中的 Fragment、文本和空结果
组件渲染函数不一定只能返回单个显式元素。
1. 返回多个根节点
JSX:
return () => (
<>
<h1>标题</h1>
<p>正文</p>
</>
)
这会生成 Fragment。Vue 3 支持多根节点组件,但属性透传需要特别注意:如果组件有多个根节点,Vue 无法自动判断 $attrs 应该落到哪个根节点。
渲染函数中也可以使用数组:
return () => [
h('h1', '标题'),
h('p', '正文')
]
如果需要明确表达 Fragment,可以使用 Vue 提供的 Fragment:
import { Fragment, h } from 'vue'
return () =>
h(Fragment, [
h('h1', '标题'),
h('p', '正文')
])
2. 返回 null
条件不满足时:
return () => visible.value
? h('div', '显示')
: null
null 表示不渲染节点,不等于渲染字符串 "null"。
3. 文本节点
通常直接使用字符串即可:
h('div', 'hello')
只有在需要直接操作特殊 VNode 类型时,才会显式使用 Text。业务代码中直接传文本更清晰。
十四、指令、透传属性和特殊边界
1. JSX 不会自动获得模板指令语法
模板中的:
<div v-show="visible">内容</div>
不是普通 HTML 属性。渲染函数中应使用对应的 DOM 逻辑,或使用 withDirectives 等 Vue API:
import { h, withDirectives, vShow } from 'vue'
return () =>
withDirectives(
h('div', '内容'),
[[vShow, visible.value]]
)
这类 API 适合确实需要复用已有指令语义的场景。若只是控制显示,条件返回 null 或用 style 可能更直接,但两者与 v-show 的 DOM 保留行为不同,不能简单互换。
2. $attrs 不会自动消失
组件可以接收未声明为 props 或 emits 的属性,并将其透传到根节点。使用渲染函数时,如果关闭或自定义属性继承,需要显式处理:
import { h, mergeProps } from 'vue'
export default {
inheritAttrs: false,
setup(_, { attrs }) {
return () =>
h('button', mergeProps(
{
class: 'base-button'
},
attrs
), '按钮')
}
}
mergeProps 可以合并 class、style、事件等常见属性,但合并顺序会影响最终结果,尤其是同名事件处理器。应明确哪个属性覆盖哪个属性,而不是依赖隐含顺序。
3. SSR 和水合边界
服务端渲染时,VNode 先被转换为 HTML 字符串;客户端水合时,Vue 会将客户端生成的 VNode 与服务端已有 DOM 对照。
如果渲染结果依赖不稳定数据:
return () => <p>{Math.random()}</p>
服务端和客户端很可能生成不同文本,导致水合不匹配警告或客户端重新修正。
同样的问题也可能来自:
- 在渲染函数中读取当前时间;
- 服务端和客户端环境变量不同;
- 浏览器专属 API 只在客户端可用;
- 列表排序依据不稳定;
- 随机生成 key。
需要保证首次服务端渲染和客户端首次渲染具有一致输入;浏览器专属逻辑应放到客户端生命周期中执行。
十五、渲染函数和 JSX 的适用边界
1. 适合使用渲染函数或 JSX 的场景
动态节点结构
当节点类型、组件类型或属性结构由数据决定时:
const tag = computed(() =>
level.value === 1 ? 'h1' : 'h2'
)
return () => {
const Tag = tag.value
return <Tag>{title.value}</Tag>
}
递归组件和树结构
树节点的子节点数量和类型不固定,使用 JavaScript 递归表达通常比模板嵌套更直接:
function renderNode(node: TreeNode): VNode {
return (
<li key={node.id}>
<span>{node.label}</span>
{node.children?.length
? <ul>{node.children.map(renderNode)}</ul>
: null}
</li>
)
}
高度可配置的组件
表格列、菜单项、字段渲染器、编辑器节点等场景通常需要把“数据如何变成 VNode”作为配置传入:
interface Column<T> {
key: string
title: string
render: (row: T) => VNodeChild
}
这时渲染函数或 JSX 可以让 render 直接返回节点。
需要与 JavaScript 控制流紧密结合的组件
例如:
- 根据多个条件组合不同组件;
- 运行时动态构造属性对象;
- 根据权限生成不同操作节点;
- 将函数、数据和 VNode 统一组合。
2. 不适合优先使用的场景
普通表单和页面布局
<form>
<label>用户名</label>
<input v-model="username" />
<button type="submit">登录</button>
</form>
这类静态结构使用模板更易读,也更方便设计师和业务开发者维护。
依赖大量模板指令和编译器优化的页面
模板对以下内容提供了更直接的表达:
v-if;v-for;v-model;- 事件修饰符;
v-bind;- 模板引用;
- 静态节点分析。
手写 JSX 可以实现相同逻辑,但需要承担更多类型、事件和节点组合细节。
团队缺乏统一 JSX 规范时
JSX 允许在视图中嵌入任意 JavaScript。如果没有约束,渲染逻辑可能混入请求、状态修改和复杂计算,最终比模板更难理解。这里的问题不是 JSX 能力不足,而是视图表达式与业务副作用的边界被破坏。
十六、性能与维护取舍
1. VNode 数量不是唯一性能指标
手写 h 或 JSX 不会自动比模板更快。性能主要受以下因素影响:
- 每次渲染生成多少 VNode;
- 哪些响应式数据被读取;
- 列表是否有稳定 key;
- 是否重复创建昂贵对象;
- 子组件边界是否合理;
- 更新是否集中在必要的组件范围;
- 编译器能否识别静态节点。
例如,下面的代码在每次渲染时都会创建一个新的对象:
return () => (
<Widget
options={{
dense: true,
theme: 'light'
}}
/>
)
这不一定是错误,因为 Vue 仍可正确更新;但如果子组件或相关逻辑依赖对象身份,可能导致额外工作。可以把真正静态的对象移到组件外:
const widgetOptions = {
dense: true,
theme: 'light'
}
return () => (
<Widget options={widgetOptions} />
)
如果对象中包含响应式值,则不能简单移出:
const options = computed(() => ({
dense: dense.value,
theme: theme.value
}))
2. 事件处理器的新建不必被机械优化
下面的代码每次渲染都会创建闭包:
{users.value.map(user => (
<button onClick={() => select(user)}>
{user.name}
</button>
))}
这是表达作用域数据最直接的方式,通常不应仅因为“创建了函数”就改写。只有在确认存在实际更新开销,并且改写后仍然保持可读性时,才需要考虑事件委托、缓存或结构调整。
3. 编译器优化边界
模板编译器可以进行静态提升、节点标记等优化。手写渲染函数和 JSX 的具体优化能力取决于 Vue JSX 转换器与构建配置,不能假设它们总能获得与模板完全相同的编译优化。
因此,性能敏感场景应通过实际构建产物、组件更新分析和运行时测量验证,而不是根据语法形式推断性能。
十七、选择方式时的判断顺序
可以按以下顺序判断:
-
结构是否主要由页面标记组成?
是:优先模板。 -
是否需要递归、动态组件、运行时节点工厂或复杂作用域插槽?
是:考虑渲染函数或 JSX。 -
团队是否已经使用 TypeScript 和 JSX 工具链?
如果没有,优先评估构建、类型检查和调试成本。 -
组件对外接口是否包含复杂插槽和渲染器配置?
是:使用 JSX 或渲染函数往往更自然,但仍应保持 props、emits、slots 接口清晰。 -
问题是语法表达能力,还是组件设计问题?
如果只是组件过于庞大、状态职责混乱,换成 JSX 不会自动解决问题。
结语
VNode 是 Vue 对渲染结果的结构化描述;h 是直接创建 VNode 的 API;渲染函数是由响应式状态生成 VNode 树的函数;JSX 则是把这种 VNode 构造过程写进 JavaScript 的语法形式。
它们之间的核心关系可以概括为:
响应式状态
│
▼
渲染函数 / JSX
│
▼
VNode
│
▼
Vue patch
│
▼
DOM 或子组件更新
Slots 的本质是延迟执行的 VNode 生成函数,事件的本质是通过 onXxx 传入原生元素或组件的监听器,key 的本质是帮助 Vue 保持列表节点身份。掌握这些机制后,渲染函数和 JSX 才不只是模板的替代写法,而是处理动态组件、递归结构和可配置渲染逻辑的底层工具。
但在普通页面结构中,模板仍然通常具有更好的可读性和编译器支持。合理的边界不是“全面使用 JSX”或“完全避免渲染函数”,而是根据结构复杂度、接口形式、团队工具链和实际更新路径选择合适的表达层。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 调度器与 nextTick:批量更新、Flush 时机和 DOM 可见性
- 下一篇:Vue 编译宏:defineProps、defineEmits、defineModel 与泛型组件
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论