乐趣区

关于前端: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
}

结尾

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

退出移动版