《虚拟 DOM 与 Diff 算法:用 1500 字把它讲成“人话”》


一、开场白:DOM 操作到底有多慢?

前端圈子里流行一句话:"DOM 是性能杀手。"

其实 DOM 本身并不慢,慢的是每一次操作都要跨线程通信

  • JS 线程 → 渲染线程 → 合成线程 → GPU
    一次 appendChild 就是一次"跨国电话",往返 4 次才算完。
    如果你手动循环插入 1000 个 <li>,就等于拨了 1000 次国际长途,再快的宽带也扛不住。

二、虚拟 DOM:把"跨国电话"变成"本地对讲机"

虚拟 DOM(Virtual DOM)的核心思想一句话:
"先用 JS 对象在内存里把页面画好,再一次性快递到真 DOM。"

js 复制代码
// 真 DOM
<ul>
  <li>Apple</li>
  <li>Banana</li>
</ul>

// 虚拟 DOM(就是普通对象)
const vTree = {
  type: 'ul',
  props: {},
  children: [
    { type: 'li', props: {}, children: ['Apple'] },
    { type: 'li', props: {}, children: ['Banana'] }
  ]
}

因为对象操作是纯 JS 计算,速度比 DOM API 快 1~2 个量级

1000 次 push 数组只要 1 ms,1000 次 appendChild 可能要 100 ms,差 100 倍。


三、Diff 算法:从 O(n³) 到 O(n) 的"剪刀手"

有了两棵虚拟树,下一步就是"找不同"。

最暴力的算法是编辑距离 :删除、插入、替换,时间复杂度 O(n³)。

1000 个节点就要算 10⁹ 次,JS 引擎直接跪了。

Facebook 的工程师甩出三把剪刀,把复杂度剪成 O(n)

  1. 只同层比较
    父节点不同 → 子树整颗扔掉,绝不往下深挖。
  2. 类型不同 → 重建
    divspan?直接销毁 div 再新建 span,不纠结。
  3. key 驱动列表复用
    同一层子节点用 key 当身份证,能移动就移动,不能移动再新建。

四、看图说话:ABCDE → EABCD 到底动了几次?

旧列表:A B C D E

新列表:E A B C D

肉眼观察 :E 从最末移到最前,其它兄弟只是集体后移。
diff 流程(双端指针):

  1. 头头比较:A ≠ E → 失败
  2. 尾尾比较:E == E → 复用,指针前移
  3. 旧尾新头:D ≠ A → 失败
  4. 旧头新尾:A ≠ D → 失败
  5. 拿旧节点建 key→index 哈希表
  6. 发现新头 E 在旧序列里存在 → 把真 DOM 节点 insertBefore 到旧头 A 前面
  7. 旧头指针 +1,继续循环 ...

最终只执行 1 次 DOM 移动 ,0 次新增,0 次删除。

如果不带 key,算法会粗暴地"删 5 个再建 5 个",DOM 操作瞬间爆炸。


五、移动优先策略:insertBefore 的"省钱哲学"

DOM API 里:

  • node.appendChild() 只做追加,便宜。
  • parent.insertBefore(newNode, refNode) 能做移动,同样便宜
  • parent.removeChild() + appendChild() 要两次线程通信,贵一倍

因此 diff 在"可移动"与"删+建"之间,优先选移动

这也是 React/Vue 官方一直强调"写 key!写 key!"的原因:

key 让算法知道"谁是谁",从而走最省钱的移动路径。


六、批量合并:把 1000 次 insert 合成 1 次

考虑下面的 React 代码:

js 复制代码
const [list, setList] = useState([]);
function add1000() {
  const new1000 = Array.from({length: 1000}, (_,i) => <li key={i}>item {i}</li>);
  setList(new1000);
}

React 会把 1000 次 <li> 的创建先放进虚拟树 ,diff 算出"整段新增",然后一次性 DocumentFragment 插入 ,真 DOM 只收到 1 次消息。

手动写 for 循环插入则要打 1000 次电话,差距 1000 倍。


七、跨平台:虚拟 DOM 不是 DOM 的舔狗

虚拟 DOM 本质是纯 JS 对象,与浏览器无关,因此可以"编译到任意宿主":

  • React Native → 把 type: 'div' 映射成 UIView / android.view.View
  • Taro / Remax → 把 type: 'div' 映射成小程序 <view>
  • Ink → 把 type: 'div' 映射成终端 ANSI 彩色字符串

只要写一套 diff + 宿主渲染器,就能让 React 跑在手机、电脑、手表、车载系统甚至命令行里。


八、常见误区三连击

  1. "虚拟 DOM 一定比原生 DOM 快"

    错!单次操作虚拟 DOM 反而多了一层计算,赢的是"批量 + 少操作"

  2. "写 key 只是为了不报错"

    错!key 是 diff 移动策略的唯一身份证,不写 key 等价于"自愿放弃性能"。

  3. "diff 会递归比较所有属性"

    错!同级节点浅比较 props,遇到 style 也只比较第一层键值;深层差异才递归。


九、手写 60 行核心代码,把上面全部串起来

js 复制代码
function h(type, props, ...kids) {
  return { type, props: props||{}, kids };
}

function render(vnode) {
  if(typeof vnode==='string') return document.createTextNode(vnode);
  const dom = document.createElement(vnode.type);
  Object.keys(vnode.props).forEach(k=> dom[k]=vnode.props[k]);
  vnode.kids.map(render).forEach(c=>dom.appendChild(c));
  return dom;
}

function diff(parent, oldNode, newNode, idx=0) {
  const dom = parent.childNodes[idx];
  if(!oldNode) parent.appendChild(render(newNode));
  else if(!newNode) parent.removeChild(dom);
  else if(oldNode.type!==newNode.type)
    parent.replaceChild(render(newNode), dom);
  else if(typeof newNode==='string'){
    if(oldNode!==newNode) dom.textContent=newNode;
  }else{
    const oKids=oldNode.kids||[], nKids=newNode.kids||[];
    for(let i=0;i<Math.max(oKids.length,nKids.length);i++)
      diff(dom, oKids[i], nKids[i], i);
  }
}

把这段代码粘进浏览器,零依赖跑通计数器 demo,你就能亲手摸到虚拟 DOM + Diff 的脉搏。


十、结语:虚拟 DOM 是"性能 + 工程"的双赢

  • 对开发者:声明式 UI,不再手动记挂每一颗节点。
  • 对框架:批量 + Diff,把 DOM 操作压到最少。
  • 对生态:一次编写,多端渲染------小程序、iOS、Android、终端,都能吃同一套虚拟树。

理解了"用 JS 对象做草稿 → Diff 找不同 → 一次性打补丁 "这条主线,

再看 React、Vue、Solid、Svelte,都只是在这条路上加减速或换车道而已。

DOM 慢的不是速度,而是"次数";虚拟 DOM 快的不是计算,而是"省掉多余动作"。

记住这句话,你就掌握了前端性能优化的底层密码。

相关推荐
月亮慢慢圆3 小时前
Intersection Observer API
前端
Mintopia4 小时前
🚀 Next.js 企业级文件上传方案全解
前端·javascript·全栈
雾岛听风来4 小时前
k9s监控k8s集群工具
前端
用户87261342418514 小时前
封装组件库并上传npm源
前端
Mintopia4 小时前
🌐 Web3.0 时代:AIGC 如何赋能去中心化内容生态?
前端·javascript·aigc
鹏多多4 小时前
前端项目eslint配置选项详细解析
前端·vue.js·react.js
然我4 小时前
面试官:这道 Promise 输出题你都错?别再踩 pending 和状态凝固的坑了!(附超全解析)
前端·javascript·面试
bug_kada4 小时前
让你彻底明白什么是闭包(附常见坑点)
前端·javascript
吴楷鹏4 小时前
TypeScript 为什么要增加一个 satisfies?
前端·typescript