Vue|Vue 响应式原理剖析 —— 数据更新常见问题

概况 在 Vue 开发的过程中,多少都会遇到数据更新后,页面没有更新渲染这类问题。而在上两篇文章《Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(上)》和《Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(下)》中,从「实例化」、「渲染」、「数据更新」三条线完整地讲述了 Vue「响应式」的工作原理,本文正是基于这些原理去解决一些常见的数据更新相关问题。
对象数据的某些修改无法被检听? 如下的一个场景,obj.message 赋值时能否被监听响应呢?

var vm = new Vue({ data: { obj: { a: 1 }, }, template: '{{ obj.message }}' }); vm.obj.message = 'modified';

答案是不能被监听的。原因:对象属性的添加和删除无法被 Object.defineProperty 监听,正如前文所述,Vue 的数据响应式基于 Object.defineProperty 实现,因此也受限。
解决办法: Vue 提供了特定的方法 vm.$set(obj, propertyName, newValue) 来处理这种情况,至于该方法的具体逻辑,后面会详细展开说明。
数组数据的某些修改无法被监听? 如下的一个场景,三个赋值语句里面,哪个能被监听响应呢?
const vm = new Vue({ data: { items: [1, 2, 3, 4, 5], }, }); vm.items[1] = 8; vm.items[5] = 6; vm.items.length = 2;

答案是三个操作都不能被监听到。原因:
  • 第二个操作 vm.items[5] = 6 应该是比较明显的,与上面对象添加和删除属性类似,数组新添加的元素和删除元素无法被 Object.defineProperty 监听。
  • 第三个操作 vm.items.length = 2 也是由于 Object.defineProperty 的限制,数组的长度直接修改也无法被监听。
  • 最容易误判的可能是第一个操作 vm.items[1] = 8,一种较为常规的说法是 Object.defineProperty 不支持监听数组元素的变化,要验证这个说法可以直接用一个例子说明真实的情况。
如下的一个例子:
function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { console.log('读取 index ' + key, '当前值是 ' + val) return val; }, set(newVal) { if (val === newVal) { return; } val = newVal console.log('修改 index ' + key, '新值是 ' + val) } }) }const testArray = [1, 2, 3, 4, 5]testArray.forEach((c, index) => { defineReactive(testArray, index, c) }); testArray[0] = 100; testArray[5] = 600;

Vue|Vue 响应式原理剖析 —— 数据更新常见问题
文章图片

也可以点击这里打开示例查看控制台输出。可以看到,超出范围的数组元素操作 Object.defineProperty 确实无法监听,但范围内的元素重新赋值是可以被监听的。那么为什么在 Vue 中对数组类型的 data,直接使用下标赋值无法被监听呢?
【Vue|Vue 响应式原理剖析 —— 数据更新常见问题】答案是出于性能考虑,从上面的基础例子中可以看到,对象和数组如果需要监听每个属性和元素,实际上是对每个属性或者元素进行 Object.defineProperty 劫持,对象是监听 key 而数组则是以数字下标作为 key,数组的数据量可能会很大,因此 Vue 出于性能考虑,并没有对元素下标进行响应式处理。作为补充,Vue 对数组原型链上的几个方法进行劫持,对于会导致元素新增的3个方法 pushpopunshift 会在内部获取新增的元素,执行响应式的处理:
const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]methodsToPatch.forEach(function (method) { // cache original method const original = arrayProto[method] def(arrayMethods, method, function mutator (...args) { const result = original.apply(this, args) const ob = this.__ob__ let inserted switch (method) { case 'push': case 'unshift': inserted = args break case 'splice': inserted = args.slice(2) break } if (inserted) ob.observeArray(inserted) ob.dep.notify() return result }) })

与对象类似,如果需要为数字元素重新赋值,可以使用 vm.$set(arr, indexOfItem, newValue) 方法,这里展示一下 $set 的具体实现:
// 精简了非 production 逻辑 export function set (target: Array | Object, key: any, val: any): any { if (Array.isArray(target) && isValidArrayIndex(key)) { target.length = Math.max(target.length, key) target.splice(key, 1, val) return val } if (key in target && !(key in Object.prototype)) { target[key] = val return val } const ob = (target: any).__ob__ if (target._isVue || (ob && ob.vmCount)) { 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) { target[key] = val return val } defineReactive(ob.value, key, val) ob.dep.notify() return val }

主要的逻辑包括以下操作:
  1. 这个方法同时用于对象和数组,因此第一步会检验传入的 target 是否为数组,并且传入的 key(即数组的数字下标)是否符合数组的长度范围(如上面所述,Object.defineProperty 不支持劫持新添加的元素),符合的元素会调用 splice 插入到数组中,由于 splice 已经被劫持,新增加的元素会进行「响应式」处理。
  2. 判断如果 key 原先已存在,则无需再监听。
  3. 判断是根节点 Vue,即最外层的 Vue,或者已经有 __ob__ 属性(表示已经进行响应式处理,详情可以浏览前文),则无需再进行监听。
  4. 如果不符合前面的条件,则表明该属性需要执行「响应式」处理,会调用 defineReactive 方法(响应式数据封装的入口方法,详情可以浏览前文)。

    推荐阅读