一、开场白: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):
- 只同层比较
父节点不同 → 子树整颗扔掉,绝不往下深挖。 - 类型不同 → 重建
div
变span
?直接销毁 div 再新建 span,不纠结。 - key 驱动列表复用
同一层子节点用key
当身份证,能移动就移动,不能移动再新建。
四、看图说话:ABCDE → EABCD 到底动了几次?
旧列表:A B C D E
新列表:E A B C D
肉眼观察 :E 从最末移到最前,其它兄弟只是集体后移。
diff 流程(双端指针):
- 头头比较:A ≠ E → 失败
- 尾尾比较:E == E → 复用,指针前移
- 旧尾新头:D ≠ A → 失败
- 旧头新尾:A ≠ D → 失败
- 拿旧节点建 key→index 哈希表
- 发现新头 E 在旧序列里存在 → 把真 DOM 节点 insertBefore 到旧头 A 前面
- 旧头指针 +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 跑在手机、电脑、手表、车载系统甚至命令行里。
八、常见误区三连击
-
"虚拟 DOM 一定比原生 DOM 快"
错!单次操作虚拟 DOM 反而多了一层计算,赢的是"批量 + 少操作"。
-
"写 key 只是为了不报错"
错!key 是 diff 移动策略的唯一身份证,不写 key 等价于"自愿放弃性能"。
-
"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 快的不是计算,而是"省掉多余动作"。
记住这句话,你就掌握了前端性能优化的底层密码。