4k+ 字分析 Vue 3.0 响应式原理(依赖收集和派发更新)

3,108 阅读13分钟

前言

前几天写了一篇关于Vue 3.0 reactive API 源码实现的文章,发现大家还是蛮有兴趣对于源码这一块的。阅读的人数虽然不多,但是 200 多次阅读,还是阔以的!并且,也有同学指出了文章存在的不足,就是没有分析 Proxy 是如何配合 Effect 实现响应式的原理,即依赖收集和派发更新的过程。

所以,这次我们就来彻底了解一下,Vue 3.0 依赖收集和派发更新的整个过程。

值得一提的是在 Vue 3.0 中没有了watcher 的概念,取而代之的是 effect ,所以接下来会接触很多和 effect 相关的函数

一、开始前准备

在文章的开始前,我们先准备这样一个简单的 case,以便后续分析具体逻辑:

main.js 项目入口

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

App.vue 组件

<template>
  <button @click="inc">Clicked {{ count }} times.</button>
</template>

<script>
import { reactive, toRefs } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,
    })
    const inc = () => {
      state.count++
    }

    return {
      inc,
      ...toRefs(state)
    }
  }
}
</script>

二、安装渲染 Effect

首先,我们大家都知道在通常情况下,我们的页面会使用当前实例的一些属性、计算属性、方法等等。所以,在组件渲染的过程就会发生依赖收集的这个过程。也因此,我们先从组件的渲染过程开始分析。

在组件的渲染过程中,会安装(创建)一个渲染 effect,即 Vue 3.0 在编译 template 的时候,对是否有订阅数据做出相应的判断,创建对应的渲染 effect,它的定义如下:

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG) => {
    // create reactive effect for rendering
    instance.update = effect(function componentEffect() {
            ....
            instance.isMounted = true;
        }
        else {
            ...
        }
    }, (process.env.NODE_ENV !== 'production') ? createDevEffectOptions(instance) : prodEffectOptions);
};

我们来大致分析一下 setupRenderEffect()。它传入几个参数,它们分别为:

  • instance 当前 vm 实例
  • initialVNode 可以是组件 VNode 或者普通 VNode
  • container 挂载的模板,例如 div#app 对应的节点
  • anchor, parentSuspense, isSVG 普通情况下都为 null

然后在当前实例 instance 上创建属性 update 赋值为 effect() 函数的执行结果,effect() 函数传入两个参数:

  • componentEffect() 函数,它会在具体逻辑之后提到,这里我们先不讲
  • createDevEffectOptions(instance) 用于后续的派发更新,它会返回一个对象:
{
    scheduler: queueJob(job) {
                    if (!queue.includes(job)) {
                        queue.push(job);
                        queueFlush();
                    }
                },
    onTrack: instance.rtc ? e => invokeHooks(instance.rtc, e) : void 0,
    onTrigger: instance.rtg ? e => invokeHooks(instance.rtg, e) : void 0
}

然后,我们再来看看effect() 函数定义:

function effect(fn, options = EMPTY_OBJ) {
    if (isEffect(fn)) {
        fn = fn.raw;
    }
    const effect = createReactiveEffect(fn, options);
    if (!options.lazy) {
        effect();
    }
    return effect;
}

effect() 函数的逻辑较为简单,首先判断是否已经为 effect,是则取出之前定义的。不是则通过 ceateReactiveEffect() 创建一个 effect,而 creatReactiveEffect() 的逻辑会是这样:

function createReactiveEffect(fn, options) {
    const effect = function reactiveEffect(...args) {
        return run(effect, fn, args);
    };
    effect._isEffect = true;
    effect.active = true;
    effect.raw = fn;
    effect.deps = [];
    effect.options = options;
    return effect;
}

可以看到在 createReactiveEffect() 中先定义了一个 reactiveEffect() 函数赋值给 effect,它又调用了 run()方法。而 run() 方法中传入三个参数,分别为:

  • effect,即 reactiveEffect() 函数本身
  • fn,即在刚开始 instance.update 是调用 effect 函数时,传入的函数 componentEffect()
  • args 为一个空数组

并且,对 effect 进行了一些初始化,例如我们最熟悉Vue 2x 中的 deps 就出现在 effect 这个对象上。

然后,我们分析一下 run() 函数的逻辑:

function run(effect, fn, args) {
    if (!effect.active) {
        return fn(...args);
    }
    if (!effectStack.includes(effect)) {
        cleanup(effect);
        try {
            enableTracking();
            effectStack.push(effect);
            activeEffect = effect;
            return fn(...args);
        }
        finally {
            effectStack.pop();
            resetTracking();
            activeEffect = effectStack[effectStack.length - 1];
        }
    }
}

在这里,初次创建 effect,我们会命中第二个分支逻辑,即当前 effectStack 栈中不包含这个 effect。那么,首先会执行 cleanup(effect),即遍历effect.deps,清空之前的依赖。

cleanup() 的逻辑其实在Vue 2x的源码中也有的,避免依赖的重复收集。并且,对比 Vue 2xVue 3.0 中的 track 其实相当于 watcher,在 track 中会进行依赖的收集,后面我们会讲 track 的具体实现

然后,执行enableTracking()effectStack.push(effect),前者的逻辑很简单,即可以追踪,用于后续触发 track 的判断:

function enableTracking() {
    trackStack.push(shouldTrack);
    shouldTrack = true;
}

而后者,即将当前的 effect 添加到 effectStack 栈中。最后,执行 fn() ,即我们一开始定义的 instance.update = effect() 时候传入的 componentEffect()

instance.update = effect(function componentEffect() {
    if (!instance.isMounted) {
        const subTree = (instance.subTree = renderComponentRoot(instance));
        // beforeMount hook
        if (instance.bm !== null) {
            invokeHooks(instance.bm);
        }
        if (initialVNode.el && hydrateNode) {
            // vnode has adopted host node - perform hydration instead of mount.
            hydrateNode(initialVNode.el, subTree, instance, parentSuspense);
        }
        else {
            patch(null, subTree, container, anchor, instance, parentSuspense, isSVG);
            initialVNode.el = subTree.el;
        }
        // mounted hook
        if (instance.m !== null) {
            queuePostRenderEffect(instance.m, parentSuspense);
        }
        // activated hook for keep-alive roots.
        if (instance.a !== null &&
            instance.vnode.shapeFlag & 256 /* COMPONENT_SHOULD_KEEP_ALIVE */) {
            queuePostRenderEffect(instance.a, parentSuspense);
        }
        instance.isMounted = true;
    }
    else {
        ...
    }
}, (process.env.NODE_ENV !== 'production') ? createDevEffectOptions(instance) : prodEffectOptions);

而接下来就会进入组件的渲染过程,其中涉及 renderComponnetRootpatch 等等,这次我们并不会分析组件渲染具体细节。

安装渲染 Effect,是为后续的依赖收集做一个前期的准备。因为在后面会用到 setupRenderEffect 中定义的 effect() 函数,以及会调用 run() 函数。所以,接下来,我们就正式进入依赖收集部分的分析。

三、依赖收集

get

前面,我们已经讲到了在组件渲染过程会安装渲染 Effect。然后,进入渲染组件的阶段,即 renderComponentRoot(),而此时会调用 proxyToUse,即会触发 runtimeCompiledRenderProxyHandlersget,即:

get(target, key) {
    ...
    else if (renderContext !== EMPTY_OBJ && hasOwn(renderContext, key)) {
        accessCache[key] = 1 /* CONTEXT */;
        return renderContext[key];
    }
    ...
}

可以看出,此时会命中 accessCache[key] = 1renderContext[key] 。对于前者是做一个缓存的作用,后者是从当前的渲染上下文中获取 key 对应的值((对于本文这个 casekey 对应的就是 count,它的值为 0)。

那么,我想这个时候大家会立即反应,此时会触发这个 count 对应 Proxyget。但是,在我们这个 case 中,用了 toRefs()reactive 包裹导出,所以这个触发 get 的过程会分为两个阶段:

两个阶段的不同点在于,第一阶段的 target 为一个 object(即上面所说的toRefs的对象结构),而第二阶段的 targetProxy对象 {count: 0}。具体细节可以看我上篇文章

Proxy 对象toRefs() 后得到对象的结构:

{
    value: 0
    _isRef: true
    get: function() {}
    set: ƒunction(newVal) {}
}

我们先来看看 get() 的逻辑:

function createGetter(isReadonly = false, shallow = false) {
    return function get(target, key, receiver) {
        ...
        const res = Reflect.get(target, key, receiver);
        if (isSymbol(key) && builtInSymbols.has(key)) {
            return res;
        }
        ...
        // ref unwrapping, only for Objects, not for Arrays.
        if (isRef(res) && !isArray(target)) {
            return res.value;
        }
        track(target, "get" /* GET */, key);
        return isObject(res)
            ? isReadonly
                ? // need to lazy access readonly and reactive here to avoid
                    // circular dependency
                    readonly(res)
                : reactive(res)
            : res;
    };
}

第一阶段:触发普通对象的 get

由于此时是第一阶段,所以我们会命中 isRef() 的逻辑,并返回 res.value 。此时就会触发 reactive 定义的 Proxy 对象的 get。并且需要注意的是 toRefs() 只能用于对象,否则我们即时触发了 get 也不能获取对应的值(这其实也是看源码的一些好处,深度理解 API 的使用)。

track

第二阶段:触发 Proxy 对象的 get

此时属于第二阶段,所以我们会命中 get 的最后逻辑:

track(target, "get" /* GET */, key);
return isObject(res)
    ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
            // circular dependency
            readonly(res)
        : reactive(res)
    : res;

可以看到,首先会调用 track() 函数,进行依赖收集,而 track() 函数定义如下:

function track(target, type, key) {
    if (!shouldTrack || activeEffect === undefined) {
        return;
    }
    let depsMap = targetMap.get(target);
    if (depsMap === void 0) {
        targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (dep === void 0) {
        depsMap.set(key, (dep = new Set()));
    }
    if (!dep.has(activeEffect)) {
        dep.add(activeEffect);
        activeEffect.deps.push(dep);
        if ((process.env.NODE_ENV !== 'production') && activeEffect.options.onTrack) {
            activeEffect.options.onTrack({
                effect: activeEffect,
                target,
                type,
                key
            });
        }
    }
}

可以看到,第一个分支逻辑不会命中,因为我们在前面分析 run() 的时候,就已经定义 ishouldTrack = trueactiveEffect = effect。然后,命中 depsMap === void 0 逻辑,往 targetMap 中添加一个键名为 {count: 0} 键值为一个空的 Map:

if (depsMap === void 0) {
    targetMap.set(target, (depsMap = new Map()));
}

而此时,我们也可以对比Vue 2.x,这个 {count: 0} 其实就相当于 data 选项(以下统称为 data)。所以,这里也可以理解成先对 data 初始化一个 Map,显然这个 Map 中存的就是不同属性对应的 dep

然后,对 count 属性初始化一个 Map 插入到 data 选项中,即:

let dep = depsMap.get(key);
if (dep === void 0) {
    depsMap.set(key, (dep = new Set()));
}

所以,此时的 dep 就是 count 属性对应的主题对象了。接下来,则判断是否当前 activeEffect 存在于 count 的主题中,如果不存在则往主题 dep 中添加 activeEffect,并且将当前主题 dep 添加到 activeEffectdeps 数组中。

if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
    activeEffect.deps.push(dep);
    // 最后的分支逻辑,我们这次并不会命中
}

最后,再回到 get(),会返回 res 的值,在我们这个 caseres 的值是 0

return isObject(res)
            ? isReadonly
                ? // need to lazy access readonly and reactive here to avoid
                    // circular dependency
                    readonly(res)
                : reactive(res)
            : res;

总结

好了,整个 reactive 的依赖收集过程,已经分析完了。我们再来回忆其中几个关键点,首先在组件渲染过程,会给当前 vm 实例创建一个 effect,然后将当前的 activeEffect 赋值为 effect,并在 effect 上创建一些属性,例如非常重要的 deps 用于保存依赖

接下来,当该组件使用了 data 中的变量时,会访问对应变量的 get()。第一次访问 get() 会创建 data 对应的 depsMap,即 targetMap。然后再往 targetMapdepMap 中添加对应属性的 Map,即 depsMap

创建完属性的 depsMap 后,一方面会往该属性的 depsMap 中添加当前 activeEffect,即收集订阅者。另一方面,将该属性的 depsMap 添加到 activeEffectdeps 数组中,即订阅主题。从而,形成整个依赖收集过程。

整个 get 过程的流程图

四、派发更新

set

分析完依赖收集的过程,那么派发更新的整个过程的分析也将会水到渠成。首先,对应派发更新,是指当某个主题发生变化时,在我们这个 case 是当 count 发生变化时,此时会触发 dataset(),即 targetdatakeycount

function set(target, key, value, receiver) {
        ...
        const oldValue = target[key];
        if (!shallow) {
            value = toRaw(value);
            if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
                oldValue.value = value;
                return true;
            }
        }
        const hadKey = hasOwn(target, key);
        const result = Reflect.set(target, key, value, receiver);
        // don't trigger if target is something up in the prototype chain of original
        if (target === toRaw(receiver)) {
            if (!hadKey) {
                trigger(target, "add" /* ADD */, key, value);
            }
            else if (hasChanged(value, oldValue)) {
                trigger(target, "set" /* SET */, key, value, oldValue);
            }
        }
        return result;
    };

可以看到,oldValue0,而我们的 shallow 此时为 falsevalue 为 1。那么,我们看一下 toRaw() 函数的逻辑:

function toRaw(observed) {
    return reactiveToRaw.get(observed) || readonlyToRaw.get(observed) || observed;
}

toRaw() 中有两个 WeakMap 类型的变量 reactiveToRawreadonlyRaw。前者是在初始化 reactive 的时候,将对应的 Proxy 对象存入 reactiveToRaw 这个 Map 中。后者,则是存入和前者相反的键值对。即:

function createReactiveObject(target, toProxy, toRaw, baseHandlers, collectionHandlers) {
    ...
    observed = new Proxy(target, handlers);
    toProxy.set(target, observed);
    toRaw.set(observed, target);
    ...
}

很显然对于 toRaw() 方法而言,会返回 observer 即 1。所以,回到 set() 的逻辑,调用 Reflect.set() 方法将 data 上的 count 的值修改为 1。并且,接下来我们还会命中 target === toRaw(receiver) 的逻辑。

target === toRaw(receiver) 的逻辑会处理两个逻辑:

  • 如果当前对象不存在该属性,触发 triger() 函数对应的 add

  • 或者该属性发生变化,触发 triger() 函数对应的 set

trigger

首先,我们先看一下 trigger() 函数的定义:

function trigger(target, type, key, newValue, oldValue, oldTarget) {
    const depsMap = targetMap.get(target);
    if (depsMap === void 0) {
        // never been tracked
        return;
    }
    const effects = new Set();
    const computedRunners = new Set();
    if (type === "clear" /* CLEAR */) {
        ...
    }
    else if (key === 'length' && isArray(target)) {
        ...
    }
    else {
        // schedule runs for SET | ADD | DELETE
        if (key !== void 0) {
            addRunners(effects, computedRunners, depsMap.get(key));
        }
        // also run for iteration key on ADD | DELETE | Map.SET
        if (type === "add" /* ADD */ ||
            (type === "delete" /* DELETE */ && !isArray(target)) ||
            (type === "set" /* SET */ && target instanceof Map)) {
            const iterationKey = isArray(target) ? 'length' : ITERATE_KEY;
            addRunners(effects, computedRunners, depsMap.get(iterationKey));
        }
    }
    const run = (effect) => {
        scheduleRun(effect, target, type, key, (process.env.NODE_ENV !== 'production')
            ? {
                newValue,
                oldValue,
                oldTarget
            }
            : undefined);
    };
    // Important: computed effects must be run first so that computed getters
    // can be invalidated before any normal effects that depend on them are run.
    computedRunners.forEach(run);
    effects.forEach(run);
}

并且,大家可以看到这里有一个细节,就是计算属性的派发更新要优先于普通属性。

trigger() 函数,首先获取当前 targetMapdata 对应的主题对象的 depsMap,而这个 depsMap 即我们在依赖收集时在 track 中定义的。

然后,初始化两个 Set 集合 effectscomputedRunners ,用于记录普通属性或计算属性的 effect,这个过程是会在 addRunners() 中进行。

接下来,定义了一个 run() 函数,包裹了 scheduleRun() 函数,并对开发环境和生产环境进行不同参数的传递,这里由于我们处于开发环境,所以传入的是一个对象,即:

{
    newValue: 1,
    oldValue: 0,
    oldTarget: undefined
}

然后遍历 effects,调用 run() 函数,而这个过程实际调用的是 scheduleRun()

function scheduleRun(effect, target, type, key, extraInfo) {
    if ((process.env.NODE_ENV !== 'production') && effect.options.onTrigger) {
        const event = {
            effect,
            target,
            key,
            type
        };
        effect.options.onTrigger(extraInfo ? extend(event, extraInfo) : event);
    }
    if (effect.options.scheduler !== void 0) {
        effect.options.scheduler(effect);
    }
    else {
        effect();
    }
}

此时,我们会命中 effect.options.scheduler !== void 0 的逻辑。然后,调用 effect.options.scheduler() 函数,即调用 queueJob() 函数:

scheduler 这个属性是在 setupRenderEffect 调用 effect 函数时创建的。

function queueJob(job) {
    if (!queue.includes(job)) {
        queue.push(job);
        queueFlush();
    }
}

这里使用了一个队列维护所有 effect() 函数,其实也和 Vue 2x 相似,因为我们 effect() 相当于 watcher,而 Vue 2x 中对 watcher 的调用也是通过队列的方式维护。队列的存在具体是为了保持 watcher 触发的次序,例如先父 watcher 后子 watcher

可以看到 我们会先将 effect() 函数添加到队列 queue 中,然后调用 queueFlush() 清空和调用 queue

function queueFlush() {
    if (!isFlushing && !isFlushPending) {
        isFlushPending = true;
        nextTick(flushJobs);
    }
}

熟悉 Vue 2x 源码的同学,应该知道 Vue 2x 中的 watcher 也是在下一个 tick 中执行,而 Vue 3.0 也是一样。而 flushJobs 中就会对 queue 队列中的 effect() 进行执行:

function flushJobs(seen) {
    isFlushPending = false;
    isFlushing = true;
    let job;
    if ((process.env.NODE_ENV !== 'production')) {
        seen = seen || new Map();
    }
    while ((job = queue.shift()) !== undefined) {
        if (job === null) {
            continue;
        }
        if ((process.env.NODE_ENV !== 'production')) {
            checkRecursiveUpdates(seen, job);
        }
        callWithErrorHandling(job, null, 12 /* SCHEDULER */);
    }
    flushPostFlushCbs(seen);
    isFlushing = false;
    if (queue.length || postFlushCbs.length) {
        flushJobs(seen);
    }
}

flushJob() 主要会做几件事:

  • 首先初始化一个 Map 集合 seen,然后在递归 queue 队列的过程,调用 checkRecursiveUpdates() 记录该 jobeffect() 触发的次数。如果超过 100 次会抛出错误。
  • 然后调用 callWithErrorHandling(),执行 jobeffect(),而我们都知道的是这个 effect 是在 createReactiveEffect() 时创建的 reactiveEffect(),所以,最终会执行 run() 方法,即执行最初在 setupRenderEffectect 定义的 effect()
    const setupRenderEffectect = (instance, initialVNode, container, anchor, parentSuspense, isSVG) => {
        // create reactive effect for rendering
        instance.update = effect(function componentEffect() {
            if (!instance.isMounted) {
                ...
            }
            else {
                ...
                const nextTree = renderComponentRoot(instance);
                const prevTree = instance.subTree;
                instance.subTree = nextTree;
                if (instance.bu !== null) {
                    invokeHooks(instance.bu);
                }
                if (instance.refs !== EMPTY_OBJ) {
                    instance.refs = {};
                }
                patch(prevTree, nextTree, 
                hostParentNode(prevTree.el), 
                getNextHostNode(prevTree), instance, parentSuspense, isSVG);
                instance.vnode.el = nextTree.el;
                if (next === null) {
                    updateHOCHostEl(instance, nextTree.el);
                }
                if (instance.u !== null) {
                    queuePostRenderEffect(instance.u, parentSuspense);
                }
                if ((process.env.NODE_ENV !== 'production')) {
                    popWarningContext();
                }
            }
        }, (process.env.NODE_ENV !== 'production') ? createDevEffectOptions(instance) : prodEffectOptions);
    };

即此时就是派发更新的最后阶段了,会先 renderComponentRoot() 创建组件 VNode,然后 patch() ,即走一遍组件渲染的过程(当然此时称为更新更为贴切)。从而,完成视图的更新。

总结

同样地,我们也来回忆派发更新过程的几个关键点。首先,触发依赖的 set(),它会调用 Reflect.set() 修改依赖对应属性的值。然后,调用 trigger() 函数,获取 targetMap 中对应属性的主题,即 depsMap(),并且将 depsMap 中的 effect() 存进 effect 集合中。接下来,就将 effect 进队,在下一个 tick 中清空和执行所有 effect。最后,和在初始化的时候提及的一样,走组件的更新过程,即 renderComponent()patch() 等等。

整个 set 过程的流程图

结语

虽然,整个依赖收集的过程我足足花费了 9 个小时来总结分析,并且整个文章的内容也达到了 4k+ 字。但是,这并不代表了它很复杂。其实整个依赖收集和派发更新的过程,还是非常简单明了的。首先定义全局的渲染 effect(),然后在 get() 中调用 track() 进行依赖收集。接下来,如果依赖发生变化,就会走派发更新的流程,先更新依赖的值,然后调用 trigger() 收集 effect(),在下一个 tick 中执行 effect(),最后更新组件。

我是五柳,喜欢创新、捣鼓源码,专注于 Vue3 源码、Vite 源码、前端工程化等技术分享,欢迎关注我的「微信公众号:Code center」。