虚拟 DOM 的核心是通过 diff 算法将多次 DOM 变更聚合成一次最小化更新:同层比对、key 驱动、就地复用;仅更新变更属性,不重建节点,从而减少重排重绘。

为什么直接操作 DOM 会导致频繁重绘
浏览器每次调用 document.createElement、element.appendChild 或修改 element.innerHTML,都可能触发样式计算、布局(reflow)和绘制(repaint)。尤其是深层嵌套节点变动时,重排会波及父级甚至整个文档流。虚拟 DOM 的核心不是“避免重绘”,而是把多次 DOM 变更聚合成一次最小化更新 —— 关键在 diff 阶段精准识别哪些节点该复用、哪些要新增/删除/移动。
diff 算法必须做的三件事:同层比对、key 驱动、就地复用
React 和 Vue 的 diff 都基于「双端对比 + key 标识」策略,不跨层级比较,也不递归遍历整棵树。重点在于:
-
key必须稳定唯一,不能用index当 key(列表顺序变化时会错乱复用) - 只比对同一层级的 vnode,父节点不同就直接卸载整棵子树
- 元素类型(
tag)或组件类型(type)不同时,不尝试 patch,直接 replace - 属性更新走
patchProps,仅更新变更字段,不全量 setAttribute
例如两个 div 节点,仅 class 不同,diff 后只执行 el.className = 'new',而非重建节点。
手写简易 diff 的关键逻辑(仅比对同层子节点)
以下是最小可行的双指针 diff 示例,聚焦子节点列表更新逻辑:
立即学习“Java免费学习笔记(深入)”;
function diffChildren(oldCh, newCh, parentEl) {
let oldStartIdx = 0, newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let newEndIdx = newCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
parentEl.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
parentEl.insertBefore(oldEndVnode.el, oldStartVnode.el);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// fallback:用 key 建哈希表查找可复用节点
const idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (idxInOld > 0) {
const vnodeToMove = oldCh[idxInOld];
patchVnode(vnodeToMove, newStartVnode);
parentEl.insertBefore(vnodeToMove.el, oldStartVnode.el);
oldCh[idxInOld] = undefined;
} else {
parentEl.insertBefore(createElement(newStartVnode), oldStartVnode.el);
}
newStartVnode = newCh[++newStartIdx];
}
}
// 清理剩余旧节点
if (oldStartIdx <= oldEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) removeElement(oldCh[i].el);
}
}
// 插入剩余新节点
if (newStartIdx <= newEndIdx) {
for (let i = newStartIdx; i <= newEndIdx; i++) {
const before = newCh[i + 1] ? newCh[i + 1].el : null;
parentEl.insertBefore(createElement(newCh[i]), before);
}
}
}
容易被忽略的性能陷阱
很多实现卡在看似合理但实际低效的细节上:
- 每次 diff 都深克隆 vnode —— 应复用原始对象,只在必要时 shallow clone
props或children - 用
JSON.stringify比较 props 是否变化 —— 开销大且无法处理函数、Symbol、循环引用 - 未跳过静态节点(如纯文本、无绑定属性的 div)—— 这类节点可标记
static: true,diff 时直接跳过比对 - 在
patchProps中对每个 prop 都调用el.setAttribute—— 应区分style、class、事件监听器等,走专用更新路径
真正减少重绘,靠的不是 diff 多快,而是它能否让 patch 阶段只触达真实需要变更的 DOM 属性和位置。算法再精妙,如果 patch 时仍粗暴 innerHTML 或强制 reflow,优化就白做了。










