一、问题:为什么不能用 index 作 key
1.1 问题代码示例
<!-- ❌ 错误用法:用 index 作 key -->
<div v-for="(item, index) in list" :key="index">
<input v-model="item.name" />
{{ item.name }}
</div>
1.2 具体会发生什么
// 初始数据
list = [
{ id: 1, name: '张三' }, // index: 0
{ id: 2, name: '李四' }, // index: 1
{ id: 3, name: '王五' } // index: 2
]
// 删除第一个元素后
list = [
{ id: 2, name: '李四' }, // 现在 index: 0
{ id: 3, name: '王五' } // 现在 index: 1
]
// 用 index 作 key 的问题:
// 旧vnode[0] (key=0, 对应"张三") vs 新vnode[0] (key=0, 对应"李四")
// Vue 认为 key=0 还是同一个节点,只是内容变了
// 于是复用 <div> 和 <input> 的 DOM
// 结果:input 的显示值变成了"李四",但 DOM 的 value 状态还保留着"张三"
二、编译阶段:模板如何变成代码
2.1 模板编译结果
<!-- 你的模板代码 -->
<div v-for="(item, index) in list" :key="item.id">
{{ item.name }}
</div>
// ↓ 编译后生成的 render 函数 ↓
function render(_ctx, _cache) {
return (_openBlock(true), // 开启一个 Fragment block
_createElementBlock(_Fragment, null,
// 🔍 关键:调用 _renderList 遍历 list
_renderList(_ctx.list, (item, index) => {
return (_openBlock(),
_createElementBlock("div", {
key: item.id, // ← key 在这里传递给 createElementBlock
}, [
_toDisplayString(item.name) // 动态文本
], 1 /* TEXT 动态标志 */))
}),
128 /* KEYED_FRAGMENT */ // ← 这是关键!因为有 :key
))
}
2.2 编译器的判断逻辑
// 编译器伪代码逻辑
function compileVFor(element) {
const hasKeyBinding = element.hasBinding('key'); // 检查是否有 :key
if (hasKeyBinding) {
// 有 :key → 设置 KEYED_FRAGMENT 标志 (128)
fragmentFlag = 128; // PatchFlags.KEYED_FRAGMENT
} else {
// 没有 :key → 设置 UNKEYED_FRAGMENT 标志 (256)
fragmentFlag = 256; // PatchFlags.UNKEYED_FRAGMENT
}
// 生成的代码中会传递这个 fragmentFlag
return `createElementBlock(Fragment, null, renderList(list, renderItem), ${fragmentFlag})`;
}
三、运行阶段:renderList 的工作原理
3.1 renderList 源码核心
// renderList 函数简化版(只看数组的情况)
function renderList(source, renderItem, cache, index) {
let ret = [];
// 遍历数组,为每个元素调用 renderItem
for (let i = 0; i < source.length; i++) {
// 🔍 关键:每次调用 renderItem 都传入当前元素和索引
ret[i] = renderItem(
source[i], // 当前元素
i, // 当前索引
undefined, // 保留参数
cache? cache[i] : undefined // 缓存(如果有)
);
}
return ret; // 返回 vnode 数组
}
3.2 renderItem 是什么?
// 你的 v-for 模板编译成的 renderItem
const renderItem = (item, index) => {
// 这个函数是编译器根据你的模板生成的
return _createElementBlock("div", {
key: item.id, // 你的 :key="item.id"
}, _toDisplayString(item.name));
};
3.3 第一次渲染和更新时的区别
// 第一次渲染
const list = [{id: 1, name: '张三'}, {id: 2, name: '李四'}];
const vnodes1 = renderList(list, renderItem);
// vnodes1 = [
// { type: 'div', key: 1, children: '张三', el: null },
// { type: 'div', key: 2, children: '李四', el: null }
// ]
// Vue 创建真实 DOM
// div1 -> <div>张三</div>
// div2 -> <div>李四</div>
// 并设置 vnode.el = 对应的 DOM 元素
// ------------------------------
// 用户删除第一个元素后
list.splice(0, 1); // 现在 list = [{id: 2, name: '李四'}]
// 响应式触发重新渲染
// 重新执行 renderList
const vnodes2 = renderList(list, renderItem);
// vnodes2 = [
// { type: 'div', key: 2, children: '李四', el: null }
// ]
// 🔍 注意:vnodes2[0] 是全新的 vnode 对象!
// 它的 key 是 2,对应 id=2 的数据
四、vnode 的生成与 key 的存储
4.1 createBaseVNode 如何提取 key
// 创建 vnode 的核心函数(简化版)
function createBaseVNode(type, props = null, children = null) {
const vnode = {
__v_isVNode: true,
type, // 如 'div'
props, // 所有属性,包括 key
key: props && normalizeKey(props), // 🔍 关键:从 props 中提取 key
children, // 子节点
el: null, // 指向真实 DOM,初始为 null
// ... 其他属性
};
return vnode;
}
// 提取 key 的函数
function normalizeKey(props) {
return props.key; // 就是这么简单!
}
// 实际调用
const vnode = createBaseVNode('div', { key: 123, class: 'item' }, '张三');
// vnode.key = 123
// vnode.props = { key: 123, class: 'item' }
五、响应式系统触发更新(简化版)
// 1. 数据变化触发更新
const list = ref([{id: 1, name: '张三'}, {id: 2, name: '李四'}]);
// 2. 删除第一个元素
list.value.splice(0, 1);
// 3. 响应式系统检测到变化,重新执行组件的渲染函数
// 4. 重新执行 render 函数
// 5. renderList 重新执行,生成新的 vnode 数组
// 6. 调用 patch 进行新旧 vnode 对比
六、Diff 算法的核心区别
6.1 两种算法的选择
当新旧 vnode 数组需要对比时,Vue 会根据编译阶段设置的 patchFlag来选择不同的算法:
// patchChildren 函数中的选择逻辑
const patchChildren = (n1, n2, container) => {
const c1 = n1.children; // 旧子节点数组
const c2 = n2.children; // 新子节点数组
if (n2.patchFlag & 128) { // KEYED_FRAGMENT
// 有 key,使用 patchKeyedChildren
patchKeyedChildren(c1, c2, container, ...);
} else if (n2.patchFlag & 256) { // UNKEYED_FRAGMENT
// 无 key,使用 patchUnkeyedChildren
patchUnkeyedChildren(c1, c2, container, ...);
}
};
6.2 patchKeyedChildren(有 key 的算法核心)
// 简化版核心代码
function patchKeyedChildren(c1, c2, container) {
// 1. 建立 key 到新索引的映射
const keyToNewIndexMap = new Map();
for (let i = 0; i < c2.length; i++) {
const child = c2[i];
if (child.key != null) {
keyToNewIndexMap.set(child.key, i);
}
}
// 2. 遍历旧节点,通过 key 查找匹配
for (let i = 0; i < c1.length; i++) {
const prevChild = c1[i];
let newIndex;
if (prevChild.key != null) {
// 🔍 关键:通过 key 精准查找
newIndex = keyToNewIndexMap.get(prevChild.key);
}
if (newIndex !== undefined) {
// 找到了,更新节点
patch(prevChild, c2[newIndex], container);
} else {
// 没找到,卸载节点
unmount(prevChild);
}
}
}
6.3 patchUnkeyedChildren(无 key 的算法核心)
// 简化版核心代码
function patchUnkeyedChildren(c1, c2, container) {
const commonLength = Math.min(c1.length, c2.length);
// 🔍 关键:直接按索引比较
for (let i = 0; i < commonLength; i++) {
// 认为相同索引位置的就是同一个节点
patch(c1[i], c2[i], container);
}
// 处理多出来的节点
if (c1.length > c2.length) {
// 卸载多余的旧节点
for (let i = commonLength; i < c1.length; i++) {
unmount(c1[i]);
}
} else if (c2.length > c1.length) {
// 挂载新节点
for (let i = commonLength; i < c2.length; i++) {
patch(null, c2[i], container);
}
}
}