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

26次阅读

共计 4660 个字符,预计需要花费 12 分钟才能阅读完成。

  最近在用 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 实现双向绑定的原理。

  当一个 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
  })
})

正文完
 0