响应式模块2: effect 实现

本文对应源码位置
vue-next/packages/reactivity/src/effect.ts
53 行

前置知识

vue3 源码中使用了 WeakMap 来储存响应式对象与依赖的对应关系,而使用 WeakMap 个人觉得完全是出于性能考虑,因此可以使用 Map 进行代替。WeakMap 的键值必须是对象,我认为这也直接促成 reactive 在日常使用中只用来封装对象数据,这点与 ref 不同(后续实现)

写来看看

可以自顶向下进行分析,先看结果,实现雏形,之后慢慢完善细节,如下所示

const reactive = target => {
    return new Proxy(target, {
        get(target, key, receiver) {
            track();
            const res = Reflect.get(target, key, receiver);
            return res;
        },

        set(target, key, value, receiver) {
            const res = Reflect.set(target, key, value, receiver);
            trigger();
            return res;
        },
    });
};

const effect = () => {
    // ?????
};

const track = () => {
    // ?????
};

const trigger = () => {
    // ?????
};

// 跑跑再说
const a = reactive({
    count: 0,
});

effect(() => {
    console.log(`effect called......a.count: ${a.count}`); // effect called......a.count: 0 使用了 effect 之后自己会先执行一次
});

a.count = 10; // effect called......a.count: 10
a.count = 20; // effect called......a.count: 20

对输出结果进行分析后可以初步得出以下结论

  1. effect 函数接收一个回调函数,暂称为 effectFn
  2. 调用 effect 之后它会自己先执行一次
  3. 每次对响应式对象的更新操作或者取值操作都会再调用一次 effectFn

综上,effect 大致要做的事情就很清晰了

const effect = effectFn => {
    effectFn();
};

是的,简单来说就是要执行一次,当然下面会加上点小细节

track

接着来看 track依赖收集本质上只是将目标对象的依赖关系以一种合适的数据结构(以下命名为 targetMap)保存了下来,简单来说即是保存了不同的目标对象与 effectFn 的映射关系,而源码中使用了 WeakMap 来保存,本着能跑就行的原则,这里使用 Map 来实现。而 targetMap 中需要保存3种数据,目标对象,键值,对应的 effectFn,因此需要嵌套一个 Map,而 effectFn 不能重复,且可能有很多不同的 effectFn,就很想当然的使用 Set 保存,最终数据结构大致长这样

{
    [targetMap]: {
        [key]: [effectFn] // [effectFn] 是一个 Set
    }
}

因此我们要做的也就只是将 effectFntarget 还有 key 逐级存进去。

数据结构解决之后又会有一个新的问题,那就是如何在 effect 中将 effectFn 暴露出来,同层级的函数之间共享数据并没有那么多花里胡哨的操作,就老老实实定义一个全局变量 activeEffect 用来保存当前正在执行的 effectFn 即可,以下先假设 activeEffect 已经定义了

分析结束后,track 具体实现如下

const targetMap = new Map()
const track = (target, key) => {
    if (!activeEffect) {
        return;
    }

    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }

    let deps = depsMap.get(key);
    if (!deps) {
        deps = new Set();
        depsMap.set(key, deps);
    }

    // 保存现在正在执行的 effect 函数
    deps.add(activeEffect);
};

trigger

剩下的 trigger 就非常简单了,只需要取出对应的 deps 依此执行其中的 effectFn 即可,实现如下

const trigger = (target, key) => {
    const depsMap = targetMap.get(target);
    if (!depsMap) {
        return;
    }

    const deps = depsMap.get(key);
    if (!deps) {
        return;
    }

    // 执行先前储存的所有 effectFn
    deps.forEach(effectFn => effectFn());
};

effect

再回到 effect 方法,effect 中需要:

  1. 执行传入的函数 fn
  2. 暴露当前正在执行的 effectFn

以上流程用一个内部函数封装,如下

let activeEffect;
const effect = fn => {
    const effectFn = () => {
        activeEffect = fn;
        return fn();
    };

    // 先执行一次
    effectFn();

    return effectFn;
};

跑来看看

以上就基本完成了 effect 部分的编写,拉过上次写过的 reactive 做个小测试

const a = reactive({
    count: 0,
});

effect(() => {
    console.log(`effect called......a.count: ${a.count}`); // effect called......a.count: 0 调用了 effect 之后自己会先执行一次
});

a.count = 10; // effect called......a.count: 10
a.count = 20; // effect called......a.count: 20

小修小补

如上就粗略完成了 effect 部分,不过仍然存在一个问题没解决,先来看看问题长什么样

const a = reactive({ num1: 10, num2: 20 });

effect(() => {
    effect(() => {
        console.log(`a.num2: ${a.num2}`);
    });
    console.log(`a.num1: ${a.num1}`);
});

a.num1 = 100;
a.num1 = 200;
a.num1 = 300;

以下是结果

请添加图片描述

上面这明显不正常,第一个和第二个是初始化时的第一次执行,这是没问题的,但是我给 a.num1 赋值时,打印的都是 a.num2

为什么会出问题

其实不难想到,我们用一个 activeEffect 来记录当前执行的 effectFn,而在 effect 中嵌套了 effect 时,内层的 effect 会覆盖外层的 effect,准确来说是覆盖了外层的 activeEffect,因此触发的是外层的 effect,但执行的是内层的 effect,要怎么解决呢

既然会产生覆盖的问题,就只需要用一个什么东西来记录一下并控制执行顺序即可,这里很容易联想到函数的执行栈,执行栈就是这样,当递归产生时,把新的函数压栈,执行完就弹栈,以此来控制函数执行的顺序,因此我们可以用一个栈来辅助 effectFn,控制它优先执行内层 effect,完事再执行外层 effect

改一下

以上分析出问题和解决方法,实现起来就很简单了,以下直接给出修改后的代码

const effectStack = [];
let activeEffect;

const effect = fn => {
    const effectFn = () => {
        try {
            effectStack.push(effectFn);
            // 此处必须要赋值一次,因为下面要 return
            activeEffect = effectFn;
            return fn();
        } finally {
            effectStack.pop();
            activeEffect = effectStack[effectStack.length - 1];
        }
    };

    effectFn();

    return effectFn;
};

解决思路就是,用一个栈来控制 activeEffect,每次执行 effectFn,就把它压栈,而 activeEffect 始终取值为栈顶元素,执行完就弹栈,由此就可以达到一个类似函数调用栈的效果,解决了 activeEffect 覆盖的问题

总结

effect 部分最核心的就是 effect 暴露出当前正在执行的 effectFn,再由 track 维护一个 targetMap 用来储存 targeteffectFn 之间的映射关系,供 trigger 消费

Q&A

Q: 就这么简单?
A: 当然不是。源码中的 effect 是通过一个 ReactiveEffect 类实现的,内部实现了成员方法 runstop,不过不是很关键(其实挺关键的,但我这么写也勉强能达到目的),因此只做了最简的实现,要看更具体的可以去看源码,路径和文件名在文章开头,精确到行号

Q: 你写这么简单会出问题么?
A: 不知道哇,很多特例处理没做,必出问题,可是最核心的就这些,差不多得了

Q: 你正文写这么乱谁看得懂啊?
A: 以下是完整代码

// effect
const effectStack = [];
let activeEffect;

const effect = fn => {
    const effectFn = () => {
        try {
            effectStack.push(effectFn);
            activeEffect = effectFn;
            return fn();
        } finally {
            effectStack.pop();
            activeEffect = effectStack[effectStack.length - 1];
        }
    };

    effectFn();

    return effectFn;
};

const targetMap = new WeakMap();

// track
function track(target, key) {
    if (!activeEffect) return;
    

    let depsMap = targetMap.get(target);
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()));
    }

    let deps = depsMap.get(key);
    if (!deps) {
        depsMap.set(key, (deps = new Set()));
    }

    deps.add(activeEffect);
}

// trigger
function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;

    const deps = depsMap.get(key);
    if (!deps) return;

    deps.forEach(effectFn => effectFn());
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值