上一章我们了解基础的响应式系统设计办法,下面我们继续学习响应式系统的相关设计
1、关于effect嵌套与effect栈
effect副作用函数式可以被嵌套,如下所示:
effect(funtion1() {
effect(function2() {
....
})
})
为什么会出现副作用函数的嵌套呢?我们拿vuejs里面的组件来说明:
const Page = {
render() {
return (<Bar>
<Content />
</Bar>)
}
}
两个组件嵌套,其实在渲染过程中会分别执行组件的render函数,向下遍历内层的组件的时候,我们需要同样遍历组件的render方法,如下面伪代码所示:
effect(() => {
Bar.render();
effect(() => {
Content.render()
})
})
接着我们上一讲的设计的响应式系统,我们测试一下嵌套的effect执行情况:
/ 副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
clearEffectDeps(effectFn);
activeEffect = effectFn;
fn()
}
effectFn.deps = []
effectFn()
}
// 每次清除副作用函数列表里面的关联关系
function clearEffectDeps(effectFn) {
effectFn.deps.forEach(i => {
i.delete(effectFn)
})
effectFn.deps.length = 0
}
// 数据准备
const data = { bar: 'hello world', content: '内容区域' };
// 响应式函数容器
const bucket = new WeakMap();
const obj = new Proxy(data, {
get(target, key) {
tack(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
return true;
}
});
// 向bucket里面注入副作用函数
function tack(target, key) {
// 没有acticeEffect
if (!activeEffect) {
return;
}
// 判断下面有没有对应的对象相关的内容
let depsMap = bucket.get(target);
if (!depsMap) {
depsMap = new Map();
bucket.set(target, depsMap);
}
// 对应key值的内容
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
// 将对应key值的副作用函数相关信息放入副作用函数
activeEffect.deps.push(deps);
}
// 副作用触发函数
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) {
return;
}
let deps = depsMap.get(key);
const effectsRun = new Set(deps);
effectsRun.forEach(fn => fn());
}
let tmp1, tmp2;
effect(function funcion1() {
console.log('funcion1 执行');
effect(function funcion2() {
console.log('funcion2 执行');
tmp2 = obj.content;
})
tmp1 = obj.bar;
});
setTimeout(() => {
obj.bar = '新内容';
}, 1000);
// 结果
// function1执行 ---(初始化执行)
// function2执行 ---(舒适化执行)
// function2执行
事实上我们最后定时器修改obj.bar的值,我们预想的结果是触发function1、function2的执行,然后我们只是执行了function2,原因就是我们其实在全局只放了一个activeEffect标志位,因此他存储的effect一定是最后一次的生成的副作用函数,其过程如下图所示:
解决这个问题,我们需要通过函数调用栈的方式处理,具体如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script>
// 副作用函数
let activeEffect;
let effectStack = [];
function effect(fn) {
const effectFn = () => {
clearEffectDeps(effectFn);
activeEffect = effectFn;
// 将当前副作用effectFn放入effectStack中
effectStack.push(effectFn);
// 执行副租用函数
fn()
// 出栈
effectStack.pop();
// 将activeEffect指向上一个effect
activeEffect = effectStack[effectStack.length - 1];
}
effectFn.deps = []
effectFn()
}
// 每次清除副作用函数列表里面的关联关系
function clearEffectDeps(effectFn) {
effectFn.deps.forEach(i => {
i.delete(effectFn)
})
effectFn.deps.length = 0
}
// 数据准备
const data = { bar: 'hello world', content: '内容区域' };
// 响应式函数容器
const bucket = new WeakMap();
const obj = new Proxy(data, {
get(target, key) {
tack(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
return true;
}
});
// 向bucket里面注入副作用函数
function tack(target, key) {
// 没有acticeEffect
if (!activeEffect) {
return;
}
// 判断下面有没有对应的对象相关的内容
let depsMap = bucket.get(target);
if (!depsMap) {
depsMap = new Map();
bucket.set(target, depsMap);
}
// 对应key值的内容
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
// 将对应key值的副作用函数相关信息放入副作用函数
activeEffect.deps.push(deps);
}
// 副作用触发函数
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) {
return;
}
let deps = depsMap.get(key);
const effectsRun = new Set(deps);
effectsRun.forEach(fn => {
fn()
});
}
let tmp1, tmp2;
effect(function funcion1() {
console.log('funcion1 执行');
effect(function funcion2() {
console.log('funcion2 执行');
tmp2 = obj.content;
})
tmp1 = obj.bar;
});
setTimeout(() => {
obj.bar = '新内容';
}, 1000);
setTimeout(() => {
obj.content = '新内容';
}, 1000);
</script>
</body>
</html>
2、避免无线递归循环
上面还有一个问题,那就是我们同时进行读写操作obj.bar = obj.bar + 1,就会出现递归死循环,导致栈溢出;原因就是每次我们调用都是从 get->set 执行有发现需要 get ->set 这样就导致无线递归调用,解决办法就只
// 副作用触发函数
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) {
return;
}
let deps = depsMap.get(key);
const effectsRun = new Set();
deps && deps.forEach(i => {
if (i !== activeEffect) {
effectsRun.add(i);
}
})
effectsRun.forEach(fn => {
fn()
});
}
3、调度器
在这里,调度是指当trigger动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、顺序、次数及方式;那我们的响应式系统来说:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script>
// 副作用函数
let activeEffect;
let effectStack = [];
function effect(fn) {
const effectFn = () => {
clearEffectDeps(effectFn);
activeEffect = effectFn;
// 将当前副作用effectFn放入effectStack中
effectStack.push(effectFn);
// 执行副租用函数
fn()
// 出栈
effectStack.pop();
// 将activeEffect指向上一个effect
activeEffect = effectStack[effectStack.length - 1];
}
effectFn.deps = []
effectFn()
}
// 每次清除副作用函数列表里面的关联关系
function clearEffectDeps(effectFn) {
effectFn.deps.forEach(i => {
i.delete(effectFn)
})
effectFn.deps.length = 0
}
// 数据准备
const data = { bar: 1 };
// 响应式函数容器
const bucket = new WeakMap();
const obj = new Proxy(data, {
get(target, key) {
tack(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
return true;
}
});
// 向bucket里面注入副作用函数
function tack(target, key) {
// 没有acticeEffect
if (!activeEffect) {
return;
}
// 判断下面有没有对应的对象相关的内容
let depsMap = bucket.get(target);
if (!depsMap) {
depsMap = new Map();
bucket.set(target, depsMap);
}
// 对应key值的内容
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
// 将对应key值的副作用函数相关信息放入副作用函数
activeEffect.deps.push(deps);
}
// 副作用触发函数
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) {
return;
}
let deps = depsMap.get(key);
const effectsRun = new Set();
deps && deps.forEach(i => {
if (i !== activeEffect) {
effectsRun.add(i);
}
})
effectsRun.forEach(fn => {
fn()
});
}
effect(function funcion1() {
console.log(obj.bar);
});
obj.bar++;
console.log('结束了');
</script>
</body>
</html>
他的执行结果是1 ,2 ,结束了, 如果我们想要调整这个代码执行的先后顺序为1,结束了,2,我们看上面的代码,显然是没有办法满足,那么我们就需要引入调度器这个概念进来,让其帮助我们在执行副作用函数时候做出一些调整,那么具体设计模式就需要调整如下:
effect(() => {
},
// 增加相应的配置项options
{
scheduler(fn) {
// todo somethings
}
})
同时我们需要将调度执行任务挂载到副作用函数上,方便我们在执行trigger时候去做相应的调度任务,我们调整effect副作用函数如下:
function effect(fn, option = {}) {
const effectFn = () => {
clearEffectDeps(effectFn);
activeEffect = effectFn;
// 将当前副作用effectFn放入effectStack中
effectStack.push(effectFn);
// 执行副租用函数
fn()
// 出栈
effectStack.pop();
// 将activeEffect指向上一个effect
activeEffect = effectStack[effectStack.length - 1];
}
effectFn.deps = []
// 增加调度任务模块
effectFn.option = option;
effectFn()
}
接下来,我们再去调整trigger执行过程的办法,其实也很简单,就是在我们执行副作用函数前,去判断是否有注册的调度器,如果有,我们就按照调度器的方式执行
// 副作用触发函数
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) {
return;
}
let deps = depsMap.get(key);
const effectsRun = new Set();
deps && deps.forEach(i => {
if (i !== activeEffect) {
effectsRun.add(i);
}
})
effectsRun.forEach(fn => {
// 执行前,判断是否有调度器
if (fn.option.scheduler) {
fn.option.scheduler(fn);
} else {
fn();
}
});
}
那么针对我们上面提到的,想要将将函数的执行修改为1, 结束了,2这种方式,我们就可以通过调度器的方式调整副作用函数执行的优先级,最简单的方式定时器变成一个宏任务的方式调整他最后执行,具体如下:
effect(function funcion1() {
console.log(obj.bar);
}, {
scheduler(fn) {
setTimeout(fn);
}
});
在往复杂一点想,如果我们执行两个obj.bar ++,如下所示:
// ....
effect(function funcion1() {
console.log(obj.bar);
}, {
// scheduler(fn) {}
});
obj.bar++;
obj.bar++;
// 结果 1 2 3
在我们实际开发过程中,其实我们更想要的是1 3重复的执行我们就合并起来
// 创建调度任务容器
let workers = new Set();
// 创建一个微任务队列
const p = Promise.resolve();
// 是否标识正在刷新队列
let isFlushing = false;
// 刷新队列
function fushWorkers() {
if (isFlushing) return
isFlushing = true
p.then(() => {
workers.forEach(w => w())
}).finally(() => {
isFlushing = false
})
}
// 调整调度器
// ....
effect(function funcion1() {
console.log(obj.bar);
}, {
scheduler(fn) {
workers.add(fn);
flushWorkers();
}
});
简单的来说分为以下几个步骤:
- 首先:我们创建一个workers任务队列,用来存放我们的副作用函数任务,这块用到Set()主要是需要 Set()会自动去重,所以会保证Set()中通类型的副作用函数始终只有一个;
- 其次:我们通过Promise.resolve()创建一个微任务,用来保证我们的程序执行顺序在任务最后,保证本次副作用函数数量是全量需要触发的;
- 再者:我们在通过ifFlushing标识确定所有内容都会被放入到workers内;
- 最后:当主任务结束,将会进入我们的微任务阶段,执行完毕后,再将isFlushing重置为false,方便我们下次执行新的任务
上面是一个基础版本的响应式系统设计办法