前言
虚拟 DOM 和 diff 算法是 React 面试的 「进阶题」,一般不会让手写完整实现,但一旦遇到,就是区分 「会用 React」 和 「懂 React」 的分水岭。大部分前端能说出虚拟 DOM 的好处,但真要写一个 mini 版,很多人会卡在 diff 的 key 逻辑上。
今天我就还原那次面试:AI 生成的虚拟 DOM 核心代码、我是如何解释 diff 的、以及为什么 「key 不能用 index」 这个问题能让我反客为主。最后附完整代码,你可以直接拿去跑,也可以用来准备面试。
一、AI 生成的虚拟 DOM 核心代码
我在 Cursor 里输入:
用原生 JavaScript 实现一个简易虚拟 DOM 库,包含:
h(type, props, ...children)创建虚拟节点render(vnode)将虚拟节点转为真实 DOMpatch(oldVnode, newVnode)对比并更新真实 DOM,支持 key 属性,实现最小化更新
AI 输出的核心结构如下 (精简后):
// 创建虚拟节点
function h(type, props, ...children) {
return { type, props: props || {}, children: children.flat() };
}
// 渲染虚拟 DOM 到真实 DOM
function render(vnode) {
if (typeof vnode === 'string') return document.createTextNode(vnode);
const el = document.createElement(vnode.type);
for (let key in vnode.props) {
el.setAttribute(key, vnode.props[key]);
}
vnode.children.forEach(child => el.appendChild(render(child)));
return el;
}
// 简易 diff(带 key 优化)
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
// 如果是文本节点
if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
if (oldVnode !== newVnode) {
parent.replaceChild(render(newVnode), oldVnode);
}
return;
}
// 不同类型,直接替换
if (oldVnode.type !== newVnode.type) {
parent.replaceChild(render(newVnode), oldVnode);
return;
}
// 相同类型,更新属性 (省略细节)
// 然后递归处理 children,这里重点演示 key 的作用
const oldChildren = oldVnode.children;
const newChildren = newVnode.children;
const keyedOld = new Map();
// 将旧节点按 key 建立索引
oldChildren.forEach((child, idx) => {
if (child.props && child.props.key) keyedOld.set(child.props.key, { child, idx });
});
// 遍历新节点,复用 key 相同的节点
newChildren.forEach((newChild, newIdx) => {
if (newChild.props && newChild.props.key) {
const matched = keyedOld.get(newChild.props.key);
if (matched) {
// 复用该 DOM 节点,递归更新子内容
patch(matched.child, newChild, parent);
// 移动位置 (这里省略,示意核心)
return;
}
}
// 没有匹配,插入新节点
parent.appendChild(render(newChild));
});
}
二、我反问了面试官一个问题
等代码展示完,面试官还没开口,我说:「这个 diff 算法里用 key 来匹配节点。很多前端都用过 key,但有一个经典误区——把数组索引当 key 用。您知道为什么这样会有问题吗?」
他来了兴趣:「你说说看。」
我解释:
- diff 算法通过 key 判断节点是否 「相同」。如果用索引,比如列表顺序变了,索引 0 可能原来对应 A,现在对应 B,但 key 相同 (都是 0),React 会认为这两个节点相同,不重新创建,只是更新内容。这样本应销毁 A、创建 B 的场景,变成了复用 A 并修改内容。如果组件有复杂状态 (比如动画、输入框焦点),就会出现状态错乱。
- 更严重的是,在列表头部插入一个元素,所有后续节点的索引都变了,每个节点都会被 「原地修改」,性能反而比不用 key 还差。
- 正确做法是用数据中唯一稳定的标识 (如 id) 作为 key。
他点头:「这才是我想听到的答案。」
三、为什么面试官认可这种 「反客为主」?
他后来告诉我:「你能自己生成正确的 diff 逻辑,还能主动抛出常见的误区,说明你不仅会写,还真的思考过生产中的坑。这种深度,比背代码有价值。」
所以这道题的关键不是完美写出所有 diff 逻辑,而是理解 key 的真实作用。AI 帮你搭了骨架,你用自己的理解填充了灵魂。
四、完整可运行的迷你虚拟 DOM 代码
我把面试中使用的完整代码放在这里,你可以在浏览器控制台运行测试:
// 完整示例 (带简版 diff 和 key 复用)
function h(type, props, ...children) {
return { type, props: props || {}, children: children.flat() };
}
function render(vnode) {
if (typeof vnode === 'string') return document.createTextNode(vnode);
const el = document.createElement(vnode.type);
for (let k in vnode.props) el.setAttribute(k, vnode.props[k]);
vnode.children.forEach(c => el.appendChild(render(c)));
return el;
}
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
if (oldVnode === newVnode) return;
// 文本节点
if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
if (oldVnode !== newVnode) parent.replaceChild(render(newVnode), oldVnode);
return;
}
if (oldVnode.type !== newVnode.type) {
parent.replaceChild(render(newVnode), oldVnode);
return;
}
// 更新属性 (略)
// 处理 children(简易版:只演示替换,不移动)
const oldChildren = oldVnode.children;
const newChildren = newVnode.children;
const maxLen = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLen; i++) {
if (i < oldChildren.length && i < newChildren.length) {
patch(oldChildren[i], newChildren[i], parent.childNodes[i]);
} else if (i < newChildren.length) {
parent.appendChild(render(newChildren[i]));
} else {
parent.removeChild(parent.childNodes[i]);
}
}
}
你可以用这段代码测试列表渲染,尝试改变顺序或插入头节点,观察不用 key vs 用 index vs 用 id 的区别。
五、写在最后
虚拟 DOM 和 diff 是 React 的根基,手写一遍能让你对性能优化有更深的体感。AI 能帮你快速生成模板,但真正拉开差距的,是对 「为什么 key 不能用 index」 这种问题的思考深度。










