【源码阅读】vue3 - reactive 源码探究三

238 阅读2分钟

在 vue3 文档 深入响应式系统 什么是响应式中有提到过几个核心的概念,副作用 (effect),依赖 (dependency),核心思想就是当读取数据时触发 track,数据变化时触发 trigger。

我们先来探究下 track 执行的过程,对这几个概念深入理解

响应式 track 过程探究

vue3 的响应式是可以单独引入的,下载 vue3 源码后,执行 pnpm build reactivity --types --sourcemap 可以在 reactivity -> dist 目录下得到 reactivity 相关代码打包后的结果

下面从一个最简单的例子看看vue3响应式都是怎么做的

<div id="app"></div>
<script type="module">
    import { effect, reactive } from '../../../reactivity/dist/reactivity.esm-browser.js'
    function render(vNode, container) {
        if (typeof container === 'string') {
            container = document.querySelector(container);
        }
        container.innerHTML = vNode;
    }
    const data = reactive({
        count: 1
    })
    effect(() => {
        render(`<h1>${data.count}</h1>`, "#app")
    })
    setTimeout(()=>{
        data.count++;
    },1000)
</script>

这段代码中 render 函数通过 innerHTML 将模板字符串转为 DOM 挂载到页面

调用栈

将断点卡在 track 函数上可以得到调用栈:

image.png

通过调用栈可以得出在调用 track 函数前会经过如下操作:

  1. 代码执行到副作用函数 effect 时,会执行作为参数传递的匿名函数。
  2. 匿名函数是在 run 函数中执行的,run 函数是类 ReactiveEffect 的方法。
  3. 匿名函数执行中读取 reactive 代理的对象的属性时触发 get ,get 是 mutableHandlers 对象的属性,执行 createGetter 方法得到。
  4. 触发 track 操作,进行依赖收集

接着逐一确认下每个函数的实现过程

effect 分析

export function effect<T = any>(
  fn: () => T,
  options?: ReactiveEffectOptions
): ReactiveEffectRunner {
  if ((fn as ReactiveEffectRunner).effect) {
    fn = (fn as ReactiveEffectRunner).effect.fn
  }

  const _effect = new ReactiveEffect(fn)
  if (options) {
    extend(_effect, options)
    if (options.scope) recordEffectScope(_effect, options.scope)
  }
  if (!options || !options.lazy) {
    _effect.run()
  }
  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
  runner.effect = _effect
  return runner
}

effect 函数先是判断 fn 上面有没有 effect 属性,一开始执行的时候肯定是没有的

image.png

那什么时候有,很明显是 runner.effect = _effect 进行赋值的,为什么这么肯定。首先对比两者的类型能发现他们是相同的

image.png

使用 ReactiveEffect 创建 _effect 实例,包含 deps/fn/parent/scheduler 属性。

image.png

定义了 runner 为 ReactiveEffect 类的 Run 函数,添加了 effect 属性值为 _effect 实例。

因为 option 未进行配置,所以会直接执行 runner,可以说 effect 函数执行的其实是 ReactiveEffect 类的 Run 函数

ReactiveEffect 分析

export class ReactiveEffect<T = any> {
  active = true
  deps: Dep[] = []
 
  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null,
    scope?: EffectScope
  ) {
    recordEffectScope(this, scope)
  }

  run() {
    if (!this.active) {
      return this.fn()
    }
    let parent: ReactiveEffect | undefined = activeEffect
    let lastShouldTrack = shouldTrack
    while (parent) {
      if (parent === this) {
        return
      }
      parent = parent.parent
    }
    try {
      this.parent = activeEffect
      activeEffect = this
      shouldTrack = true

      trackOpBit = 1 << ++effectTrackDepth

      if (effectTrackDepth <= maxMarkerBits) {
        initDepMarkers(this)
      } else {
        cleanupEffect(this)
      }
      return this.fn()
    } finally {
      if (effectTrackDepth <= maxMarkerBits) {
        finalizeDepMarkers(this)
      }

      trackOpBit = 1 << --effectTrackDepth

      activeEffect = this.parent
      shouldTrack = lastShouldTrack
      this.parent = undefined

      if (this.deferStop) {
        this.stop()
      }
    }
  }

}

在这个简单的例子中, ReactiveEffect 实际也就 run 函数运行了,重点看下 run 相关的代码。run 函数的代码逻辑在 3.2 版本后有更新了 EffectScope 相关的功能,左侧为更新前的代码,右侧为更新后的代码。所以我们重点关注下红框中的代码逻辑即可。

image.png

在红框中,存在 trackOpBit,effectTrackDepth,maxMarkerBits:

  • effectTrackDepth 用来记录 effect递归嵌套的深度
  • trackOpBit 和前者进行左移运算得到最高位为1,其他位为0的数,用来记录依赖收集的状态,具体怎么记录 track 的时候进行分析
  • maxMarkerBits 表示最大的递归嵌套深度

判断 effectTrackDepth 和 maxMarkerBits 大小,分别执行不同的函数。cleanupEffect 函数是为了解决分支切换带来不必要的更新而存在,当嵌套深度小于 maxMarkerBits 时,使用的是 initDepMarkers 方法。

关于分支切换的问题可以通过如下代码理解:

const data = reactive({
    isShow:true,
    text:'hello world'
})
effect(()=>document.body.innerText = data.isShow ? data.text:'hehe')

当 isShow 为 true 时,text 需要收集依赖。当 isShow 为 false 时,text 不需要收集依赖。所以 vue 在每次执行 run 函数前都先清空收集的依赖,然后再执行依赖收集。也就是 cleanupEffect 函数的作用。

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
  }
}

从 effect 能够取出 deps ,其是在 track 的时候进行赋值的,dep.add(activeEffect!);activeEffect!.deps.push(dep),先将当前执行中的副作用函数 _effect 收集到 dep 集合内,同时还把 dep 集合绑定到当前执行中的副作用函数,这样做的目的是为了知道 _effect 关联了几个依赖集合,当数据发生更新时能获得这些依赖集合。

执行 return this.fn(),这里的 fn 是 effect 的参数即匿名函数。执行匿名函数则会执行里面的 render 方法,data 是代理对象,读取属性触发 get ,get 触发 track。

最后当函数执行完后,重新计算 trackOpBit 。如果 effect 为嵌套 effectTrackDepth 则会减1。

对 ReactiveEffect 有了一定了解后,接下来看看 track 做了什么:

track 分析

export function track(target: object, type: TrackOpTypes, key: unknown) {
  if (shouldTrack && activeEffect) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      // 每一个 target 对应一个 depsMap
      targetMap.set(target, (depsMap = new Map()))
    }
    let dep = depsMap.get(key)
    if (!dep) {
      // 每一个 key 对应一个 dep 集合
      /**
      wasTracked和newTracked 根据比特位的来记录递归状态。
      每个比特定义是否跟踪依赖项。
          export const createDep = (effects?: ReactiveEffect[]): Dep => {
              const dep = new Set<ReactiveEffect>(effects) as Dep
              dep.w = 0 // wasTracked
              dep.n = 0 // newTracked
              return dep
          }
      */
      depsMap.set(key, (dep = createDep()))
    }

    trackEffects(dep, eventInfo)
  }
}


track 函数主要做的事情是根据被代理对象 target ,属性 key,将_effect(ReactiveEffect的实例)储存到 set 中,这个过程就是依赖收集,三者关系如下:

image.png

创建过程如上过程是在 track 函数中完成的,依赖收集过程是通过 trackEffects 完成的。

实际情况如下图:

image.png

为了更加清楚的理解 trackEffects 的依赖收集过程,我们多添加几个响应式对象和 effect 嵌套执行来说明

<div id="app"></div>
<div id="app2"></div>
<div id="app3"></div>
<script type="module">
    import { effect, reactive } from '../../../reactivity/dist/reactivity.esm-browser.js'
    function render(vNode, container) {
        if (typeof container === 'string') {
            container = document.querySelector(container);
        }
        container.innerHTML = vNode;
    }
    const data = reactive({
        count: 1,
        foo: {
            bar: 2
        }
    })
    const data2 = reactive({
        msg: "hello world",
    })
    effect(() => {
        render(`<h1>${data.count} - ${data.foo.bar}-${data2.msg}</h1>`, "#app");
        effect(() => {
            render(`<h2>${data.foo.bar}</h2>`, "#app2");
            effect(() => {
                render(`<h3>${data2.msg}</h3>`, "#app3");
            })
        })
    })

    setTimeout(() => {
        data.count++;
    }, 1000)
</script>

括号内代表二进制

trackEffects 执行次数effectTrackDepth 值trackOpBit 值dep.n 值targetkey
112(10)2(10){count:1,foo:{}}count
212(10)2(10){count:1,foo:{}}foo
312(10)2(10){bar:2}bar
412(10)2(10){msg:"hello world"}msg
524(100)6(110){count:1,foo:{}}foo
624(100)6(110){bar:2}bar
738(1000)10(1010){msg:"hello world"}msg

image.png

从表格和执行结果很容易回答 trackOpBit 怎么记录依赖收集的状态:

trackOpBit 初始值为 1,dep.n 初始值为 0。当 effect 执行时,收集 target 每个 key 的依赖, trackOpBit 只有在最高位为 1,effect 嵌套一层,trackOpBit 按位左移一位,dep.n |= trackOpBit dep.n 和 trackOpBit 按位或运算,对于相同的 key 只要在当前 effect 的嵌套层级出现则会将该比特位置 1 ,没有则是 0。对于每个 key 就可以从 dep.n 的比特位看出其依赖收集状况。

track优化 --- 分支切换

另外关于当 effectTrackDepth < maxMarkerBits 时,是使用 initDepMarkers 来代替 cleanupEffect,为什么可以这么代替呢?

和这功能实现相关代码主要是如下部分:

export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

export const finalizeDepMarkers = (effect: ReactiveEffect) => {
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    for (let i = 0; i < deps.length; i++) {
      const dep = deps[i]
      if (wasTracked(dep) && !newTracked(dep)) {
        dep.delete(effect)
      } else {
        deps[ptr++] = dep
      }
      // clear bits
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

首次进行 track 时,由于 deps 是空的 initDepMarkers 其实是不进行任何操作的。

track 完成后最后会执行 finalizeDepMarkers 函数重置 dep.w 和 dep.n 为 0; 再看下如下例子

const data2 = reactive({
    isShow:true,
    msg: "hello world",
})
effect(() => {
    render(`<h1>${data2.isShow ? data2.msg : 'hello vue'} </h1>`, "#app");
})
setTimeout(() => {
    data2.isShow = false;
}, 1000)

当 isShow = true 时,会收集 isShow 和 msg 的依赖,WeakMap 如下:

image.png

当 1s 后 isShow 设置为 false 我们想要的结果只会收集 isShow 的依赖,但是在执行 finalizeDepMarkers 时可以看出两个依赖目前还是存在,只是 dep.w 和 dep.n 不同,从红框中的代码可以看出是否删除是由这两个变量来决定的

image.png

这两个变量又是在什么时候变的不一样的呢,dep.w 在同一个状态下两者的值是相同的,和 dep.w 变化相关的代码如下:

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

当isShow = false 时,deps.length = 2,trackOpBit = 2,按位或运算 dep.w 会统一设置为 2

dep.n 的值改变相关代码如下:

export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // set newly tracked
      shouldTrack = !wasTracked(dep)
    }
  } else {
    // ...
  }
}

isShow = false 触发 track 会调用 trackEffects 执行 dep.n |= trackOpBit 得到 2,但是 msg 不会触发 track

了解了 dep.w 和 dep.n 的差异后,看看 wasTracked 和 newTracked 实现后

export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0
状态keydep.wdep.ntrackOpBitwasTrackednewTracked是否删除依赖
isShow = trueisShow02(10)2(10)falsetruefalse
isShow = truemsg02(10)2(10)falsetruefalse
isShow = falseisShow2(10)2(10)2(10)truetruefalse
isShow = falsemsg2(10)02(10)truefalsetrue

只要 wasTracked 为真,newTracked 为假就会删除依赖,达到和 cleanEffect 相同的目的