ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

vue v-for 必须加 key 的原因与 diff 算法原理详解

vue v-for 必须加 key 的原因与 diff 算法原理详解 刚入行那阵子我在一个后台管理系统里写商品列表因为删除商品后输入框里的内容串到了下一行排查了一下午。后来把:keyitem.id换成:keyindex的同事悄悄告诉我问题就是 key。这个经历让我意识到很多人天天写 v-for 却不明白 key 为什么是必选项。这篇内容就围绕为什么 v-for 必须加 key这件事展开适合刚学 Vue 的初学者也适合那些一直用 index 当 key、从没出过问题的正常开发者。你会搞清楚 diff 算法到底怎么用它来识别节点、去掉 key 后会有什么隐患、index 作为 key 有哪些隐蔽炸弹以及一套真正可靠的 key 选择方案。1. 先从 diff 算法说起v-for 更新列表时到底在做什么1.1 虚拟 DOM 与 diffVue 是怎么找不同的Vue 能成为主流框架很大程度靠的是数据驱动视图这套机制。你修改了数组页面自动更新听起来像魔法本质上是三件事模板编译成渲染函数、渲染函数生成虚拟 DOM一个用 JS 对象描述 DOM 结构的树、新旧虚拟 DOM 做对比后只更新发生变化的部分。这个对比过程就是 diff。Vue 的 diff 不是把整棵树推倒重来而是在同层级做比较逐层对比标签名、属性、子节点。同层比较的时间复杂度低配合 key 之后还能做更精准的节点移动识别。生活里可以这样理解你整理两个版本的乐高拼装说明书diff 就是只看同一页的图纸差异而不是把两套几千页的说明书从头到尾重新读一遍。1.2 没有 key 时的就地复用策略如果 v-for 没加 keyVue 会采用一种就地复用in-place patch的策略。具体来说新旧列表在同一位置上只要标签名相同就直接把旧节点的内容修补成新节点的内容能不重建就不重建。听起来很高效对吧问题就出在就地这两个字上。当列表的顺序发生变化或者中间插入、删除了一个节点后面的节点不会跟着调整自己的位置而是被强制原地更新成新的内容。这个策略假设你的列表项是纯展示的、没有内部状态那么复用确实省性能。可一旦列表项里存在输入框、复选框、组件状态、甚至只是图片的选中高亮就地复用就会让你看到匪夷所思的 bug。1.3 key 的存在让 Vue 有了身份标识加上 key 之后Vue 在 diff 时多了一个判断依据两棵树的同一层级上先根据 key 找到对应的老节点再判断节点本身是否需要更新。key 相当于给每个节点发了一张身份证即使它在数组里的位置变了Vue 也认得它可以把它移动到新位置而不是粗暴地覆盖内容。没有这个身份标识Vue 只能靠位置认人位置一变人就容易认错。这一点对于简单的li文本列表影响不明显一旦列表项内容负责比如带有表单控件、展开了面板、加载了图片认清谁是谁就至关重要。所以官方文档里那句key 是必须的不是规范洁癖是 diff 算法正确工作的前提。2. 为什么不能用 index 作为 key最常见也最隐蔽的错误2.1 index 作为 key 的典型翻车现场输入框串数据很多初学者的代码长这样li v-for(item, index) in list :keyindex input v-modelitem.name / button clickremove(index)删除/button /li看似一切正常因为你删除的是最后一项index 唯一且稳定不会出问题。但删除中间项试试。比如 list 有三项[{name: 苹果}, {name: 香蕉}, {name: 橙子}]你在第二项的输入框里敲了香蕉很甜然后删除第二项。此时你以为剩下的是苹果、橙子第二项输入框应该消失。实际上由于 key 用的是 indexVue 在 diff 时看到的是index 0 位置的节点还在index 1 位置的节点还在只是内容从香蕉很甜覆盖成了橙子对应的值。数组变成了两项index 0 和 index 1 都还在于是两个输入框仍然存在第一个是苹果第二个却是被你删掉的那个香蕉很甜。这种串数据的现象极其隐蔽因为页面结构没有明显崩坏只是某个输入框的内容停留在了不该出现的位置。如果把删除操作和接口请求、异步刷新结合起来基本只能靠断点日志去抓。这个案例是我当年排查数小时的根源。2.2 数组顺序变化时的状态错乱比删除中间项更常见的是排序和筛选。你有一个表格支持按价格升序、降序切换行内有个展开详情的开关某个用户把第三行展开了你一排序展开状态跑到了另一行上。原因跟上面一模一样index 作为 key排序之后新数组中每个位置对应的人变了但 Vue 认为位置没变就是同一个节点所以原封不动保留了这个位置的组件状态。复选框的勾选状态、手风琴的展开项、正在编辑的弹窗记录、图片懒加载的 loading 状态全都可能因为顺序变化而漂移。这些问题不是必现的有时候你加了一个新列或者改了某个字段状态错乱突然出现又消失非常考验耐心。2.3 index 作为 key 勉强可用的场景也不是说 index 作为 key 一定十恶不赦。如果你的列表满足下面所有条件用 index 作为 key 勉强能接受列表是静态数据渲染后不再增删改排序列表项没有任何内部状态纯展示列表项不包含表单控件、组件实例、异步加载内容你明确知道数据量很小性能不是瓶颈比如渲染一个从后端一次性获取的静态菜单性能要求也不高index 作为 key 基本不会出问题。但哪怕满足这些条件我依然建议用业务 id 或其他稳定标识。原因是今天满足明天不一定需求一变bug 就来了而且这类 bug 总是出现在最不该出现的生产环境。3. 正确姿势key 到底应该怎么选3.1 首选业务唯一 id最理想的 key 是后端返回的数据主键比如商品 id、用户 id、文章 id。这类 id 在业务上唯一且稳定不会随着列表顺序、筛选条件的变化而变化。写法上注意保持类型一致数字就都是数字字符串就都是字符串不要一会儿数字一会儿字符串类型不稳定也可能导致 diff 异常。li v-foritem in products :keyitem.id {{ item.name }} /li如果有复合主键比如同一个用户下有多个订单项订单项 id 本来就在全局唯一直接用订单项 id。如果接口没有给唯一 id可以请求后端补一个或者在前端组装数据时统一生成。前端的生成方案可以用Symbol()或者crypto.randomUUID()但注意别在渲染函数里直接调应该在数据进 store、进 data 的时候一次性生成并挂在对象上保证后续渲染和更新用的是同一份 id。3.2 没有 id 时的组合 key 方案有些数据天生没有唯一标识比如一段固定格式的日志、一组同名的配置项。这时候可以组合多个字段作为 keyli :key${item.type}-${item.code}-${item.idx}组合 key 的关键是保证组合后的字符串在当前列表内具备唯一性。你可以把type 当作分类、code 当作具体配置项、idx 当作同 code 下的顺序号。这种方案比直接套 index 稳妥得多至少删掉中间项后后面的节点不会因为 index 改变而错认身份。如果数据本身连可组合的字段都没有那就回到上一节说的进渲染前先给数据补一个自增 id 或随机 id。注意生成时机要在数据进入状态管理时就完成不要写在 computed 里每次计算都生成新 id那样会导致每次渲染 key 都变反而起不到稳定标识的作用。3.3 列表项包含组件时的 key 选择当你 v-for 的每一项不是纯标签而是一个封装好的子组件key 的重要性会再次翻倍。子组件内部往往有自己的 data、生命周期、异步请求组件内部状态一旦因为 key 错误被复用会出现比输入框串数据更严重的问题比如接口请求被跳过、生命周期不执行、watch 不触发。比如一个列表项组件在 mounted 里拉取详情数据如果你用 index 作为 key删除中间某一项后后面的组件实例被直接复用mounted 不会重新执行新项展示的还是旧项请求来的详情。这种 bug 从表现上看就是数据一直是旧的。换成稳定的 key 之后Vue 能识别出组件身份变化销毁旧组件、创建新组件生命周期正常跑。有一种稳妥做法即使子组件内部没有状态也给组件加 key。多一层保障不亏尤其代码要给别人维护时一个醒目的:keyitem.id就能避免后续同事踩坑。4. 实操演示从 bug 复现到修复全过程4.1 复现一个可删除的列表却出现了状态串位搭一个最小可复现的 demo。场景待办事项列表每项有一个输入框一个删除按钮初始数据是三个对象data() { return { todos: [ { id: 1, text: 写周报 }, { id: 2, text: 修 bug }, { id: 3, text: 开会 } ] } }渲染层用 index 作为 keyul li v-for(todo, index) in todos :keyindex input v-modeltodo.text / button clickremoveAt(index)删除/button /li /ul此时在修 bug那一行输入框里敲入修一个超级难的 bug然后点击这行的删除按钮。删除后打印 todos你发现剩下的是写周报和开会但页面上两个输入框的内容分别是写周报和修一个超级难的 bug。第二项输入框残留了已删除项的内容虽然底层数据已经删掉了那个对象可输入框显示的内容却停留在旧状态。这个现象就是 index 作为 key 引发的经典状态漂移。Vue 复用了第二行的 DOM 节点和输入框实例只是把原本绑定到新数据的 text 覆盖上去可输入框内部的值并没有正确同步又或者同步了但组件状态和 DOM 状态不一致最终展示出错。4.2 修复换成稳定 id 之后的变化一种简单的修法是把 key 换成 todo.idul li v-fortodo in todos :keytodo.id input v-modeltodo.text / button clickremoveById(todo.id)删除/button /li /ul删除按钮改成按下 id 删除removeById(id) { this.todos this.todos.filter(t t.id ! id) }此时再重复上一节操作在修 bug输入框敲入内容后删除该项页面剩下两项输入框内容分别是写周报和开会完全符合预期。因为 Vue 通过 id 识别出删除的是第二项所以它直接把第二项对应的 DOM 节点移除而不是把第三项原地覆盖成第二项也就不存在输入框内容被错误保留的问题了。这里有个细节值得注意Vue 对于 key 不同的节点会走创建/销毁逻辑对于 key 相同的节点才会走更新逻辑。id 唯一且稳定删掉的节点 id 不存在了新数组里没有这个 keyVue 就正常卸载而 index 作为 key 时删除后 index 1 仍然存在Vue 认为节点没被删只是内容变了所以就做了更新而不是删除问题就出在这里。4.3 测试建议什么场景下特别要验证 key改完 key 之后除了功能回归我建议针对下面几个场景做专门验证删除第一项、中间项、最后一项分别观察后续项的表单状态、展开状态是否正常对列表做升序、降序、随机排序观察是否有状态漂移在数据前面插入一项观察原本位于后面的组件是否出现 mounted 不执行、数据错乱打开控制台看 Vue 的 warning如果有Duplicate keys found说明 key 不唯一需要立即修复如果你在公司项目里接手了一个没有 key 或 key 乱写的列表按上面几个场景过一遍大概率能暴露出隐藏 bug。修好之后再回归这些场景基本就能确认 key 方案可靠了。5. 常见问题与进阶排查手册5.1 key 在 v-for 里的作用域范围先说一个很多人搞错的概念key 不需要全局唯一只需要在同一层级的兄弟节点之间唯一。你写两个独立的 v-for一个渲染商品列表一个渲染日志列表两边都用 id1 没有任何问题因为它们在不同的容器里。Vue 做 diff 时是在各自的父节点下比较子节点key 的作用域就是当前父节点的直接子节点。所以你在嵌套循环里可以放心地写两个:keyitem.id只要它们不在同一个容器下就互不干扰。这个特性和 React 的 key 语义一致理解透了就不会因为两个列表 key 冲突这种伪问题分心。5.2 v-for 与 v-if 同用的坑另一个和 v-for 绑定的高频问题是 v-if 和 v-for 写在同一个元素上。Vue 2 中 v-for 的优先级比 v-if 高也就是说每次渲染都会先循环再判断哪怕你只显示其中一小部分也要遍历整个列表。Vue 3 把优先级改了v-if 优先于 v-for但代价是 v-if 访问不到循环变量容易直接报错。所以不管哪个版本我都建议把需要过滤的数据先用 computed 处理再交给 v-for 渲染。这样既避免优先级陷阱也减轻了视图层逻辑的复杂度。配合 key 一起使用一个完整健康的列表渲染应该是computed 过滤数据、v-for 循环、稳定 key 标识每一项。5.3 key 的另类玩法强制重新渲染组件key 值变化会导致组件销毁重建很多人只把它当成一个错误规避工具但它其实是一个有用的主动手段。比如你要在某个组件状态彻底混乱时强制重置可以这样child-component :keyrefreshKey /点按钮时让 refreshKey 自增组件就会被销毁重建所有内部数据、DOM 状态、异步请求全部从零开始。这个方法可以用于刷新当前模块、重置表单、切换表格视图等场景比手动调用组件内部 reset 方法更彻底因为整个实例都换掉了。不过要注意频繁改变 key 会带来销毁重建的性能开销不能把它当日常刷新按钮用。适合的场景是确实需要组件回到初始状态且组件内部状态太多、手动重置容易遗漏。5.4 Vue 中 key 相关问题的快速检查表最后整理一份可以直接翻的检查表按优先级从高到低检查点做法说明key 是否必填所有 v-for 都加 key没有 key 会产生就地复用隐患key 是否稳定使用业务唯一 idindex 和随机值都不稳定key 是否唯一在同层兄弟节点内确认无重复重复 key 会导致节点错乱key 类型是否一致全部使用数字或全部使用字符串混合类型可能影响比较是否在渲染中生成 key不可以在渲染中随机生成每次渲染都变化会破坏复用嵌套循环是否放心不同父节点下 key 可以相同作用域仅限兄弟节点之间强制刷新是否用 key确定需要重建时再用频繁重建会带来额外开销这份表格是我多年排列表相关 bug 的经验浓缩。你可以在代码 review 时按这个清单逐项过也能在接到列表状态错乱的工单时用来定位问题。大多数情况下前三条查完就破案了。我个人强烈建议在团队的 Vue 项目里做一个 ESLint 规则强制要求vue/require-v-for-key开启很多初学者试着把这条规则关掉的时候我都会拦一下关掉之前先想清楚能不能保证列表永不改顺序、永不加删、永无内部状态。如果答案是不确定那这条规则就老老实实留着。再分享一个小技巧如果你负责维护一个老项目里面大量代码都是 index 作为 key不敢一下子全改可以先挑带表单控件、带展开面板、带异步请求的列表组件动手改。这类组件最容易出状态漂移。改完之后跑一遍上面的验证场景你大概率会看到不少隐藏 bug 被顺带修掉。那种咦这个 bug 一直存在但没人发现的成就感还是很值得体验的。
返回列表