前言
虚拟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输出的核心结构如下(精简后):
js
// 创建虚拟节点
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代码
我把面试中使用的完整代码放在这里,你可以在浏览器控制台运行测试:
js
// 完整示例(带简版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"这种问题的思考深度。