虚拟 DOM 的核心是通过 diff 算法将多次 DOM 变更聚合成一次最小化更新:同层比对、key 驱动、就地复用;仅更新变更属性,不重建节点,从而减少重排重绘。
浏览器每次调用 document.createElement、element.appendChild 或修改 element.innerHTML,都可能触发样式计算、布局(reflow)和绘制(repaint)。尤其是深层嵌套节点变动时,重排会波及父级甚至整个文档流。虚拟 DOM 的核心不是“避免重绘”,而是把多次 DOM 变更聚合成一次最小化更新 —— 关键在 diff 阶段精准识别哪些节点该复用、哪些要新增/删除/移动。
React 和 Vue 的 diff 都基于「双端对比 + key 标识」策略,不跨层级比较,也不递归遍历整棵树。重点在于:
key 必须稳定唯一,不能用 index 当 key(列表顺序变化时会错乱复用)tag)或组件类型(type)不同时,不尝试 patch,直接 replacepatchProps,仅更新变更字段,不全量 setAttribute例如两个 div 节点,仅 class 不同,diff 后只执行 el.className = 'new',而非重建节点。
以下是最小可行的双指针 diff 示例,聚焦子节点列表更新逻辑:
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);
}
}
}
很多实现卡在看似合理但实际低效的细节上:
props 或 children
JSON.stringify 比较 props 是否变化 —— 开销大且无法处理函数、Symbol、循环引用static: true,diff 时直接跳过比对patchProps 中对每个 prop 都调用 el.setAttribute —— 应区分 style、class、事件监听器等,走专用更新路径真正减少重绘,靠的不是 diff 多快,而是它能否让 patch 阶段只触达真实需要变更的 DOM 属性和位置。算法再精妙,如果 patch 时仍粗暴 innerHTML 或强制 reflow,优化就白做了。