一、前言
上一篇文章我们基本实现了 Vue3 的响应式原理,代码如下:
js
/** 存储副作用的桶 */
const bucket = new WeakMap();
const data = { text: "222" };
// 当前正在执行的副作用函数
let activeEffect;
// effect 栈
const effectStack = [];
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
fn();
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key);
// 返回属性值
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 把副作用函数从桶里取出并执行
trigger(target, key);
},
});
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const newEffectsToRun = new Set();
effects &&
effects.forEach((effectFn) => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
newEffectsToRun.add(effectFn);
}
});
newEffectsToRun.forEach((effectFn) => effectFn());
}
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i];
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn);
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0;
}
effect(() => {
console.log("text:", obj.text);
});
setTimeout(() => {
obj.text = "hello world";
}, 1000);
但是基于目前的代码还是无法实现这 Computed 计算属性。接下来,我们一步步完善代码,从而实现 Vue3 的计算属性。
二、调度器 scheduler 与 lazy 配置
首先,我们需要改造一下 effect 函数,让它接受第二个参数 options,这个参数有两个属性:scheduler 调度器和 lazy 配置。接下来我们会分别介绍这两个属性。
1. scheduler 调度器
可调度性是响应系统非常重要的特性,它指的是当 trigger 动作触发副作用函数重新执行时,有能力决定该函数执行的时机、次数以及方式。
js
effect(
() => {
console.log(obj.text);
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// ...
},
},
);
如上所示,我们为 effect 函数传递第二个参数 options,这个参数是个对象,这个对象里面有个属性 scheduler ,这个属性就是我们的调度器,它是个函数,接收参数为当前的副作用函数,使得用户可以决定何时执行副作用函数。
我们会在 effect 函数中把 options 配置挂载到对应的副作用函数上:
js
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn);
fn();
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// 将 options 挂载到 effectFn 上
effectFn.options = options; // 新增
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
有个调度器后,我们在 trigger 函数触发副作用执行时,就可以把需要执行的副作用函数传递给调度器函数,从而把函数执行的控制权交给用户:
js
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const newEffectsToRun = new Set();
effects &&
effects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
newEffectsToRun.forEach((effectFn) => {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
// 新增
effectFn.options.scheduler(effectFn); // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn(); // 新增
}
});
}
下面我们来看一个实际的使用例子:
js
const data = { age: 1 };
const obj = new Proxy(data, {
/* ... */
});
effect(
() => {
console.log(obj.age);
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// 将副作用函数放到宏任务队列中执行
setTimeout(fn);
},
},
);
obj.age++;
console.log("结束了");
// 1
// 结束了
// 2
在这个例子中,首先会输出 1,接着我们修改 obj.age,此时会触发 trigger 重新执行副作用函数,由于我们在调度器函数中设置了定时器,延迟副作用的执行,因此会先输出 结束了,再输出 2。
2. lazy 配置
一般情况下,当我们执行 effect 时,副作用函数会立即执行,而有些场景下我们并不希望它立即执行,而是在需要的时候才执行,如 computed 计算属性。由此我们在 options 中引入了 lazy 属性来达到这个目的,代码如下所示:
js
effect(
// 指定了 lazy 选项,这个函数不会立即执行
() => {
console.log(obj.text);
},
// options
{ lazy: true},
);
我们需要改一下 effect 的逻辑,当 lazy 为 true 时,则不立即执行副作用函数:
js
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
effectFn.options = options;
effectFn.deps = [];
// 只有非 lazy 的时候,才执行
if (!options.lazy) {
// 新增
// 执行副作用函数
effectFn();
}
// 将副作用函数作为返回值返回
return effectFn; // 新增
}
当 lazy 为 false 时,还是和之前一样,副作用函数会立即执行;当 lazy 为 true 时,副作用函数会作为返回值进行返回,这样用户就能自己去手动执行副作用函数:
js
const effectFn = effect(
() => {
console.log(obj.text);
},
{ lazy: true },
);
effectFn();
三、computed 实现
我们先分析一下 computed 函数的特点:
- 接收
getter函数作为参数; - 值缓存。
我们可以把传递给 effect 的函数看作一个 getter,那么这个 getter 函数可以返回任何值:
js
const effectFn = effect(
() => obj.foo + obj.bar,
{ lazy: true },
);
// value 是 getter 的返回值
const value = effectFn();
由于副作用函数执行时,是没有返回内容的,因此 value 是 undefined,为此我们要改造一下副作用函数执行的逻辑:
js
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
// 将 fn 的执行结果存储到 res 中
const res = fn(); // 新增
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
// 将 res 作为 effectFn 的返回值
return res; // 新增
};
effectFn.options = options;
effectFn.deps = [];
if (!options.lazy) {
effectFn();
}
return effectFn;
}
此时我们的 computed 函数初步实现如下:
js
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
});
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn();
},
};
return obj;
}
我们使用 computed 函数来创建一个计算属性:
js
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {
/* ... */
});
const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value); // 3
可以看到它能够正确地工作,但是每次访问 sumRes.value 时,都会重新执行 effectFn 函数,即使 obj.foo 和 obj.bar 没有变化。即没有对值进行缓存。
为了实现值缓存,我们在 computed 函数内部新增两个变量 value 和 dirty, value 用来存储上一次计算的值, dirty 用于标识是否需要重新计算值,为 true 时则表示 value 是脏数据,需要重新获取新数据。代码如下:
js
function computed(getter) {
// value 用来缓存上一次计算的值
let value;
// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着"脏",需要计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
scheduler() {
// 添加调度器,在调度器中将 dirty 重置为 true
dirty = true
}
});
const obj = {
get value() {
// 只有"脏"时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn();
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false;
}
return value;
},
};
return obj;
}
这时的计算属性已经很完善了,但是当我们在另外一个 effect 中读取计算属性时:
js
const sumRes = computed(() => obj.foo + obj.bar);
effect(() => {
// 在该副作用函数中读取 sumRes.value
console.log(sumRes.value);
});
// 修改 obj.foo 的值
obj.foo++;
这时候修改 obj.foo,副作用函数并没有执行,这是因为计算属性内容有自己的 effect,且它是懒执行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把computed 内部的 effect 收集为依赖。而当把计算属性用于另外一个 effect 时,就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集。
为了解决这个问题,我们需要在读取计算属性的值时,手动调用 track 函数进行依赖追踪,而计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应:
js
function computed(getter) {
let value;
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true;
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, "value");
}
},
});
const obj = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, "value");
return value;
},
};
return obj;
}
四、总结
我们为 effect 增加第二个参数 options,该参数是个对象,包含两个属性:scheduler 和 lazy。scheduler 调度器函数能够让用户自己控制副作用函数的执行时间,而 lazy 配置为 true 时,副作用函数在注册时不会立即调用,并把副作用函数返回,交由用户自己执行。基于此配置,我们实现了 Vue3 中的计算属性。
