该系列文章为《Vue.js设计与实现》这本书的读书笔记,若想了解更详细的内容可以阅读原书。
示例代码:Github
一、调度执行
什么是可调度性?
可调度,指的是当 trigger
动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
指定调度器
为 effect 函数添加一个选项参数 options
,允许自定义调度器 scheduler
:
effect(
() => {
console.log(obj.foo);
},
// options
{
// 调度器
scheduler(fn) {
// ...
},
}
);
然后将 options
挂载到 effectFn
上:
function effect(fn, options = {}) {
const effectFn = () => {/* 省略 */};
// 将 options 挂载到 effectFn 上
effectFn.options = options; // 新增
effectFn.deps = [];
effectFn();
}
在 trigger
触发副作用函数重新执行时,就可以直接调用自定义的调度器函数了:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects &&
effects.forEach((effectFn) => {
// 如果 trigger 触发执行的副作用函数和当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
// 执行副作用函数
effectsToRun.forEach((effectFn) => {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) { // 新增
effectFn.options.scheduler(effectFn); // 新增
} else {
// 否则直接执行副作用函数
effectFn();
}
});
}
修改执行顺序
const data = { foo: 1 };
const obj = new Proxy(data, {/* 省略 */});
// 控制调用顺序
effect(
() => {
console.log(obj.foo);
},
{
scheduler(fn) {
setTimeout(fn);
},
}
);
obj.foo++;
console.log("结束了");
在不添加 scheduler
配置的时候,输出结果是:
1
2
结束了
而添加了 scheduler
配置后,输出顺序被改变了,结果是:
1
结束了
2
修改执行次数
Vue.js 中连续多次修改响应式数据但只会触发一次更新,我们来简单模拟一下这个流程:
// 定义一个任务队列
const jobQueue = new Set();
// 创建一个 promise 实力,我们用它将一个任务添加到微任务队列
const p = Promise.resolve();
// 标志,代表是否正在刷新列表
let isFlushing = false;
function flushJob() {
// 如果正在刷新,则什么都不做
if (isFlushing) return;
// 设置 true, 表示正在刷新
isFlushing = true;
// 在微任务队列中刷新 jobQueue 队列
p.then(() => {
jobQueue.forEach((job) => job());
}).finally(() => {
// 结束后重置 isFlushing
isFlushing = false;
});
}
// 控制副作用函数的执行次数
effect(
() => {
console.log(obj.foo);
},
{
scheduler(fn) {
jobQueue.add(fn);
flushJob();
},
}
);
obj.foo++;
obj.foo++;
最终输出结果是:
1
3
代码执行流程:
- 初始时,
jobQueue
为空,isFlushing
为false
,obj.foo
为1
; - 执行
effect
,输出1
; - 第一次同步执行
obj.foo++
,obj.foo
为2
,触发trigger
并同步执行scheduler
调度函数,将当前副作用函数添加到jobQueue
中; - 执行
flushJob
,此时isFlushing
为false
,所以继续向下执行,isFlushing
置为true
; p.then
的回调中会依次执行jobQueue
中保存的副作用函数。不过,因为p.then
中的是异步的,并且是微任务,所以只是将其添加到微任务队列里,并不会立刻执行;- 继续同步执行第二次的
obj.foo++
,obj.foo
为3
,并继续同步执行scheduler
调度函数,因为Set
会去重,所以执行jobQueue.add
后,jobQueue
中的元素还是一个; - 继续执行
flushJob
,此时isFlushing
为true
,所以不会继续向下执行,到此处同步代码执行完毕; - 此时便会继续执行微任务队列,也就是执行依次执行
jobQueue
中保存的副作用函数,队列中只有一个元素,所以最终只执行了一次,输出3
。
二、计算属性 computed 和 lazy
懒执行和副作用函数返回值
现在我们所实现的 effect
函数,会立即执行传递给它的副作用函数:
effect(() => {
console.log(obj.foo);
});
但在一些场景中,我们不希望它立刻执行,而是希望它在需要的时候才执行。那副作用函数应该什么时候执行呢?我们可以直接手动执行,但仅仅支持手动执行,那意义也不大。如果我们把传递给 effect
的函数看做一个 getter
,getter
可以返回值,那么我们手动执行的时候,就能拿到它的返回值。
对 effect
进行改造,支持懒执行和副作用函数支持返回值:
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(activeEffect);
const res = fn(); // 新增
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
return res; // 新增
};
effectFn.options = options;
effectFn.deps = [];
if (!options.lazy) { // 新增
// 执行副作用函数
effectFn();
}
return effectFn; // 新增
}
这样我们变可以拿到副作用的返回值
const effectFn1 = effect(
() => obj.foo + obj.bar,
{ lazy: true}
)
console.log(effectFn1())
computed 的基础实现
我们已经实现了懒执行的副作用函数,并能拿到副作用函数的执行结果,那么我们便可以简单实现计算属性了:
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {/* 省略 */});
function computed(getter) {
const effectFn = effect(getter, {
lazy: true
});
const obj = {
get value() {
return effectFn();
},
};
return obj;
}
const sumRes = computed(() => {
console.log("computed run");
return obj.foo + obj.bar;
});
console.log("value", sumRes.value);
console.log("value", sumRes.value);
定义一个 computed
函数,以参数 getter
为副作用函数创建一个懒执行的 effect
。返回一个对象,在读取该对象的 value
属性时,才会执行 effectFn
,并将结果作为返回值返回。
缓存计算结果以及缓存更新
我们发现多次访问 sumRes.value
的值的时候,会多次运行 effectFn
函数,即使 obj.foo
和 obj.bar
的值没有发生任何变化。所以我们需要为 computed
添加缓存功能:
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {/* 省略 */});
function computed(getter) {
// 对值进行缓存
let value;
// 标识是否需要重新计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
});
const obj = {
get value() {
if (dirty) {
// 需要重新计算,将值进行缓存
value = effectFn();
dirty = false;
}
return value;
},
};
return obj;
}
const sumRes = computed(() => {
console.log("computed run");
return obj.foo + obj.bar;
});
console.log("value", sumRes.value); // 3
console.log("value", sumRes.value); // 3
obj.foo++;
console.log("value", sumRes.value); // 3
我们新增了 value
和 dirty
,value
用来缓存上次计算的结果,dirty
用来标识是否需要重新计算。
但是我们发现,我们改变了 obj.foo
的值以后,sumRes.value
的值没有发生变化。这是因为 dirty
的值置为 false
以后就没有再改变过了。
解决办法就是,当 obj.foo
或 obj.bar
发生改变时,我们将 dirty
的值重置为 true
就行了,这就要用到前面的 scheduler
函数了:
function computed(getter) {
// 对值进行缓存
let value;
// 标识是否需要重新计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
scheduler() {
dirty = true;
},
});
const obj = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
return value;
},
};
return obj;
}
为 effect
添加 scheduler
函数,它会在 getter
函数中所依赖的响应式数据发生改变时执行,这样我们就能将 dirty
重置为 true
了。下次访问 sumRes.value
的值就是最新的了。
在副作用函数中读取 computed 属性的值
我们发现在另一个 effect
的副作用函数中读取 sumRes.value
的值,在修改 obj.foo
的值后,副作用函数并没有执行,这和我们的预期不符,我们希望这时也能触发副作用函数的执行:
const sumRes = computed(function fn1 () {
return obj.foo + obj.bar;
});
effect(function fn2 () {
console.log("effect run", sumRes.value);
});
obj.foo++;
我们来分析一下原因:
sumRes
是一个计算属性,每个计算属性内部都有自己的effect
,在运行后,computed
的getter
参数fn1
,只会被computed
内部的effect
收集为依赖,而不会被外部的effect
收集。sumRes
并不是被代理的响应式数据,所以调用sumRes.value
时,并不会触发track
。
解决方法:当读取计算属性时,我们可以手动调用 track
函数进行追踪;当计算属性依赖的响应式数据发生变化时,我们手动调用 trigger
函数触发响应:
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
中读取计算属性sumRes
的value
时,我们手动调用track
函数。注意,此时的activeEffect
中的fn
是fn2
,所以我们便建立了这样的关系:
obj
└── value
└── fn2
- 当
obj.foo
变化时,会执行computed
内部effect
的调度函数scheduler
,触发trigger
操作,这样我们便可以根据上面的关系,取出fn2
对应的effectFn
执行。这样就符合我们的预期了。
三、实现 watch
watch
本质上就是观测一个响应式数据,当数据发生变化时,通知并执行相应的回调函数:
watch(obj, () => {
console.log("数据变了");
})
obj.foo++;
最简单的 watch 实现
前面我们知道 effect
在指定了 scheduler
选项的情况下,当响应式数据发生改变,就会触发 scheduler
函数的执行。所以,我们就可以利用 scheduler
调度函数的这个特点,来实现一个简单的 watch
函数:
function watch(source, cb) {
effect(() => source.foo, {
scheduler(fn) {
cb();
},
});
}
完善 source 的通用性
上面的代码,我们硬编码了对 source.foo
的读取操作,这不够通用,所有我们需要封装一个通用的读取操作:
function traverse(value, seen = new Set()) {
// 如果读取原始值 或 已经读取过,什么都不做
if (typeof value !== "object" || value === null || seen.has(value)) {
return;
}
// 记录,避免死循环
seen.add(value);
// 暂时只支持 对象。递归遍历
for (const key in value) {
traverse(value[key], seen);
}
return value;
}
function watch(source, cb) {
effect(() => traverse(source), {
scheduler(fn) {
// 发生变化时,调用 callback
cb();
},
});
}
我们在 watch
内部的 effect
中递归读取 source
的属性。这样,当任意属性发生变化时都能触发回调函数执行。
watch
函数除了可以观察响应式数据,还可以接收一个 getter
函数:
function watch(source, cb) {
let getter
// 兼容 source 是 function 的情况
if(typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
effect(() => getter(), {
scheduler(fn) {
// 发生变化时,调用 callback
cb();
},
});
}
watch(() => obj.foo, () => {
console.log("数据变了2");
});
obj.foo++;
这个只需要判断 source
的类型就可以了。如果是 function
,直接使用传入的 getter
函数;否则的话,还是使用 traverse
递归读取 source
的属性。
回调函数中拿到 oldVal 和 newVal
现在还有一个重要的能力:我们的回调函数还拿不到旧值和新值。我们来分析下,调用 watch
的时候,会自动执行 getter
,并且数据发生变化的时候,scheduler
也没有再去获取新值。所以,只要我们能手动控制执行的时机就可以了。这正是 effect
函数的 lazy
选项可以办到的:
function watch(source, cb) {
let getter;
// 兼容 source 是 function 的情况
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
// 新值和旧值
let oldVal, newVal;
// 递归读取
let effectFn = effect(() => getter(), {
lazy: true, // 懒执行
scheduler(fn) {
// 重新执行副作用函数,获取新值
newVal = effectFn();
// 发生变化时,调用 callback
cb(newVal, oldVal);
// 更新旧值
oldVal = newVal;
},
});
// 手动调用副作用函数,拿到的就是旧值
oldVal = effectFn();
}
watch(
() => obj.foo,
(newVal, oldVal) => {
console.log("数据变了2", newVal, oldVal);
}
);
obj.foo++;
整体流程更改为:
- 用
lazy
创建一个懒执行的effect
; - 手动执行一次。第一次调用
effectFn
得到的就是旧值; - 当数据发生变化时触发
scheduler
,调用effectFn
得到新值; - 回调中传递新值和旧值;
- 更新旧值。
立即执行的 watch
watch
还支持立即执行的回调函数。默认情况下一个 watch
的回调只会在响应式数据发生变化时才执行。所以,我们需要加一个 immediate
选项,表示回调函数在 watch
创建时立即执行一次。然后对代码优化后,逻辑为:
function watch(source, cb, options = {}) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () =>traverse(source);
}
let oldVal, newVal;
// 封装为一个独立的 job 函数
const job = () => {
newVal = effectFn();
cb(newVal, oldVal);
oldVal = newVal;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
job();
},
});
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job();
} else {
oldVal = effectFn();
}
}
immediate
为 true
时立即执行回调函数,此时 oldVal
为 undefined
,这也是符合预期的。
回调函数的执行时机
除了指定回调函数立即执行外,还可以通过 flush
选项指定回调函数的执行时机。
function watch(source, cb, options = {}) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
let oldVal, newVal;
const job = () => {
newVal = effectFn();
cb(newVal, oldVal);
oldVal = newVal;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === "post") {
// 将其放到微任务队列中
const p = Promise.resolve();
p.then(job);
} else {
job();
}
},
});
if (options.immediate) {
job();
} else {
oldVal = effectFn();
}
}
我们可以规定当 flush
为 'post'
时,代表调度函数需要将副作用函数放到一个微任务队列中,等 DOM 更新完后再执行。
watch( obj, () => {
console.log("数据变了1");
},{
flush: "post",
});
watch( obj, () => {
console.log("数据变了2");
});
obj.foo++;
这样输出结果为:
数据变了2
数据变了1
四、过期的副作用
我们来看一个例子:
const data = { foo: 1 };
const obj = new Proxy(data, {/* 省略 */});
// 模拟网络请求,会根据传的值计算延迟返回的时间
function mockFetch(data) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: data });
}, data * 100);
});
}
let finalData;
watch(obj, async () => {
const res = await mockFetch(obj.foo);
finalData = res.data;
console.log('finalData', finalData);
});
obj.foo = 2; // 触发请求 A
obj.foo = 1; // 触发请求 B
我们使用 watch
观测 obj
的变化,每次变化都会发送网络请求,并将结果赋值给 finalData
。
但是这段代码其实是有问题的,它发生了竞态问题。当我们先执行 obj.foo = 2
,触发请求A,再执行 obj.foo = 1
触发请求 B ,根据 mockFetch
的逻辑,我们知道肯定是请求 B 的请求结果先返回,请求 A 的请求结果再返回。也就是模拟了请求 A 比请求 B 先发出去,但是请求 B 的结果比请求 A 的结果先返回的情况,这时会发现 finalData
最后的值是 2,也就是请求 A 的返回结果。这是不符合预期的,我们希望 finalData
的值是最新的,也就是 1 。归根结底是,请求 A 过期了,但是它返回的结果依然被使用了。
所以,我们需要一个让副作用过期的手段。在 Vue.js 中,watch
函数的回调函数接收第三个参数 onInvalidate
,它是一个函数,类似于事件监听器,我们可以使用 onInvalidate
注册一个回调函数,这个回调函数会在当前副作用函数过期时执行:
let finalData;
watch(obj, async (newVal, oldVal, onInvalidate) => {
// 定义一个标识,代表当前副作用函数是否过期
let expired = false;
// 注册过期回调
onInvalidate(() => {
expired = true;
});
const res = await mockFetch(obj.foo);
// 只有当该副作用函数的执行没有过期时,才会执行后续操作
if (!expired) {
finalData = res.data;
console.log("finalData", finalData);
}
});
我们再来看下 onInvalidate
的实现原理:在 watch
内部每次检测到变更后,在副作用函数重新执行前,会先调用我们通过 onInvalidate
注册过的回调函数:
function watch(source, cb, options = {}) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
let oldVal, newVal;
// 存储注册的过期回调
let cleanup;
function onInvalidate(fn) {
// 将过期回调存储到 cleanup
cleanup = fn;
}
const job = () => {
newVal = effectFn();
// 在调用回调函数前,先调用过期回调
if (cleanup) {
cleanup();
}
// 将 onInvalidate 作为回调函数的第三个参数
cb(newVal, oldVal, onInvalidate);
oldVal = newVal;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === "post") {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
},
});
if (options.immediate) {
job();
} else {
oldVal = effectFn();
}
}
这里,我们先定义了 cleanup
变量,用来存储通过 onInvalidate
注册的过期回调。onInvalidate
的实现很简单,只是把过期回调赋值给了 cleanup
。然后在 job
函数内,每次执行 cb
之前,先检查是否存在过期回调,若存在则先执行。最后我们把 onInvalidate
作为回调函数的第三个参数传递给 cb
,方便外部使用。
我们再分析一下加上过期回调后的例子:
- 连续两次修改
obj.foo
的值,都是立即执行,这会导致watch
的回调函数连续执行 2 次。同时我们在回调函数中注册过期回调。 - 第一次执行,我们在回调函数中注册过期回调,然后触发请求 A,我们知道在 200ms 后会返回请求结果
- 第二次执行,我们发现存在过期回调,会执行过期回调,这是请求 A 对应的
expired
置为true
,接着触发请求 B,我们知道在 100ms 后会返回请求结果 - 100ms后,请求B的结果返回,这时请求 B 所在的闭包中的
expired
为false
,所以对finalData
进行赋值 - 200ms后,请求A的结果返回,这时请求 A 所在的闭包中的
expired
为true
,所以不做赋值操作
五、总结
所谓可调度,指的是当 trigger
动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。为了实现调度能力,我们为 effect
函数 增加了第二个选项参数,可以通过 scheduler
选项指定调用器,这样用户可以通过调度器自行完成任务的调度。
计算属性,即 computed
,实际上是一个懒执行的副作用函数,我们通过 lazy
选项使得副作用函数可以懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。 利用这个特点,我们设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。当计算属性依赖的响应式数据发生变化时,会通过 scheduler
将 dirty
标记设置为 true
。这样,下次读取计算属性的值时,我们会重新计算真正的值。
watch
本质上利用了副作用函数重新执行时的可调度性。一个 watch
本身会创建一个 effect
,当这个 effect
依赖的响应式数据发生变化时,会执行该 effect
的调度器函数,即 scheduler
。这里的 scheduler
可以理解为“回调”,所以我们只需要在 scheduler
中执行用户通过 watch
函数注册的回调函数即可。
过期的副作用函数,它会导致竞态问题。为了解决这个问题,Vue.js 为 watch
的回调函数设计了第三个参数,即 onInvalidate
。它是一个函数,用来注册过期回调。每当 watch
的回调函数执行之前,会优先执行用户通过 onInvalidate
注册的过期回调。这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。
系列文章: