Pixiv - KiraraShss
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 的价值在于:
- 声明式编程:开发者描述”是什么”,框架处理”怎么做”
- 跨平台能力:同一套 VNode 可以渲染到 DOM、Canvas、Native
- 最小化更新:通过 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/ 相关文章 智能推荐
1
Vue3 自定义渲染器:从零实现一个 Canvas 渲染器
Vue 深入 2026-03-07
2
Vue3 编译器优化:静态提升、补丁标记与 Block Tree 的实现原理
Vue 深入 2026-03-04
3
Vue 3.5 深度解析:Alien Signals 响应式重构与五大新特性全面拆解
Vue 深入 2026-04-06
4
Vue3 依赖注入深入:provide/inject 的架构设计与最佳实践
Vue 深入 2026-01-21
5
Vue3 Suspense 与异步组件:优雅处理加载状态的完整方案
Vue 深入 2026-01-24
随机文章 随机推荐