计算属性 computed 与 lazy
在深入讲解计算属性之前,我们需要先来聊聊关于懒执行的 effect,即 lazy 的effect。 目前实现的 effect 函数会立即执行传递给它的副作用函数
effect(
// 这个函数会立即执行
() => {
console.log(obj.foo)
}
)
但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性。这时我们可以通过在 options 中添加 lazy 属性来达到目的,如下面的代码所示:
effect(
// 指定了 lazy 选项,这个函数不会立即执行
() => {
console.log(obj.foo)
},
// options
{
lazy: true
}
)
lazy 选项和之前介绍的 scheduler 一样,它通过 options 选项对象指定。有了它,我们就可以修改 effect 函数的实现逻辑了,当 options.lazy 为 true 时,则不立即执行副作用函数:
// 存储副作用函数的桶 Set 改为 WeakMap
const bucket = new WeakMap();
// 原始数据 变为两个属性
const data = { foo: 1, bar: 1 };
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 栈
const effectStack = [];
// 对原始数据的代理
export 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);
return true;
},
});
// 在 get 拦截函数内调用 track 函数追踪变化
export function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) {
return;
}
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target);
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect);
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps); // 新增
}
// 在 set 拦截函数内调用 trigger 函数触发变化
export function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key);
// 执行副作用函数
// 使用一个新 set 避免死循环
const effectsToRun = new Set(effects);
effectsToRun.forEach((effectFn) => {
// effectFn !== activeEffect &&
// effectStack.every((effectFn) => effectFn !== activeEffect) &&
// effectFn();
if (
effectFn !== activeEffect &&
effectStack.every((effectFn) => effectFn !== activeEffect)
) {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
// 新增
effectFn.options.scheduler(effectFn); // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
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;
}
// 添加配置参数 options
export function effect(fn, options = {}) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn);
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
// 用一个全局变量存储当前激活的 effect 函数
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
const res = fn();
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
return res;
};
// 将 options 挂载到 effectFn 上
effectFn.options = options;
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 只有非 lazy 的时候,才执行
if (!options.lazy) {
// 执行副作用函数
effectFn();
}
// 将副作用函数作为返回值返回
return effectFn; // 新增
}
const effectFn = effect(
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,
{ lazy: true }
);
// 手动执行副作用函数
// effectFn();
const value = effectFn();
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Demo</title>
</head>
<body>
</body>
<script src="./8_计算属性_computed.js" type="module"></script>
</html>
通过这个判断,我们就实现了让副作用函数不立即执行的功能。但问题是,副作用函数应该什么时候执行呢?通过上面的代码可以看到,我们将副作用函数 effectFn 作为effect 函数的返回值,这就意味着当调用 effect 函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行该副作用函数了
const effectFn = effect(() => {
console.log(obj.foo)
}, { lazy: true })
// 手动执行副作用函数
effectFn()
如果仅仅能够手动执行副作用函数,其意义并不大。但如果我们把传递给 effect 的函数看作一个 getter,那么这个 getter 函数可以返回任何值,例如:
const effectFn = effect(
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,
{ lazy: true }
)
const val = effectFn()
现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了,如下所示:
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
})
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn()
}
}
return obj
}
watch 的实现原理
本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数
watch(obj, () => {
console.log('数据变了')
})
// 修改响应数据的值,会导致回调函数执行
obj.foo++
最简单的 watch 函数的实现
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
import { effect, obj } from './0_effect.js';
// // watch 函数接收两个参数,source 是响应式数据,cb 是回调函数 (硬编码)
// function watch1(source, cb) {
// effect(
// // 触发读取操作,从而建立联系
// () => source.foo,
// {
// scheduler() {
// // 当数据变化时,调用回调函数 cb
// cb();
// },
// }
// );
// }
// watch1(obj, () => {
// console.log('数据变化了');
// });
// obj.foo++;
function watch(source, cb) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
// 定义旧值与新值
let oldValue, newValue;
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(() => getter(), {
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn();
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue);
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue;
},
});
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn();
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) return;
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value);
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen);
}
return value;
}
// watch(
// () => obj.foo,
// // 回调函数
// () => {
// console.log('obj.foo 的值变了');
// }
// );
// obj.foo++;
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(newValue, oldValue); // 2, 1
}
);
obj.foo++;
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Demo</title>
</head>
<body>
</body>
<script src="./9_watch.js" type="module"></script>
</html>
最核心的改动是使用 lazy 选项创建了一个懒执行的 effect。注意上面代码中最下面的部分,我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值
立即执行的 watch 与回调执行时机
watch 的两个特性:一个是立即执行的回调函数,另一个是回调函数的执行时机。
watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
immediate: true
})
flush 本质上是在指定调度函数的执行时机。前文讲解过如何在微任务队列中执行调度函数 scheduler,这与 flush 的功能相同。当 flush 的值为 'post' 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行
import { effect, obj } from './0_effect.js';
// 添加 options
function watch(source, cb, options = {}) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
};
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
// 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
},
}
);
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job();
} else {
oldValue = effectFn();
}
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) return;
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value);
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen);
}
return value;
}
// watch(
// () => obj.foo,
// // 回调函数
// () => {
// console.log('obj.foo 的值变了');
// }
// );
// obj.foo++;
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(newValue, oldValue); // 2, 1
},
{
immediate: true,
// 回调函数会在 watch 创建时立即执行一次
flush: 'pre', // 还可以指定为 'post' | 'sync'
}
);
obj.foo++;
obj.foo++;
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Demo</title>
</head>
<body>
</body>
<script src="./10_执行时机_watch.js" type="module"></script>
</html>
过期的副作用
竞态问题
let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
finalData = res
})
在 Vue.js 中,watch 函数的回调函数接收第三个参数 onInvalidate,它是一个函数,类似于事件监听器,我们可以使用 onInvalidate 函数注册一个回调,这个回调函数会在当前副作用函数过期时执行
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
let expired = false
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
// 当过期时,将 expired 设置为 true
expired = true
})
// 发送网络请求
const res = await fetch('/path/to/request')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作。
if (!expired) {
finalData = res
}
})
import { effect, obj } from './0_effect.js';
// 添加 options
function watch(source, cb, options = {}) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
// cleanup 用来存储用户注册的过期回调
let cleanup;
// 定义 onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调存储到 cleanup 中
cleanup = fn;
}
const job = () => {
newValue = effectFn();
// 在调用回调函数 cb 之前,先调用过期回调
if (cleanup) {
cleanup();
}
// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
cb(newValue, oldValue, onInvalidate);
oldValue = newValue;
};
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
},
}
);
if (options.immediate) {
job();
} else {
oldValue = effectFn();
}
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) return;
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value);
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen);
}
return value;
}
watch(obj, async (newValue, oldValue, onInvalidate) => {
let expired = false;
onInvalidate(() => {
expired = true;
});
const time = Math.random() * 1000;
await new Promise((resolve) => {
setTimeout(() => {
resolve();
}, newValue.foo * 1000);
});
// console.log('结果:' + expired + time + '--' + newValue.foo);
if (!expired) {
console.log('结果:' + time + '--' + newValue.foo);
}
});
// 第一次修改
obj.foo++;
setTimeout(() => {
obj.foo = 3;
}, 700);
setTimeout(() => {
obj.foo = 1;
}, 990);
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Demo</title>
</head>
<body>
</body>
<script src="./11_过期_watch.js" type="module"></script>
</html>