Vue3 自定义渲染器与虚拟 DOM Diff 算法深度解析

2237 字
11 分钟
Vue3 自定义渲染器与虚拟 DOM Diff 算法深度解析

Vue3 自定义渲染器与虚拟 DOM Diff 算法深度解析#

Vue3 的渲染器(Renderer)是框架的另一个核心模块。它负责将虚拟 DOM 转换为真实 DOM,并在更新时通过 Diff 算法最小化 DOM 操作。本文将深入解析 Vue3 的渲染器架构和最长递增子序列(LIS)Diff 算法。

一、虚拟 DOM 的本质#

虚拟 DOM 本质上就是用 JavaScript 对象描述真实 DOM:

// 真实 DOM
// <div id="app" class="container">
// <p>Hello</p>
// <span>World</span>
// </div>
// 虚拟 DOM(VNode)
const vnode = {
type: 'div',
props: {
id: 'app',
class: 'container'
},
children: [
{ type: 'p', children: 'Hello' },
{ type: 'span', children: 'World' }
]
}

为什么需要虚拟 DOM?#

不是因为它快——直接操作 DOM 在简单场景下更快。虚拟 DOM 的价值在于:

  1. 声明式编程:开发者描述”是什么”,框架处理”怎么做”
  2. 跨平台能力:同一套 VNode 可以渲染到 DOM、Canvas、Native
  3. 最小化更新:通过 Diff 找出最少的 DOM 操作

二、自定义渲染器架构#

Vue3 最精妙的设计之一是渲染器与平台解耦

function createRenderer(options) {
// 平台相关的 API 由外部传入
const {
createElement,
setElementText,
insert,
patchProps,
remove,
createText,
setText
} = options
// 渲染器核心逻辑
function render(vnode, container) {
if (vnode) {
patch(container._vnode || null, vnode, container)
} else if (container._vnode) {
unmount(container._vnode)
}
container._vnode = vnode
}
function patch(n1, n2, container, anchor = null) {
// 类型不同,直接卸载旧节点
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// 普通元素
if (!n1) {
mountElement(n2, container, anchor)
} else {
patchElement(n1, n2)
}
} else if (typeof type === 'object') {
// 组件
if (!n1) {
mountComponent(n2, container, anchor)
} else {
patchComponent(n1, n2)
}
} else if (type === Text) {
// 文本节点
if (!n1) {
const el = n2.el = createText(n2.children)
insert(el, container)
} else {
const el = n2.el = n1.el
if (n2.children !== n1.children) {
setText(el, n2.children)
}
}
}
}
return { render }
}

浏览器渲染器#

const browserRenderer = createRenderer({
createElement(tag) {
return document.createElement(tag)
},
setElementText(el, text) {
el.textContent = text
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
},
remove(el) {
const parent = el.parentNode
if (parent) parent.removeChild(el)
},
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 事件处理
const name = key.slice(2).toLowerCase()
const invokers = el._vei || (el._vei = {})
let invoker = invokers[name]
if (nextValue) {
if (!invoker) {
invoker = el._vei[name] = (e) => {
// 事件触发时间早于绑定时间,忽略
if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
invoker.attached = performance.now()
el.addEventListener(name, invoker)
} else {
// 更新事件:只需更新 value,无需 remove/add
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// className 性能最好
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key)) {
el[key] = nextValue
} else {
el.setAttribute(key, nextValue)
}
},
createText(text) {
return document.createTextNode(text)
},
setText(el, text) {
el.nodeValue = text
}
})

Canvas 渲染器(示例)#

const canvasRenderer = createRenderer({
createElement(tag) {
// 返回一个虚拟的 canvas 元素对象
return { tag, props: {}, children: [], x: 0, y: 0 }
},
setElementText(el, text) {
el.text = text
},
insert(el, parent) {
parent.children.push(el)
// 重新绘制 canvas
drawCanvas(parent)
},
// ... 其他 API
})

三、Diff 算法:核心中的核心#

当新旧 VNode 都有子节点时,需要 Diff 算法找出最小更新操作。

3.1 简单 Diff#

最直觉的方式——逐个比较:

function patchChildren_simple(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
const commonLength = Math.min(oldChildren.length, newChildren.length)
// 1. 比较公共部分
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i])
}
// 2. 新节点多,挂载
if (newChildren.length > commonLength) {
for (let i = commonLength; i < newChildren.length; i++) {
patch(null, newChildren[i], container)
}
}
// 3. 旧节点多,卸载
if (oldChildren.length > commonLength) {
for (let i = commonLength; i < oldChildren.length; i++) {
unmount(oldChildren[i])
}
}
}

问题:当节点只是顺序变了,也会全部卸载重建。

3.2 双端 Diff(Vue2 的方案)#

使用四个指针从两端向中间收缩:

function patchChildren_double(n1, n2, container) {
let oldStartIdx = 0
let oldEndIdx = n1.children.length - 1
let newStartIdx = 0
let newEndIdx = n2.children.length - 1
let oldStartVNode = n1.children[oldStartIdx]
let oldEndVNode = n1.children[oldEndIdx]
let newStartVNode = n2.children[newStartIdx]
let newEndVNode = n2.children[newEndIdx]
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 头头比较
patch(oldStartVNode, newStartVNode)
oldStartVNode = n1.children[++oldStartIdx]
newStartVNode = n2.children[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
// 尾尾比较
patch(oldEndVNode, newEndVNode)
oldEndVNode = n1.children[--oldEndIdx]
newEndVNode = n2.children[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 头尾比较:需要移动
patch(oldStartVNode, newEndVNode)
insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
oldStartVNode = n1.children[++oldStartIdx]
newEndVNode = n2.children[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
// 尾头比较:需要移动
patch(oldEndVNode, newStartVNode)
insert(oldEndVNode.el, container, oldStartVNode.el)
oldEndVNode = n1.children[--oldEndIdx]
newStartVNode = n2.children[++newStartIdx]
} else {
// 四种都没命中,遍历查找
// ...
}
}
}

3.3 快速 Diff(Vue3 的方案)#

Vue3 采用了更高效的快速 Diff 算法,核心是最长递增子序列(LIS)

function patchKeyedChildren(c1, c2, container) {
let i = 0
let e1 = c1.length - 1
let e2 = c2.length - 1
// ① 从头部开始,跳过相同的前缀
while (i <= e1 && i <= e2) {
if (c1[i].key === c2[i].key) {
patch(c1[i], c2[i])
i++
} else {
break
}
}
// ② 从尾部开始,跳过相同的后缀
while (i <= e1 && i <= e2) {
if (c1[e1].key === c2[e2].key) {
patch(c1[e1], c2[e2])
e1--
e2--
} else {
break
}
}
// ③ 旧节点已遍历完,新节点有剩余 → 挂载
if (i > e1 && i <= e2) {
const anchor = e2 + 1 < c2.length ? c2[e2 + 1].el : null
while (i <= e2) {
patch(null, c2[i++], container, anchor)
}
}
// ④ 新节点已遍历完,旧节点有剩余 → 卸载
else if (i > e2 && i <= e1) {
while (i <= e1) {
unmount(c1[i++])
}
}
// ⑤ 中间乱序部分:使用 LIS 优化移动
else {
const s1 = i
const s2 = i
// 5.1 建立新节点 key → index 映射
const keyToNewIndexMap = new Map()
for (let j = s2; j <= e2; j++) {
keyToNewIndexMap.set(c2[j].key, j)
}
// 5.2 遍历旧节点,尝试复用
const toBePatched = e2 - s2 + 1
const newIndexToOldIndexMap = new Array(toBePatched).fill(0)
let moved = false
let maxNewIndexSoFar = 0
for (let j = s1; j <= e1; j++) {
const oldVNode = c1[j]
const newIndex = keyToNewIndexMap.get(oldVNode.key)
if (newIndex === undefined) {
// 旧节点在新列表中不存在,卸载
unmount(oldVNode)
} else {
newIndexToOldIndexMap[newIndex - s2] = j + 1
// 判断是否需要移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(oldVNode, c2[newIndex])
}
}
// 5.3 移动和挂载
if (moved) {
// 计算最长递增子序列
const seq = getSequence(newIndexToOldIndexMap)
let seqIdx = seq.length - 1
// 从后往前遍历,保证 anchor 正确
for (let j = toBePatched - 1; j >= 0; j--) {
const newIndex = s2 + j
const anchor = newIndex + 1 < c2.length ? c2[newIndex + 1].el : null
if (newIndexToOldIndexMap[j] === 0) {
// 新节点,挂载
patch(null, c2[newIndex], container, anchor)
} else if (moved) {
if (seqIdx < 0 || j !== seq[seqIdx]) {
// 不在 LIS 中,需要移动
insert(c2[newIndex].el, container, anchor)
} else {
// 在 LIS 中,不需要移动
seqIdx--
}
}
}
}
}
}

四、最长递增子序列(LIS)算法#

这是快速 Diff 的关键优化——找出最多不需要移动的节点:

function getSequence(arr) {
const p = arr.slice() // 前驱索引数组
const result = [0] // 存储 LIS 的索引
const len = arr.length
for (let i = 0; i < len; i++) {
const current = arr[i]
if (current === 0) continue // 跳过新增节点
const lastIdx = result[result.length - 1]
// 如果当前值大于 result 末尾值,直接追加
if (arr[lastIdx] < current) {
p[i] = lastIdx
result.push(i)
continue
}
// 否则二分查找,找到第一个 >= current 的位置
let lo = 0
let hi = result.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (arr[result[mid]] < current) {
lo = mid + 1
} else {
hi = mid
}
}
// 替换
if (current < arr[result[lo]]) {
if (lo > 0) {
p[i] = result[lo - 1]
}
result[lo] = i
}
}
// 回溯,得到正确的 LIS
let length = result.length
let idx = result[length - 1]
while (length-- > 0) {
result[length] = idx
idx = p[idx]
}
return result
}

LIS 的作用#

假设旧节点顺序为 [A, B, C, D, E],新节点为 [A, C, B, E, D]

新节点在旧数组中的索引:[0, 2, 1, 4, 3]
LIS: [0, 2, 4] → 对应 [A, C, E]
A, C, E 不需要移动
只需移动 B 和 D → 最少 2 次 DOM 操作

五、Vue3 编译时优化#

Vue3 不仅在运行时优化了 Diff,还在编译时做了大量标记:

5.1 PatchFlags#

// 编译器会标记动态内容
const vnode = {
type: 'div',
children: [
{ type: 'p', children: 'Static Text' }, // 静态,跳过
{
type: 'span',
children: ctx.msg, // 动态文本
patchFlag: PatchFlags.TEXT // 标记:只需比较文本
}
],
dynamicChildren: [/* 只包含动态节点 */] // Block Tree
}

5.2 Block Tree#

<template>
<div> <!-- Block Root -->
<p>静态内容</p> <!-- 跳过 -->
<p>{{ message }}</p> <!-- 收集到 dynamicChildren -->
<div v-if="show"> <!-- Block -->
<span>{{ text }}</span> <!-- 收集到内层 Block -->
</div>
</div>
</template>

编译后,更新时只需遍历 dynamicChildren,跳过所有静态节点。

5.3 静态提升#

// 编译前
function render() {
return h('div', [
h('p', 'Static'), // 每次渲染都创建
h('p', ctx.msg)
])
}
// 编译后(静态提升)
const _hoisted = h('p', 'Static') // 只创建一次
function render() {
return h('div', [
_hoisted, // 复用
h('p', ctx.msg)
])
}

总结#

优化策略阶段效果
PatchFlags编译时精确知道哪些属性是动态的
Block Tree编译时跳过静态子树
静态提升编译时避免重复创建静态 VNode
前后缀跳过运行时减少需要 Diff 的节点
LIS 算法运行时最少 DOM 移动操作
自定义渲染器架构跨平台能力

Vue3 的渲染器设计体现了编译时 + 运行时协同优化的思路,这也是 Vue3 性能大幅提升的关键原因。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Vue3 自定义渲染器与虚拟 DOM Diff 算法深度解析
https://boke.hackerdream.xyz/posts/vue3-renderer-diff-algorithm/
作者
晴天
发布于
2025-12-31
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录