Vue 3 v-for key 原理核心笔记

0 阅读4分钟

一、问题:为什么不能用 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);
    }
  }
}