12_第_7_章_渲染器的设计
约 5759 字大约 19 分钟
2026-10-05
在第 3 章中,我们初次认识了虚拟 DOM 和渲染器,并写了一个迷你版的渲染器练了练手。从这里开始,就要认真、详细地讨论渲染器是怎么实现的了。
你会发现,渲染器其实是 Vue.js 中非常重要的一块。很多你平时用的功能——Transition 组件、Teleport 组件、Suspense 组件、template ref、自定义指令等等——全都依赖渲染器才能工作。
更重要的是,渲染器的实现直接决定框架的性能。Vue.js 3 的渲染器不仅有传统的 Diff 算法,还有独创的“快捷路径”更新方式,能充分利用编译器提供的信息,让更新速度快到飞起。
在正式开讲之前,先把本章会反复出现的两个词立住。
Diff 算法(diff algorithm):
拿新旧两棵虚拟 DOM 树做对比,找出“哪里变了、哪里没变”的算法。名字来自英文 different(不同),起得非常直白。
打个比方:你要把一篇作文的第 3 段“高兴”改成“开心”,不可能把整篇推倒重写 —— 你只需要找出差异(第 3 段那个“高兴”),然后只改这一处。Diff 算法干的就是“找出差异”这一步,而怎么高效地找出差异,正是第 9、10、11 三章的主题。
编译器(compiler):
在你代码还没运行之前,先把源码“翻译”一遍的程序。翻译的结果通常更贴近机器,执行起来更快。
打个比方:编译器就像图纸翻译员 —— 你画的是“这儿放个书架、那儿开个窗”(人话),翻译完变成工人能直接照着干的施工指令。运行时不用再猜你的意思,照着指令干就行。
本章提到的“快捷路径”就是靠这个翻译结果实现的:编译器提前把“哪些节点会变”写在图纸上,运行时就不用挨个房间检查了。
虽然渲染器代码量大、设计复杂,但核心思路其实并不复杂。下面我们就从“渲染器怎么和响应系统结合”开始,一步一步把它拆给你看。
7.1 渲染器与响应系统的结合
顾名思义,渲染器就是用来执行渲染任务的家伙。在浏览器里,它的工作是渲染真实的 DOM 元素(也就是你在网页上能看到的方块、文字、图片)。
但渲染器的作用不只是渲染 DOM。它还是 Vue.js 跨平台能力的关键——同一套渲染器,稍微改改就能渲染到别的地方去(比如 Canvas、小程序、甚至终端命令行)。
既然要设计一个“通用”的渲染器,就一定要把“哪些动作是可定制的”想清楚。
一个最最简单、但确实能跑的渲染器
我们暂时只关心浏览器平台。既然渲染器的工作是渲染真实 DOM,那么下面这个 3 行的小函数,其实就是一个“勉强合格”的渲染器:
function renderer(domString, container) {
container.innerHTML = domString
}我们逐行看:
function renderer(domString, container):定义一个叫renderer的函数,它接收两个“原料”:domString(一段 HTML 字符串)和container(一个真实的 DOM 元素,挂载点)。container.innerHTML = domString:把那段 HTML 字符串直接塞进容器里。innerHTML你可以理解为“这个盒子里面的内容”。
下面来用一下:
renderer('<h1>Hello</h1>', document.getElementById('app'))调用 renderer('<h1>Hello</h1>', document.getElementById('app')) 之后,只要页面上确实有 id="app" 的元素,<h1>Hello</h1> 就会出现在那个元素里。
静态字符串太无聊了。我们来看看动态拼接的 HTML:
let count = 1
renderer(`<h1>${count}</h1>`, document.getElementById('app'))用模板字符串(反引号)把 count 拼进去,最终渲染出来就是 <h1>1</h1>。
让渲染器“自动”工作
注意看上面代码里的变量 count。如果它是一个响应式数据(也就是第二篇讲过的、会“自动通知变化”的数据),会发生什么?让我们用第二篇的响应式 API 试一试:
const count = ref(1)
effect(() => {
renderer(`<h1>${count.value}</h1>`, document.getElementById('app'))
})
count.value++我们逐行看:
const count = ref(1):用ref创建一个响应式数据,初始值是1。ref是 reference 的缩写,它把一个普通数字“包裹”成“会自己叫唤”的数据。effect(() => { ... }):这里的effect是第二篇讲过的副作用函数 —— 一个会自动运行的函数,它会自动追踪里面用到的响应式数据。当那些数据变化时,它会自动重新执行。renderer(`<h1>${count.value}</h1>`, ...):在副作用函数里调用渲染器。每次副作用函数运行时,都会重新渲染一次。count.value++:把响应式数据count加一。这一步会触发副作用函数重新执行,于是渲染器又跑一次,最终用户看到<h1>2</h1>。
结论:响应系统负责“数据变了就叫人”,渲染器负责“把人叫来把页面画出来”。 这两者一结合,就实现了数据一变、页面自动更新的效果。
用 @vue/reactivity 来真正上手
从这一章开始,我们会用 @vue/reactivity 包提供的响应式 API 来写代码。这个包在第二篇里已经讲过原理了。@vue/reactivity 提供了 IIFE 模块格式(IIFE = Immediately Invoked Function Expression,立即执行函数表达式,是一种“加载后立刻能用”的模块写法),所以可以直接通过 <script> 标签在浏览器里用:
<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>它暴露的全局 API 名叫 VueReactivity,所以完整代码长这样:
const { effect, ref } = VueReactivity
function renderer(domString, container) {
container.innerHTML = domString
}
const count = ref(1)
effect(() => {
renderer(`<h1>${count.value}</h1>`, document.getElementById('app'))
})
count.value++注意第一行 const { effect, ref } = VueReactivity:
- 解构赋值(destructuring):从
VueReactivity这个大对象里,把effect和ref这两个函数“拿出来”用。这就好比从一盒糖里只挑出你喜欢的那几颗。
可以看到,我们通过 VueReactivity 拿到了 effect 和 ref 这两个 API。
7.2 渲染器的基本概念
理解渲染器涉及的基本概念,能帮我们看懂后面的所有内容。这一节我们来过一遍术语,每个都配上大白话解释和代码例子。
渲染器(renderer):
我们通常用英文 renderer 来表达“渲染器”。
⚠️ 千万别把它和
render弄混。
| 词 | 词性 | 含义 |
|---|---|---|
renderer | 名词 | 渲染器 —— 一个工具、一段代码 |
render | 动词 | 渲染 —— 一个动作 |
渲染器的作用是把虚拟 DOM 渲染为特定平台上的真实元素。在浏览器里,它把虚拟 DOM 变成真实 DOM。
虚拟 DOM(Virtual DOM):
用 JavaScript 对象来“描述”真实 DOM 的样子。它不是真的 DOM 元素,只是一个“用 JS 对象写的 DOM 草图”。
为什么需要虚拟 DOM? 想象一下:你盖房子之前要先画图纸。图纸不是真实的房子,但它能告诉你“这个房间该有多大、门该往哪开”。虚拟 DOM 就是真实 DOM 的“图纸”。
虚拟 DOM 通常用英文 virtual DOM 来表达,有时会简写成 vdom。虚拟 DOM 和真实 DOM 结构一样,都是由一个个节点组成的树型结构。所以我们经常会听到“虚拟节点”这个词,即 virtual node,有时会简写成 vnode。
| 说法 | 英文全称 | 常见简写 |
|---|---|---|
| 虚拟 DOM | virtual DOM | vdom |
| 虚拟节点 | virtual node | vnode |
虚拟 DOM 是树型结构,这棵树里的任何一个 vnode 都可以是一棵子树,所以 vnode 和 vdom 有时可以混着使用。为了避免困惑,本书统一使用 vnode 这个名字。
挂载(mount):
渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程。
比如 Vue.js 组件中的 mounted 钩子,就是在挂载完成时触发的。“mounted” 字面意思就是“已经被挂上了”,也就是真实 DOM 已经出现在页面上了。在 mounted 钩子里就可以访问真实 DOM 元素了。理解这些名词,有助于我们更好地理解框架的 API 设计。
渲染器把真实 DOM 挂到哪里呢?渲染器自己不知道该往哪里挂。所以通常需要给它传一个“挂载点”。
容器 / 挂载点(container):
渲染器把内容塞进去的那个 DOM 元素。
生活中的比喻:它就像一个“相框”,渲染器把图画画好之后挂上去。
我们通常用英文 container 来表达容器。
上文分别阐述了渲染器、虚拟 DOM、挂载以及容器等概念。为了便于理解,下面来看一段最小可用的代码:
function createRenderer() {
function render(vnode, container) {
// ...
}
return render
}我们逐行看:
function createRenderer():定义一个叫createRenderer的工厂函数 —— 一个用来“生产”东西的函数。createRenderer就是“生产渲染器的工厂”。function render(vnode, container) { ... }:在工厂里面定义一个render函数。render接收一个虚拟节点vnode和一个容器container,然后把 vnode 渲染到 container 里。return render:把render函数返回出去,这样外面就能拿到它去用了。
为什么用 createRenderer 而不是 render
你可能会问:为什么要有 createRenderer?直接定义一个 render 不行吗?
还真不行。因为渲染器是一个比“渲染”更宽泛的概念。渲染器不仅能“渲染”,还能激活已有的 DOM 元素(这个过程叫 hydrate,用在服务端渲染的场景)。所以用 createRenderer 来“打包”会更合理:
function createRenderer() {
function render(vnode, container) {
// ...
}
function hydrate(vnode, container) {
// ...
}
return {
render,
hydrate
}
}可以看到,createRenderer 返回的对象里既有 render(渲染),也有 hydrate(激活)。hydrate 函数(hydrate 来自英文 hydrate,意思是“注水、激活”)会把服务端渲染好的“半成品”页面在客户端“激活”成可交互的页面。
实际上,在 Vue.js 3 中,连创建应用的 createApp 函数也是渲染器的一部分。createApp:用来创建一个 Vue 应用的入口函数。你在 Vue 项目里写的 createApp(App).mount('#app'),里面的 createApp 就是渲染器家族的一员。
有了渲染器,就可以用它来干活了:
const renderer = createRenderer()
// 首次渲染
renderer.render(vnode, document.querySelector('#app'))我们来看:
const renderer = createRenderer():调用工厂函数,得到一个渲染器实例。renderer.render(vnode, document.querySelector('#app')):调用渲染器的render方法。第一次调用时,只需创建新 DOM 元素就行,这个过程叫挂载(mount)。
挂载与更新(patch)
但事情没那么简单。多次在同一个 container 上调用 renderer.render 时,渲染器要做的事情就不只是“挂载”了,还要“更新”。
const renderer = createRenderer()
// 首次渲染
renderer.render(oldVNode, document.querySelector('#app'))
// 第二次渲染
renderer.render(newVNode, document.querySelector('#app'))第一次渲染时已经把 oldVNode 渲染到 container 里了,所以再次调用渲染并传入 newVNode 时,就不能简单“挂载”了。
这种情况下,渲染器会拿 newVNode 和上一次渲染的 oldVNode 做对比,试图找出“哪些地方变了”,然后只更新变化的地方。这个过程叫作“打补丁”(或更新),英文通常用 patch 来表达。
打补丁(patch):
拿新旧两个 vnode 对比之后,只在变化的地方做修改,而不是把已有的东西推倒重做。
生活中的比喻:缝补衣服上的洞 —— 旧的东西(oldVNode)已经在那儿了,新的东西(newVNode)来了,我们把两者对比一下,只在“有洞的地方”补一补,而不是把整件衣服扔了重做。
小细节:挂载动作本身也可以看成一种特殊的打补丁——它的特殊之处在于旧的 vnode 不存在。所以不用太较真“挂载”和“打补丁”这两个词的区别。
render 函数和 patch 函数
下面给出 render 函数的基本实现(暂时简化):
function createRenderer() {
function render(vnode, container) {
if (vnode) {
// 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
// 只需要将 container 内的 DOM 清空即可
container.innerHTML = ''
}
}
// 把 vnode 存储到 container._vnode 下,即后续渲染中的旧 vnode
container._vnode = vnode
}
return {
render
}
}我们逐行看:
function render(vnode, container):定义渲染函数。vnode是新的虚拟节点,container是容器。if (vnode) { ... } else { ... }:分支判断。如果传入了新vnode,就走打补丁流程;如果没传(传了null),就走卸载流程。patch(container._vnode, vnode, container):调用patch函数进行打补丁。第一个参数是“上一次的 vnode”(旧 vnode),第二个是“这次的 vnode”(新 vnode)。container.innerHTML = '':清空容器(暂时这样写,后面会改进)。container._vnode = vnode:把当前的 vnode 存到容器的_vnode属性里,留作下一次渲染时的“旧 vnode”。这就像记账——记下“上次的结果”,下次用得上。
两个函数的分工可以这样记:
render | patch | |
|---|---|---|
| 接收什么 | vnode、container | n1(旧 vnode)、n2(新 vnode)、container |
| 做什么 | 判断这次走打补丁还是走卸载,然后把 vnode 存到 container._vnode | 拿 n1 和 n2 对比:n1 不存在就挂载,存在就打补丁 |
| 谁调谁 | 调用 patch | 被 render 调用 |
三次渲染的执行流程
我们来连续调用三次渲染函数看看:
const renderer = createRenderer()
// 首次渲染
renderer.render(vnode1, document.querySelector('#app'))
// 第二次渲染
renderer.render(vnode2, document.querySelector('#app'))
// 第三次渲染
renderer.render(null, document.querySelector('#app'))上面三次调用,按“分支”看分别落在哪一条:
如果按时序看:
第一次渲染:容器里啥也没有(container._vnode 是 undefined),所以渲染器会把 vnode1 渲染成真实 DOM。渲染完成后,vnode1 会被存到 container._vnode 里。
第二次渲染:旧 vnode(vnode1)已经存在了。这时渲染器会把 vnode2 作为新 vnode,把它们俩一起传给 patch 函数去打补丁。
第三次渲染:新 vnode 是 null,啥也不渲染。但容器里还有 vnode2 渲染出来的内容,所以渲染器要把容器清空。这里用 container.innerHTML = '' 清空容器。
⚠️ 这种写法有缺陷(后面会讲怎么改进),不过先凑合着用。
patch 函数的签名
注意上面的代码里,patch 函数是这样被调用的:
patch(container._vnode, vnode, container)它的签名长这样:
function patch(n1, n2, container) {
// ...
}| 参数 | 是什么 |
|---|---|
n1 | 旧 vnode |
n2 | 新 vnode |
container | 容器 |
首次渲染时,container._vnode 不存在(即 undefined),所以传给 patch 的第一个参数 n1 也是 undefined。这时 patch 会执行挂载动作——忽略 n1,直接把 n2 描述的内容渲染到容器里。
结论:patch 函数既能打补丁,也能挂载——它就是渲染器的“万能入口”。
7.3 自定义渲染器
渲染器不仅能把虚拟 DOM 渲染到浏览器里。如果我们把渲染器设计成“可配置的通用渲染器”,就能让它渲染到任意目标平台上。
这一节我们以浏览器为目标平台,写一个渲染器,然后看看哪些东西可以抽象出来,最后把这些浏览器特有的 API 抽离掉,就能让渲染器跨平台了。
渲染一个 <h1> 标签
我们从渲染一个普通的 <h1> 标签开始。可以用这样的 vnode 对象来描述一个 <h1>:
const vnode = {
type: 'h1',
children: 'hello'
}我们来看这个对象:
type: 'h1':用type属性描述 vnode 的类型。这里是字符串'h1',意思是“这是一个普通的<h1>标签”。children: 'hello':用children描述该节点的内容。这里是字符串'hello',意味着标签里塞的是一行文字。
对于这样的 vnode,可以用 render 函数渲染它:
const vnode = {
type: 'h1',
children: 'hello'
}
// 创建一个渲染器
const renderer = createRenderer()
// 调用 render 函数渲染该 vnode
renderer.render(vnode, document.querySelector('#app'))补上 patch 函数
要把渲染工作搞定,还得写 patch 函数:
function createRenderer() {
function patch(n1, n2, container) {
// 在这里编写渲染逻辑
}
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
container.innerHTML = ''
}
}
container._vnode = vnode
}
return {
render
}
}如上所示,patch 函数也写在 createRenderer 函数内部。后面的讲解里,如果没有特别说明,写的函数都是定义在 createRenderer 函数里的。
它的具体实现是这样的:
function patch(n1, n2, container) {
// 如果 n1 不存在,意味着挂载,则调用 mountElement 函数完成挂载
if (!n1) {
mountElement(n2, container)
} else {
// n1 存在,意味着打补丁,暂时省略
}
}我们逐行看:
function patch(n1, n2, container):接收三个参数——旧 vnode、新 vnode、容器。if (!n1):判断旧 vnode 是否不存在。!n1是“n1 为假”的意思(undefined、null、0、空字符串都算“假”)。mountElement(n2, container):如果旧 vnode 不存在,就调用mountElement完成挂载。else { ... }:旧 vnode 存在就走打补丁分支,先留空。
mountElement 函数的实现是这样的:
function mountElement(vnode, container) {
// 创建 DOM 元素
const el = document.createElement(vnode.type)
// 处理子节点,如果子节点是字符串,代表元素具有文本节点
if (typeof vnode.children === 'string') {
// 因此只需要设置元素的 textContent 属性即可
el.textContent = vnode.children
}
// 将元素添加到容器中
container.appendChild(el)
}我们逐行看:
const el = document.createElement(vnode.type):调用浏览器 APIcreateElement,根据 vnode 的type(如'h1')创建出一个真实的 DOM 元素。if (typeof vnode.children === 'string'):判断子节点是不是字符串。el.textContent = vnode.children:是字符串就直接设为元素的文本内容。textContent是 DOM 元素的“内容文字”。container.appendChild(el):把创建好的元素塞到容器里。appendChild把一个 DOM 元素“挂”到某个父元素下面,挂在最后面。
挂载一个普通标签元素就完成了。
但是这段代码有个大问题:我们的目标是写一个不依赖浏览器平台的通用渲染器。然而 mountElement 函数里调用了大量浏览器特有的 API:
document.createElement:浏览器才有document对象。el.textContent:这是 DOM 元素的属性。container.appendChild:这是 DOM 元素的 API。
想要设计通用渲染器,第一步就是把浏览器特有的 API 抽离。
把 DOM API 抽离成配置项
怎么做呢?把这些操作 DOM 的 API 作为配置项传给 createRenderer:
// 在创建 renderer 时传入配置项
const renderer = createRenderer({
// 用于创建元素
createElement(tag) {
return document.createElement(tag)
},
// 用于设置元素的文本节点
setElementText(el, text) {
el.textContent = text
},
// 用于在给定的 parent 下添加指定元素
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
}
})可以看到,我们把“操作 DOM 的 API”封装成一个对象,作为参数传给 createRenderer。这样,mountElement 等函数就可以通过配置项来拿到这些 API 了:
function createRenderer(options) {
// 通过 options 得到操作 DOM 的 API
const {
createElement,
insert,
setElementText
} = options
// 在这个作用域内定义的函数都可以访问那些 API
function mountElement(vnode, container) {
// ...
}
function patch(n1, n2, container) {
// ...
}
function render(vnode, container) {
// ...
}
return {
render
}
}抽离前后的对应关系是这样的:
| 原来的浏览器 API | 抽离后的配置项 | 配置项内部做的事 |
|---|---|---|
document.createElement | createElement(tag) | document.createElement(tag) |
el.textContent | setElementText(el, text) | el.textContent = text |
container.appendChild | insert(el, parent, anchor = null) | parent.insertBefore(el, anchor) |
我们来看一下 createRenderer 函数:
function createRenderer(options):接收一个options对象,里面装着“操作 DOM 的方法”。const { createElement, insert, setElementText } = options:从 options 里解构出我们需要的三个函数。解构就是把对象里的属性拆出来变成独立的变量。- 在
createRenderer内部定义的函数(如mountElement、patch、render)都可以访问createElement、insert、setElementText,因为它们都在同一个作用域里。作用域(scope)就是“这一块代码能看到的变量范围”。
接着,我们用从配置项里拿到的 API 重新实现 mountElement:
function mountElement(vnode, container) {
// 调用 createElement 函数创建元素
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
// 调用 setElementText 设置元素的文本节点
setElementText(el, vnode.children)
}
// 调用 insert 函数将元素插入到容器内
insert(el, container)
}重构后的 mountElement 在功能上没有任何变化。不同的是,它不再直接依赖浏览器特有的 API 了。只要传入不同的配置项,就能完成非浏览器环境下的渲染工作。
一个打印日志的自定义渲染器
为了证明这点,我们可以实现一个只打印渲染流程、不真渲染的自定义渲染器:
const renderer = createRenderer({
createElement(tag) {
console.log(`创建元素 ${tag}`)
return { tag }
},
setElementText(el, text) {
console.log(`设置 ${JSON.stringify(el)} 的文本内容:${text}`)
el.textContent = text
},
insert(el, parent, anchor = null) {
console.log(`将 ${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)} 下`)
parent.children = el
}
})观察上面这段代码。在调用 createRenderer 创建 renderer 时,传入了不同的配置项:
createElement(tag):不再调用浏览器的document.createElement,而是直接返回一个普通对象{ tag },并把它当作“创建出来的 DOM 元素”。setElementText(el, text):不调用浏览器 API,只是打印“正在设置文本”。insert(el, parent):也不调用浏览器 API,只是打印“正在插入元素”。
这样,我们就实现了一个自定义渲染器。可以这样测试它:
const vnode = {
type: 'h1',
children: 'hello'
}
// 使用一个对象模拟挂载点
const container = { type: 'root' }
renderer2.render(vnode, container)因为这个自定义渲染器不依赖浏览器特有的 API,所以这段代码不仅能在浏览器里跑,还能在 Node.js 里跑。下面是它在浏览器控制台里的运行结果:

图 7-1 渲染器的运行结果
自定义渲染器不是“黑魔法”
现在你应该对自定义渲染器有了更直观的认识。它不是黑魔法,只是通过抽象的手段,把核心代码和平台特有的 API 解耦,再通过支持个性化配置来实现跨平台。
结论:这就是软件工程里常说的“依赖倒置”思想。 核心代码不直接依赖具体的实现,而是依赖抽象(这里的“抽象”就是 createElement 等函数的“约定”)。这样一来,浏览器、Node.js、小程序、Canvas、游戏引擎……谁来实现这些 API,渲染器就能在谁那里跑。
本章小结
- 渲染器(renderer)是 Vue.js 中负责“把虚拟 DOM 变成真实 DOM”的核心模块。
- 渲染器与响应系统结合后,就能实现“数据变→页面自动更新”的魔法。
- 虚拟 DOM(vnode)就是用普通 JS 对象描述的 DOM 草图;挂载(mount)是第一次渲染;打补丁(patch)是后续更新;容器(container)是挂载点。
patch函数是渲染器的核心入口,它既能挂载也能打补丁。- 通过把浏览器特有的 API 抽离成配置项,渲染器就能跨平台运行——这就是自定义渲染器的核心思想。
- 在 Vue.js 里,
Transition、Teleport、Suspense、自定义指令等高级特性,全都建立在渲染器之上。
下一章预告:第 8 章我们要把渲染器的“挂载”和“更新”做得更完整——子节点怎么挂、属性怎么处理、事件怎么绑、卸载怎么搞,全都会讲到。
