网络日志

Vue3响应式源码分析 - ref + ReactiveEffect篇

在Vue3中,因为reactive创建的响应式对象是通过Proxy来实现的,所以传入数据不能为基础类型,所以 ref 对象是对reactive不支持的数据的一个补充。

refreactive 中还有一个重要的工作就是收集、触发依赖,那么依赖是什么呢?怎么收集触发?一起来看一下吧:

我们先来看一下 ref 的源码实现:

export function ref(value?: unknown) {
  return createRef(value, false)
}

export function shallowRef(value?: unknown) {
  return createRef(value, true)
}

const toReactive = (value) => isObject(value) ? reactive(value) : value;

function createRef(rawValue: unknown, shallow: boolean) {
  // 如果是ref则直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
  private _value: T
  // 存放 raw 原始值
  private _rawValue: T

  // 存放依赖
  public dep?: Dep = undefined
  public readonly __v_isRef = true

  constructor(value: T, public readonly __v_isShallow: boolean) {
    // toRaw 拿到value的原始值
    this._rawValue = __v_isShallow ? value : toRaw(value)
    // 如果不是shallowRef,使用 reactive 转成响应式对象
    this._value = __v_isShallow ? value : toReactive(value)
  }

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

  // setter拦截器
  set value(newVal) {
    // 如果是需要深度响应的则获取 入参的raw
    newVal = this.__v_isShallow ? newVal : toRaw(newVal)
    // 新值与旧值是否改变
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      // 更新value 如果是深入创建并且是对象的话 还需要转化为reactive代理
      this._value = this.__v_isShallow ? newVal : toReactive(newVal)
      // 触发依赖
      triggerRefValue(this, newVal)
    }
  }
}

RefImpl 采用ES6类的写法,包含 getset,其实大家可以用 webpack 等打包工具打包成 ES5 的代码,发现其实就是 Object.defineProperty

可以看到,shallowRefref 都调用了 createRef,只是传入的参数不同。当使用 shallowRef 时,不会调用 toReactive 去将对象转换为响应式,由此可见,shallowRef对象只支持对value值的响应式,ref对象支持对value深度响应式,ref.value.a.b.c中的修改都能被拦截,举个🌰:

<template>
    <p>{{ refData.a }}</p>
    <p>{{ shallowRefData.a }}</p>
    <button @click="handleChange">change</button>
</template>


let refData = ref({
  a: 'ref'
})
let shallowRefData = shallowRef({
  a: 'shallowRef'
})

const handleChange = () => {
  refData.value.a = "ref1"
  shallowRefData.value.a = "shallowRef1"
}

当我们点击按钮修改数据后,界面上的 refData.a 的值会变为 ref1,而 shallowRefData.a 应该会不发生变化,但其实在这个例子里,shallowRefData.a 在视图上也会发生变化的🐶,因为修改 refData.a 时候,触发了setter函数,内会去调用 triggerRefValue(this, newVal) 从而触发了 视图更新,所以shallow的最新数据也会被更新到了视图上 (把 refData.value.a = "ref1" 去掉它就不会变了)。

ref 里最关键的还是trackRefValuetriggerRefValue,负责收集触发依赖。

如何收集依赖:

function trackRefValue(ref) {
    // 判断是否需要收集依赖
    // shouldTrack 全局变量,代表当前是否需要 track 收集依赖
    // activeEffect 全局变量,代表当前的副作用对象 ReactiveEffect
    if (shouldTrack && activeEffect) {
        ref = toRaw(ref);
        {
            // 如果没有 dep 属性,则初始化 dep,dep 是一个 Set<ReactiveEffect>,存储副作用函数
            // trackEffects 收集依赖
            trackEffects(ref.dep || (ref.dep = createDep()), {
                target: ref,
                type: "get",
                key: 'value'
            });
        }
    }
}

为什么要判断 shouldTrackactiveEffect,因为在Vue3中有些时候不需要收集依赖:

  • 当没有 effect 包裹时,比如定义了一个ref变量,但没有任何地方使用到,这时候就没有依赖,activeEffect 为 undefined,就不需要收集依赖了
  • 比如在数组的一些会改变自身长度的方法里,也不应该收集依赖,容易造成死循环,此时 shouldTrack 为 false

*依赖是什么?

ref.dep 用于储存 依赖 (副作用对象),ref 被修改时就会触发,那么依赖是什么呢?依赖就是 ReactiveEffect

为什么要收集依赖(副作用对象),因为在Vue3中,一个响应式变量的变化,往往会触发一些副作用,比如视图更新、计算属性变化等等,需要在响应式变量变化时去触发其它一些副作用函数。

在我看来 ReactiveEffect 其实就和 Vue2 中的 Watcher 的作用差不多,我之前写的《Vue源码学习-响应式原理》里做过说明:

class ReactiveEffect {
    constructor(fn, scheduler = null, scope) {
        // 传入一个副作用函数
        this.fn = fn;
        this.scheduler = scheduler;
        this.active = true;
        // 存储 Dep 对象,如上面的 ref.dep
        // 用于在触发依赖后, ref.dep.delete(effect),双向删除依赖)
        this.deps = [];
        this.parent = undefined;
        recordEffectScope(this, scope);
    }
    run() {
        // 如果当前effect已经被stop
        if (!this.active) {
            return this.fn();
        }
        let parent = activeEffect;
        let lastShouldTrack = shouldTrack;
        while (parent) {
            if (parent === this) {
                return;
            }
            parent = parent.parent;
        }
        try {
            // 保存上一个 activeEffect
            this.parent = activeEffect;
            activeEffect = this;
            shouldTrack = true;
            // trackOpBit: 根据深度生成 trackOpBit
            trackOpBit = 1 << ++effectTrackDepth;
            // 如果不超过最大嵌套深度,使用优化方案
            if (effectTrackDepth <= maxMarkerBits) {
                // 标记所有的 dep 为 was
                initDepMarkers(this);
            }
            // 否则使用降级方案
            else {
                cleanupEffect(this);
            }
            // 执行过程中重新收集依赖标记新的 dep 为 new
            return this.fn();
        }
        finally {
            if (effectTrackDepth <= maxMarkerBits) {
                // 优化方案:删除失效的依赖
                finalizeDepMarkers(this);
            }
            // 嵌套深度自 + 重置操作的位数
            trackOpBit = 1 << --effectTrackDepth;
            // 恢复上一个 activeEffect
            activeEffect = this.parent;
            shouldTrack = lastShouldTrack;
            this.parent = undefined;
            if (this.deferStop) {
                this.stop();
            }
        }
    }
}

ReactiveEffect 是副作用对象,它就是被收集依赖的实际对象,一个响应式变量可以有多个依赖,其中最主要的就是 run 方法,里面有两套方案,当 effect 嵌套次数不超过最大嵌套次数的时候,使用优化方案,否则使用降级方案。

降级方案:
function cleanupEffect(effect) {
    const { deps } = effect;
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            // 从 ref.dep 中删除 ReactiveEffect
            deps[i].delete(effect);
        }
        deps.length = 0;
    }
}

这个很简单,删除全部依赖,然后重新收集。在各个 dep 中,删除该 ReactiveEffect 对象,然后执行 this.fn()(副作用函数) 时,当获取响应式变量触发 getter 时,又会重新收集依赖。之所以要先删除然后重新收集,是因为随着响应式变量的变化,收集到的依赖前后可能不一样。

const toggle = ref(false)
const visible = ref('show')
effect(() = {
  if (toggle.value) {
    console.log(visible.value)
  } else {
    console.log('xxxxxxxxxxx')
  }
})
toggle.value = true
  • 当 toggle 为 true 时,toggle、visible 都能收集到依赖
  • 当 toggle 为 false 时,只有visible 可以收集到依赖
优化方案:

全部删除,再重新收集,明显太消耗性能了,很多依赖其实是不需要被删除的,所以优化方案的做法是:

// 响应式变量上都有一个 dep 用来保存依赖
const createDep = (effects) => {
    const dep = new Set(effects);
    dep.w = 0;
    dep.n = 0;
    return dep;
};
  1. 执行副作用函数前,给 ReactiveEffect 依赖的响应式变量,加上 w(was的意思) 标记。
  2. 执行 this.fn(),track 重新收集依赖时,给 ReactiveEffect 的每个依赖,加上 n(new的意思) 标记。
  3. 最后,对有 w 但是没有 n 的依赖进行删除。

其实就是一个筛选的过程,我们现在来第一步,如何加上 was 标记:

// 在 ReactiveEffect 的 run 方法里
if (effectTrackDepth <= maxMarkerBits) {
    initDepMarkers(this);
}

const initDepMarkers = ({ deps }) => {
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            deps[i].w |= trackOpBit;
        }
    }
};

这里使用了位运算,快捷高效。trackOpBit是什么呢?代表当前嵌套深度(effect可以嵌套),在Vue3中有一个全局变量 effectTrackDepth

// 全局变量 嵌套深度 
let effectTrackDepth = 0;

// 在 ReactiveEffect 的 run 方法里
// 每次执行 effect 副作用函数前,全局变量嵌套深度会自增1
trackOpBit = 1 << ++effectTrackDepth

// 执行完副作用函数后会自减
trackOpBit = 1 << --effectTrackDepth;

当深度为 1 时,trackOpBit为 2(二进制:00000010),这样执行 deps[i].w |= trackOpBit 时,操作的是第二位,所以第一位是用不到的。

为什么Vue3中嵌套深度最大是 30 ?

1 << 30
// 0100 0000 0000 0000 0000 0000 0000 0000
// 1073741824

1 << 31
// 1000 0000 0000 0000 0000 0000 0000 0000
// -2147483648 溢出

因为js中位运算是以32位带符号的整数进行运算的,最左边一位是符号位,所以可用的正数最多只能到30位。

可以看到,在执行副作用函数之前,使用 deps[i].w |= trackOpBit,对依赖在不同深度是否被依赖( w )进行标记,然后执行 this.fn(),重新收集依赖,上面说到收集依赖调用 trackRefValue 方法,该方法内会调用 trackEffects

function trackEffects(dep, debuggerEventExtraInfo) {
    let shouldTrack = false;
    if (effectTrackDepth <= maxMarkerBits) {
        // 查看是否记录过当前依赖
        if (!newTracked(dep)) {
            dep.n |= trackOpBit;
            // 如果 w 在当前深度有值,说明effect之前已经收集过
            // 不是新增依赖,不需要再次收集
            shouldTrack = !wasTracked(dep);
        }
    }
    else {
        shouldTrack = !dep.has(activeEffect);
    }
    if (shouldTrack) {
        // dep添加当前正在使用的effect
        dep.add(activeEffect);
         // effect的deps也记录当前dep 双向引用
        activeEffect.deps.push(dep);
    }
}

可以看到再重新收集依赖的时候,使用 dep.n |= trackOpBit 对依赖在不同深度是否被依赖( n )进行标记,这里还用到两个工具函数:

const wasTracked = (dep) => (dep.w & trackOpBit) > 0;
const newTracked = (dep) => (dep.n & trackOpBit) > 0;

使用 wasTracked 和 newTracked,判断 dep 是否在当前深度被标记。比如判断依赖在深度 1 时 (trackOpBit第二位是1) 是否被标记,采用按位与:

最后,如果已经超过最大深度,因为采用降级方案,是全部删除然后重新收集的,所以肯定是最新的,所以只需要把 trackOpBit 恢复,恢复上一个 activeEffect:

finally {
    if (effectTrackDepth <= maxMarkerBits) {
        // 优化方案:删除失效的依赖
        finalizeDepMarkers(this);
    }
    trackOpBit = 1 << --effectTrackDepth;
    // 恢复上一个 activeEffect
    activeEffect = this.parent;
    shouldTrack = lastShouldTrack;
    this.parent = undefined;
    if (this.deferStop) {
        this.stop();
    }
}

如果没超过最大深度,就像之前说的把失效的依赖删除掉,然后更新一下deps的顺序:

const finalizeDepMarkers = (effect) => {
    const { deps } = effect;
    if (deps.length) {
        let ptr = 0;
        for (let i = 0; i < deps.length; i++) {
            const dep = deps[i];
            // 把有 w 没有 n 的删除
            if (wasTracked(dep) && !newTracked(dep)) {
                dep.delete(effect);
            }
            else {
                // 更新deps,因为有的可能会被删掉
                // 所以要把前面空的补上,用 ptr 单独控制下标 
                deps[ptr++] = dep;
            }
            // 与非,恢复到进入时的状态
            dep.w &= ~trackOpBit;
            dep.n &= ~trackOpBit;
        }
        deps.length = ptr;
    }
};

举个简单的🌰,理解起来可能简单点,有两个组件,一个父组件,一个子组件,子组件接收父组件传递的 toggle 参数显示在界面上,toggle 还控制着 visible 的显示,点击按钮切换 toggle 的值:

// Parent
<script setup lang="ts">
const toggle = ref(true)
const visible = ref('show')

const handleChange = () => {
  toggle.value = false
}
</script>

<template>
  <div>
    <p v-if="toggle">{{ visible }}</p>
    <p v-else>xxxxxxxxxxx</p>
    <button @click="handleChange">change</button>
    <Child :toggle="toggle" />
  </div>
</template>
// Child
<script setup lang="ts">
const props = defineProps({
  toggle: {
    type: Boolean,
  },
});
</script>

<template>
  <p>{{ toggle }}</p>
</template>

第一次渲染,因为toggle 默认为 true,我们可以收集到 togglevisible 的依赖,

  1. Parent 组件, 执行 run 方法中的 initDepMarkers 方法,首次进入,还未收集依赖,ReactiveEffectdeps 长度为0,跳过。
  2. 执行 run 方法中的 this.fn,重新收集依赖,触发 trackEffects:

    • toggle 的 dep = {n: 2, w: 0}shouldTrack 为 true,收集依赖。
    • visible 的 dep = {n: 2, w: 0}shouldTrack 为 true,收集依赖。
  3. 进入 Child 组件,执行 run 方法中的 initDepMarkers 方法,首次进入,还为收集依赖,deps长度为0,跳过。
  4. 执行 run 方法中的 this.fn,重新收集依赖,触发 trackEffects:

    • toggle 的 dep = {n: 4, w: 0}shouldTrack 为 true,收集依赖。

这样首次进入页面的收集依赖就结束了,然后我们点击按钮,把 toggle 改为 false:

  1. Parent 组件: 执行 run 方法中的 initDepMarkers 方法,之前在 Parent 组件里收集到了两个变量的依赖,所以将他们 w 标记:

    • toggle 的 dep = {n: 0, w: 2}
    • visible 的 dep = {n: 0, w: 2}
  2. 执行 run 方法中的 this.fn,重新收集依赖,触发 trackEffects:

    • toggle 的 dep = {n: 2, w: 2}shouldTrack 为 false,不用 收集依赖。
    • visible 不显示了,所以没有重新收集到,还是 {n: 0, w: 2}
  3. 进入 Child 组件,执行 run 方法中的 initDepMarkers 方法,之前 收集过 toggle 依赖了,将 toggle 的 w 做标记,toggle 的 dep = {n: 0, w: 4}
  4. 执行 run 方法中的 this.fn,重新收集依赖,触发 trackEffects:

    • toggle 的 dep = {n: 4, w: 4}shouldTrack 为 false,不用收集依赖。

最后发现 visiblew 没有 n,在 finalizeDepMarkers 中删除掉失效依赖。

如何触发依赖:

在一开始讲到的 ref 源码里,可以看到在 setter 时会调用 triggerRefValue 触发依赖:

function triggerRefValue(ref, newVal) {
    ref = toRaw(ref);
    if (ref.dep) {
        {
            triggerEffects(ref.dep, {
                target: ref,
                type: "set",
                key: 'value',
                newValue: newVal
            });
        }
    }
}

function triggerEffects(
  dep: Dep | ReactiveEffect[]
) {
  // 循环去取每个依赖的副作用对象 ReactiveEffect
  for (const effect of isArray(dep) ? dep : [...dep]) {
    // effect !== activeEffect 防止递归,造成死循环
    if (effect !== activeEffect || effect.allowRecurse) {
      // effect.scheduler可以先不管,ref 和 reactive 都没有
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        // 执行 effect 的副作用函数
        effect.run()
      }
    }
  }
}

触发依赖最终的目的其实就是去执行依赖每个的副作用对象副作用函数,这里的副作用函数可能是执行更新视图、watch数据监听、计算属性等。


🤨🧐我个人再看源码的时候还遇到了一个问题,不知道大家遇到没有(我看的代码版本算是比较新v3.2.37),一开始我也是上网看一些源码的解析文章,看到好多讲解 effect 这个函数的,先来看看这个方法的源码:

function effect(fn, options) {
    if (fn.effect) {
        fn = fn.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);
    runner.effect = _effect;
    // 返回一个包装后的函数,执行收集依赖
    return runner;
}

这个函数看上去挺简单的,创建一个 ReactiveEffect 副作用对象,将用户传入的参数附加到对象上,然后调用 run 方法收集依赖,如果有 lazy 配置不会自动去收集依赖,用户主动执行 effect 包装后的函数,也能够正确的收集依赖。

🤨🧐但我找了一圈,发现源码里一个地方都没调用,于是我就在想是不是以前用到过,现在去掉了,去commit记录里找了一圈,还真找到了:

这次更新把 ReactiveEffect 改为用类来实现,避免不必要时也创建 effect runner,节省了17%的内存等。

原来的 effect 方法包括了现在的 ReactiveEffect,在视图更新渲染、watch等地方都直接引用了这个方法,但更新后都是直接 new ReactiveEffect,然后去触发 run 方法,不走 effect 了,可以说现在的 ReactiveEffect 类就是之前的 effect 方法 。

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  const effect = createReactiveEffect(fn, options)
  return effect
}

let uid = 0

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    if (!effect.active) {
      return fn()
    }
    if (!effectStack.includes(effect)) {
      cleanup(effect)
      try {
        enableTracking()
        effectStack.push(effect)
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        const n = effectStack.length
        activeEffect = n > 0 ? effectStack[n - 1] : undefined
      }
    }
  } as ReactiveEffect
  effect.id = uid++
  effect.allowRecurse = !!options.allowRecurse
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}

结尾

我是周小羊,一个前端萌新,写文章是为了记录自己日常工作遇到的问题和学习的内容,提升自己,如果您觉得本文对你有用的话,麻烦点个赞鼓励一下哟~