当心数据传递陷阱

在开发 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 数组进行修改的,就需要克隆,尽量不要影响原始数据,也就是说传入的数据应该是“只读的”。但是有时候,基于性能的考虑,就不会做克隆了。

另外,如果要克隆,也可以根据具体场景,做部分克隆,而不是整个对象深度克隆,在性能和安全之间找到平衡点。

你可能感兴趣的:(当心数据传递陷阱)