Vue3和vue2新增属性触发更新对比

3,230 阅读6分钟

要触发更新,就需要我们在这之前进行依赖收集。但是新增的属性,并没有依赖收集,如何派发更新呢?借助之前已经收集依赖的属性,通知更新。

vue2新增属性的更新

对象新增属性的更新

因为vue2中是用Object.definedProperty实现响应式原理,所有原生不支持新增属性的拦截。vue2中提供了set方法供我们对新增的属性进行监听并触发更新。

function set (target: Array<any> | Object, key: any, val: any): any {
  ...
  defineReactive(ob.value, key, val)   //内部对新属性使用Object.definedProperty重新进行监听
  ob.dep.notify()                      //触发新增属性的目标对象的更新,来代替新增属性不能更新的缺陷
  return val
}

核心思路

  1. 手动调用set方法替代Object.definedProperty不能对新增属性进行监听的缺陷
  2. 因为新增属性没有收集依赖,所以借助新增属性的对象派发更新

数组调用常用api的更新

vue2中调用push,splice等数组api会触发更新,我们需要对原生api进行拦截,添加派发更新的操作。如果我们直接去修改Array.prototype的原型方法,会污染数组的原型,正常调用数组api会被修改。所有最好的办法是创建一个继承数组原型的新对象,在新对象上进行扩展需要触发更新的增强方法。

const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto) //创建一个继承数组原型的新对象

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) {  //在arrayMethods对象上扩展数组同名新方法
    const result = original.apply(this, args)  //调用数组原生api
    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
  })
})

然而这就结束了吗?我们怎么在调用数组的方法时映射到arrayMethods对象上呢? 所以我们需要在访问一个数组的时候对它进行拦截,代理到arrayMethods对象上。

export 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 ('__proto__' in {}) {
        protoAugment(value, arrayMethods)   // 将数组的原型指向arrayMethods
      } else {
        copyAugment(value, arrayMethods, arrayKeys)   // 对数组中扩展arrayMethods中的方法
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  ...
}

核心思路

  1. 对原数组api进行拦截,生成一个新方法(调用原方法,派发更新)扩展到从数组原型继承的新对象
  2. 访问数组的时候代理到新创建的对象上

vue3新增属性的更新

因为vue3中响应式api用的是proxy,proxy代理的是一个对象,天生支持对对象新增属性的监听。但是和vue2同样的问题,新增的属性并没有做依赖收集啊,怎么触发更新呢?

新增属性的更新

先看一个例子

const obj = {a:1}
const proxyObj = reactive(obj)
effect(()=>{
  console.log("... update", JSON.stringify(proxyObj));
})
proxyObj.b = 2

这种情况会触发更新么,会。JSON.stringify会对对象每个属性就行遍历,所以新增属性后遍历前后的值不相同,vue3中在遍历的时候用了一个特殊的标识进行依赖收集,然后在新增属性后触发那个特殊标识的收集的依赖的更新。

new Proxy(target,{
  get,
  set,
  deleteProperty,
  has,
  ownKeys: function ownKeys(target: object): (string | number | symbol)[] {
    track(target, "iterate", isArray(target) ? 'length' : Symbol("iterate")) //遍历数组用length作为特殊标识收集依赖,对象用Symbol("iterate")
    return Reflect.ownKeys(target)
  }
})

遍历数组和对象分别用不同的标识收集依赖,在新增属性的时候派发更新。

new Proxy(target,{
  get,
  set:function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    const oldValue = (target as any)[key]
    ...
    //判断是否是已有属性(数组则判断索引是否小于数组length,对象则通过hasOwnProperty判断是否对象中属性)
    const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key) 
    const result = Reflect.set(target, key, value, receiver)
    // don't trigger if target is something up in the prototype chain of original
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        trigger(target, "add", key, value)    // 新增属性的更新
      } else if (hasChanged(value, oldValue)) {
        trigger(target, "set", key, value, oldValue)
      }
    }
    return result
  },
  deleteProperty,
  has,
  ownKeys
})

再看下trigger函数,怎么处理新增属性的更新

function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  // targetMap是个weakMap,存储target对应一个存储所有属性的map映射,map中是每个属性对象effect的set集合的映射   targetMap<target,map>  map<prop,set>  set<effeft>
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // never been tracked
    return
  }
    
  //临时待执行的effect的set集合
  const effects = new Set<ReactiveEffect>()
  //把effetct添加到effects的集合中
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }
  //如果target是数组且key修改的是length的长度,例:数组[1,2,3] 修改 length=1则数组变为 [1],所以数组length之后的访问索引都变为undefined需要重新触发更新
  if (key === 'length' && isArray(target)) {
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        add(dep)
      }
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      add(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    switch (type) {
      case "add": //新增属性
        if (!isArray(target)) {   
          add(depsMap.get(Symbol("itrator")))   //对象添加遍历时Symbol("itrator")标识收集的effect
          if (isMap(target)) {
            add(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {
          // new index added to array -> length changes
          add(depsMap.get('length'))    //数组则添加遍历时length标识收集的effect
        }
        break
      case "delete":
        if (!isArray(target)) {
          add(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            add(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case "set":
        if (isMap(target)) {
          add(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }
  //执行每个effect
  const run = (effect: ReactiveEffect) => {
    if (__DEV__ && effect.options.onTrigger) {
      effect.options.onTrigger({
        effect,
        target,
        key,
        type,
        newValue,
        oldValue,
        oldTarget
      })
    }
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }
  //遍历effects集合
  effects.forEach(run)
}

核心思路

  1. 在遍历数组或对象的时候用一个特殊的标识作为key进行依赖收集
  2. 新增属性的时候触发这个特殊标识收集的依赖

调用数组api的更新

先看下proxy中对数组方法的拦截

new Proxy(target,{
  get: function get(target: Target, key: string | symbol, receiver: object) {
    ...
    const targetIsArray = isArray(target)
    //如果是数组且key是arrayInstrumentations中属性则代理调用arrayInstrumentations中方法
    if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }
    ...
  },
  set,
  deleteProperty,
  has,
  ownKeys
})

'includes', 'indexOf', 'lastIndexOf'方法的拦截。这些方法是遍历整个数组去查找某个元素,所以数组中任意一项值修改了的话,都有可能影响执行的结果,所以需要对每一项进行依赖收集,在修改的时候进行更新

(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
  const method = Array.prototype[key] as any
  arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
    const arr = toRaw(this)
    //对数组中每项进行依赖收集
    for (let i = 0, l = this.length; i < l; i++) {
      track(arr, "get", i + '')
    }
    // 执行原生方法
    const res = method.apply(arr, args)
    if (res === -1 || res === false) {
      // if that didn't work, run it again using raw values.
      return method.apply(arr, args.map(toRaw))
    } else {
      return res
    }
  }
})

'push', 'pop', 'shift', 'unshift', 'splice'因为执行这些方法的时候,会对一些其他属性的访问,例如:length等。所以在执行这些方法的时候需要暂停依赖收集,执行完过后恢复收集。

(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
  const method = Array.prototype[key] as any
  arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
    pauseTracking()  //暂停收集
    const res = method.apply(this, args)
    resetTracking()  //恢复收集
    return res
  }
})

核心思路

  1. 拦截原生api,做一些增强的操作
  2. 调用'includes', 'indexOf', 'lastIndexOf'方法时对数组每一项进行依赖收集
  3. 调用'push', 'pop', 'shift', 'unshift', 'splice'方法时暂停依赖收集,执行完毕后恢复

总结

1.不管是vue2还是vue3在新增属性的时候都是借助之前已经收集依赖的属性的做派发更新。vue2借助的是新增属性的那个对象访问的时候收集的依赖,vue3则是proxy代理后在遍历时进入ownKeys拦截方法中定义的特殊的值进行依赖收集。

2.拦截原生数组api进行增强,具有依赖收集或派发更新等能力