vue3响应式原理

本文基于 vue-next 3.2.4 版本,对 @vue/reactivity 进行解读,仅仅是 vue 的响应式原理解析,不包含 DOM

响应式原理

Vue 3 实现响应式,本质上是通过 Proxy API 劫持了数据对象的读写

当我们访问数据时,会触发 getter 执行依赖收集(track);

修改数据时,会触发 setter 执行通知派发(trigger)。

我们从 ref 对象入手,看看具体的过程是什么

Ref对象的实现

通过 ref 的实现,看依赖是什么,是怎么被收集的

// https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/ref.ts#L81
export function ref(value?: unknown) {
  return createRef(value)
}

// https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/ref.ts#L121
function createRef(rawValue: unknown, shallow = false) {
  // 如果已经是ref,则直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

我们直接看看 RefImpl 是什么

class RefImpl<T> {
  private _value: T
  private _rawValue: T

  // 用于存储依赖,作用后面细说
  public dep?: Dep = undefined
  public readonly __v_isRef = true

  constructor(value: T, public readonly _shallow = false) {
    // 保存原始value
    this._rawValue = _shallow ? value : toRaw(value)
    // convert是,如果value是对象则 reactive(value),否则返回value
    this._value = _shallow ? value : convert(value)
  }

  get value() {
    // 收集依赖
    trackRefValue(this)
    return this._value
  }

  set value(newVal) {
    newVal = this._shallow ? newVal : toRaw(newVal)
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : convert(newVal)
      // 触发依赖
      triggerRefValue(this, newVal)
    }
  }
}

所以定义 ref 的时候,主要做了以下事情:

  1. 判断入参是否已经是 ref 了,是的话直接返回
  2. new RefImpl 对象,在 ref 对象被访问时,trace 收集依赖,被修改时,trigger 触发依赖

​ img

依赖是怎么被收集的

// 节选自 https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/ref.ts#L29
export function trackRefValue(ref: RefBase<any>) {
  // 判断是否需要搜集依赖
  if (isTracking()) {
    ref = toRaw(ref)
    if (!ref.dep) {
      ref.dep = createDep()
    }
    trackEffects(ref.dep)
  }
}

几个问题:

  1. 为什么需要判断是否收集依赖?

​ 不是任何情况 ref 被访问时,都需要收集依赖,例如,onMounted 的时候,访问了ref,难道 ref 被修改了,需要重新出发 onMounted 吗?这显然不合理,所以需要判断,具体怎么判断,后面再说

  1. ref.dep是什么?

​ ref.dep:Set<ReactiveEffect> ,是一个存储 effect(副作用)的集合,effect 是什么?后面会细说。暂时先理解为被收集的依赖,就是effect。track收集依赖的过程,就是把当前的 effect,加入到 ref.dep 中

我们再来看看 trackEffects:

// 节选自 https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/effect.ts#L212

let activeEffect: ReactiveEffect | undefined
export function trackEffects(
  dep: Dep
) {
  let shouldTrack = false
  // 已经track收集过依赖,就可以跳过了
  shouldTrack = !dep.has(activeEffect!)

  if (shouldTrack) {
    // 收集依赖,将effect存储到dep
    dep.add(activeEffect!)
    // 同时effect也记录一下dep,用于trigger触发effect后,清除dep里面对应的effect
    activeEffect!.deps.push(dep)
}

收集依赖,就是把 activeEffect(当前的 effect ),放到 ref.dep 中(当触发依赖时,遍历 ref.dep 执行 effect )

然后把 ref.dep,也放到 effect.deps 中(用于在触发依赖后, ref.dep.delete(effect),删除依赖)

​ img

依赖是怎么被触发的

track 收集依赖看完,那看看依赖时怎么触发的

// 节选自 https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/ref.ts#L47
export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
  ref = toRaw(ref)
  if (ref.dep) {
     triggerEffects(ref.dep)
  }
}

再来看看 triggerEffects

export function triggerEffects(
  dep: Dep | ReactiveEffect[]
) {
  for (const effect of isArray(dep) ? dep : [...dep]) {
    // 默认不允许递归
    if (effect !== activeEffect || effect.allowRecurse) {
      // effect.scheduler可以先不管,computed有,ref和reactive没有
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        // 执行effect
        effect.run()
      }
    }
  }
}

这里省略了一些代码,这样结构更清晰

当 ref 被修改时,会trigger触发依赖,触发依赖,实际上是执行了 ref.dep 里的所有 effect

effect 副作用是什么?

先总结一下

track 收集依赖 —— 将effect保存响应式对象的 dep 中(如 ref.dep )

trigger 触发依赖 —— 将响应式对象的 dep(如 ref.dep ),中的所有 effect 执行

那么 effect 是什么呢?

在介绍它之前,我们来回顾一下什么是响应式的需求

import { ref } from 'vue'
const counter = ref(0)
function logCount() {   
  console.log(counter)
}
logCount()
counter.value++

我们的预期是 counter.value++ 之前,counter.value 打印出0, counter.value++ 之后,打印出1

但很遗憾,事实上只打印出了0。

因为依赖没有被搜集到(就像上面说的 onMounted 没有执行依赖收集)

那怎么才能知道 logCount 依赖了counted 呢?

我们来看看一个简单的实现

let activeEffect: ReactiveEffect | undefined
function wrapper(fn) {   
  const wrapped = function(...args) {
    // 标记我当前正在执行fn
    activeEffect = fn     
    // 执行需要收集依赖的函数
    fn(...args) 
    activeEffect = undefined
  }   
  return wrapped
}

在我们的这个例子中,我们在 logCount 包一层 wrapper 函数:

const counter= ref(1)
function logCount() {   
  console.log(counter)
}
wrapper(logCount)()

这样 fn 执行的时候,activeEffect === fn,在 fn 里面使用到了 counter,会执行 trackEffect,将 activeEffect 记录下来,就能知道,fn(logCount),依赖了 counter

回顾一下,上一节的 trackEffect,是不是把 activeEffect 保存在了 ref.dep 中完成收集。于是 ref 就知道了,依赖了 effect

在 vue3,effect 函数的作用就是,要 track 收集 effect 函数里面的依赖,我们先看 effect 函数的其中一个测试用例

// https://github.com/vuejs/vue-next/blob/v3.1.5/packages/reactivity/__tests__/ref.spec.ts#L22
it('should be reactive', () => {
  const a = ref(1)
  let dummy
  let calls = 0
  effect(() => {
    calls++
    dummy = a.value
  })
  expect(calls).toBe(1)
  expect(dummy).toBe(1)
  a.value = 2
  expect(calls).toBe(2)
  expect(dummy).toBe(2)
  // same value should not trigger
  a.value = 2
  expect(calls).toBe(2)
  expect(dummy).toBe(2)
})

effect 的使用方法,如测试用例那样,里面传入一个需要 track 收集依赖的函数即可

我们来看一下 effect 的实现

// 节选自 https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/effect.ts#L145
export function effect<T = any>(
  fn: () => T
){
  // effect的fn,可能也是一个effect,所以要获取到最初始的fn
  if ((fn as ReactiveEffectRunner).effect) {
    fn = (fn as ReactiveEffectRunner).effect.fn
  }

  const _effect = new ReactiveEffect(fn)
  _effect.run()
  
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

所以 effect 函数主要做了两件事情

  1. const _effect = new ReactiveEffect(fn)
  2. _effect.run()

再看看 ReactiveEffect是什么

// https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/effect.ts#L53
export class ReactiveEffect<T = any> {
  active = true
  deps: Dep[] = []

  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null,
    scope?: EffectScope | null
  ) {
    recordEffectScope(this, scope)
  }

  run() {
    if (!effectStack.includes(this)) {
      try {
        // 保存上一个的activeEffect,因为effect可以嵌套
        effectStack.push((activeEffect = this))
        // 开启shouldTrack开关,缓存上一个值
        enableTracking()

        // 在该effect所在的所有dep中,清除还effect
        cleanupEffect(this)
        // 执行函数,执行过程中,又会track当前的effect进来
        return this.fn()
      } finally {
        // 关闭shouldTrack开关,恢复上一个值
        resetTracking()
        // 恢复上一个的activeEffect
        effectStack.pop()
        const n = effectStack.length
        activeEffect = n > 0 ? effectStack[n - 1] : undefined
      }
    }
  }
}

关注几个问题:

  1. 为什么要用栈保存状态?

​ 因为effect可能会嵌套,需要保存之前的状态,effect执行完成后恢复

  1. cleanupEffect 做了什么?

​ 回顾一下下图,effect.deps,存储着所有依赖的 dep(这些 dep 里,都包含该 effect ),为的是用于 effect 执行后,能够找到对应的 dep 集合,并删除当前执行过的 effect

function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}
  1. 为什么要在所有dep删除该effect?

​ 在所有dep删除该effect,然后在执行effect的时候,会重新将依赖收集回来。因为这执行前后的依赖可能不一致,考虑一下情况:

const switch = ref(true)
const foo = ref('foo')
effect( () = {
  if(switch.value){
    console.log(foo.value)
  }else{
    console.log('else condition')
  }
})
switch.value = false

当 switch 为 true 时,triggerEffect,清除所有 dep 中的该 effect,执行一遍 effect,switch、foo 会重新搜集到依赖 effect

​ img

当 switch 变成 false 后,triggerEffect,清除所有 dep 中的该 effect,effect 执行后,仅有 switch 能重新搜集到依赖 effect

​ img

由于 effect 前后,依赖的响应式对象(这里是 ref )可能不一致,响应式对象(这里是 ref )的 dep 重新搜集到的 effect 也不一致。因此需要先清除,再重新搜集。

其实这里也省略了一个优化(跟之前省略的是同一个),优化方案中,并不是先清除,再搜集的,而是 effect 执行后,对比 effect 前后差异,仅清除没有被重新收集到的响应式对象(这里是 ref )的 dep 中的 effect。具体优化细节,后面细说

阶段总结

我们完整地看一遍以下这段代码的依赖收集和触发:

const a = ref(1)
let dummy
let calls = 0
effect(() => {
  if(calls===0) b.value++
  calls++
  dummy = a.value
})
b.value++

img

触发依赖的响应式优化

在 trackEffect、effect.run 执行时,省略了一部分优化内容。

回顾一下,effect.run() 执行副作用fn前,会先把所有的依赖都删除,然后 fn 执行时,会 track 重新把依赖收集回来。

其实这是不必要的,因为大多数情况下,依赖都是不变的。

因此,可以将 fn 执行前后依赖,进行对比,然后只删除fn执行后,不再使用的依赖

我们来看看 Dep 的真实结构:

// https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/dep.ts#L3
export type Dep = Set<ReactiveEffect> & TrackedMarkers
type TrackedMarkers = {
  /**
   * wasTracked
   */
  w: number
  /**
   * newTracked
   */
  n: number
}

要进行前后对比,就要有相应的标记为,w 就是前标记,n 就是后标记,是个 number 类型。

img 标记,则是将某一位,设置为 1,表示该 dep(该 ref )在该深度,被搜集依赖了

是不是有点算法题的味道了

具体怎么使用后面会细说

export class ReactiveEffect<T = any> {
  run() {
    // 调用stop函数后的effect,不再收集依赖
    if (!this.active) {
      return this.fn()
    }
    
    if (!effectStack.includes(this)) {
      try {
        // 当前effect压栈
        effectStack.push((activeEffect = this))
        // 开启全局 shouldTrack,允许依赖收集
        enableTracking()
            
        // 追踪深度自增,最大30深度
        // 追踪标记的位,这个后面细说
        trackOpBit = 1 << ++effectTrackDepth

        // 超过最大追踪深度,降级为cleanupEffect,执行前删除所有依赖,执行时重新追踪依赖
        if (effectTrackDepth <= maxMarkerBits) {
          // 将所有dep.w 标记,视为为旧依赖
          initDepMarkers(this)
        } else {
          cleanupEffect(this)
        }
        return this.fn()
      } finally {
        if (effectTrackDepth <= maxMarkerBits) {
          // 清理不再使用的依赖
          finalizeDepMarkers(this)
        }

        // 恢复trackOpBit和effectTrackDepth
        trackOpBit = 1 << --effectTrackDepth

        // 恢复上一次的全局 shouldTrack状态
        resetTracking()
        // 恢复上一个activeEffect
        effectStack.pop()
        const n = effectStack.length
        activeEffect = n > 0 ? effectStack[n - 1] : undefined
      }
    }
  }

  stop() {
    if (this.active) {
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
      this.active = false
    }
  }
}

再看看如何标记旧依赖

export const initDepMarkers = ({ deps }: ReactiveEffect) => {
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].w |= trackOpBit // set was tracked
    }
  }
}

deps[i].w |= trackOpBit,就是将( effectTrackDepth + 1)位,设置为 1

img

所以,当 dep.w 的某个 bit 被标记为1,就是旧依赖

同理,当 dep.n 的某个 bit 被标记为1,就是新依赖

详细的看 trackEffects 代码:

// https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/effect.ts#L212
let effectTrackDepth = 0
export let trackOpBit = 1
export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  // 如果符合条件,使用优化方案,否则降级使用优化前的方案,effect.run会cleanup所有dep
  if (effectTrackDepth <= maxMarkerBits) {
    // 如果没有被标记新依赖
    if (!newTracked(dep)) {
      // 标记为新依赖
      dep.n |= trackOpBit 
      // 如果依赖已经被收集,则不需要再次收集
      shouldTrack = !wasTracked(dep)
    }
  } else {
      // 降级方案
      shouldTrack = !dep.has(activeEffect!)
  }

  if (shouldTrack) {
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)
  }
}

判断,在当前深度,dep 是否被标记为新依赖/旧依赖

// https://github.com/vuejs/vue-next/blob/v3.2.4/packages/reactivity/src/dep.ts#L28
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0

用位运算,这种非常巧妙的判断是否被标记

img

至此,我们已经知道了如何标记一个 ref,在当前的深度,effect 执行前后,是否有被标记

effect 的 fn 执行前,先标记所有dep为旧依赖

effect 执行时,trackEffec t时,标记依赖的 dep 为新依赖

一前一后的标记,然后对比差异,删除不需要的依赖,代码如下:

export const finalizeDepMarkers = (effect: ReactiveEffect) => {
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    // 遍历所有dep
    for (let i = 0; i < deps.length; i++) {
      const dep = deps[i]
      // 如果是旧依赖、且不是新依赖,就删除依赖
      if (wasTracked(dep) && !newTracked(dep)) {
        dep.delete(effect)
      } else {
        // 否则,将dep保存到前面的位置,后面 deps.length = ptr 删除多余的元素
        deps[ptr++] = dep
      }
      // 将当前深度的w和n,设置为0
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    // 删除多余的元素
    deps.length = ptr
  }
}

再次被这美妙的位运算惊艳到了!

img

另外,还有 wasTracked 和 newTracked 有以下组合: img

总结一下优化点:

  1. 在 effect 执行前,将 effect.deps 的所有 dep,标记为旧依赖
  2. 在 effect 执行时,trackEffect 收集依赖时,将当前被收集的响应式对象属性的 dep,标记为新依赖
  3. 遍历 effect.deps,去除不需要使用的依赖