Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue 2中array observer和$set如何实现 #7

Open
shen-zhao opened this issue Apr 27, 2021 · 3 comments
Open

Vue 2中array observer和$set如何实现 #7

shen-zhao opened this issue Apr 27, 2021 · 3 comments
Labels

Comments

@shen-zhao
Copy link
Owner

shen-zhao commented Apr 27, 2021

最近发现了一个比较大的认知错误,一直以为vue官方说不支持数组的索引进行get和set就是因为Object.defineProperty不支持数组索引的拦截,这个认知真是打错特错了,自动动手测试了一下,数组索引是可以拦截的,静下心来思考一下,本来在js中数组就是一种特殊的对象,数组的索引其实就是对象的属性,理应和对象的行为是一样的,通过这个问题我们得到一个教训:一定要自己实践去验证,要不然误人子弟!!!

既然如此,那么vue 2为什么没有对数组的索引定义响应式属性呢?这个问题在这个文章中有一定结论,总的来说:性能代价和实际用户体验的权衡,未来我也会专门写一篇文章来探讨一下。所以下面探讨的内容的动机也就变成了:vue 2中对于数组索引没有使用get/setter,那么如何实现自动响应呢?

可能大家都比较了解vue 2基于 Object.defineProperty 实现的响应式原理,以及通过 getter/setter 实现自动依赖收集的过程,不过 Object.defineProperty 这个api有一些缺陷:

  • 只能对对象的属性进行拦截
  • 不支持数组索引的访问器定义
  • 属性必须先初始化才能定义拦截

虽然 Object.defineProperty 有上述的缺陷,但是vue 2还是实现了几乎全场景的自动响应和依赖收集(只限对象和数组,数组不能索引操作)

$set

$set 可以实现对于某个对象新增响应式属性,并触发一次 notify,而且对于数组也是有效的,那么它到底是如何确保watcher能够收集数组的依赖而进行更新呢?

让我们在回顾一下响应式对象的定义和依赖收集过程

1. 响应式对象(数组)定义

class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    // 对对象设置响应式标识
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      // 复制代理方法
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

响应式对象定义的实现是通过上面的 Observer

属性:

  • value
  • dep
  • vmCount

方法:

  • constructor
  • walk
  • observeArray

实例化过程中,判断 value 类型,如果是对象,则对对象的的属性定义访问器属性,如果是数组,则进行其他特殊处理。

了解vue 2响应式的同学可能都知道,在定义对象的访问器属性时,对于每个属性都会定义一个 Dep 实例,并存在闭包当中,用于后续的属性触发依赖收集和通知更新(notify),但是定义响应式对象的构造器为什么也会存在一个 Dep 实例呢?这个是我一直没有搞明白的地方。

同时疑问也来源于 getter 里的这段代码:

// defineReactive

let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        /* 疑问开始 */
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
        /* 疑问结束 */
      }
      return value
    },

正如上面这段代码中注释的位置,我一直搞不明白这里是为了做什么。
疑问:watcher对于该属性的依赖已经收集了,为什么还要对属性值为对象的进行依赖收集呢?

这个疑问存在了好久,当某次再次重温这段代码的时候,突然想到,既然这里可以收集依赖,那么将来必定会有通知(notify)依赖更新的逻辑,所以全局搜索的了一下 notify 方法名,出现的位置(排除setter里的):

  • $set 原始实现
  • $del 原始实现
  • array特定方法的拦截实现

我们先来研究一下 $set 方法

暂时跳过数组的相关逻辑

function set (target: Array<any> | 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)) { // 判断vmCount来判断是否是root $data
    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
}

这个方法的主要逻辑是对一个对象的属性进行扩展,如果对象中已经存在该属性,则直接设置新值后返回,如果没有这个这个属性,首先判断这个对象是不是一个响应式对象(__ob__),如果不是,直接设置属性和值后返回,如果是响应式对象,则对这个新属性设置访问器属性。然后通知这个依赖于该对象的 watcher 进行更新(ob.dep.notify)

$set 方法里我们发现了 ob.dep.notify 的调用,这个 dep 就是在 Observer 实例中针对响应式对象的 Dep 实例,这既然有 notify,那就意味着 dep 会在某个时机触发依赖收集,那么 Observer 实例中的 dep 在哪个环节进行的依赖收集呢?还记得刚才提出的疑问代码吗,不错,就是上面 getter 里的逻辑,让我们再来看一下:

// defineReactive

let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        /* Observer dep 收集开始 */
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
        /* Observer dep 收集结束 */
      }
      return value
    },

正是这段逻辑触发了依赖收集,具体分析下:childOb 是对 value 进行响应式处理(构造 Observer),如果 childOb 存在,说明 value 是一个对象或数组,当属性读操作正好读取到这个 value 的时候,watcher 除了收集属性本身的依赖,顺便也针对这个 value 对象进行收集,只有这样,在运行时针对这个 value 进行 $set 操作时,才能正确的通知 watcher 更新。

局限性的:只有父级进行属性访问的时候,子级 value 才能触发 value 本身的依赖收集,所以进行 $set 是,target 参数不能设置 $data (组件根 data)本身,只能针对 $data 的属性值。

本身对于 data 选项还有一个局限性:data 本身必须返回的是纯对象([Object object]),不能是一个数组,因为数组无法对下标设置访问器属性,这个在 data 初始化时已经进行了处理:

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  // data必须是一个纯Object
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  
  // .....
}

同理,$del$set 原理类似:

function del (target: Array<any> | Object, key: any) {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  if (!hasOwn(target, key)) {
    return
  }
  delete target[key]
  if (!ob) {
    return
  }
  ob.dep.notify()
}

Array Observer

开篇我们提到了 Object.defineProperty 不支持数组,那么vue2是如何实现数组修改的自动响应呢?
vue 2通过对数组的一些原始方法的代理,实现了数组的响应,但是对于数组的索引操作还是无能为力,所以vue2建议操作数组使用数组的原生方法进行操作。

下面我们来了解一下vue 2如何对数组的方法进行代理,并如何进行依赖收集的
回到 Observer 类(只保留了数组的处理):

class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

实例化 Observer 类时,会判断 value 是否为数组,当为数组时对数组进行了如下处理:

if (hasProto) {
  protoAugment(value, arrayMethods)
} else {
  copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)

不管是 protoAugment 还是 copyAugment 都是为了改写需要代理的数组方法:
我们看一下如何进行方法代理:

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
/**
 * Intercept mutating methods and emit events
 */
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) // !!!
    // notify change
    ob.dep.notify() // !!!
    return result
  })
})

这里逻辑比较简单,就是常规对于方法的重写代理,关键点在于对于进行依赖通知以及新添加元素的响应式处理。

observeArray 方法,对数组的每一项进行响应式处理,这样做也是为了手动跳过数组索引不能拦截的问题,直接访问数组每一项并尝试进行响应式注册:

observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }

notify 逻辑比较容易理解,那么数组时如何进行依赖收集的呢?其实在探究 $set 时已经出现答案了,当属性访问器触发 getter 时,会进行该属性的依赖收集,同时如果属性值为对象或数组,会同时触发属性值(Observer.dep)本身的依赖收集,数组更为特殊,不仅触发自己的依赖收集,还会对数组的每一项触发收集。

为什么需要对数组的每一项执行收集呢?因为数组不像对象那样属性被代理后同时收集 value 对象的代理,因为数组每一项的访问拦截是断层的,arr[i]不会触发get,如果此项是一个对象,此对象不会触发依赖收集,由于数组本身依赖收集就需要对象属性访问的支持,所以在数组进行收集时,必须也对数组的每一项(对象)进行依赖收集,这样做就绕过数组每一项的访问无法被拦截的问题而直接访问每一项并收集当前依赖,如果不这样做,对于数组项中的对象进行 $set 操作就会失效,因为该对象没有被依赖,就无通知可发,但是$set新增的属性有可能真的在这个组件中使用,这就会导致组件没有更新,这样的错误是不应该出现的!。

if (childOb) {
  childOb.dep.depend()
  if (Array.isArray(value)) {
    dependArray(value)
  }
}

function dependArray (value: Array<any>) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i]
    e && e.__ob__ && e.__ob__.dep.depend()
    if (Array.isArray(e)) {
      dependArray(e)
    }
  }
}

总结

为了实现数组的响应式处理,在 Observer 类中也存在一个 Dep 实例进行依赖收集,这样可以通过一定约束(根data必须为纯对象),在对属性进行依赖收集时同时对属性值为对象或数组类型的进行 Observer 构造,使 value 也能收集依赖,根本上来说这是为了解决数组响应式才采取的方案,通过这个方案,同时可以实现对对象和数组的运行时进行 $set 和 $del 的能力,不得不惊叹尤大的巧妙设计!

@TcTOrz
Copy link

TcTOrz commented May 7, 2021

总结的很好哇!

父级进行依赖收集以后,如果子集是对象或数组,那么子集也进行一次收集,这样Vue.set等方法就可以使用dep.notify方法来更新watcher。
但是这样做也有缺陷,就是必须要有父级并且父级必须是被Observer过的才能使用Vue.set等方法。
这么理解对么?

@shen-zhao
Copy link
Owner Author

shen-zhao commented May 10, 2021

总结的很好哇!

父级进行依赖收集以后,如果子集是对象或数组,那么子集也进行一次收集,这样Vue.set等方法就可以使用dep.notify方法来更新watcher。
但是这样做也有缺陷,就是必须要有父级并且父级必须是被Observer过的才能使用Vue.set等方法。
这么理解对么?

我重新组织思路:
$set提供一种机制,针对对象或数组动态设置属性和值(针对对象内部会设置计算属性)时,能触发一次更新,更新就意味着需要通知 watcher 进行 update,首先谁触发通知?答案是$set的目标对象,当某个属性访问访问到该对象时,watcher收集了属性为依赖,也要对对象触发一次收集。其次哪些 watcher 需要update?答案就是前面对象本身触发收集的 watcher,为什么呢?因为属性访问了该对象,那么就说明 watcher 可能依赖$set设置或更新的新属性和值,注意这里说的是可能,如果是新属性,我们不能确定该依赖是否读取了新属性,但是因为不确定,所以不一定真的需要更新,所以本次更新带有不确定性,但是万一依赖了呢,所以还必须要更新。所以官方建议对所有可能会使用的值进行初始化,而不建议用$set,除非特殊场景

数组比较特殊,defineProperty本身就不支持数组索引访问,vue没有对数组的索引进行响应式处理,而是通过方法拦截实现的,而且方法拦截依赖于数组对象本身的Dep,数组中的每一项的访问跟对象的属性访问相比其实是断层的,对象可以通过 a.b.c逐层收集依赖,但是数组不支持a[i]的拦截,所以,虽然出现了断层,但是我们可以在改数组被读取时,直接对数组进行遍历,跳过数组对数组的每一项主动收集依赖,这其实也认定了数组的每一项一定会被依赖到,也就是哪怕我们只是读取了该数组,比如:let arr = this.form.arr,即使没有用到数组的每一项,数组的每一项也都被watcher订阅为依赖,想想一种场景,我们把这个数组又通过props层层传递给子孙组件,在子孙组件里使用,比我数组的某一项是个对象,我们在子孙组件里修改了这个对象的值,结果是这个组件本身会update,并且定义该数组的祖先组件也会触发update,即使祖先组件里没有使用这一项对象里的某个属性。其实对象也会出现这种极端情况,但是对象需要使用$set才会触发,数组触发的条件更隐秘。

这些问题其实也是由于js的数据类型特性(引用数组类型)带来的,所以就强调单向数据流、prop 原子化,如果很难原子化,不管什么情况都不建议直接修改prop,这样也就能尽量避免这种无法预知的update。

这里感觉还是有点绕,实际上需要很多极端的case来验证,上述观点某些是根据代码的意图来总结的,可能哪里不对造成误解...

@shen-zhao
Copy link
Owner Author

shen-zhao commented May 10, 2021

总结的很好哇!

父级进行依赖收集以后,如果子集是对象或数组,那么子集也进行一次收集,这样Vue.set等方法就可以使用dep.notify方法来更新watcher。
但是这样做也有缺陷,就是必须要有父级并且父级必须是被Observer过的才能使用Vue.set等方法。
这么理解对么?

对的,第一句话没有问题,第二句话其实不算是一个问题,因为只要保证data选项返回的是一个纯对象,那么用户真正定义的属性和值肯定是有父级的,源码中在data初始化的时候也进行了判断,data选项必须返回一个纯对象{},这是一个强制约定:

// 这种情况肯定有父级
export default {
  data() {
    return {
      arr: [1,2,3],
      obj: {}
    }
  }
}

// 这种情况是不被允许的
// 从使用的角度上将,vue把data的数据都挂载到实例上,比如this.arr,那么下面这种写法也读取不到,难道用`this.$data`吗,哈哈,就算这样,没有收集到依赖,也没法触发更新。
export default {
  data() {
    return [1,2,3,4]
  }
}

@shen-zhao shen-zhao removed the 源码 label May 21, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants