渲染器
很多人学 Vue 时,更熟悉的是模板:
<div>{{ count }}</div>
但 Vue 真正把页面变出来,靠的不是模板本身,而是渲染器。
你可以把渲染器理解成这样一层:
给我一棵虚拟节点树,我来决定怎么把它挂到真实页面上,以及数据变化后怎么高效更新。
这张图可以帮助你快速区分渲染器最重要的两类工作:首次挂载,以及后续更新。
一、渲染器到底在做什么
渲染器主要做三件事:
- 把虚拟节点挂载成真实节点
- 在新旧节点之间做对比
- 用尽量少的 DOM 操作完成更新
简单说,它负责把“声明式 UI”落地成“命令式 DOM 操作”。
二、为什么需要虚拟节点
因为 Vue 不希望你每次更新都自己手动写:
const el = document.createElement('div')
el.textContent = String(count)
container.appendChild(el)
它希望你只描述“页面应该长什么样”:
h('div', null, String(count))
这个描述结果就是虚拟节点,也就是常说的 VNode。
三、VNode 可以简单理解成什么
你可以把它理解成“页面结构的 JavaScript 对象表示”。
例如:
const vnode = {
type: 'div',
props: { class: 'box' },
children: 'hello',
}
这不是 Vue 真实源码里的完整结构,但足够帮助理解:
type:节点类型props:属性children:子节点
四、渲染器的核心问题:第一次渲染和后续更新
渲染器最重要的判断其实很简单:
- 如果旧节点不存在,就是挂载
- 如果旧节点存在,就是更新
所以很多渲染逻辑,最终都会汇总到一个核心函数里:
patch(n1, n2, container)
这里通常约定:
n1:旧节点n2:新节点
五、patch 为什么是渲染器的中心
因为无论是元素、组件、文本节点、Fragment,本质上都要回答同一个问题:
旧的是什么?新的是什么?我该怎么最小代价地把旧的变成新的?
一个极简版本可以理解成:
function patch(n1, n2, container) {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
}
真实源码里当然会区分:
- 普通元素
- 组件
- 文本
- 注释
- Fragment
但主线不变。
六、挂载阶段发生了什么
假设第一次渲染的是:
h('div', { class: 'title' }, 'hello')
渲染器大致会做:
- 创建真实 DOM 节点
- 处理属性
- 处理子节点
- 插入到容器中
伪代码:
function mountElement(vnode, container) {
const el = document.createElement(vnode.type)
for (const key in vnode.props) {
el.setAttribute(key, vnode.props[key])
}
if (typeof vnode.children === 'string') {
el.textContent = vnode.children
}
container.appendChild(el)
}
七、更新阶段为什么更难
因为更新不能每次都把整棵 DOM 删掉重建。
否则虽然功能上也能跑,但性能会很差,用户体验也不好。
因此更新阶段真正要做的是:
- 比较新旧节点是否是同一种类型
- 如果不是,直接替换
- 如果是,尽量复用已有 DOM
- 只更新真正变化的部分
八、元素更新通常分成哪几步
1. 比较类型
如果旧节点是 div,新节点变成了 span,那通常就没必要继续细比了,直接替换更干脆。
2. 更新属性
例如:
- 新的有、旧的没有 -> 新增
- 新旧值不同 -> 更新
- 旧的有、新的没有 -> 删除
3. 更新子节点
这部分通常最复杂,因为子节点可能是:
- 文本
- 数组
- 空
并且数组和数组之间还会涉及列表 diff。
九、列表 diff 为什么是渲染器最难的一块
假设旧列表是:
[a, b, c]
新列表变成:
[b, a, d, c]
渲染器要解决的问题不是“我能不能更新”,而是:
怎样用尽量少的移动、插入、删除操作完成更新。
这也是为什么 Vue 的渲染器源码中,子节点 diff 会占据很重要的位置。
十、为什么 key 很重要
因为 key 帮渲染器识别“这个节点到底是不是同一个东西”。
没有 key 或 key 不稳定时,渲染器会更难判断:
- 哪些节点该复用
- 哪些节点该移动
- 哪些状态应该保留
所以 key 不是为了“消除警告”,而是为了帮助 diff 更准确。
十一、从源码角度,渲染器最值得看哪些东西
如果你要读 Vue 3 渲染器源码,最值得先建立下面几条主线:
1. createRenderer
这是渲染器工厂函数。
它很重要,因为 Vue 并没有把渲染器硬编码成“只能操作浏览器 DOM”。
相反,Vue 的设计是:
- 提供一套通用渲染流程
- 具体如何创建元素、插入元素、设置文本,交给宿主环境实现
这也是为什么 Vue 不只能渲染到浏览器,还能扩展到其他平台。
2. 宿主操作 host*
在源码里你会看到一组很重要的宿主方法,例如:
hostCreateElementhostInserthostSetElementTexthostPatchProphostRemove
这组方法把“通用渲染逻辑”和“具体平台 API”分开了。
3. patch
这是整个渲染流程的中枢。
无论是初次渲染还是更新,最终都会汇总到这里。
4. processElement 和 processComponent
源码里通常会按节点类型拆分处理逻辑:
- 元素走元素逻辑
- 组件走组件逻辑
这让渲染器结构更清晰。
十二、一个简化版源码脉络
可以把渲染器主流程压缩成这样:
function createRenderer(options) {
const { createElement, insert, patchProp, setElementText } = options
function mountElement(vnode, container) {
const el = vnode.el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
}
if (vnode.props) {
for (const key in vnode.props) {
patchProp(el, key, null, vnode.props[key])
}
}
insert(el, container)
}
function patch(n1, n2, container) {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
}
return {
render(vnode, container) {
patch(null, vnode, container)
},
}
}
真实渲染器会复杂很多,但这段已经足够帮助你抓主线:
- 创建
- 挂载
- 对比
- 更新
十三、渲染器和响应式是什么关系
它们不是同一个东西,但配合得非常紧。
- 响应式负责发现“数据变了”
- 组件更新 effect 负责重新执行 render
- 渲染器负责把新旧
VNode做对比并更新 DOM
也就是说:
响应式解决“什么时候要更新”,渲染器解决“怎么更新”。
十四、读渲染器源码时最容易卡住的点
1. 一开始就钻列表 diff 细节
这会很容易把自己看晕。
更好的顺序是:
- 先看
createRenderer - 再看
patch - 再看元素挂载
- 再看元素更新
- 最后再看 keyed diff
2. 把组件逻辑和元素逻辑混在一起
组件是组件,元素是元素。先把元素渲染主线看清楚,再去看组件挂载会更轻松。
总结
渲染器最核心的价值是:
- 把声明式 UI 变成真实视图
- 在更新时尽可能复用已有 DOM
- 通过 diff 减少不必要操作
你真正要抓住的不是每个细节分支,而是这条主线:
- 先有
VNode - 再由
patch决定挂载还是更新 - 最后通过宿主 API 操作真实平台
