如何用JavaScript实现虚拟DOM_diff算法如何减少重绘

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

为什么直接操作 DOM 会导致频繁重绘

浏览器每次调用 document.createElementelement.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 示例,聚焦子节点列表更新逻辑:

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 propschildren
  • JSON.stringify 比较 props 是否变化 —— 开销大且无法处理函数、Symbol、循环引用
  • 未跳过静态节点(如纯文本、无绑定属性的 div)—— 这类节点可标记 static: true,diff 时直接跳过比对
  • patchProps 中对每个 prop 都调用 el.setAttribute —— 应区分 styleclass、事件监听器等,走专用更新路径

真正减少重绘,靠的不是 diff 多快,而是它能否让 patch 阶段只触达真实需要变更的 DOM 属性和位置。算法再精妙,如果 patch 时仍粗暴 innerHTML 或强制 reflow,优化就白做了。