浅浅析vue3数据响应式原理

686 阅读10分钟

前言

Vue 3.0 中,响应式数据部分弃用了 Object.defineProperty,使用Proxy来代替它。本文将主要通过以下方面来分析vue3数据响应式原理的变化。

  • Object.defineProperty的问题以及Vue2.x 的处理
  • 对比Object.defineProperty和 Proxy。
  • vue3.0中是如何使用Proxy来实现响应式的

Object.defineProperty的问题以及Vue2.x 的处理

1.为什么vue2.x不支持使用数组下标来响应数据变化,只能用$set?

事实上,Object.defineProperty 本身是可以监控到数组下标的变化的,只是在 Vue 的实现中,从性能 / 体验的性价比考虑,放弃了这个特性。 下面是栗子:

function defineReactive(data, key, value) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
     get: function defineGet() {
      console.log(`get key: ${key} value: ${value}`)
      return value
    },
     set: function defineSet(newVal) {
      console.log(`set key: ${key} value: ${newVal}`)
      value = newVal
    }
  })
}
function observe(data) {
  Object.keys(data).forEach(function(key) {
    defineReactive(data, key, data[key])
  })
}
let arr = [1, 2, 3]
observe(arr)

  1. 通过下标获取某个元素和修改某个元素的值

可以看到,通过下标获取某个元素会触发 getter 方法, 设置某个值会触发 setter 方法。

接下来,我们再试一下数组的一些操作方法,看看是否会触发。

  1. 数组的 push 方法

push 并未触发 setter 和 getter方法,数组的下标可以看做是对象中的 key ,这里push 之后相当于增加了下索引为 3 的元素,但是并未对新的下标进行 observe ,所以不会触发。

  1. 数组的 unshift 方法

unshift 操作会导致原来索引为 0、1、2、3 的值发生变化,这就需要将原来索引为 0、1、2、3 的值取出来,然后重新赋值,所以取值的过程触发了 getter ,赋值时触发了 setter 。

这里我们可以对比对象来看,arr 数组初始值为 [1, 2, 3],即只对索引为 0、1、2 执行了 observe 方法,所以无论后来数组的长度发生怎样的变化,依然只有索引为 0、1、2 的元素发生变化才会触发,其他的新增索引,就相当于对象中新增的属性,需要再手动 observe 才可以。

  1. 数组的 pop 方法

当移除的元素为引用为 2 的元素时,会触发 getter 。

删除了索引为 2 的元素后,再去修改或获取它的值时,不会再触发 setter 和 getter 。

这和对象的处理是同样的,数组的索引被删除后,就相当于对象的属性被删除一样,不会再去触发 observe。

到这里,我们可以简单地总结一下结论。

  • Object.defineProperty 在数组中的表现和在对象中的表现是一致的,数组的索引就可以看做是对象中的 key。

  • 通过索引访问或设置对应元素的值时,可以触发 getter 和 setter 方法。

  • 通过 push 或 unshift 会增加索引,对于新增加的属性,需要再手动初始化才能被 observe。

  • 通过 pop 或 shift 删除元素,会删除并更新索引,也会触发 setter 和 getter 方法。

所以,Object.defineProperty是有监控数组下标变化的能力的,只是 Vue2.x 放弃了这个特性。

2.vue2.x对数组的处理

Vue 对数组的 observe 做了哪些处理?

可以看到,Vue 的 Observer 对数组做了单独的处理。 arrayMethods 是对数组的方法进行重写。

/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */
import { def } from '../util/index'
// 复制数组构造函数的原型,Array.prototype 也是一个数组。
const arrayProto = Array.prototype
// 创建对象,对象的 __proto__ 指向 arrayProto,所以 arrayMethods 的 __proto__ 包含数组的所有方法。
export const arrayMethods = Object.create(arrayProto)
// 下面的数组是要进行重写的方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
/**
 * Intercept mutating methods and emit events
 */
// 遍历 methodsToPatch 数组,对其中的方法进行重写
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  // def 方法定义在 lang.js 文件中,是通过 object.defineProperty 对属性进行重新定义。
  // 即在 arrayMethods 中找到我们要重写的方法,对其进行重新定义
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      // 上面已经分析过,对于 push,unshift 会新增索引,所以需要手动 observe
      case 'push':
      case 'unshift':
        inserted = args
        break
      // splice 方法,如果传入了第三个参数,也会有新增索引,所以也需要手动 observe
      case 'splice':
        inserted = args.slice(2)
        break
    }
    // push,unshift,splice 三个方法触发后,在这里手动 observe,其他方法的变更会在当前的索引上进行更新,所以不需要再执行 ob.observeArray
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

$set方法对数组的处理,对于数组,就是直接调用重写后的 splice 方法。

	
/**
 * Set a property on an object. Adds the new property and
 * triggers change notification if the property doesn't
 * already exist.
 */
export function set (target: Array<any> | Object, key: any, val: any): any {
  // 如果 target 是数组,且 key 是有效的数组索引,会调用数组的 splice 方法,
  // 我们上面说过,数组的 splice 方法会被重写,重写的方法中会手动 Observe
  // 所以 vue 的 set 方法,对于数组,就是直接调用重写 splice 方法
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 对于对象,如果 key 本来就是对象中的属性,直接修改值就可以触发更新
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  // vue 的响应式对象中都会添加了 __ob__ 属性,所以可以根据是否有 __ob__ 属性判断是否为响应式对象
  const ob = (target: any).__ob__
  // 如果不是响应式对象,直接赋值
  if (!ob) {
    target[key] = val
    return val
  }
  // 调用 defineReactive 给数据添加了 getter 和 setter,
  // 所以 vue 的 set 方法,对于响应式的对象,就会调用 defineReactive 重新定义响应式对象,defineReactive 函数
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

对比Object.defineProperty和 Proxy。

Proxy

Proxy用于修改某些操作的默认行为,也可以理解为在目标对象之前架设一层拦截,外部所有访问都先经过这层拦截,所以我们叫它为代理模式。

ES6原生提供了Proxy构造函数,用来生成Proxy实例。

var proxy = new Proxy(target, handler);

Proxy对象的所有用法,都是上面这种形式,不同的只是handle参数的写法。其中new Proxy用来生成Proxy实例,target是表示所要拦截的对象,handle是用来定制拦截行为的对象。

const target = []
const proxy = new Proxy(target, {
    get: (obj, prop) => {
        console.log('设置 get 操作')
        return obj[prop];
    },
    set: (obj, prop, value) => {
        console.log('set 操作')
        obj[prop] = value;
        return true
    }
});
proxy.push(1)  // 设置 get 操作*2 set 操作*2 
proxy[0]  // 设置 get 操作

当给目标对象进行赋值或获取属性时,就会分别触发get和set方法,get和set就是我们设置的代理,覆盖了默认的赋值或获取行为。 当然,除了get和set,Proxy还可以拦截其他共计13种操作

1. Object.defineProperty只能劫持对象的属性,而 Proxy 是直接代理对象。

由于 Object.defineProperty 只能对属性进行劫持,需要遍历对象的每个属性,如果属性值也是对象,则需要深度遍历。而 Proxy 直接代理对象,不需要遍历操作,但Proxy只能监听第一层,稍后看vue3.0怎么解决这个问题。

2. Object.defineProperty对新增属性需要手动进行 Observe。

由于 Object.defineProperty劫持的是对象的属性,所以新增属性时,需要重新遍历对象,对其新增属性再使用 Object.defineProperty 进行劫持。

3. Proxy支持 13 种拦截操作,这是defineProperty所不具有的。

  • get(target, propKey, receiver):拦截对象属性的读取,比如 proxy.foo 和proxy['foo']。

  • set(target, propKey, value, receiver):拦截对象属性的设置,比如proxy.foo = v 或 proxy['foo'] = v,返回一个布尔值。

  • has(target, propKey):拦截 propKey in proxy 的操作,返回一个布尔值。

  • deleteProperty(target, propKey):拦截 delete proxy[propKey] 的操作,返回一个布尔值。

  • ownKeys(target):拦截Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而 Object.keys() 的返回结果仅包括目标对象自身的可遍历属性。

  • getOwnPropertyDescriptor(target, propKey):拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。

  • defineProperty(target, propKey, propDesc):拦截Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。

  • preventExtensions(target):拦截 Object.preventExtensions(proxy),返回一个布尔值。

  • getPrototypeOf(target):拦截 Object.getPrototypeOf(proxy),返回一个对象。

  • isExtensible(target):拦截 Object.isExtensible(proxy),返回一个布尔值。

  • setPrototypeOf(target, proto):拦截 Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。

  • apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)。

  • construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如new proxy(...args)。

4. 新标准性能红利

Proxy 作为新标准,从长远来看,JS 引擎会继续优化 Proxy,但 getter 和 setter 基本不会再有针对性优化。

5. Proxy 兼容性差

handler.enumerate() 决定了被代理对象在for...in中的行为。不过这个方法已经在ES2016标准中被移除了

vue3.0中是如何使用Proxy来实现响应式的

1.初始化阶段

通过 reactive() 方法将数据转化成 Proxy 对象

<!DOCTYPE html>
<html lang="en">
<body>
  <div id='app'></div>
</body>
<script src="./dist/vue.global.js"></script>
<script>
const { createApp, reactive, computed } = Vue;

const RootComponent = {
  template: `
    <button @click="increment">
      Count is: {{ state.count }}
    </button>
  `,
  setup() {
    const state = reactive({
      count: 0,
    })

    function increment() {
      state.count++
    }

    return {
      state,
      increment
    }
  }
}

createApp().mount(RootComponent, '#app')
</script>
</html>

reactive() 方法如下,主要是新建一个proxy对象

export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (readonlyToRaw.has(target)) {
    return target
  }
  // target is explicitly marked as readonly by user
  if (readonlyValues.has(target)) {
    return readonly(target)
  }
  return createReactiveObject(
    target,
    rawToReactive,
    reactiveToRaw,
    mutableHandlers,
    mutableCollectionHandlers
  )
}

...

// 创建proxy对象
function createReactiveObject(
  target: any,
  toProxy: WeakMap<any, any>,
  toRaw: WeakMap<any, any>,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // target already has corresponding Proxy
  let observed = toProxy.get(target)
  if (observed !== void 0) {
    return observed
  }
  // target is already a Proxy
  if (toRaw.has(target)) {
    return target
  }
  // only a whitelist of value types can be observed.
  if (!canObserve(target)) {
    return target
  }
  const handlers = collectionTypes.has(target.constructor)
    ? collectionHandlers
    : baseHandlers
  observed = new Proxy(target, handlers)
  toProxy.set(target, observed)
  toRaw.set(observed, target)
  if (!targetMap.has(target)) {
    targetMap.set(target, new Map())
  }
  return observed
}

Vue3 如何进行深度观测的?先看下面这段代码

let data = { x: {y: {z: 1 } } }
let p = new Proxy(data, {
  get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    console.log('get value:', key)
    console.log(res)
    return res
  },
  set(target, key, value, receiver) {
    console.log('set value:', key, value)
    return Reflect.set(target, key, value, receiver)
  }
})
p.x.y = 2

// get value: x
// {y: 2}

上面代码我们可以知道 Proxy 只会代理一层,因为这里只是触发了一次最外层属性 x 的 get,而重新赋值的其内部属性 y,此时 set 并没有被触发,所以改变内部属性是不会监测到的。继续看,Reflect.get返回的结果正是 target 的内层结构,此时p.x.y的值也已经变成 2 了,我们可以判断当前 Reflect.get 返回的值是否为 object,若是则再通过 reactive 做代理,这样就达到了深度观测的目的了。 Vue3实现过程:

function createGetter(isReadonly: boolean) {
  return function get(target: any, key: string | symbol, receiver: any) {
    const res = Reflect.get(target, key, receiver)
    if (typeof key === 'symbol' && builtInSymbols.has(key)) {
      return res
    }
    if (isRef(res)) {
      return res.value
    }
    track(target, OperationTypes.GET, key)
    // 当代理的对象是多层结构时,Reflect.get会返回对象的内层结构,我们可以拿到当前res再做判断是否为object,进而进行reactive,就达到了深度观测的目的了
    return isObject(res)
      ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
          // circular dependency
          readonly(res)
        : reactive(res)
      : res
  }
}

2.依赖收集阶段

所谓的依赖在Vue3可简单理解为各种 effect 响应式函数,其中包括了属性依赖的 effect,计算属性 computedEffect 以及组件视图的 componentEffect

1、在视图挂载渲染时会执行一个 componentEffect,触发相关数据属性getter操作来完成视图依赖收集。

2、effect 函数执行也会触发相关属性的getter操作,此时操作了某个属性的 effect 也会被该属性对应进行收集(就是说,你搞我的时候,我会把你怎么搞得我的具体过程用小本本记下来)。

之所以说是响应式的,是因为effect方法回调中关联了被观测的数据属性,而effect一般是立即执行的,此时触发了该属性的 getter,进行依赖收集,当该属性触发 setter 时,便会触发执行收集的依赖。另外,这里每次effect执行时,当前的effect会被压入一个名为 activeReactiveEffectStack 的栈中,是在依赖收集的时候使用。

export function effect(
  fn: Function,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect {
  if ((fn as ReactiveEffect).isEffect) {
    fn = (fn as ReactiveEffect).raw
  }
  const effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    // effect立即执行,触发effect回调函数fn中相关响应数据属性的getter操作,从而进行依赖收集
    effect()
  }
  return effect
}
...
// 触发getter操作,进行依赖收集
export function track(
  target: any,
  type: OperationTypes,
  key?: string | symbol
) {
  if (!shouldTrack) {
    return
  }
  const effect = activeReactiveEffectStack[activeReactiveEffectStack.length - 1]
  if (effect) {
    if (type === OperationTypes.ITERATE) {
      key = ITERATE_KEY
    }
    let depsMap = targetMap.get(target)
    if (depsMap === void 0) {
      targetMap.set(target, (depsMap = new Map()))
    }
    let dep = depsMap.get(key!)
    if (dep === void 0) {
      depsMap.set(key!, (dep = new Set()))
    }
    // 防止依赖重复收集
    if (!dep.has(effect)) {
      dep.add(effect)
      effect.deps.push(dep)
      if (__DEV__ && effect.onTrack) {
        effect.onTrack({
          effect,
          target,
          type,
          key
        })
      }
    }
  }
}

targetMap 对象在依赖收集过程中的有着重要作用,它维护了一个依赖收集的关系表,targetMap 是一个 WeakMap,其 key 值是当前被代理的对象 target,而 value 则是该对象所对应的 depsMap,它是一个 Map,key 值为触发 getter 时的属性值,而 value 值则是触发过该属性值所对应的各个 effect。

故 targetMap 的关系映射可以看成 target --> key --> effect,可以看出 target 被观测后,其属性 key 在被触发 getter 操作时,收集了所依赖的 effect,可以说 targetMap 是Vue3进行依赖收集的一个核心对象。

3.响应阶段

当触发属性 setter 时,通过 trigger 函数会执行属性对应收集的 effects,也包括 computedEffects,此时通过 scheduleRun 逐个调用 effect,最后完成视图更新。(就是说,我变了的时候,我会把你怎么搞得我的具体过程从小本本拿出来再搞一遍)

上面我们讲过监测数组的时候可能触发多次 get/set, 那么如何防止触发多次的呢?先看Vue3的源码(简写省略了部分代码):

// setter操作触发响应
function set(
  target: any,
  key: string | symbol,
  value: any,
  receiver: any
): boolean {
  value = toRaw(value)
  // 判断key是否为当前target自身属性
  const hadKey = hasOwn(target, key)
  // 获取旧值
  const oldValue = target[key]
  if (isRef(oldValue) && !isRef(value)) {
    oldValue.value = value
    return true
  }
  const result = Reflect.set(target, key, value, receiver)
  ...
  if (!hadKey) {
    // 若属性不存在标记为add操作
    trigger(target, OperationTypes.ADD, key)
  } else if (value !== oldValue) {
    // 若值不相等在触发,并且标记为set操作
    trigger(target, OperationTypes.SET, key)
  }
  ...
  return result
}

export function trigger(
  target: any,
  type: OperationTypes,
  key?: string | symbol,
  extraInfo?: any
) {
  const depsMap = targetMap.get(target)
  if (depsMap === void 0) {
    // never been tracked
    return
  }
  const effects = new Set<ReactiveEffect>()
  const computedRunners = new Set<ReactiveEffect>()
  // 这里遍历找出相关依赖的effect
  if (type === OperationTypes.CLEAR) {
    // collection being cleared, trigger all effects for target
    depsMap.forEach(dep => {
      addRunners(effects, computedRunners, dep)
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      addRunners(effects, computedRunners, depsMap.get(key))
    }
    // also run for iteration key on ADD | DELETE
    if (type === OperationTypes.ADD || type === OperationTypes.DELETE) {
      // 这里当改变数组length长度时也会触发相关effect进行响应
      const iterationKey = Array.isArray(target) ? 'length' : ITERATE_KEY
      addRunners(effects, computedRunners, depsMap.get(iterationKey))
    }
  }
  // 遍历执行依赖的effect
  const run = (effect: ReactiveEffect) => {
    scheduleRun(effect, target, type, key, extraInfo)
  }
  computedRunners.forEach(run)
  effects.forEach(run)
}

function scheduleRun(
  effect: ReactiveEffect,
  target: any,
  type: OperationTypes,
  key: string | symbol | undefined,
  extraInfo: any
) {
  ...
  if (effect.scheduler !== void 0) {
    effect.scheduler(effect)
  } else {
    effect()
  }
}

由源码我们可以分析出:

1、判断key是否为当前被代理对象target自身属性;

2、判断旧值与新值是否相等。只有这两个条件其中一个满足,才有可能执行 trigger。

总结

ReactiveEffect 是一个Function对象,用于执行组件的挂载和更新。

参考:

1.jungahuang.com/2019/10/11/…

2.segmentfault.com/a/119000002…

3.www.infoq.cn/article/sPC…

4.juejin.cn/post/684490…