源码探秘 - Vue 3.0 响应式原理解析

avatar
公众号「 微医大前端技术 」

刘崇桢,微医云服务团队前端工程师,左手抱娃、右手持家的非典型码农。

  • 前情提要:在 Vue3 初始化 的过程中,我们在 setupStatefulComponent 中留下一个 TODO 坑:Reactive,介绍到:在这里会初始化响应式。
  • 我们把断点打在 reactive 函数调用这里,看一下调用堆栈的情况,回顾 Vue3 初始化 过程,来衔接本次 Reactive 的介绍。

reactive 调用堆栈.png 泳道 setup.png

看下我们的 demo 中的 setup,我们以这一小段代码为基础,翻开 Vue3.0 的响应式。 reactive-setup.png

一、Reactive

reactive 的实现是通过 createReactiveObject: 将 target 转化为响应式对象

看下目标对象 targetflag,便于代码的理解

export interface Target {
  [ReactiveFlags.SKIP]?: boolean // 跳过,不对 target 做响应式处理
  [ReactiveFlags.IS_REACTIVE]?: boolean // target 是响应式的
  [ReactiveFlags.IS_READONLY]?: boolean // target 是只读的
  [ReactiveFlags.RAW]?: any // target 对应的原始数据源,未经过响应式代理
}

1.1 createReactiveObject

createReactiveObj.png

createReactiveObject 用于将 target 转化为响应式对象,这里最重要的就是 new Proxy,返回的是我们想要的响应式代理。相比旧的 defineProperty API ,Proxy 可以代理数组,再通过 Reflect 的配合,完美地实现 traps 拦截。

其实通过 proxy 中的 target 来实现 trap 拦截中的操作(例如下面代码中的 set)也可以,但是对于一些比较复杂的默认行为处理,不如 Reflect 方便,只要是 Proxy 对象的方法,就能在 Reflect 对象上找到对应的方法。

let proxy = new Proxy(target, handlers);

// set trap
let proxy = new Proxy(target, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    return Reflect.set(target, key, value, receiver)
  }
})

Proxy 对象的所有用法,都是上面这种形式,不同的只是 handler 参数的写法。

代码中根据 target 的不同类型,使用不同的 handlers 来定制拦截行为,针对 ObjectArray 的拦截使用的是 baseHandlers,我们经常使用的也是这个。

注意:reactive 的入参 target 必须是 object 类型,如果想将基本数据类型转化为响应式对象,需要用到 ref,他可以接受基本类型和引用类型。

baseHandlers.png

如上 baseHandlers 对目标对象的 get、set、deleteProperty、has、ownKeys 操作进行了拦截,我们具体看下 getset 的处理函数。

1.2 createGetter

createReactiveObject 方法中创建 proxy 对象时会为 Proxy 设置定义拦截行为的 handlerscreateGetter 创建 getter 拦截器。

  1. vue3.0 对数组的方法进行了 hack,存储在 arrayInstrumentations 中,主要 hack 了遍历查找的方法(indexOf、lastIndexOf、includes)和改变数组长度的方法(push、pop、shift、unshift、splice)
    • hack 遍历查找的方法是将数组中的元素用 for 循环遍历 track 一遍,按着数组下标收集各元素的依赖项;
    • hack 改变数组长度的方法。改变数组长度的方法,在执行期间会多次触发 get 操作 和 set 执行,每次的 trap 操作都会触发依赖的收集和副作用派发,例如一次数组的操作,导致 render 函数 patch 多次,这是一个问题。
// 改变数组长度的方法,一定会触发.length 这个 property。
const arr = [1, 2];
const proxy = new Proxy(arr, {
  get(target, key, receiver) {
    console.log('get', key);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log('set', key, value);
    return Reflect.set(target, key, value, receiver);
  },
});
proxy.unshift(3);

// 'get' 'unshift' - trap unshift 方法
// 'get' 'length' - trap length 属性
// 'get' '1' - 拿到数组的最末位的下标 1
// 'set' '2' 2 - 开辟新的下标 2,存放原先最末位下标 1 的值 2
// 'get' '0' - 拿到数组的下标 0
// 'set' '1' 1 - 将原先下标 0 的值往后移
// 'set' '0' 3 - 将下标 0 设值 3
// 'set' 'length' 3 - 设置数组 length 为 3
// 3

// 上面会引发一个 bug,如下面这个 DEMO。push(1)立即执行,.push 操作会 get length 收集依赖,然后 set length 的时候触发 trigger,
// 重新执行 effect 中的.push(1),形成 track-trigger 的死循环。所以通过 pauseTracking
// 停止收集上面这些改变数组长度的方法执行期间的依赖,通过.length 来进行 track-trigger。
const arr = reactive([])
// watchEffect:立即执行传入的一个函数,并响应式追踪其依赖,并在其依赖变更时重新运行该函数。
watchEffect(()=>{
 arr.push(1)
})

在 hack 改变数组长度的方法中,源码通过 pauseTracking(),将 shouldTrack 设为 false,方法执行完后,通过 resetTracking ,恢复上一下的 track 状态,这样这些方法的执行期间就不再重新对数组元素做依赖的收集和响应。

  1. track 收集依赖存储到全局仓库中

track-trigger是一对孪生兄弟,track 收集依赖,trigger 触发依赖。先介绍 track,他是触发 getter 过程中做依赖收集的函数。

shouldTrack:是否收集,上面数组 hack 的介绍中,pauseTracking 就是设置全局 shouldTrack 为 false。

既然总是讲:收集依赖,那么收集的主语是谁,收集到的依赖又是什么?主(?)谓(收集)宾(?)

我们看到下图中 tragetMap 是一个数据响应关系仓库。是 WeakMap 类型的数据结构,key 即对应着原始数据源,可以是 reactive 函数中包裹的 data(也可以是computedRefImpl的实例等);value 即 Map 类型的数据结构,Map 的 key 是数据源的一个 property,Map 的 value 是 Set 类型数据结构(dep set),存储对应的 deps,即收集到的依赖项。 所以我们可以理解为依赖就是一种 effect 副作用,tragetMap 这个全局数据响应关系仓库(主语),收集依附响应式对象的变化而重新运行得到相应的副作用表现的函数(宾语)。我们也可以理解为 track 是 deps 的生产者,注意 deps 里面很多 effect

track-trigger.png

track 代码中还有一个细节,activeEffect.deps.push(dep)。收集到的各个依赖,自己会为自己维护一个 deps,记录持有依赖自身的 dep set,用于记录 effect 和依赖仓库的相互持有关系(很多人订阅了我,我也记录一下是哪些人干事?)。在 effect 执行期间,通过更新持有关系保持相互持有关系是最新的而且有效的。

  1. 递归处理,按需转化,reactive 处理 data 时,只有触发 key 的 getter 拦截到对象时才会继续做响应式处理

2.x 版本,响应式转化是在初始化阶段一次性递归转化完成的。3.0 做了优化,只有是触发了 getter,而且是一个对象类型的数据时,才会继续对当前这个 property 对应的 value 进行响应式转化。做到了按需转化,声明一个响应式对象,只有实际中使用触发 property 的 getter 时,才会对其进行依赖收集。 而且 Proxy 只能代理一层,对对象内部的深度侦测,需要开发者自己处理,这里的按需递归也有这个原因。

  1. 通过Reflect返回值

1.3 createSetter

createSetter就是创建setter拦截器,用来做更新派发

两步走:1、通过 Reflect.set 执行原本的 set 操作 2、触发 trigger 函数,但是是有条件的

trigger 的触发条件:限制 1、限制 2、限制 3

// createSetter
if (target === toRaw(receiver)) { // 限制 1
  if (!hadKey) {  // 限制 2
    trigger(target, TriggerOpTypes.ADD, key, value)
  } else if (hasChanged(value, oldValue)) { // 限制 3
    trigger(target, TriggerOpTypes.SET, key, value, oldValue)
  }
}

createGetter代码,在 getter handler 中做了一层拦截,当访问 ReactiveFlags.RAW:__v_raw 属性时,只有 receiver 指向的调用者是 proxy 实例本身时才会返回 target,即原始目标对象。

// createGetter
if (key === ReactiveFlags.RAW && receiver === (isReadonly ? readonlyMap : reactiveMap).get(target)){
	reutrn target   
}

如果触发拦截 handler(setter)的是 proxy 的继承者,就不会触发 trigger。此处即上述 限制 1 的逻辑。(注:receiver 是指向访问属性并触发 handler 的真正源头对象)

// demo,数据的 push 操作会触发多次 set 拦截
let arr = ref(['a', 'b'])
arr.value.push('c')
// 'set' '2' 'c'
// 'set' 'length' 3

如何避免多次 trigger 呢?答案就在限制 2、限制 3。

  • 'set' '2' 'c' 这次 set 触发,target 不存在下标 2,触发 trigger限制 2 通过
  • 'set' 'length' 3 这次set 触发,target 存在属性 length限制 2 不通过,新设置的值是 3(length),数组已经执行过 set 操作,所以旧值也是 3(length),限制 3 也不通过。

trigger 扣动扳机,派发依赖。

trigger.png

  1. trigger 的触发是有条件的。
  2. add 方法是将 depsMapdep set 添加到局部的 effects 中,然后过滤符合条件的 effect,用于批量触发,完成副作用执行。add 的入参,effectsToAdd,需要考虑数据结构的差异、边际的情况。总结概括,就是将 depsMap 中存储依赖的 Set,按着规则收集到 effects 中。
  3. run 方法用于批量执行 add 方法添加进来的 effect。
// run 方法执行 effect,引入了一个 scheduler 调度员
const run = (effect: ReactiveEffect) => {
    if (effect.options.scheduler) { // 非立即执行的 effect,把 effect 添加到调度器中,控制 effect 执行的时机,TODO
      effect.options.scheduler(effect)
    } else {
      effect() // 立即执行的 effect,无需特殊调度处理
    }
}

1.4 ref

ref,用于将某个值转化为响应式对象。不同于 reactive 的是,reactive 只能接受 object 类型的入参,ref 可以接受基本类型和引用类型。

例如:const count = ref(0);

ref.png

ref 可接受基本类型数据和引用类型数据,实际是基于 createReactive 方法的封装、扩展。

  • 当传入引用类型数据源时,ref 中的 convert 会调用 createReactive 方法对数据源进行响应式转化;
  • 当传入基本数据类型时,class RefImpl 的实例,访问 value 时会通过 track 函数进行依赖收集,设置 value 同样会通过 track 派发更新。

另外 ref 命名的原因是,ref 会返回一个可变的响应式对象,该对象作为【它的内部值,一个响应式】的引用。此对象只包含一个名为 valueproperty

const stateRef = ref(0)
stateRef++ // 报错,stateRef 用 const 声明,是一个常量
stateRef.value++ // 正确,stateRef 是一个响应式引用,他的 value 是一个响应式对象

1.5 其他

如果你已经忘掉前面这个【5】,我们稍微回顾一下:代码执行到 setup 中的 reactive(data) 的时候,我们 F11 进入了响应式的初始化阶段,通过 createReactiveObject 返回 proxy 响应式代理,代理了 target 的 get、set 等操作(【等操作】不再深入了)。

baseHandlers.png

其实上面代码分析的不要不要的,但是 track-trigger 这两个处心积虑设计的套路还没有被触发...T_T 年轻人不讲武德...

我们回到起点【1】,安装完组件实例后,setupRenderEffect 立马激活了渲染函数中的副作用,这个副作用是用于渲染的。接下来就进入了响应式的另一个核心 effect

如果你在 setup 函数中,直接使用了 effect (computed、watch 等 api 的实现都是基于 effect),setup 中的 effect 的执行过程就会更早,早于我们接下来讲解的用于渲染的 effect。那样代码分析的过程会变得复杂,建议先从最简单的测试代码开始。

setup-effect.png

二、Effect 副作用

effect 就是 Vue3.0 响应式中的「依赖」,也叫做「副作用」。 他可以建立一个依赖关系:传入 effect 的回调函数和响应式数据之间的关系。

setupRenderEffect 往组件实例上挂载了一个 update 函数,这个 update 是一个 effect 副作用,effect 创建后立即执行。

2.1 createReactiveEffect

createReactiveEffect 创建 effect

我们可以先描述一下 effect 应该具备什么样的功能:

  1. 执行函数,回调函数,执行后可以得到相应的副作用表现;
  2. 具备依赖激活能力;响应式对象的拦截已经准备就绪,但是一直没有触发拦截,effect 的执行会触发 getter,以便收集依赖;
  3. 具备自我更新的能力,逻辑上形成闭环;收集到的依赖不可能一直保持不变,副作用消费掉后,应该重新收集新鲜的副作用。

我们再看 effect 的执行过程(下面流程图中蓝色的模块)

  • cleanup,每次执行 effect 时做当前 effect 的清理。收集到的依赖会维护一个 deps(记录的哪些人干事),记录持有依赖自身的dep set。当 effect 进入执行过程中后(我理解为 effect 被消费了,只需要消费一次就够了),从当前 effect 的 deps 引用中依次清除自身。比如 leader-A、leader-B 都跟踪我做的一个重点需求 X ,我作为 X 的执行者,知道需要随时更新需求的状态,当 X 完成后,即使通知 A、B,这样 A、B 就会维护各自的重点项目的 TODO-LIST,delete 当前的 X。还有一个原因,如果响应式的属性被delete了,属性搜集的 dep set 也应该被清除。只要被消费过,就丢弃掉,然后 fn 执行过程中,重新收集新的依赖,这就是 effect 的自我更新能力。
  • setup 函数执行期间,会 pauseTracking,避免对 state 的操作(比如 state[key])触发依赖收集,effect 执行时开放 track 恢复收集,当 fn 执行完,当前 effect 依赖收集完毕,再把 track 恢复到上次的状态
  • effectStack 是一个全局 effect 栈,防止重复入栈激活过的 effect,同时用作执行调度
  • 设置当前执行的 effect 为全局的激活 effect,他会在 track 期间被收集到依赖仓库
  • fn 执行,当前流程中就是 render 逻辑,即根据渲染器 render 生成 vnode 并 patch 到真实 dom 这个过程。 这个执行过程会触发响应式对象的 getter 拦截,收集 activeEffect 完成依赖收集,这是依赖激活能力。
  • effectStack 出栈执行过的 effect

loop1.png

响应式工作流程:

  1. setupRenderEffect 激活渲染函数的副作用,在根组件实例上挂载一个 update 函数,他是一个立即执行的 effect;
  2. effect 执行时,清理当前 effect,以便重新收集依赖;
  3. effect 的执行函数,触发响应式对象的 getter 拦截,全局数据响应关系仓库收集依赖;
  4. 响应式对象的变化触发更新,批量执行 effect
  5. 循环:2 -> 3 -> 4 -> 2

reactive.png

三、计算属性Computed

依赖于其他状态的状态,返回一个不可变的、或者可写的响应式 ref 对象。

  1. computed 传入一个 getter 函数,比如 demo 中的 () => state.counter * 2,返回一个默认不可手动修改的 ref 对象。
  2. 或者传入一个包含 getset 函数的对象,创建一个可手动修改的计算状态。

computed1.png

computed 返回值是一个 ComputedRefImpl 实例,可见他也是一个 ref,是一个引用。

  • 在构造函数中,把传入的 getter(传入的计算函数) 包装成一个 effect
  • ComputedRefImpl 实例在被 get value 拦截时,立即执行 effect,得到计算函数的值;同时 track 当前 ComputedRefImpl 实例这个数据源,收集依赖
  • 如果是一个可写的计算属性,触发计算属性的 set value,就会 run effect

所以 computedVue3.0 对响应式系统的一个巧妙的实践,核心都是一样的。

还有一些代码细节,实例的 _dirty 属性,他控制着实例 get value 重新计算得到新值的时机,只有为 true,才会重新计算计算属性的新值。

// computedRefImpl get value
get value() {
  if (this._dirty) { // dirty 控制 get 新的计算值的时机,只有 dirty 为 true 时才会触发新的计算
    this._value = this.effect()
    this._dirty = false
  }
  track(toRaw(this), TrackOpTypes.GET, 'value')
  return this._value
}

_dirty 的声明上也能理解出一些意思,脏了 => 重新计算。怎么算 脏的 呢?_dirtyeffect 执行时被设置为 脏的

// computedRefImpl constructor,声明实例的 effect
this.effect = effect(getter, {
  lazy: true, // computed 懒更新,effect 包装时不会立即执行
  scheduler: () => {
    if (!this._dirty) {
      this._dirty = true
      trigger(toRaw(this), TriggerOpTypes.SET, 'value')
    }
  }
})

第二节介绍到的 effectscheduler,调度者。trigger 中的 run 方法检查到 effect.options 中有 scheduler 时,会优先执行 scheduler 回调。所以 effect 可以借助 scheduler 做一些中间态的调度行为。

let doubleCounter = computed(() => state.counter * 2) 计算属性中的副作用的操作流程被如下这么调度:

  1. 计算属性的 getter 中的响应式数据(counter)更新了,触发了响应式数据(counter)的 trigger 派发更新
  2. 当(counter)搜集到的 dep set run 到 computedRefImpleffect 时,将会执行 scheduler 回调,将 _dirty 设为 true,同时激活了没有 newValue 入参的 trigger,派发另一个副作用(本 demo 中的这个副作用最终触发的是渲染副作用),细节还涉及调度
  3. 上面这一步还没有执行 computedRefImpleffectgetter 回调,当前 effect 处于等待触发 fn 回调的状态。中间态,等待触发
  4. 计算属性中的 scheduler 中的 trigger 触发了渲染 effect 的执行,渲染 effect 立即执行,渲染 effect 的 fn 回调 render 函数,重新生成 vnode,patch 的过程中,访问到计算属性 (doubleCounter) 时,会触发 computedRefImplgetter,3 中的中间态状态解禁,这个时候因为 _dirtyscheduler 执行时设置 true,就可以计算计算属性的最新值了。

以我现在粗浅的理解,减少了重复计算 计算属性 的次数,需要计算的时候再计算。effect 借助 scheduler 做中间态的调度行为,可见一斑。

四、Watch 监听

watch API 完全等效于 2.x this.$watch (以及 watch 中相应的选项)。 watch 需要侦听特定的数据源,并在回调函数中执行副作用。默认情况是懒执行的,也就是说仅在侦听的源变更时才执行回调。

Vue3.0 中除了 watch API,还新增了一个 watchEffect API

watchEffect(() => console.log(state.counter))

// watch 的重载有多种,我们只例举出 2.x 中常见的用法。
const count = ref(0)
watch(
  count,
  (count, prevCount) => {
    /* ... */
  }
)

两个 API 有什么不同呢:

  1. watchEffect 不需要指定监听的属性,watchEffect 会立即执行传入的函数,执行期间收集依赖,并在其依赖变更时重新运行该函数;
  2. watch 可以明确哪些状态的改变会触发侦听器重新运行副作用,初始化阶段不需要立即执行收集依赖,他可以是懒执行副作用;
  3. watch 可以在回调函数 cb 中,访问侦听状态变化前后的值,watchEffect 没有这个回调函数 cb
export function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase
): WatchStopHandle {
  return doWatch(effect, null, options)
}

export function watch<T = any, Immediate extends Readonly<boolean> = false>(
  source: T | WatchSource<T>,
  cb: any,
  options?: WatchOptions<Immediate>
): WatchStopHandle {
  return doWatch(source as any, cb, options)
}

可见两个 API 的核心处理逻辑就是 doWatch

4.1 doWatch

两个监听 API 的实现都是基于 doWatch(source, cb, options)doWatch 要达到的目的:

  • 当数据源(source)发生变化时,执行回调(cb) -- watch API
  • source 是一个函数,而且没有 cb 时,依赖项在 source 函数中,需要先收集依赖,在其依赖变更时重新运行该 source -- watchEffect API

很明显需要通过发布订阅模式进行实现,跟计算属性 computed 的实现一样,都是响应式系统的一个巧妙的实践。

watch API 的重载支持侦听单个数据源、响应式的单个数据源、多个数据源,代码需要处理多种场景,我们只看 watchEffect 的实现,就能贯通 doWatch 的逻辑。watch API 的一些执行时机调度逻辑,涉及到 Vue3.0 的调度实现,我们另开篇介绍。

watchEffect-doWatch.png

getter = () => {
  if (instance && instance.isUnmounted) {
    return
  }
  if (cleanup) {
    cleanup()
  }
  return callWithErrorHandling(
    source,
    instance,
    ErrorCodes.WATCH_CALLBACK,
    [onInvalidate]
  )
}
  1. watchEffect 包裹的函数可以接收一个 onInvalidate 函数作入参, 用来注册清理失效时的回调。cleanup,就是执行清理工作,他在副作用重新执行时、监听被停止时触发
  2. callWithErrorHandling 的功能,就是带异常捕获功能的执行 source 函数
const runner = effect(getter, {
  lazy: true,
  onTrack,
  onTrigger,
  scheduler
})

watchEffect 中的 scheduler,只有简单的 runner

第二节介绍到的 effectscheduler,调度者。trigger 中的 run 方法检查到 effect.options 中有 scheduler 时,会优先执行 scheduler 回调,否则直接执行 effect()watchEffecteffect 的执行塞到了 scheduler 中,相当于直接执行 effect()。但是在 scheduler 加上一些调度逻辑 queuePreFlushCb 等,将副作用函数进入队列,在所有的组件更新后执行,这样可以避免同一个 tick 中多个状态改变导致的不必要的重复调用。

最后 doWatch 返回了一个清除副作用的函数。

附录