带你深入Vue3响应式系统

2022-12-09 09:55:45 浏览数 (2)

一、初识响应性

Vue3 中可以通过响应式 API 来创建响应式对象, 之前介绍过一些响应式 API, 如 ref、computed、reactive、shallowRef、shallowReactive等等. 相较于 Vue2 中使用 Object.definProperty 来劫持 get 和 set 不同, Vue3 中使用的是 Proxy 来创建响应式对象,仅将 get 和 set 仅用于 ref. 与此同时, 响应式 API 大致都有一个共同的特征, 就是在 get 劫持中进行某个属性的 tarck, 在 set 劫持中进行某个属性的 trigger.

可以想想, 如果某个数据改变了, 依赖于该数据的相关对象或者状态必然也要跟着改变, 如何找到哪些是依赖于该数据的对象? 当该数据变化了应该如何去触发与之相关的状态变更? 这就是 Vue3 响应式系统做的事情.

我们一般遇到上面这种类型的问题, 首先就会想到发布-订阅的设计模式, Vue3 也采用了类似的模式, 不过有一些自己的特点, 先来看几个概念性的东西. 先以 reactive 的 get 和 set 为例, 来看看 track 和 trigger 是用来干嘛的, 可以简单理解 track 就是添加订阅, trigger 就是发布执行. 下面我们仔细看看

reactive 的 get/set 代码大致如下, 详细解读见这篇文章:

代码语言:javascript复制
function createGetter(isReadonly = false, shallow = false) {
    return function get(target, key, receiver) {
        ...
        if (!isReadonly) {
            track(target, "get" /* GET */, key);
        }
        ...
    };
}

function createSetter(shallow = false) {
    return function set(target, key, value, receiver) {
        ...
        else if (shared.hasChanged(value, oldValue)) {
           trigger(target, "set" /* SET */, key, value);
        }
        ...
    };
}

二、副作用与依赖

1、 概念

上面说到了 track, 我们就先来看下 track 的具体内容, 其英文翻译是追踪的意思, 这里可以认为是对依赖的副作用追踪, 其函数定义如下

代码语言:javascript复制
function track(target: object, type: TrackOpTypes, key: unknown)

track 要做的就是将 target 对应属性 key 的副作用添加到其订阅的副作用集合中, 理解起来有点别扭, 举个官方的例子

代码语言:javascript复制
let A2
function update() {  A2 = A0   A1; }

update 函数会产生一个副作用, 因为他会改变全局状态 A2. 而 A0 和 A1 被认为是这个副作用的依赖, 因为它们的值被用来执行这个副作用. 为了能够在依赖 (A0、A1)变化的时候去触发副作用 (执行update), 需要将该副作用关联到他的每个依赖上, 即将该副作用设为各依赖的一个订阅者, 当某个依赖变化时, 通知他的所有订阅者 (副作用) 重新执行.

结合到 Vue3 中, 例如有如下表达式时,

代码语言:javascript复制
const d = ref(3); // 创建了一个响应式对象
const c = computed(() => d.value   2); // 有响应式对象计算

我们可以对照着理解一下, 如果视图上使用了变量 c, 则 computed 的回调函数会产生一个副作用, 因为他通过计算得到 c 的值而影响视图的更新. 响应式对象 d 是这个副作用的依赖, 因为 c 的计算值是由 d 的值来决定的, 当 d 的值改变后, 要去重新计算 c 的值, 从而更新视图. 上面说了, Vue3 中是通过劫持响应式对象的 set 来更新值, 通过劫持响应式对象的 get 来获取值. 因此可以在 get 的时候将 computed 的副作用设为其依赖 d 的一个订阅者, 在依赖 d 的值 set 的时候通知其订阅者 computed 的副作用去更新变量 c, 这样就完成了一个响应性的工作.

用一个流程图再次帮助理解, 响应式对象认为是 d, 副作用函数认为是 computed 的回调, 假设有一个副作用管理器, 来专门管理副作用与其依赖之间的映射关系.

这里可以简单的理解, 上面的红线就是 track 的过程, 蓝线就是 trigger 的过程. 为什么说是简单的理解, 因为 Vue3 在实际实现过程中要更为复杂, 为了逐步理解其实现原理, 首先我们从副作用管理器入手.

2、 副作用存储

副作用管理器存储了全部的副作用订阅, 他是一个全局的 WeakMap 对象, 话不多说, 先看看其数据结构定义

代码语言:javascript复制
WeakMap<target, Map<key, Set<effect>>> // effect即为副作用
const targetMap = new WeakMap();

结合上面的数据结构, 再来分析一下他的几个特点:

  • 他是以响应式代理对象为维度来分别存储的, 因此他的 key 是 target. 【WeakMap<target, ...>】
  • 同时, 对于任何一个 target, 由于每个与其相关的副作用所依赖的对象属性是不一样的, 所以需要按照 target 的属性来分开存储和处理这些副作用. 【Map<key, ...>】
  • 最后, target 的某个对象属性可能被不同的副作用所依赖, 所以需要管理一个依赖于此对象属性的副作用 Set 列表.【Set<effect>】

Vue3 中使用全局对象 targetMap 在存储这些信息, 其仅在 track 和 trigger 的过程中会使用到. 来看下代码中对依赖于 target 某个对象属性的副作用 Set , 即 Set<effect>是如何定义的.

代码语言:javascript复制
// effects是依赖的副作用集合, 初始化时为undefined
const createDep = (effects) => {
    const dep = new Set(effects);
    dep.w = 0; // wasTracked: 已追踪过
    dep.n = 0; // newTracked: 新的追踪
    return dep;
};

说明:

  • createDep 函数创建了某个依赖的副作用集合, 这个集合会存储到 targetMap 中或被直接 trigger 执行
  • effects 是当前对象属性已有的的副作用集合, 如果第一次创建则为undefined, 用于初始化该集合, 如果不是第一次创建, 则会先从 targetMap 中获取到对应依赖的副作用集合, 再传入, effects 的具体定义看下面第三小部分 (当前副作用)
  • n 和 w 代表的是 wasTracked 和 newTracked 两个状态, 他们维护着依赖递归追踪副作用的层级状态, 每一个层级的状态对应 w 和 n 的一个位 (bit) 的值, 其用来表示该依赖的副作用是否被追踪执行. 先有个大概理解, 后面会详细说到

3、 当前副作用

在 Vue3 中有很多地方都需要用到响应式的副作用, 例如我们定义的不同页面和组件, 这些都依赖于响应式数据, 所以在 setup 中会调用 setupRenderEffect 进行依赖收集, 还有我们使用的computed、deferredComputed、watch、watchEffect、effect, 都会创建依赖的副作用.

对于单线程的 js 来说, 无法同时去执行多个副作用的处理, 所以要按一定的顺序去收集和处理依赖的副作用. 因此有一个全局的变量 activeEffect 去指向当前正在运行的副作用, 这个副作用会去收集此时与之相关的依赖, 他是在 ReactiveEffect 对象里面赋值的, 具体看下流程

代码语言:javascript复制
class ReactiveEffect {
    constructor(fn, scheduler = null, scope) {
        this.fn = fn; // 副作用函数
        this.scheduler = scheduler; // 调度函数, 一般在trigger中执行
        this.active = true; // 标识当前副作用是否可用
        this.deps = []; // 和当前副作用有关的依赖
        this.parent = undefined; // 父指针
        recordEffectScope(this, scope); // 如果有scope, 则创建一个effect作用域
    }
    // 激活当前的副作用
    run() {
        // 当前副作用不可用, 则直接运行副作用函数
        if (!this.active) {
            return this.fn();
        }
        let parent = 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; // 从栈中删除该副作用
            // 如果需要延迟终止, 则调用stop
            if (this.deferStop) {
                this.stop();
            }
        }
    }
    stop() {
        // 如果当前正在运行该副作用, 则等运行完后再终止
        if (activeEffect === this) {
            this.deferStop = true;
        } else if (this.active) {
            cleanupEffect(this); // 在依赖中清理掉该副作用
            if (this.onStop) {
                this.onStop();
            }
            this.active = false; // 标识当前副作用不可用
        }
    }
}

代码比较长, 这里做一些说明

1) ReactiveEffect 实例化的对象在这里也叫副作用, 但是要和之前说的副作用区别开, 这里理解为副作用处理对象稍微区分下, 他是站在副作用的角度来定义的, 收集与某个副作用相关的所有依赖放入依赖列表 deps, 并且在某个依赖变化时去触发执行该副作用处理对象中的副作用函数 fn 或者 调度器 scheduler. 而前两小节说的那个副作用就相当于这里的副作用函数, 站在依赖的角度来说, 副作用函数被关联到某个依赖的 Set 中.

2) 这里维护了一个 ReactiveEffect 实例化对象的栈, 也可以理解成单向链表, 通过对象中的 parent 指针指向其前一个副作用处理对象. 执行一个副作用处理对象的 run 方法即可将该对象压入栈中, 激活为正在执行的副作用 (activeEffect), 当 run 函数执行结束后, 会将该对象从栈中弹出, 将新的栈顶对象 (该对象的parent) 激活为正在执行的副作用.

3) 可以看到代码中有 trackOpBit 和 effectTrackDepth 这两个全局变量, 其中 effectTrackDepth 表示当前已追踪的副作用对象的数量, 或者叫递归追踪的副作用深度, 可以理解为上面栈的长度. trackOpBit 用于标识某个依赖属于哪一个副作用对象, 即属于追踪深度的哪一个层级, 只有在当前深度层级的依赖才会被处理. 他们的初始值如下

代码语言:javascript复制
let effectTrackDepth = 0; // 自增
let trackOpBit = 1; // 按位运算

记得在上面创建某个对象属性的副作用 Set 时定义了 dep.n 和 dep.w, 通过这两个标识可以很容易的过滤和清理某个副作用的依赖. 那这些依赖是如何处理的呢 ? 其包含三个部分, 每次副作用处理对象执行的时候都会重复这三个部分, 如此循环往复, 形成响应式更新.

第一部分是收集依赖, 在 track 阶段, 采用按位的或运算将 dep 标识为属于正在运行副作用的新的依赖 (dep.n)

代码语言:javascript复制
function trackEffects(dep, debuggerEventExtraInfo) {
    ...
    if (effectTrackDepth <= maxMarkerBits) {
        if (!newTracked(dep)) {
            dep.n |= trackOpBit; // set newly tracked
            ...
        }
    }
    ...
}

第二部分是追踪依赖, 在 ReactiveEffect 的 run 函数中, 将副作用处理对象的所有依赖进行初始化, 并采用按位的或运算将 dep 标识为已经追踪到的依赖 (dep.w)

代码语言:javascript复制
const initDepMarkers = ({ deps }) => {
    if (deps.length) {
        for (let i = 0; i < deps.length; i  ) {
            deps[i].w |= trackOpBit; // set was tracked
        }
    }
};

第三部分是清理依赖, 同样是在 ReactiveEffect 的 run 函数执行完其副作用函数 fn 后, 进行依赖的清理. 他会遍历副作用处理对象的 deps, 将没有在第一部分收集到的, 也没有在第二部分追踪到的依赖剔除掉, 避免一些无效依赖的处理. 同时会将有效依赖的 dep.n 和 dep.w 恢复, 便于下一次的依赖处理.

代码语言:javascript复制
const finalizeDepMarkers = (effect) => {
    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;
    }
};

4) 可以看到所有依赖的 dep.n 和 dep.w 都是按位来运算的, 这样容易确认依赖属于哪一个副作用处理对象. 而 V8 引擎内会把数字分成两种类型:Smi 和 HeapNumber. Vue3 采用的是Smi 模式, 即范围是 -2³¹ 到 2³¹-1的整数,在栈中直接存值, 所以其最大的位数是 30 位. 由于 trackOpBit 是根据追踪深度来移位计算的, 所以 effectTrackDepth 的最大深度被限制为 30

代码语言:javascript复制
trackOpBit = 1 <<   effectTrackDepth

如果超过了最大深度该怎么办 ? 超过之后副作用处理对象的依赖 deps 将会逐个去清理, 断开与当前正在运行的副作用的关联, 不再追踪对应依赖的副作用.

代码语言:javascript复制
function cleanupEffect(effect) {
    const { deps } = effect;
    if (deps.length) {
        for (let i = 0; i < deps.length; i  ) {
            deps[i].delete(effect);
        }
        deps.length = 0;
    }
}

5) 使用者也可以手动控制去停止副作用处理对象的工作, 通过调用 ReactiveEffect 的 stop 函数进行 deps 与 effect 的关联, 并且将该副作用处理对象设置为不可用 (active = false). 要注意的是, 当副作用处理对象正在执行 run 函数时是不允许中断的, 会在执行完之后再执行 stop.

4、 副作用的工作流

上面说了这么多, 让我们来总结梳理一下副作用的工作流, 同时再对依赖、副作用、副作用处理对象这几个有个清晰的认识

1) 首先如果代码中使用到了响应式 API 求值 (如computed )或者监听 (如watch), 为了能够追踪所依赖变量的变化, 都会去实例化一个副作用处理对象(ReactiveEffect), 并且由于要通过副作用函数 fn 得到初始计算值, 一般会先执行一次 ReactiveEffect 的 run 函数.

2) 在执行 run 函数的过程中会将副作用处理对象压入到副作用对象栈中, 并将其设置为当前正在运行的副作用, 同时计算新的追踪深度 (effectTrackDepth) 和当前的追踪标识位 (trackOpBit), 然后会去执行副作用函数.

3) 在执行副作用函数的同时会触发响应式对象的 track, 进行依赖收集, 将副作用函数的依赖推入到副作用处理对象的 deps 依赖列表中, 与此同时也会讲副作用处理对象作为订阅者放入该依赖中 (这块具体流程下面会说到).

4) 当副作用函数的依赖发生变化后, 会触发该依赖的 trigger, trigger 会去遍历订阅了该依赖的所有副作用处理对象, 并且执行他的 run 函数, 然后重新接上面的第二步执行

5) 执行完后会去清理副作用处理对象的依赖列表 deps, 复位追踪深度和追踪标识位, 将副作用处理对象从栈中弹出

三、track工作流

上面说副作用和依赖的时候, 已经介绍了一些关于 track 的内容, 即他是用来进行依赖收集和副作用追踪的, 我们直接来看下 track 的代码实现.

代码语言:javascript复制
// target: 响应式代理对象, type: 订阅类型(get、hase、iterate), key: 要获取的target的键值
function track(target, type, key) {
    // 如果允许追踪, 并且当前有正在运行的副作用
    if (shouldTrack && activeEffect) {
        // 获取当前target订阅的副作用集合, 如果不存在, 则新建一个
        let depsMap = targetMap.get(target);
        if (!depsMap) {
            targetMap.set(target, (depsMap = new Map()));
        }
        // 获取对应属性key订阅的副作用, 如果不存在, 则新建一个
        let dep = depsMap.get(key);
        if (!dep) {
            depsMap.set(key, (dep = createDep()));
        }
        // 处理订阅副作用
        trackEffects(dep);
    }
}
function trackEffects(dep, debuggerEventExtraInfo) {
    // 先暂停追踪
    let shouldTrack = false;
    // 如果当前追踪深度不超过最大深度(30), 则添加订阅
    if (effectTrackDepth <= maxMarkerBits) {
        // 如果未订阅过, 则新建
        if (!newTracked(dep)) {
            // 根据当前的追踪标识位设置依赖的new值
            dep.n |= trackOpBit;
            // 开启订阅追踪
            shouldTrack = !wasTracked(dep);
        }
    }
    else {
        shouldTrack = !dep.has(activeEffect);
    }
    if (shouldTrack) {
        // 将当前正在运行副作用作为新订阅者添加到该依赖中
        dep.add(activeEffect);
        // 缓存依赖到当前正在运行的副作用依赖数组
        activeEffect.deps.push(dep);
    }
}

其实有了之前的理解, 这块就比较容易看懂了, 这里主要说明如下几点:

1、 shouldTrack 是一个全局变量, 用来控制开启和暂停追踪, 在执行 trackEffects 处理依赖和正在运行的副作用关系时, 会先暂停追踪, 防止重复执行.

2、 要注意的是这里要站在依赖的角度去理解会更加的容易, 即响应式对象 (target) 的某个属性 (key). 处理时会先去从副作用管理器 (targetMap) 中查找是否已经存在该依赖的副作用 Set, 如果不存在的话就会去初始化一个该依赖的空的副作用 Set.

3、 在 trackEffects 中如果追踪深度没有超过最大深度, 会去根据当前的追踪标识设置该依赖的 dep.n 标识位, 一来可以确定该依赖属于哪一个递归层级, 即属于哪一个 activeEffect; 二来可以在 activeEffect 的清理阶段 (finalizeDepMarkers) 将无效的依赖清理掉.

4、 在 trackEffects 中有两个依赖关系的处理要区分开, 一个是依赖的副作用 Set 关联正在运行的副作用 (activeEffect), 这样在 trigger 的时候可以找到对应的 effect 触发副作用; 另一个是正在运行的副作用 (activeEffect) 依赖列表中关联当前依赖, 这样可以及时的跟踪与副作用有关的依赖, 并在需要的时候清理他们.

四、trigger工作流

trigger 是在当某个依赖值发生变化时触发的, 根据依赖值的变化类型, 会收集与依赖相关的不同副作用处理对象, 然后逐个触发他们的 run 函数, 通过执行副作用函数获得与依赖变化后对应的最新值. 具体来看下代码实现

代码语言:javascript复制
function trigger(target, type, key, newValue, oldValue, oldTarget) {
    // 获取响应式对象的副作用Map, 如果不存在说明未被追踪, 则不需要处理
    const depsMap = targetMap.get(target); 
    if (!depsMap) {
        return;
    }
    let deps = [];
    // 如果是调用了集合的clear方法, 则要对其所有的副作用进行处理
    if (type === "clear") {
        deps = [...depsMap.values()];
    }
    // 如果是设置数组的长度, 则依赖于数组长度和之前获取数组长度比新的数组长度大的, 都需要进行响应变更
    else if (key === 'length' && shared.isArray(target)) {
        depsMap.forEach((dep, key) => {
            if (key === 'length' || key >= newValue) {
                deps.push(dep);
            }
        });
    }
    else {
        // 针对key值对应的副作用Set进行处理, 主要是set | add | delete
        if (key !== void 0) {
            deps.push(depsMap.get(key));
        }
        // 同时针对add | detelet | Map.set的附加影响进行处理
        switch (type) {
            case "add" /* ADD */:
                if (!shared.isArray(target)) {
                    deps.push(depsMap.get(ITERATE_KEY));
                    if (shared.isMap(target)) {
                        deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
                    }
                }
                else if (shared.isIntegerKey(key)) {
                    // new index added to array -> length changes
                    deps.push(depsMap.get('length'));
                }
                break;
            case "delete" /* DELETE */:
                if (!shared.isArray(target)) {
                    deps.push(depsMap.get(ITERATE_KEY));
                    if (shared.isMap(target)) {
                        deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
                    }
                }
                break;
            case "set" /* SET */:
                if (shared.isMap(target)) {
                    deps.push(depsMap.get(ITERATE_KEY));
                }
                break;
        }
    }
    // 处理副作用Set
    if (deps.length === 1) {
        if (deps[0]) {
            {
                triggerEffects(deps[0]);
            }
        }
    }
    else {
        const effects = [];
        for (const dep of deps) {
            if (dep) {
                effects.push(...dep);
            }
        }
        {
            triggerEffects(createDep(effects));
        }
    }
}
function triggerEffects(dep, debuggerEventExtraInfo) {
    // 对副作用Set去重后逐个调用副作用的scheduler或者run函数
    for (const effect of shared.isArray(dep) ? dep : [...dep]) {
        if (effect !== activeEffect || effect.allowRecurse) {
            if (effect.scheduler) {
                effect.scheduler();
            }
            else {
                effect.run();
            }
        }
    }
}

同样说明几点

1、 在之前的文章中说到过, 触发 trigger 的类型一共有 add、set、delete、clear 这么几种, 这里包括了普通 COMMON 类型和集合 COLLECTION 类型的响应式对象, 另外针对数组还需要特别去处理与其长度有关的 length 获取. 而其实这几种中有的变化, 还会引起一些其他的附加影响, 上面代码中都有体现, 这里也总结下

trigger类型

适用的响应式对象类型

释义

附加影响

副作用收集

clear

COLLECTION

移除集合对象中的所有元素

与该对象有关的所有副作用都要处理

add

COMMON、COLLECTION

集合或对象中添加元素

对集合的size、forEach、keys、values、entries, 对象的ownKeys, 数组的length的获取都会有影响

与被添加元素相关的副作用和附加影响的相关副作用都要处理

set

COMMON、COLLECTION

集合或对象中更改元素

对集合Map类型的size、forEach、keys、values、entries的获取会有影响

与被更改元素相关的副作用和附加影响的相关副作用都要处理

delete

COMMON、COLLECTION

集合或对象中删除元素

对集合的size、forEach、keys、values、entries, 对象的ownKeys的获取都会有影响

与删除元素相关的副作用和附加影响的相关副作用都要处理

length

COMMON

数组长度发生变化

与数组长度相关的副作用都要处理

2、 依赖 dep 中存储的都是 ReactiveEffect 实例对象, 当拿到与当前依赖变更有关的所有副作用处理对象后, 就要对其进行去重, 防止重复处理, 然后运行副作用的工作流, 去触发依赖带来的响应

五、示例

为了弄清楚 Vue3 中响应式系统的工作流程, 其提供了一些调试的钩子函数, 具体可以看官网文档, 这里以 computed 为例演示一遍, 先在 setup 函数中做如下定义, 在开发环境下提供了 onTrack 和 onTrigger 这些钩子函数, 可以方便我们调试观察

代码语言:javascript复制
<div @click="click">
    {{ b }}
</div>
    
setup() {
    const a = ref(1); // 定义一个响应式ref对象 
    const b = computed(() => a.value   2, {
      // track钩子函数 
      onTrack(event) {
        console.log('computed onTrack', event);
      },
      // trigger钩子函数
      onTrigger(event) { 
        console.log('computed onTrigger', event);
      },
    });
    // 点击事件让响应式对象a的值自增
    const click = () => { a.value  = 1; };

    return { b, click };
}

上面的例子很简单, 一个与响应式对象 a 有关的计算属性 b 挂在视图上, 然后点击可以改变 a 的值, 然后重新计算 b 去更新视图. 这里有两个依赖部分, 一个是 ref 对象, 一个是 computed 对象, ref 对象作为 computed 的依赖, 而 computed 作为视图层的依赖, 所以下面我们分别来看一下.

首先来看下 ref 对象, 他是通过实例化 RefImpl 生成的非浅层响应式对象, 代码大致如下

代码语言:javascript复制
class RefImpl {
    constructor(value, __v_isShallow) {
        this.__v_isShallow = __v_isShallow;
        this.dep = undefined; // 依赖的副作用收集器
        this.__v_isRef = true;
        this._rawValue = __v_isShallow ? value : toRaw(value);
        this._value = __v_isShallow ? value : toReactive(value);
    }
    get value() {
        trackRefValue(this); // 收集依赖的副作用
        return this._value;
    }
    set value(newVal) {
        newVal = this.__v_isShallow ? newVal : toRaw(newVal);
        if (shared.hasChanged(newVal, this._rawValue)) {
            this._rawValue = newVal;
            this._value = this.__v_isShallow ? newVal : toReactive(newVal);
            // 当ref对象值变化时, 通知依赖的订阅者
            triggerRefValue(this); 
        }
    }
}

定义 ref 对象 a 后, 当 computed 执行其副作用函数 () => a.value 2 时, 会触发 get valud 中的 trackRefValue(this) 运行, 实际上就是执行 trackEffects 函数, 在这个时候就会去收集依赖于 ref 对象的副作用. 我们打断点来看一下这个过程

1) 定义好 ref 对象后, 他的值是 1, 此时由于还没有触发 get, 所以依赖他的副作用列表 dep 是空的

2) 进入到依赖收集函数中, 可以看到当前正在运行的副作用 activeEffect 正是 computed 的副作用处理对象, 其实在这个过程中已经完成了依赖的收集了, 可以看到 activeEffect 的 deps 中已经关联上了对应的依赖

3) 我们再来看下 ref 对象的情况, 可以看到 ref.dep 中已经把 computed 的副作用处理对象收集到 Set 列表中了, 并且其 dep.n 标识位也根据当前的追踪标识位置位 (32), 将其与当前正在运行的副作用关联上

4) 依赖副作用收集完成后, 就返回当前值 1, 至此, ref 对象的整个 get 过程就结束了

5) 我们看下 ref 对象最后完整的输出, 可以看到他已经跟 computed 对象关联了起来

6) 当我们触发 click 事件, 让 ref 对象的值加 1 时, 会触发 Set 过程, 可以看到这个时候他的值已经变成了 2, 通过 hasChanged 判断值发生了变化, 就进入到 triggerRefValue 阶段, 也就是去执行 triggerEffects

7) 此时可以看到依赖于 ref 对象的副作用只有一个, 那就是 computed, 并且在上一次副作用处理对象执行完成后, dep.n 的值已经复位了, 现在直接获取到 computed 的副作用 effect 然后往后执行

8) 由于 computed 的副作用处理对象生成时定义了 scheduler 函数, 所以就只会执行 scheduler 而不会再去执行副作用处理对象的 run 函数了. 可以看到下面的截图, 在scheduler 函数中调用了 triggerRefValue, 其实也就是 triggerEffects 去触发订阅依赖的副作用 (computed 的副作用处理对象) 执行

9) 注意这里又进入了 triggerEffects 阶段, 但是此时 dep 不再是依赖 ref 的副作用了, 而是依赖于 computed 的副作用, 可以看到 scheduler 是执行一个带有 queueJob 的函数, 这个是在 setupRenderEffect 时定义的副作用, 其实就是在处理视图层对 computed 的依赖了, 具体可以看看 setupRenderEffect 函数

10) 最后就是触发 computed 的副作用函数计算出新的值 4, 然后交个 dom diff 和渲染流程, 反馈到真实的页面上, 这个过程不做详细说明

以上就是 Vue3 中关于响应性的一些关键流程, 充分理解副作用和依赖之间的关系是非常重要的, 特别是什么时候应该站在依赖的角度去看问题, 什么时候应该站在副作用的角度去看问题, 这个很重要

0 人点赞