4. 响应系统的作用与实现
4.1 响应式数据与副作用函数
副作用函数
// 全局变量
let val = 1;
function effect() {
val = 2; // 修改全局变量,产生副作用
}
const obj = { text: "hello world" };
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text;
}
obj.text = "hello vue3"; // 修改 obj.text 的值,同时希望副作用函数会重新执行
我们希望当值变化后,副作用函数自动重新执行,如果能实现这个目标,那么对象 obj 就是响应式数据
4.2 响应式数据的基本实现
- 当读取操作发生时,将副作用函数收集到“桶”中
- 当设置操作发生时,从“桶”中取出副作用函数并执行
// 存储副作用函数的桶
const bucket = new Set();
// 原始数据
const data = { text: "hello world" };
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect);
// 返回属性值
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 把副作用函数从桶里取出并执行
bucket.forEach((fn) => fn());
// 返回 true 代表设置操作成功
return true;
},
});
测试
// 副作用函数
function effect() {
document.body.innerText = obj.text;
}
// 执行副作用函数,触发读取
effect();
// 1 秒后修改响应式数据
setTimeout(() => {
obj.text = "hello vue3";
}, 1000);
缺点
- 副作用函数名被硬编码
- 没有在副作用函数与被操作的目标字段之间建立明确的联系
4.3 设计一个完善的响应系统
effect(function effectFn() {
document.body.innerText = obj.text;
});
在这段代码中存在三个角色:
- 被操作(读取)的代理对象
obj
; - 被操作(读取)的字段名
text
; - 使用 effect 函数注册的副作用函数
effectFn
。
如果用 target
来表示一个代理对象所代理的原始对象,用 key
来表示被操作的字段名,用 effectFn
来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
target
└── key
└── effectFn
const data = {
name: "cx",
};
const bucket = new WeakMap();
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);
effects && effects.forEach((fn) => fn());
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn;
// 执行副作用函数
fn();
}
测试
effect(() => {
console.log(obj.name);
});
setTimeout(() => {
obj.name = "cx1";
}, 2000);
4.4 分支切换与 cleanup
const data = { ok: true, text: "hello world" };
const obj = new Proxy(data, {
/* ... */
});
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : "not";
});
此时副作用函数 effectFn
与响应式数据之间建立的联系如下
data
└── ok
└── effectFn
└── text
└── effectFn
当字段 obj.ok
的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。分支切换可能会产生遗留的副作用函数
- 初始时分别收集
obj.ok
和obj.text
的副作用函数 effectFn - 当
obj.ok
变为 false 时,obj.text
无论为何值document.body.innerText
的值都为not
- 但是
obj.text
副作用函数仍然存在,obj.text
变化时依然会执行它的副作用函数
解决:每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除
重新设计 effect 函数
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
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;
}
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 中
deps.add(activeEffect);
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps);
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
// 在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问
const effectsToRun = new Set(effects);
effectsToRun.forEach((effectFn) => effectFn());
// effects && effects.forEach(effectFn => effectFn()) // 删除
}
4.5 嵌套的 effect 与 effect 栈
// 原始数据
const data = { foo: true, bar: true };
// 代理对象
const obj = new Proxy(data, {
/* ... */
});
// 全局变量
let temp1, temp2;
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log("effectFn1 执行");
effect(function effectFn2() {
console.log("effectFn2 执行");
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar;
});
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo;
});
// 'effectFn1 执行'
// 'effectFn2 执行'
// 'effectFn2 执行'
同一时刻 activeEffect
所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect
的值,并且永远不会恢复到原来的值
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;
// effect 栈
const effectStack = [];
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 iveEffect
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
fn();
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 iveEffect 还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
4.6 避免无限递归循环
const data = { foo: 1 };
const obj = new Proxy(data, {
/*...*/
});
effect(() => obj.foo++);
读取 obj.foo
的值触发 track
操作,将当前副作用函数收集到“桶”中,接着将其加 1 后再赋值给 obj.foo
,此时会触发 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) => effectFn());
// effects && effects.forEach(effectFn => effectFn())
}
4.7 调度执行
当 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) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
effectsToRun.forEach((effectFn) => {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn();
}
});
}
下面这段代码中字段 obj.foo
的值一定会从 1 自增到 3,2 只是它的过渡状态。如果我们只关心最终结果而不关心过程,那么执行三次打印操作是多余的
const data = { foo: 1 };
const obj = new Proxy(data, {
/* ... */
});
effect(() => {
console.log(obj.foo);
});
obj.foo++;
obj.foo++;
// 定义一个任务队列
const jobQueue = new Set();
// 使用 Promise.resolve() 创建一个 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 队列中
jobQueue.add(fn);
// 调用 flushJob 刷新队列
flushJob();
},
}
);
4.8 计算属性 computed 与 lazy
lazy
lazy
选项和之前介绍的 scheduler
一样,它通过 options
选项对象指定。有了它,我们就可以修改 effect
函数的实现逻辑了
将副作用函数 effectFn
作为 effect
函数的返回值,这就意味着当调用 effect
函数时,通过其返回值能够拿到对应的副作用函数
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 = [];
// 只有非 lazy 的时候,才执行
if (!options.lazy) {
// 执行副作用函数
effectFn();
}
// 将副作用函数作为返回值返回
return effectFn;
}
effect(
// 指定了 lazy 选项,这个函数不会立即执行
() => {
console.log(obj.foo);
},
// options
{
lazy: true,
}
);
计算属性
computed
函数的执行会返回一个对象,该对象的 value
属性是一个访问器属性,只有当读取 value
的值时,才会执行 effectFn
并将其结果作为返回值返回
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
});
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn();
},
};
return obj;
}
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {
/* ... */
});
const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value); // 3
缓存值和嵌套 effect
变量
value
用来缓存上一次计算的值,而dirty
是一个标识,代表是否需要重新计算。当我们通过sumRes.value
访问值时,只有当dirty
为 true 时才会调用effectFn
重新计算值在
scheduler
函数内将dirty
重置为 true,当下一次访问sumRes.value
时,就会重新调用effectFn
计算值当读取一个计算属性的
value
值时,我们手动调用track
函数,把计算属性返回的对象obj
作为target
,同时作为第一个参数传递给track
函数
// 嵌套 effect
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {
/* ... */
});
const sumRes = computed(() => obj.foo + obj.bar);
effect(() => {
// 在该副作用函数中读取 sumRes.value
console.log(sumRes.value);
});
// 修改 obj.foo 的值
obj.foo++;
function computed(getter) {
// value 用来缓存上一次计算的值
let value;
// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true;
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, "value");
}
},
});
const obj = {
get value() {
// 只有“脏”时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn();
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false;
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, "value");
return value;
},
};
return obj;
}
4.9 watch 的实现原理
watch
的实现本质上就是利用了effect
以及options.scheduler
选项
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb();
},
}
);
}
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
函数的第一个参数还可以是一个getter
函数
function watch(source, cb) {
// 定义 getter
let getter;
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === "function") {
getter = source;
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source);
}
effect(
// 执行 getter
() => getter(),
{
scheduler() {
cb();
},
}
);
}
watch
函数 可以利用effect
函数的lazy
选项拿到新值和旧值
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();
}
4.10 立即执行的 watch 与回调执行时机
在 Vue.js 中可以通过选项参数 immediate
来指定回调是否需要立即执行,则把 scheduler
调度函数封装为一个通用函数,分别在初始化和变更时执行它
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,
// 使用 job 函数作为调度器函数
scheduler: job,
}
);
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job();
} else {
oldValue = effectFn();
}
}
在 Vue.js 3 中使用 flush
选项来指定回调函数的执行时机,当 flush
的值为 'post'
时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行
watch(
obj,
() => {
console.log("变化了");
},
{
// 回调函数会在 watch 创建时立即执行一次
flush: "pre", // 还可以指定为 'post' | 'sync'
}
);
function watch(source, cb, options = {}) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
let oldValue, newValue;
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) {
job();
} else {
oldValue = effectFn();
}
}
4.11 过期的副作用
在日常工作中我们可能早就遇到过与竞态问题相似的场景
let finalData;
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch("/path/to/request");
// 将请求结果赋值给 data
finalData = res;
});
设想 :
- 假设第一次请求记为
A
, - 请求未完成的状态下我们修改了
obj
对象,发送第二个请求记为B
- 请求
B
完成后,请求A
才完成 - 此时
finalData
的结果为A
返回的,而B
的结果被丢弃
在 Vue.js 中, watch
函数的回调函数接收第三个参数 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;
}
});
onInvalidate
的原理是什么呢?其实很简单,在 watch
内部每次检测到变更后,在副作用函数重新执行之前,会先调用我们通过 onInvalidate
函数注册的过期回调
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();
}
}