Vue 3 的渲染系统是整个框架的另一大核心。它负责将组件的状态转化为可交互的 DOM,并在状态变更时高效地更新视图。本文会从 h 函数创建 VNode 开始,一直深入到 renderer 的挂载与更新流程,以及那个被誉为"vue-next"精华的快速 Diff 算法实现。
一、VNode 的诞生:h 函数与 createVNode
当你使用 h('div', { id: 'app' }, 'Hello') 时,背后调用的是 createVNode 函数。这是一个精心设计的工厂函数,负责规范化各种参数,生成描述视图结构的 JavaScript 对象。
ts
typescript
// packages/runtime-core/src/vnode.ts 简化
function createVNode(type, props, children) {
// 规范化子节点,确保 children 始终是数组或字符串等标准形式
if (isVNode(children)) {
children = [children]
}
const vnode = {
__v_isVNode: true,
type, // 'div' 或 组件对象
props,
children,
el: null, // 对应的真实 DOM,挂载后赋值
key: props?.key ?? null,
component: null, // 组件实例
shapeFlag: getShapeFlag(type),
patchFlag: 0, // 编译优化的靶向标记
dynamicChildren: null // 动态子节点数组
}
// 根据 children 类型设置子节点标志位
if (Array.isArray(children)) {
vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
} else if (typeof children === 'string') {
vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
}
// 对动态节点进行标记 (由编译器生成的代码中携带)
if (currentBlock && vnode.patchFlag > 0) {
currentBlock.push(vnode)
}
return vnode
}
这里的 shapeFlag 用一个数字的二进制位高效记录了 VNode 自身类型和子节点类型,后续 diff 时可以直接按位运算判断,避免多次 typeof 检查。例如判断是否含有数组子节点只需 vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN。
二、渲染器的骨架:createRenderer
Vue 3 的渲染器通过 createRenderer 生成,它接收平台相关的节点操作函数(如 createElement、insert 等),返回 render 函数。这种跨平台设计使得 Vue 可以轻松适配 DOM、Canvas 甚至是原生小程序。
ts
scss
function createRenderer(options) {
const {
createElement,
setElementText,
patchProp,
insert,
remove,
} = options
function render(vnode, container) {
if (vnode === null) {
// 卸载
if (container._vnode) {
unmount(container._vnode, null)
}
} else {
// 初次挂载或更新
patch(container._vnode || null, vnode, container)
}
container._vnode = vnode
}
function patch(n1, n2, container, anchor = null) {
if (n1 === n2) return
// 类型不同直接替换
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type, shapeFlag } = n2
switch (type) {
case Text:
processText(n1, n2, container)
break
case Fragment:
processFragment(n1, n2, container)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container, anchor)
}
}
}
return { render }
}
这个 patch 函数就是一切更新的入口。它通过 n1 是否为空判断挂载还是更新,并根据 VNode 类型分发到不同处理流程。processElement 则是处理普通 DOM 元素的核心。
三、元素挂载与更新:mountElement 与 patchElement
mountElement 负责将 VNode 转为真实 DOM 并插入页面,同时处理子节点的递归挂载。
ts
scss
function mountElement(vnode, container, anchor) {
const el = (vnode.el = createElement(vnode.type))
const { props, children, shapeFlag } = vnode
// 挂载属性
if (props) {
for (const key in props) {
patchProp(el, key, null, props[key])
}
}
// 挂载子节点
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
setElementText(el, children)
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(children, el)
}
insert(el, container, anchor)
}
function mountChildren(children, container) {
children.forEach(child => {
patch(null, child, container)
})
}
更新元素节点时,patchElement 体现了大量编译优化。它会优先检查 dynamicChildren,实现靶向更新。
ts
scss
function patchElement(n1, n2) {
const el = (n2.el = n1.el)
const oldProps = n1.props || {}
const newProps = n2.props || {}
// 1. 更新 props
patchProps(el, n2, oldProps, newProps)
// 2. 如果存在动态子节点,跳过静态内容,直接比对动态部分
if (n2.dynamicChildren) {
patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren, el)
} else {
// 否则全量更新子节点
patchChildren(n1, n2, el)
}
}
patchBlockChildren 仅仅是遍历 dynamicChildren 数组,一一对应地调用 patch,因为编译器保证了动态节点数组结构稳定,无需进行复杂的移动比对。
四、Diff 算法的核心:快速 Diff 的实现
当新旧子节点都是数组且没有 dynamicChildren 优化时,会进入完整的 Diff 流程。Vue 3 采用了快速 Diff 算法,它从两端向中间收缩,尽力复用稳定区间的节点,仅对中间的乱序部分进行最小操作。
ts
ini
function patchKeyedChildren(c1, c2, container) {
let i = 0
let e1 = c1.length - 1
let e2 = c2.length - 1
// 1. 从左端开始匹配相同节点
while (i <= e1 && i <= e2 && c1[i].key === c2[i].key) {
patch(c1[i], c2[i], container)
i++
}
// 2. 从右端开始匹配相同节点
while (i <= e1 && i <= e2 && c1[e1].key === c2[e2].key) {
patch(c1[e1], c2[e2], container)
e1--
e2--
}
// 3. 仅有新增节点
if (i > e1) {
while (i <= e2) {
patch(null, c2[i], container)
i++
}
}
// 4. 仅有卸载节点
else if (i > e2) {
while (i <= e1) {
unmount(c1[i])
i++
}
}
// 5. 中间存在乱序区域
else {
const s1 = i
const s2 = i
const keyToNewIndexMap = new Map()
// 构建新子节点中未处理部分的 key 到 index 的映射
for (i = s2; i <= e2; i++) {
if (c2[i].key !== null) {
keyToNewIndexMap.set(c2[i].key, i)
}
}
// 处理旧节点
let patched = 0
const toBePatched = e2 - s2 + 1
const newIndexToOldIndexMap = new Array(toBePatched).fill(0)
for (i = s1; i <= e1; i++) {
const oldVNode = c1[i]
if (patched >= toBePatched) {
unmount(oldVNode)
continue
}
const newIndex = keyToNewIndexMap.get(oldVNode.key)
if (newIndex === undefined) {
// 旧节点在新列表中不存在,卸载
unmount(oldVNode)
} else {
// 记录下新位置对应的旧位置,用于求最长递增子序列
newIndexToOldIndexMap[newIndex - s2] = i + 1
patch(oldVNode, c2[newIndex], container)
patched++
}
}
// 移动和挂载
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
let j = increasingNewIndexSequence.length - 1
for (i = toBePatched - 1; i >= 0; i--) {
const newIndex = s2 + i
const newVNode = c2[newIndex]
const anchor = newIndex + 1 < c2.length ? c2[newIndex + 1].el : null
if (newIndexToOldIndexMap[i] === 0) {
// 全新节点,挂载
patch(null, newVNode, container, anchor)
} else if (i !== increasingNewIndexSequence[j]) {
// 不在最长递增子序列中,需要移动
container.insertBefore(newVNode.el, anchor)
} else {
j--
}
}
}
}
这套算法的精妙之处在于仅对混乱区域动用移动逻辑,并利用最长递增子序列 最大程度保留不动的节点,将 DOM 移动次数降至最低。getSequence 返回的是可以保持不变的新索引数组下标序列,其余节点才需要移动。
五、组件渲染与调度
组件级别的更新最终也落在这套 Diff 上。Vue 3 会为每个组件创建一个渲染 effect,当组件内部响应式数据变化时,effect.scheduler 把组件的更新函数加入微任务队列。队列清空时,批量执行所有更新,调用组件 render 生成新的子 VNode 树,再作为 patch 的入参去比对。这就是整个视图更新链路闭环:响应式触发 ------ 组件更新入队 ------ 生成新 VNode ------ 快速 Diff 更新 DOM。
六、总结
Vue 3 的渲染器通过分层设计、标志位加速判断、Block Tree 收集动态节点以及快速 Diff 算法,在保持虚拟 DOM 灵活性的同时,极大缩小了更新时的遍历范围。理解 createVNode、patch、patchKeyedChildren 这些底层函数,不仅有助于读懂框架源码,更能让我们在写模板时,有意识地利用 key 和静态结构帮助编译器生成更优的更新路径。