第9章 9.2 Vue 3 源码:组件渲染
前置知识:上一章我们揭开了 Vue 3 响应式的秘密——数据变了,界面自动跟着变,就像变色龙自动变色一样自然。
本章目标:这一章我们要深入 Vue 3 的"组装车间",看看一个 .vue 文件是怎么变成屏幕上真实可见的像素的。
🎯 开场 3 分钟:为什么要学这个?
你有没有想过这个问题:Vue 凭什么知道 {{ message }} 要渲染在哪个位置?
<div>{{ message }}</div>
这一行代码背后,Vue 做了大量工作:
1. 把 {{ message }} 解析成一个"指令"
2. 去响应式数据里找到 message 的值
3. 创建 DOM 节点,塞进去
如果你写过原生 JS,可能会这样干:
const div = document.createElement('div')
div.innerText = message
document.body.appendCh\n\n\n\n\n\nild(div)
问题来了:当 message 变了,你得手动再执行一遍这套流程。10 个变量还好,100 个呢?
学完这章,你能说清楚 Vue 的组件渲染管线是怎么工作的,遇到"为什么我的界面没更新"这种 bug,你也能自己 Debug 了。
🧱 基础 25 分钟:核心概念
概念 1:虚拟 DOM——组件的"蓝图"
是什么:虚拟 DOM(Virtual DOM)就是一个描述 UI 长什么样的普通 JS 对象。
举个例子,你在相亲网站上填的"个人信息表"就是你的虚拟 DOM——它不是真实的你,但是一个可以展示、可以修改的"描述"。
// 虚拟 DOM 节点(简化版)
const vnode = {
type: 'div',
props: { class: 'container' },
children: [
{ type: 'h1', children: '标题' },
{ type: 'p', children: '这是一段文字' }
]
}
为什么要用:如果每次改数据都直接操作真实 DOM,浏览器要反复"重绘",性能很差。虚拟 DOM 让你先在内存里算出最优方案,再一次性更新。
类比一下:装修房子之前,先画设计图,确认没问题了再动手砸墙。
概念 2:h 函数——创建虚拟 DOM 的"工厂"
是什么:h 函数是 Vue 提供的工具,用来创建虚拟 DOM 节点。它的名字来自 hyperscript(写 JS 脚本创建 HTML)。
为什么要用:手写上面那种嵌套对象太累了,h 函数让创建过程更声明式。
import { h } from 'vue'
// 之前的写法
const vnode1 = { type: 'div', props: { class: 'box' }, children: ['Hello'] }
// 用 h 函数的写法
const vnode2 = h('div', { class: 'box' }, 'Hello')
看,h('div', props, children) 三参数,简单明了。
概念 3:createApp——启动 Vue 应用的"钥匙"
是什么:createApp 是 Vue 3 的入口函数,它接收一个根组件,返回一个应用实例。
类比:createApp 就像电影开场的制片公司 logo——它不负责拍电影,但它是一切的前提。
import { createApp } from 'vue'
import App from './App.vue'
// 这就是启动 Vue 应用的那行代码
const app = createApp(App)
// 告诉 Vue 把 App 挂载到哪个 DOM 元素上
app.mount('#app')
执行完这行代码,App.vue 里的模板就开始渲染到页面上了。
概念 4:patch 算法——对比蓝图找不同
是什么:当数据变了,Vue 需要重新生成虚拟 DOM,然后和上一次的对比,找出哪里变了,只更新需要改的地方。
为什么要用:不然每次数据变都要重新创建整个 DOM 树,太慢了。
类比:就像老师批改作业,不用把学生整篇作文重写一遍,只需要圈出写错的地方让学生改。
概念 5:组件的本质——返回虚拟 DOM 的函数
是什么:在 Vue 3 里,组件本质上就是一个返回虚拟 DOM 的函数。
// 这就是一个最最简单的组件
const MyButton = {
setup() {
const count = ref(0)
return () => h('button', { onClick: () => count.value++ }, `点击了 ${count.value} 次`)
}
}
注意看,setup 返回的是一个函数,这个函数返回 h('button', ...)——也就是虚拟 DOM。
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):用 h 函数手动创建一个页面
目标:体验一下"声明式"创建 UI 的感觉
// 创建一个完整的页面组件
import { h, createApp } from 'vue'
const App = {
setup() {
const message = 'Hello Vue 3!'
const count = ref(0)
return () => h('div', { class: 'container' }, [
h('h1', message),
h('p', `计数器: ${count.value}`),
h('button', {
onClick: () => count.value++
}, '点我加一')
])
}
}
createApp(App).mount('#app')
预期输出:页面上显示标题、计数器数字、一个按钮。点击按钮,数字+1。
解释:这三行代码完成了之前原生 JS 需要 20 行才能搞定的事,而且还自带"响应式"——点击按钮,界面自动更新。
项目 2(15 分钟):用组件嵌套做一个待办清单
目标:理解组件之间的嵌套关系
import { h, createApp, ref } from 'vue'
// 单个待办项组件
const TodoItem = {
props: ['todo'],
setup(props) {
return () => h('li', {
class: ['todo-item', props.todo.done ? 'done' : '']
}, props.todo.text)
}
}
// 待办清单组件
const TodoList = {
setup() {
const todos = ref([
{ id: 1, text: '买菜', done: false },
{ id: 2, text: '做饭', done: true },
{ id: 3, text: '洗碗', done: false }
])
const addTodo = () => {
todos.value.push({
id: Date.now(),
text: '新任务',
done: false
})
}
return () => h('div', { class: 'todo-list' }, [
h('h2', '我的待办'),
h('ul', todos.value.map(todo => h(TodoItem, { todo }))),
h('button', { onClick: addTodo }, '添加待办')
])
}
}
createApp(TodoList).mount('#app')
预期输出:
- 页面显示"我的待办"标题
- 列表里有 3 个待办项,其中"做饭"有删除线样式(因为 done: true)
- 底部有一个"添加待办"按钮,点击会在列表末尾添加新项目
解释:重点在于 h(TodoItem, { todo })——组件作为 h 函数的第一个参数,实现嵌套。
项目 3(15 分钟):实现一个简易的"数据驱动的表格渲染器"
目标:综合运用虚拟 DOM + 组件化思维,做一个有点实用价值的小工具
这个场景很常见:后端返回了一批数据,你要渲染成表格展示。
import { h, createApp, ref } from 'vue'
// 表格单元格组件
const TableCell = {
props: ['value', 'column'],
setup(props) {
return () => {
// 根据列类型渲染不同的样式
const style = props.column.type === 'number'
? { textAlign: 'right', color: '#2563eb' }
: { textAlign: 'left' }
return h('td', { style }, String(props.value))
}
}
}
// 可排序的表格组件
const DataTable = {
props: ['columns', 'data'],
setup(props) {
const sortKey = ref(null)
const sortOrder = ref('asc')
const sortedData = () => {
if (!sortKey.value) return props.data
return [...props.data].sort((a, b) => {
const valA = a[sortKey.value]
const valB = b[sortKey.value]
const modifier = sortOrder.value === 'asc' ? 1 : -1
return valA > valB ? modifier : -modifier
})
}
const toggleSort = (key) => {
if (sortKey.value === key) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortOrder.value = 'asc'
}
}
return () => h('table', { class: 'data-table' }, [
// 表头
h('thead', [
h('tr', props.columns.map(col =>
h('th', {
onClick: () => toggleSort(col.key),
style: { cursor: 'pointer' }
}, col.label)
))
]),
// 表体
h('tbody', sortedData().map(row =>
h('tr', props.columns.map(col =>
h(TableCell, { value: row[col.key], column: col })
))
))
])
}
}
// 根组件:演示数据
const App = {
setup() {
const columns = [
{ key: 'name', label: '姓名', type: 'text' },
{ key: 'age', label: '年龄', type: 'number' },
{ key: 'city', label: '城市', type: 'text' }
]
const data = ref([
{ name: '张三', age: 28, city: '北京' },
{ name: '李四', age: 22, city: '上海' },
{ name: '王五', age: 35, city: '深圳' }
])
return () => h('div', { class: 'app' }, [
h('h1', '员工信息表'),
h(DataTable, { columns, data: data.value })
])
}
}
createApp(App).mount('#app')
预期输出:
- 显示一个三列表格:姓名、年龄、城市
- 点击表头可以排序(再点击反转顺序)
- 数字列右对齐且蓝色显示
解释:这个例子展示了 Vue 虚拟 DOM 的真正威力——你可以用纯 JS 对象来描述任意复杂的 UI 结构,然后 Vue 会帮你高效地渲染到屏幕上。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:忘记 setup 返回的是函数
// ❌ 错误写法:直接返回虚拟 DOM 对象
setup() {
return h('div', 'Hello') // 错!这里返回的是对象,不是函数
}
// ✅ 正确写法:返回函数
setup() {
return () => h('div', 'Hello') // 对!返回的是函数
}
坑 2:在 setup 里直接修改 props
// ❌ 错误写法
setup(props) {
props.count = 100 // Vue 不允许直接修改 props
}
// ✅ 正确做法:通过 emit 通知父组件修改
setup(props, { emit }) {
const update = () => emit('update', 100)
}
坑 3:循环渲染时忘记加 key
// ❌ 错误写法:没有 key,Vue 不知道谁是谁
h('ul', items.map(item => h('li', item.text)))
// ✅ 正确写法:给每个节点唯一的 key
h('ul', items.map(item => h('li', { key: item.id }, item.text)))
坑 4:组件名写错大小写
// ❌ 错误:HTML 标签不区分大小写,但组件名区分
h('MyComponent', ...) // 在模板里会变成 <mycomponent>
// ✅ 正确: PascalCase 用于 JSX/ render 函数
import { MyComponent } from './MyComponent'
h(MyComponent, ...)
坑 5:忘记 ref 要 .value
// ❌ 错误:以为 count 自动解包
const count = ref(0)
count++ // 错!这样改不了响应式数据
// ✅ 正确:要加 .value
count.value++
性能小贴士:避免不必要的父组件重渲染
如果子组件接收的 props 没变,可以用 markRaw 标记不会被修改的对象:
import { markRaw } from 'vue'
setup() {
// 这个对象永远不会变,不需要 Vue 追踪它
const staticConfig = markRaw({
apiUrl: 'https://api.example.com',
timeout: 5000
})
return () => h(ChildComponent, { config: staticConfig })
}
调试技巧:用 Vue DevTools 可视化虚拟 DOM
打开浏览器 DevTools 的 Vue 面板,你可以看到:
- 组件树结构
- 每个组件的 props、data、computed
- 虚拟 DOM 的变化历史
或者在代码里加 log:
setup() {
const count = ref(0)
// 监控 count 的变化
watch(count, (newVal) => {
console.log('count 变了:', newVal)
})
return () => h('div', count.value)
}
✏️ 练习题
练习 1(2 分钟):改标题
- 输入:把项目 1 的
message改成"你好 Vue" - 预期输出:页面标题变成"你好 Vue"
- 提示:直接在
setup里改const message = '你好 Vue'就行
练习 2(2 分钟):加个判断
- 输入:在项目 1 里,当
count >= 5时,按钮文字变成"不能再点了" - 预期输出:点够 5 次后,按钮文字变化
- 提示:用三元表达式:
count.value >= 5 ? '不能再点了' : '点我加一'
练习 3(3 分钟):新数据排序
- 输入:给项目 2 的待办清单添加一个功能:已完成的排到最后面
- 预期输出:列表顺序变成:买菜、洗碗、做饭(做饭因为 done:true 排到后面)
- 提示:在
sortedData的排序函数里加一个条件:done为 true 的权重更低
练习 4(5 分钟):串联两个项目
- 输入:把项目 2 的待办清单嵌入到项目 3 的表格里,作为每行的"操作列"
- 预期输出:表格的最后一列显示"完成/删除"按钮
- 提示:新建一个操作列的配置
column,类型用'actions',在TableCell里特殊处理
练习 5(5 分钟):分析报错
- 输入:运行下面的代码,看看会报什么错
const App = {
setup() {
const items = ref(['a', 'b', 'c'])
return () => h('ul', items.map(item => h('li', item)))
}
}
createApp(App).mount('#app')
- 预期输出:应该正常运行。但如果改成
return h('ul', items.map(...))(不加())会报错 - 提示:报错信息大概是 "Objects are not valid as a valid child"(对象不能作为有效的子元素)
作业:做一个「组件渲染调试器」
需求描述:做一个可视化工具,实时显示你创建的虚拟 DOM 结构。
功能点:
1. 输入一个虚拟 DOM 描述(可以用简化语法),实时渲染出对应的 UI
2. 显示这个虚拟 DOM 的嵌套树状结构图
3. 当你修改描述时,高亮显示哪些 DOM 节点发生了变化
加分项:
1. 支持导出/导入虚拟 DOM 配置(JSON 格式)
2. 支持查看 patch 算法的对比过程
验收标准:
- 能跑起来
- 输入简单配置后能正确渲染 UI
- 树状结构图清晰展示组件层级
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
一句话总结:Vue 3 的组件渲染管线是:模板 → 虚拟 DOM → patch 对比 → 真实 DOM,理解了这个流程,你就能看透大多数"界面不更新"的问题。
延伸学习资源:
1. Vue 3 官方文档 - 渲染函数——最权威的参考
2. Vue 3 Design RFCs——可以看到 Vue 每一个特性是怎么设计出来的
3. 《深入浅出 Vue.js》——刘建国著,对虚拟 DOM 和响应式讲得很透
互动钩子:你在实际项目里遇到过"改了数据但界面没更新"的情况吗?当时是怎么解决的?评论区聊聊,老粉优先回复!
📌 下章预告:学会了组件渲染,你有没有想过一个问题——
.vue文件里的<template>标签,Vue 是怎么"认识"它的?下一章「第 9.3 Vue 3 源码:编译器」我们将揭晓这个秘密。

评论(0)