Vue 虚拟 DOM 与 Diff 算法
概述
虚拟 DOM 是用 JavaScript 对象描述真实 DOM 的抽象层,通过 Diff 算法计算最小更新,提高渲染效率。
一、虚拟 DOM 基础
VNode 结构
typescript
interface VNode {
type: string | Component; // 标签名或组件
props: Record<string, any>; // 属性
children: VNode[] | string; // 子节点
key: string | number; // 唯一标识
el: Element | null; // 真实 DOM 引用
shapeFlag: number; // 类型标记
patchFlag: number; // 优化标记
}创建 VNode
javascript
// h 函数
function h(type, props, children) {
return {
type,
props,
children,
key: props?.key,
el: null,
shapeFlag: getShapeFlag(type)
};
}
// 使用
const vnode = h('div', { class: 'container' }, [
h('h1', null, 'Hello'),
h('p', null, 'World')
]);
// JSX 编译结果
<div class="container">
<h1>Hello</h1>
<p>World</p>
</div>
// ↓ 编译为
h('div', { class: 'container' }, [
h('h1', null, 'Hello'),
h('p', null, 'World')
])ShapeFlag
javascript
const ShapeFlags = {
ELEMENT: 1, // 普通元素
FUNCTIONAL_COMPONENT: 1 << 1, // 函数组件
STATEFUL_COMPONENT: 1 << 2, // 有状态组件
TEXT_CHILDREN: 1 << 3, // 子节点是文本
ARRAY_CHILDREN: 1 << 4, // 子节点是数组
SLOTS_CHILDREN: 1 << 5, // 子节点是插槽
// ...
};
// 使用位运算判断类型
if (shapeFlag & ShapeFlags.ELEMENT) {
// 是元素
}
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 子节点是数组
}二、渲染流程
mount 挂载
javascript
function mount(vnode, container) {
// 创建真实 DOM
const el = document.createElement(vnode.type);
vnode.el = el;
// 处理属性
if (vnode.props) {
for (const key in vnode.props) {
patchProp(el, key, null, vnode.props[key]);
}
}
// 处理子节点
if (typeof vnode.children === 'string') {
el.textContent = vnode.children;
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => mount(child, el));
}
// 挂载到容器
container.appendChild(el);
}patch 更新
javascript
function patch(n1, n2, container) {
// 类型不同,直接替换
if (n1.type !== n2.type) {
unmount(n1);
mount(n2, container);
return;
}
const el = (n2.el = n1.el);
// 更新属性
patchProps(el, n1.props, n2.props);
// 更新子节点
patchChildren(n1, n2, el);
}三、Diff 算法
子节点对比情况
javascript
function patchChildren(n1, n2, container) {
const c1 = n1.children;
const c2 = n2.children;
// 新子节点是文本
if (typeof c2 === 'string') {
if (Array.isArray(c1)) {
// 卸载旧子节点
c1.forEach(child => unmount(child));
}
if (c1 !== c2) {
container.textContent = c2;
}
}
// 新子节点是数组
else if (Array.isArray(c2)) {
if (Array.isArray(c1)) {
// 核心 Diff
patchKeyedChildren(c1, c2, container);
} else {
container.textContent = '';
c2.forEach(child => mount(child, container));
}
}
// 新子节点为空
else {
if (Array.isArray(c1)) {
c1.forEach(child => unmount(child));
} else {
container.textContent = '';
}
}
}核心 Diff: 双端对比
javascript
function patchKeyedChildren(c1, c2, container) {
let i = 0;
let e1 = c1.length - 1;
let e2 = c2.length - 1;
// 1. 从头部开始对比
while (i <= e1 && i <= e2) {
if (isSameVNodeType(c1[i], c2[i])) {
patch(c1[i], c2[i], container);
i++;
} else {
break;
}
}
// 2. 从尾部开始对比
while (i <= e1 && i <= e2) {
if (isSameVNodeType(c1[e1], c2[e2])) {
patch(c1[e1], c2[e2], container);
e1--;
e2--;
} else {
break;
}
}
// 3. 新节点有剩余,挂载
if (i > e1 && i <= e2) {
while (i <= e2) {
mount(c2[i], container);
i++;
}
}
// 4. 旧节点有剩余,卸载
else if (i > e2 && i <= e1) {
while (i <= e1) {
unmount(c1[i]);
i++;
}
}
// 5. 中间乱序部分
else {
patchUnkeyedChildren(c1, c2, container, i, e1, e2);
}
}
function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}最长递增子序列优化
javascript
function patchUnkeyedChildren(c1, c2, container, s1, e1, s2, e2) {
// 建立新节点 key -> index 映射
const keyToNewIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
keyToNewIndexMap.set(c2[i].key, i);
}
// 记录新节点对应的旧节点索引
const newIndexToOldIndexMap = new Array(e2 - s2 + 1).fill(0);
// 遍历旧节点
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i];
const newIndex = keyToNewIndexMap.get(prevChild.key);
if (newIndex === undefined) {
// 旧节点不在新列表中,卸载
unmount(prevChild);
} else {
// 记录映射
newIndexToOldIndexMap[newIndex - s2] = i + 1;
patch(prevChild, c2[newIndex], container);
}
}
// 获取最长递增子序列
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
let j = increasingNewIndexSequence.length - 1;
// 从后向前遍历,移动/挂载节点
for (let i = e2 - s2; i >= 0; i--) {
const newIndex = s2 + i;
const anchor = c2[newIndex + 1]?.el;
if (newIndexToOldIndexMap[i] === 0) {
// 新增节点
mount(c2[newIndex], container, anchor);
} else if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 需要移动
move(c2[newIndex], container, anchor);
} else {
// 在最长递增子序列中,不需要移动
j--;
}
}
}
// 最长递增子序列算法
function getSequence(arr) {
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}四、Vue 3 编译时优化
PatchFlag 静态标记
javascript
// 模板
<div>
<span>静态文本</span>
<span>{{ dynamic }}</span>
</div>
// 编译结果
const _hoisted_1 = /*#__PURE__*/_createVNode("span", null, "静态文本", -1 /* HOISTED */);
function render(_ctx) {
return (_openBlock(), _createBlock("div", null, [
_hoisted_1, // 静态节点提升
_createVNode("span", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
]))
}
// PatchFlag 类型
const PatchFlags = {
TEXT: 1, // 动态文本
CLASS: 2, // 动态 class
STYLE: 4, // 动态 style
PROPS: 8, // 动态属性
FULL_PROPS: 16, // 有动态 key
HYDRATE_EVENTS: 32,// 需要事件监听器
STABLE_FRAGMENT: 64,
KEYED_FRAGMENT: 128,
UNKEYED_FRAGMENT: 256,
NEED_PATCH: 512, // ref 或 hooks
DYNAMIC_SLOTS: 1024,
HOISTED: -1, // 静态提升
BAIL: -2 // 退出优化
};Block Tree
javascript
// 开启 Block
function openBlock() {
blockStack.push([]);
}
function createBlock(type, props, children) {
const vnode = createVNode(type, props, children);
// 收集动态子节点
vnode.dynamicChildren = blockStack.pop();
return vnode;
}
// Diff 时只对比动态子节点
function patchBlockChildren(n1, n2) {
for (let i = 0; i < n2.dynamicChildren.length; i++) {
patch(n1.dynamicChildren[i], n2.dynamicChildren[i]);
}
}静态提升
javascript
// 编译前
<div>
<span class="static">不变的</span>
<span>{{ msg }}</span>
</div>
// 编译后
const _hoisted_1 = /*#__PURE__*/_createVNode("span", { class: "static" }, "不变的", -1);
function render(_ctx) {
return (_openBlock(), _createBlock("div", null, [
_hoisted_1, // 重复渲染使用同一个 VNode
_createVNode("span", null, _toDisplayString(_ctx.msg), 1)
]))
}事件缓存
javascript
// 编译前
<button @click="onClick">点击</button>
// 编译后(无缓存)
_createVNode("button", { onClick: _ctx.onClick }, "点击")
// 编译后(有缓存)
_createVNode("button", {
onClick: _cache[0] || (_cache[0] = (...args) => _ctx.onClick(...args))
}, "点击")五、与 React Diff 对比
| 特性 | Vue 3 | React |
|---|---|---|
| 策略 | 双端对比 + LIS | 单向遍历 |
| 复杂度 | O(n) | O(n) |
| key 处理 | 建立 index map | 建立 fiber map |
| 优化方式 | 编译时 PatchFlag | 运行时 fiber |
| 静态提升 | ✅ | ❌ |
| Block Tree | ✅ | ❌ |
面试高频题
Q1: 虚拟 DOM 的优势?
答:
- 跨平台: 可以渲染到 DOM、Canvas、Native
- 批量更新: 合并多次更新,减少重排重绘
- Diff 优化: 最小化 DOM 操作
- 开发体验: 声明式编程
Q2: key 的作用是什么?
答:
- 标识节点唯一性,帮助 Diff 算法识别节点
- 避免就地复用导致的状态错误
- 触发组件的卸载和重新挂载
javascript
// ❌ 使用 index 可能导致问题
<li v-for="(item, index) in list" :key="index">
// ✅ 使用唯一 id
<li v-for="item in list" :key="item.id">Q3: Vue 3 相比 Vue 2 的 Diff 有什么优化?
答:
- PatchFlag: 标记动态节点类型
- Block Tree: 只 Diff 动态节点
- 静态提升: 静态节点只创建一次
- 事件缓存: 避免重新创建事件处理函数
Q4: 最长递增子序列的作用?
答: 在乱序节点移动时,找出不需要移动的最大子集,减少 DOM 操作次数。