第四章:响应系统的作用与实现
响应式数据与副作用函数
副作用函数:当一个函数直接或间接影响了其他函数(比如修改了全局变量)的执行就称其为副作用函数
const obj = { text: 'hello world' };
function effect() {
document.body.innerText = obj.text;
}
我们希望对对象修改后 obj.text = 'abc'
,副作用函数会自动执行,如果能实现这个目标,就成其为响应式。
响应式数据的基本实现
用 Object.defineProperty
或 Proxy
来劫持对象的 get
和 set
方法
const bucket = new Set();
const data = { text: 'hello' };
const obj = new Proxy(data, {
get(target, key) {
// 访问时将副作用函数加入到 桶 中
bucket.add(effect);
return target[key];
},
set(target, key, val) {
target[key] = val;
// 设置时 触发 effect 函数
bucket.forEach(fn => fn());
return true;
}
});
设计一个完善的响应式系统
在上面的例子里,副作用函数的名称被硬编码(只能是 effect),为了解决这个问题,将 effect 改为一个注册副作用函数的函数。
// 用一个全局变量临时存储被注册的副作用函数
let activeEffect;
// 用于注册副作用函数
function effect(fn) {
activeEffect = fn;
// 执行副作用函数 (触发收集)
fn();
}
const obj = new Proxy(data, {
get(target, key) {
// 将 activeEffect 存储的副作用函数收集到桶里
if (activeEffect) {
bucket.add(activeEffect);
}
return target[key];
},
set(target, key, val) {
target[key] = val;
// 设置时 触发 effect 函数
bucket.forEach(fn => fn());
return true;
}
});
现在可以这样使用
effect(() => {
console.log('effect run');
document.body.innerText = obj.text;
});
如果我们执行如下操作,仍然会触发副作用函数执行。
// 副作用函数里根本没有访问这个字段
obj.notExist = '123';
其原因就是副作用函数和目标对象没有建立明确的关系。为了解决这个问题我们修改下 bucket 的数据结构
const bucket = new WeakMap();
const obj = new Proxy(data, {
get(target, key) {
// 将 activeEffect 存储的副作用函数收集到桶里
track(target, key);
return target[key];
},
set(target, key, val) {
target[key] = val;
// 将副作用函数从 桶 中取出来并调用
trigger(target, key);
}
});
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
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());
}
分支切换与 cleanup
const obj = { ok: true, text: 'hello world' };
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'not';
});
根据上面的代码可知,副作用函数会被会 obj.ok
和 obj.text
收集。 我们修改 obj.ok = false
。现在的副作用函数与 obj.text
是无关的,但是修改仍然会触发副作用函数。
解决这个问题的思路很简单:每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除。
// 用一个全局变量临时存储被注册的副作用函数
let activeEffect;
// 用于注册副作用函数
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
// activeEffect.deps 用来存储所有该副作用函数相关的依赖合集
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
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);
activeEffect.deps.push(deps);
}
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
// 将副作用函数在 deps(依赖集合)中移除
deps.delete(effectFn);
}
effectFn.deps.length = 0;
}
WARNING
根据 Set.prototype.forEach
规范,我们这样实现会出现死循环,解决方案就是创建一个新的 Set 数组
const set = new Set([1]);
// 有问题
set.forEach(() => {
set.delete(1);
set.add(1);
console.log('set forEach');
});
const set1 = new Set(set);
// 无问题
set1.forEach(() => {
// 注意 此处处理的是原集合而非新集合
set.delete(1);
set.add(1);
console.log('set1 forEach');
});
我们回到出问题的地方进行修正
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effectsToRun.forEach(fn => fn());
// effects && effects.forEach(fn => fn());
}
嵌套的 effect 与 effect 栈
const data = { foo: true, bar: true };
const obj = new Proxy(data, {
/** */
});
let temp1, temp2;
effect(function fn1() {
console.log('fn1 exec');
effect(function fn2() {
console.log('fn2 exec');
temp2 = obj.bar;
});
temp1 = obj.foo;
});
/**
* 修改 foo 输出:
* fn1 exec
* fn2 exec
* fn2 exec
*/
obj.foo = false;
可以看到这是不符合预期的,我们期望修改 foo 时执行 fn1, 修改 bar 时执行 fn2,但是事实上 修改 foo 后执行的 fn2。
原因: 我们用全局的 activeEffect 来存储副作用函数,意味着同一时刻只会存在一个,新来的会将旧的值覆盖掉,并且永远不会恢复到原来的值。 在执行嵌套的逻辑时,收集的永远都是最内层的,所以会出现这个问题
解决: 用 effectStack 来存储所有的副作用函数,当副作用函数执行时入栈,执行结束后将其弹出,并始终让 activeEffect 指向栈顶。
// 用一个全局变量临时存储被注册的副作用函数
let activeEffect;
const effectStack = [];
// 用于注册副作用函数
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// activeEffect.deps 用来存储所有该副作用函数相关的依赖合集
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
避免无限递归循环
effect(function fn1() {
console.log('fn1 exec', obj.foo);
temp1 = obj.foo++;
});
在副作用函数中执行了自增 obj.foo++
, 我们模拟下执行逻辑:
- 执行 effect 里的副作用函数
effectFn()
- 副作用函数中读取 obj.foo,触发 track,将副作用函数添加到 bucket 中
- 副作用函数中执行
++
,触发 trigger,将副作用函数取出执行 - 此时副作用函数还未执行完就要开始下一轮执行,因此出现了死递归
解决: 如果 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(fn => {
if (fn !== activeEffect) {
effectsToRun.add(fn);
}
});
effectsToRun.forEach(fn => fn());
}
调度执行
可调度性:当 trigger 函数触发副作用函数执行时,有能力决定其执行时机、执行次数。
如何控制副作用函数的调用?
effect 函数支持传递 options,并将 options 挂载到副作用函数,在 trigger 调用时,将副作用函数传递给 options.scheduler
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// activeEffect.deps 用来存储所有该副作用函数相关的依赖合集
effectFn.deps = [];
effectFn.options = options;
// 执行副作用函数
effectFn();
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects &&
effects.forEach(fn => {
if (fn !== activeEffect) {
effectsToRun.add(fn);
}
});
effectsToRun.forEach(fn => {
if (fn.options.scheduler) {
fn.options.scheduler(fn);
} else {
fn();
}
});
}
let temp1;
const data = { foo: 1, bar: true };
const obj = new Proxy(data, {
/** */
});
effect(
function fn1() {
console.log('fn1 exec', obj.foo);
temp1 = obj.foo;
},
{
scheduler: effectFn => {
setTimeout(effectFn, 1000);
}
}
);
obj.foo = 0;
console.log(`[a] `, obj);
// ------------ 输出 ------------
// fn1 exec 1
// [a] { foo: 0, bar: true }
// fn1 exec 0 => 1s 后输出
当我们执行了多次 obj.foo++
,会触发多次副作用函数,通过调度器还可以实现控制调用次数,使其只在最后一次执行。
let temp1, temp2;
const jobs = new Set();
const p = Promise.resolve();
let isFlushing = false;
function flushJob() {
if (isFlushing) return;
isFlushing = true;
p.then(() => {
jobs.forEach(job => job());
}).finally(() => {
isFlushing = false;
});
}
effect(
function fn1() {
console.log('fn1 exec', obj.foo);
temp1 = obj.foo;
},
{
scheduler: effectFn => {
jobs.add(effectFn);
flushJob();
}
}
);
obj.foo++;
obj.foo++;
obj.foo++;
console.log(`[a] `, obj);
// --------------- 输出 --------------
// fn1 exec 1
// [a] { foo: 4, bar: true }
// fn1 exec 4
计算属性 computed 与 lazy
- 不会立即执行副作用函数
- 能够返回计算的结果
// 用于注册副作用函数
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
// 将计算的值返回
const res = fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
return res;
};
// activeEffect.deps 用来存储所有该副作用函数相关的依赖合集
effectFn.deps = [];
effectFn.options = options;
// 用于计算属性,不会立即执行副作用函数
if (!options.lazy) {
// 执行副作用函数
effectFn();
} else {
return effectFn;
}
}
function computed(fn) {
let value;
// 为计算属性提供缓存,每次访问计算属性取的都是缓存的值。 如果依赖的值有更新,会先刷新缓存 然后在返回值
let dirty = true;
const effectFn = effect(fn, {
lazy: true,
scheduler() {
if (!dirty) {
// 当依赖更新时,不需要执行计算 而是将 dirty=true,这样当计算属性访问时就可
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;
}
watch 的实现原理
利用调度器可以实现一个最基础版本的 watch,但是 source.foo
是硬编码的 不通用,而且回调只有新的值
function watch(source, cb) {
effect(() => source.foo, {
scheduler(effectFn) {
cb(effectFn());
}
});
}
实现一个通用的 watch
/**
* 遍历对象的所有属性,这样就能触发所有属性的 get,从而实现 track
* @param {*} obj 原始对象
* @param {*} seen 防止重复引用
*/
function traverse(obj, seen = new Set()) {
if (typeof obj !== 'object' || obj === null || seen.has(obj)) return;
seen.add(obj);
// 假设只有对象,其他数据结构先不处理
for (const key in obj) {
traverse(obj[key], seen);
}
return obj;
}
/**
* @param source
* 如果传递的是函数 () => obj.foo,直接当作副作用函数
* 如果是对象,就构建一个函数,通过 traverse 递归的取所有值
*
* @param cb watch 的回调
*/
function watch(source, cb) {
const getter = typeof source === 'function' ? source : () => traverse(source);
effect(getter, {
scheduler(effectFn) {
cb(effectFn());
}
});
}
实现回调给出新、老值
function watch(source, cb) {
let oldVal, newVal;
const getter = typeof source === 'function' ? source : () => traverse(source);
const effectFn = effect(() => getter(), {
lazy: true,
scheduler(effectFn) {
// 获取新值
newVal = effectFn();
cb(oldVal, newVal);
// 更新旧值
oldVal = newVal;
}
});
// 使用 lazy,然后手动调用,此时拿到的就是老值
oldVal = effectFn();
}
立即执行的 watch 与回调机制
通过第三个选项中的 immediate 来决定是否立即执行回调。
function watch(source, cb, option) {
let oldVal, newVal;
const getter = typeof source === 'function' ? source : () => traverse(source);
/**
* 立即执行和 scheduler 调用本质上是一样的
*/
const job = () => {
newVal = effectFn();
cb(oldVal, newVal);
oldVal = newVal;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: job
});
if (option.immediate) {
job();
} else {
oldVal = effectFn();
}
}