一、前言
上一篇文章我们基本实现了 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
中的计算属性。
