从数组入手浅析Vue响应式原理

 约 13 分钟

  最近在用Vue开发一个后台管理的demo,有一个非常常规的需求。然而这个常规的需求中,包含了大量的知识点。有一个产品表格,用来显示不同产品的信息。然后表格要有一个内嵌编辑的功能,点击操作栏的编辑按钮,对应行的信息列就变成输入框。第一版的代码大致上像这样。

<template>
    <!-- ... -->
    <el-input v-show="scope.row.edit" size="small" v-model="scope.row.description"></el-input>
    <span v-show="!scope.row.edit">{{scope.row.description}}</span>
    <!-- ... -->
    <el-button @click="scope.row.edit = !scope.row.edit" type="text" size="small">编辑</el-button>
    <!-- ... -->
</template>

<script lang="ts">
import { Vue, Component} from "vue-property-decorator";

@Component
export default class Project extends Vue {
  products = [];
  mounted() {
    this.$store.dispatch('GET_PRODUCTS').then(() => {
       this.products = this.$store.getters.products
       this.products.map((item: any) => {
         item.edit = false;
         return item;
       });
    });
  }
}
</script>

  逻辑很简单,我在表格数据数组中,给每一个对象都加入一个初始值为false的属性"edit",然后根据这个属性的值,使用v-show来决定渲染的是文本还是输入框,是“编辑”还是“保存”。
  然而运行起来之后的表现并不是像我想的一样,事实上,点击编辑按钮后,对应产品的“产品描述”并没有变成输入框,编辑按钮也没有变成保存按钮。而我通过vue-devtool查看数据发现,事实上对应的edit属性确实已经变了,只是页面上的组件没有正确渲染。这让我很困惑,说好的双向绑定呢,为什么model层上的变化没有响应到view层上呢。

原因分析

  首先,由于页面初始显示是正确的,把edit的初始值改成true后,也会有输入框出现,所以肯定不是代码逻辑的问题。当我试着把v-show的判断条件改成数组中的对象原本就有的属性时,发现编辑状态的切换突然变得正常了。而一旦我把判断条件改回后来插入的edit时,一切又变得不正常了。因此我推测,一定是数据绑定出了什么问题。
  我在网上查了一下,有些类似的问题,大多数的解决方案是,el-table加上一个随机数key值:key="Math.random()"。试了一下,发现真的有用。之所以有用是因为,每次对这个表格有操作,key值都会变,这就相当于产生了一个新的table,浏览器就会根据model层的数据重新渲染,这时候显示当然就正确了。但可想而知,这样也会造成极大的性能浪费,而且这也没有解决数据绑定的问题。
  我又试着对代码做了一些修改。我把map和赋值操作放到了同一句里面去,代码变成了这样

 this.$store.dispatch(GET_PRODUCTS).then(() => {
       this.products = this.$store.getters.products.map((item: any) => {
         item.edit = false;
         return item;
       });
    });

神奇的事发生了,居然一切都恢复正常了。那么我就知道了,问题出在了数组和map函数上。

响应式原理

  为了探究这一切的原因,我再次点开了Vue的官网。在官网很下面的位置,找到了关于响应式原理的说明。这张图很好地说明了Vue实现双向绑定的原理。
image
  当一个javscript对象传入Vue实例的data中时,Vue会遍历该对象的所有属性,同时使用 Object.defineProperty方法将这些属性全都转成 getter/setter每个组件实例都对应一个watcher实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。之后当依赖项的数据发生变化,也就是setter触发时,会通知watcher,从而使它关联的组件重新渲染。
  而由于javascript的限制,Vue不能检测到对象的添加或者删除。并且Vue在初始化实例时就对属性执行了setter/getter转化过程,所以属性必须开始就在对象上,这样才能让Vue转化它。而动态添加的根级别的属性,则不会转化成响应式的属性。也就是说,往已经创建的实例上添加的根级别的属性,都会是非响应式的。但是,可以使用 Vue.set(object, propertyName, value) 或者vm.$set(object, propertyName, value)方法向嵌套对象添加响应式属性。
  这里,数组相关的注意事项被额外提了出来。由于 JavaScript 的限制,Vue 不能检测以下数组的变动:

  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength

  解决方法也很简单,使用上面提到的set方法就可以解决这个问题。与此同时,官网上还有一段专门针对数组的变异方法的说明。
  所谓的变异方法,顾名思义,会改变调用了这些方法的原始数组。相比之下,也有非变异 (non-mutating method) 方法,例如 filter()、concat() 和 slice() 。它们不会改变原始数组,而总是返回一个新数组。当使用非变异方法时,可以用新数组替换旧数组。并且,Vue还非常智能的会对于没有变化的dom进行重用,并不会整个进行更新。
  看到这儿,我终于找到问题的关键在哪儿了。其实网上的各种说法都不准确,真正出问题的点在于map函数的使用上。map是一个非变异方法,方法本身并不会改变原数组,而是会返回一个新数组。因此,Vue并没有对map方法进行包装,而是建议替换原数组。然而我在用的时候并没有注意到这一点,在使用的时候利用指针特性,把map方法当做变异方法来用,直接改变原数组,这自然就不会被Vue检测到了。因此,新添加到数组中的对象中的edit属性,就成了非响应式的属性了,改变它自然不会让组件重新渲染。

解决方法

原理都已经搞清楚了,接下来我总结了一下这类数组问题的几种解决方法。

1.添加随机数key(不建议)

  在el-table标签上添加:key="Math.random()",不管做了什么,都强制刷新整个表格,非常不推荐,极大的性能消耗。

2.正确使用数组方法

  在使用数组方法的时候,分清变异方法和非变异方法,用非变异方法的时候,要用新数组替代旧数组,而不是直接变换原数组。

3.使用Vue.set方法(建议)

  我在'vue/src/core/observer/index.js'中找到了set方法的源码。我们发现set函数接收三个参数分别为 target、key、val,其中target的值为数组或者对象,这正好和官网给出的调用Vue.set()方法时传入的参数参数对应上。然后往下看实现,我基本上给每一行都加上了注释。

export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {//判断target的类型是否符合要求,若不符合要求,且不在生产环境下,就抛出警告。
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {//如果target是数组,且key值合法
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)//用包装好的变异方法splice进行赋值。
    return val
  }
  if (key in target && !(key in Object.prototype)) {//如果key是target中原有的属性,就直接赋值。
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__//响应式属性的observer对象,有这个对象就代表是响应式的。
  if (target._isVue || (ob && ob.vmCount)) {//如果当前的target对象是vue实例对象或者是根数据对象,就抛出警告。
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if (!ob) {//如果不存在observer,那就不是响应式对象,直接赋值。
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)//给新属性添加依赖,以后直接修改属性就能重新渲染。
  ob.dep.notify()//直接触发依赖。
  return val
}

可以看到,set方法对于数组的处理其实非常简单,就是调用了包装好的splice方法。那么再来看一下包装Array变异方法的代码实现,我同样给每一行加上了注释。其实做的事情也不多,主要就是给每个新添加的元素都加上观察者。

...
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]//保存原方法。
  def(arrayMethods, method, function mutator (...args) {//修改方法映射,调用数组方法的时候实际上调用的是对应的mutator方法。
    const result = original.apply(this, args)//调用原方法,先把结果求出来
    const ob = this.__ob__//获取observer
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }//对于往数组中加元素的方法,获得添加的元素。
    if (inserted) ob.observeArray(inserted)//给添加的元素添加观察者。
    // notify change
    ob.dep.notify()//触发依赖。
    return result
  })
})
阅读 544

推荐阅读
目录