背景:
日常开发中,v-for不加key或者直接用数组索引做key有时候会出现元素错乱问题。
有时候不加key,也不会有问题。有时候却会有问题。
但是如果任何渲染列表的情况都加唯一key。那么有时候一些简单的结构是没有唯一key的。需要自己生成。这就增加了心智负担和不必要的代码。
本文深入解析不加key会产生问题的情况原因及解决方法(反之能知道什么时候可以不加key)。理解原理后写出更健壮的代码。
注:不加key或者用数组索引做key。虽然走的diff算法不同(有key走patchKeyedChildren(快速diff算法),没key走patchUnkeyedChildren)(简单diff算法),但是都会产生一样的问题,因为都是通过数组索引找到新旧元素,找到的元素可能不是同一个元素。
本文解析用patchUnkeyedChildren算法,也就是不加key的情况作为测试用例。
react也有一样的问题。react文档对于受控/非受控组件和组件复用的解释更详细。
patchUnkeyedChildren 算法步骤
-
找到新旧节点子节点的最短长度。
-
遍历最短长度的节点。新节点替换旧节点。
-
处理剩余的节点:
-
如果新节点长度大于旧节点长度,挂载剩余的新节点。
-
如果新节点长度小于旧节点长度,卸载剩余的旧节点。
这个过程实际上就是两个数组之间的对比和操作,通过索引来进行逐个元素的比较和处理
不加 Key 导致的问题解析
文本节点的情况【无问题】
例如:
- 旧节点:
[1,2,3] - 新节点:
[1,3]
按照上述算法:
- 旧节点
1和新节点1对比,相同,不更新。 - 旧节点
2和新节点3对比,相同(根据索引key,误认为是同一个节点),新节点3的值更新到旧节点2。(这里的更新单纯的 el.textContent='新的值') - 旧节点
3被卸载。
结果:
- 旧节点变为
[1,3]。
有自己状态的组件或临时 DOM(非受控输入框)的情况【有问题】
组件的对比主要是 props 是否有变化。例如:
- 旧组件:
[1,2,3] - 新组件:
[1,3]
按照上述算法:
- 旧组件
1和新组件1对比,相同,不更新。 - 旧组件
2和新组件3对比,props 不同,新组件3的 props 传给旧组件2(旧组件2并没有被删除,相同类型的dom,会被复用),又因为props是响应式对象,它的修改会触发使用了它值的页面(执行渲染函数)。 - 针对有自己状态的组件:props的更新虽然重新触发了渲染函数。但是setup不会再次执行。所以组件的状态保留了下来。
- 针对临时dom(非受控输入框):props的更新虽然重新触发了渲染函数。但是input标签在非受控模式下,其内部会维护值的状态。所以它的状态保留了下来。
旧组件
3被卸载。
结果变为:
- 新组件
[1,2],旧组件3被卸载。
具体原因
原因是复用 DOM 结构(dom类型一致就复用)只更新属性。例如:
- input 内部维护的值无法更新。
- 组件内部维护的值依赖组件的 props,但组件内部状态的初始化仅在组件初始化时执行。
解决方法
使用唯一 Key:
- 加了唯一 key,新旧节点可以找到对应的节点(相同节点)。这样,即使组件有自己的状态,也只需更新 props,不会出现问题。
- 不加 key 或使用索引作为 key 时,
找到的新旧节点可能不是相同节点。
测试用例
基于上述的结论。进行代码验证。使问题出现。
临时dom问题复现【会出现问题】
依次在输入框输入1 2 3
删除中间元素。结果符合上面的分析。
列表内子组件有自己的状态 【会出现问题】
依次在输入框输入1 2 3
删除中间元素。结果。主要看
组件自己的状态,不会更新:2。 可以证明这是旧节点。
被复用了。并且只更新了props。
临时dom改成受控模式【不会出现问题】
依次在输入框输入1 2 3
删除中间元素。不会出问题。 2被正确的删除掉了。
解析代码:
<template>
<div v-for="(item,index) in list">
<!-- 形如:
<input :value="text" @input="event => text = event.target.value">
可以看到text绑定了:value。那么text的更新会触发input的重新渲染。
-->
<input v-model="item.text" />
<button @click="deleteItem(index)">删除当前元素</button>
</div>
</template>
<script>
import { defineComponent, ref } from 'vue';
export default defineComponent({
setup() {
const list = ref([{text:''},{text:''},{text:''}]);
const deleteItem = (index)=>{
list.value.splice(index,1)
}
return { list , deleteItem };
},
});
</script>
日常开发中也要注意:
组件的props是响应式的。如果用在模板(编译后会变成渲染函数)上。 props修改会触发副作用函数(渲染函数)重新渲染页面。 但是setup里面的代码只会在组件初始化也就是created前执行一次。
如果基于props的值初始化定义了响应式数据并且使用到了模板上。props的值改变并不会重新触发渲染函数。
这个情况可以通过watch监听props的值进行处理。
总结
不加 key 或使用索引作为 key,(列表内的组件有自己状态 或者 临时dom(就是非受控组件/元素))会导致更新错误:
临时dom:
如<input/>标签 内部会维护自己的输入值状态,input 内部维护的值无法得到更新。
有自己的状态的子组件:
组件更新的时候主要替换props。
- 替换 props,更换属性。
- 如果组件内部状态依赖 props 初始化,随后渲染到页面,更新 props 只会触发页面的渲染,而 setup 函数仅在组件初始化时执行。
小结(精简版)
v-for不加key为什么会出问题?
其实就是dom元素被复用了。
旧组件和新组件都是相同类型节点的时候旧组件被复用。
复用:新组件的props更新到旧组件的props。 这样来完成组件更新。然后props的变化,导致旧组件会重新触发渲染函数。
这时候:
例如上面说的组件有自己的状态(并且被显示在界面(模板)上,这种情况下才会有显示问题)。或者单独的<input />,都是没办法更新到的。界面效果就是删除了但是又没删除掉。
组件自己的状态 setup在组件初始化时执行,props的变化影响不到它。
临时dom input标签的值是标签内部维护的值(非受控模式下)。