在开发 Vue 组件的时候,会遇到各种复杂数据传递的问题,稍不留神,就容易出现一些莫名的错误。针对这块内容,先从一段代码说起。
一段代码
import Vue from 'vue';
Vue.component('FilterPanel', {
props: {
datasource: Array
},
data() {
return {
filteredDatasource: []
};
},
watch: {
datasource: {
handler(val) {
this.filter();
},
immediate: true
}
},
methods: {
filter() {
this.filteredDatasource = this.datasource.map(item => {
return {
...item,
hidden: false
};
});
}
},
template: `
`
});
Vue.component('List', {
props: {
datasource: Array,
selected: Array
},
watch: {
datasource: {
handler(val) {
const selected = [];
this.datasource.forEach(item => {
if (item.name === 'yibuyisheng') {
selected.push(item.value);
}
});
this.$emit('update:selected', selected);
},
immediate: true
}
},
template: `
- {{ item.name }}
`
});
new Vue({
el: '#app',
data() {
return {
selected: [],
originDatasource: [
{
name: 'yibuyisheng',
value: 'yibuyisheng'
},
{
name: 'zhangsan',
value: 'zhangsan'
}
]
};
},
template: `
`
});
代码功能介绍
在这段代码中,存在两个组件: FilterPanel 、 List 。 FilterPanel 主要用于过滤数据, List 主要用于展示过滤后的数据。
FilterPanel 在过滤数据的时候,并没有直接把不满足条件的数据从数据源中删掉,而是打上一个标记,让外部调用者决定如何处理打上标记的数据项。
而在把过滤好的数据( scopes.options )传递给 List 之前,事先做了一遍“数据项删除”操作,根据原数据中打好的 filter 标记来删除被过滤掉的项,然后传给 List 。
List 组件除了直接展示传入的数据之外,也做了“多选功能”,将指定选中的项通过 prop sync 的方式传出去。
为了演示目的,示例代码中省去了很多重要的功能逻辑处理,仅留下了关键部分。
问题来了
完成这块功能之后,接下来就是在浏览器里面执行了。很不幸,执行时,我们发现浏览器卡死了!
看起来功能实现都挺正常的,为什么会卡死呢?
执行流程
为了搞清楚这个问题,我们就得仔细思考其中的执行流程了。
首先从这块模板代码看起:
初始的 originDatasource
传给了 FilterPanel , FilterPanel 在拿到 datasource 数据之后,立即对其进行了筛选过滤操作:
{
...
watch: {
datasource: {
handler(val) {
this.filter();
},
immediate: true
}
}
...
}
在过滤操作中,新生成了 filteredDatasource 数组(注意并没有删除掉被过滤项,只是打了一个 hidden
标记):
filter() {
this.filteredDatasource = this.datasource.map(item => {
return {
...item,
hidden: false
};
});
}
新生成的 filteredDatasource 通过作用域插槽传给了父模板( 最开始看到的“入口模板” ):
父模板拿到这个新对象之后,先根据 hidden
过滤掉不需要的数据:
scopes.options.filter(item => !item.hidden)
然后传给 List 组件。
List 组件在接收到 datasource 之后,马上做两件事:
渲染 datasource 数据。
-
根据新的 datasource 刷新一下 selected 数据,防止 selected 数组中存在和 datasource 不一致的数据:
{ ... watch: { datasource: { handler(val) { const selected = []; this.datasource.forEach(item => { if (item.name === 'yibuyisheng') { selected.push(item.value); } }); this.$emit('update:selected', selected); }, immediate: true } } ... }
在 selected 更新完之后,会立马通知外部(“入口模板”) selected 已经发生了变化,在父模板中就对 selected 进行了同步。
由于父模板对应的组件中有 selected 数据,并且在模板中得到了使用,并且接收了 List 组件同步上来的新数组对象,那么对应的模板函数就应该重新渲染,于是如下模板代码得以重新执行:
下面这块表达式也会重新执行:
scopes.options.filter(item => !item.hidden)
执行完之后,生成新的对象,传给 List 组件,然后 List 组件中监听到 datasource 变化,重新计算出新的 selected 对象,再将新的 selected 对象同步到父模板中……
所以,就卡死了。
解决方案
通过对执行流程的分析,我们理解到了问题所在,那么如何解决呢?
很明显, List 组件中,在抛出 selected 发生变化的事件之前,应该检查“ selected 是否真的发生变化了”,借助于 lodash.union
方法,我们可以这么判断:
// 如果相等,就说明没发生变化
union(newSelected, oldSelected).length === newSelected.length;
注意此处不能用简单的 deepEqual
去判断,因为 [1, 2]
和 [2, 1]
是相等的。
发散
对于上面这个场景引发的问题,可以发散思考一下。
关于向上同步数据 update:xxx
在实际开发中,我们应当根据具体场景,确保数据真的发生了变化,才向上同步数据。
对于 js 中的简单类型(比如 string 、 number 等等),很容易判断是否发生了变化。
对于复杂的类型(比如复杂对象、数组),情况就复杂了,得看具体场景:
- 有的场景,只需要比较引用是否发生了变化,即是否生成了新的对象/数组。
- 大多数时候,可能得关注对象/数组内容是否真的发生了变化,比如上述示例。
watch 中新旧值的比较
Vue 的 watch 默认是采用 ===
的方式判断相等性,所以并不能满足所有场景。而 watch 的 deep
配置也有局限性,并不能覆盖所有场景,很多时候我们要根据具体需求实现自己的相等判断。比如:
- 判断两个数据相等,很多时候是看两个数组是否包含相同的元素,而不严格要求相同位置必须包含相同元素。
- 判断两个对象相等,有时候并不需要完整的深度递归判断,可能判断到某一层级就行了,或者说判断两个对象里面特定的属性值是否相同就行了。
要不要克隆传入的 props
在组件开发中,很多组件会接收复杂数据,比如 Table 组件,要接收一个数组,数组中每个元素是一个复杂的对象。这个时候就要考虑是不是要克隆一下传入的复杂数据了。
为什么要克隆呢?因为组件内部可能会对 props 数据进行非引用式修改,从而对外部访问该数据的地方造成无法预知的行为。
是不是任何时候都应该克隆呢?显然不是的,一般来说:组件内部会对 props 数组进行修改的,就需要克隆,尽量不要影响原始数据,也就是说传入的数据应该是“只读的”。但是有时候,基于性能的考虑,就不会做克隆了。
另外,如果要克隆,也可以根据具体场景,做部分克隆,而不是整个对象深度克隆,在性能和安全之间找到平衡点。