一个响应系统的工作流程如下:
- 当读取操作发生时,将副作用函数收集到"桶"中;
- 当设置操作发生时,从"桶"中取出副作用函数并执行。
一、设计合理的数据结构
1、 Set 数据结构作为存储副作用函数的"桶"
首先我们需要一个全局变量,用来存储副作用函数, 使其能够正确地被收集到"桶"中: 如以下代码所示:
javascript
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时, 将副作用函数 fn 赋值给 activeEffect
activeEffect = fn;
// 执行副作用函数
fn();
}
javascript
effect(()=>{
// 一个匿名的副作用函数
document.body.innnerText = obj.text;
})
effect 函数执行时, 首先会把匿名函数 fn 赋值给全局变量 activeEffect。执行被注册的匿名副作用函数fn,这将会触发响应式数据 obj.text 的读取操作, 进而触发代理对象 Proxy 的 get 拦截函数:
javascript
// 作用是存储被注册的副作用函数
let activeEffect;
function effect(fn) {
activeEffect = fn;
fn();
}
const data = { text: 'hello world' };
// 存储副作用函数的桶
const bucket = new Set();
const obj = new Proxy(data,{
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, value) {
target[key] = value;
bucket.forEach(fn => fn());
return true;
}
});
effect(() => {
console.log('effect run');
document.body.innerHTML = obj.text
});
setTimeout(() => {
// // 副作用函数中并没有读取 notExist 属性的值
obj.notExist = 'hello vue3'
}, 1000);
匿名副作用函数内部读取了 obj.text 的值, 于是匿名副作用函数与字段 obj.text 之间会建议响应联系。
出现问题: 上述中的定时器中, 为 obj 添加了一个新的 notExist 属性的值, 理论上, obj.notExist 并没有与副作用建议响应联系, 因此, 定时器内语句的执行不应该触发匿名副作用函数重新执行。 但执行上述代码之后, 发现匿名函数重新执行了, 这是不正确的。
根本原因: 我们没有在副作用函数与被操作的目标之间建立明确的联系。副作用函数与被操作的字段之间没有明确的联系。
如何解决:只需要在副作用函数与被操作的字段之间建立联系即可, 需要重新设计"桶"的数据结构
如何设计数据结构?
javascript
effect(function effectFn() {
document.body.innerText = obj.text
})
上述代码存在 3 个角色:
- 被操作的代理对象(obj)
- 被操作的字段名(text)
- 使用 effect 函数注册的副作用函数effectFn
如果用 target 来表示一个代理对象所代理的原始对象, 用 key 来表示被操作的字段名, 用 effectFn 来表示被注册的副作用函数, 那么可以为这三个角色建立如下关系:
css
target
-----key
-----effectFn
如果有两个副作用函数同时读取同一个对象的属性值
javascript
effect(function effectFn1() {
document.body.innerText = obj.text
})
effect(function effectFn2() {
document.body.innerText = obj.text
})
那么关系如下:
css
target
-----key
-----effectFn1
-----effectFn2
如果一个副作用函数读取了同一个对象的两个不同的属性值
javascript
effect(function effectFn() {
console.log(obj.text1)
console.log(obj.text2)
})
那么关系如下:
css
target
-----text1
-----effectFn
-----text2
-----effectFn
如果在不同的副作用函数中读取了两个不同对象的不同属性:
javascript
effect(function effectFn1() {
console.log(obj1.text1)
})
effect(function effectFn2() {
console.log(obj2.text2)
})
那么关系如下:
css
obj1
-----text1
-----effectFn1
obj2
-----text2
-----effectFn2
总之 这其实就是一个树形数据结构。这个联系建立起来之后, 就可以解决前文提到的问题了。
2、使用 WeakMap 代替 Set 作用"桶"的数据结构
使用 WeakMap 配合 Map 构建了新的"桶"结构,从而能够在响应式数据与副作用函数之间 建立更加精确的联系。WeakMap 是弱引用的,它不影响垃圾回收器的 工作。当用户代码对一个对象没有引用关系时,WeakMap 不会阻止垃 圾回收器回收该对象。
2.1 使用 WeakMap 数据结构
javascript
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时, 将副作用函数 fn 赋值给 activeEffect
activeEffect = fn;
// 执行副作用函数
fn();
}
// 存储副作用函数的桶
const bucket = new WeakMap();
// 修改拦截器代码
const obj = new Proxy(data, {
get(target, key) {
// 没有 activeEffect, 直接 return
if (!activeEffect) return target[key];
let depsMap = bucket.get(target);
// 如果不存在 depsMap, 那么新建一个 Map,并与 target 关联
if (!depsMap){
bucket.set(target, ( depsMap= new Map()));
}
// 再根据 key 从 depsMap 中去的 deps,它是一个 Set 类型
// 如果deps 不存在, 同样新建一个 Set 并与 key 关联
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将当前激活的副作用函数添加到桶中
deps.add(activeEffect);
return target[key]
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 根据 target 从桶中取得 depsMap,它是 key----effects
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
}
})
从上面代码, 不难看出 WeakMap、Map 和 Set 之间的关系:
- WeakMap 由 target--->Map构成
- Map 由 key---->Set 构成
2.2 Map/WeakMap/Set 之间的关系
javascript
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时, 将副作用函数 fn 赋值给 activeEffect
activeEffect = fn;
// 执行副作用函数
fn();
}
// 存储副作用函数的桶
const bucket = new WeakMap();
// 修改拦截器代码
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key]
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
trigger(target, key, newVal);
}
})
function track(target, key) {
// 没有 activeEffect, 直接 return
if (!activeEffect) return target[key];
let depsMap = bucket.get(target);
// 如果不存在 depsMap, 那么新建一个 Map,并与 target 关联
if (!depsMap){
bucket.set(target, ( depsMap= new Map()));
}
// 再根据 key 从 depsMap 中去的 deps,它是一个 Set 类型
// 如果deps 不存在, 同样新建一个 Set 并与 key 关联
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将当前激活的副作用函数添加到桶中
deps.add(activeEffect);
}
function trigger (target, key, newVal) {
// 根据 target 从桶中取得 depsMap,它是 key----effects
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
}
二、分支切换与 cleanup
1、分支切换带来的问题
javascript
effect(function effectFn(){
document.body.innerText = obj.ok ? obj.text : 'not'
})
在上述代码中, effectFn 内部存在一个三元表达式, 根据字段obj.ok 值的不同会执行不同的代码分支。 当 obj.ok值发生变化时, 代码执行的分支会跟着改变, 这就是所谓的分支切换。
发生问题:产生遗留的副作用函数 字段 obj.ok 的初始值为 true,这时会读取字段 obj.text 的值, 所以当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text 这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之 间建立的联系如下: 副作用函数 effectFn 分别被字段 data.ok 和字段 data.text 所对应的依赖集合收集,当字段 obj.ok 的值修改为 false,并触发副作用函数重新执行后,由于此时字段 obj.text 不 会被读取,只会触发字段 obj.ok 的读取操作,所以理想情况下副作 用函数 effectFn 不应该被字段 obj.text 所对应的依赖集合收集:
问题产生: 如果obj.ok 设置为 false, 这时就产生了遗留的副作用函数。无论 obj.text 如何改变, document.body.innerText 的值始终都是'not', 最好的结果是 无论obj.text 的值怎么变, 都不需要重新执行副作用函数。 但事实是, 我们修改 obj.text 会导致副作用函数重新执行。
解决思路:每次副作用函数执行时, 我们可以先把它从所有与之关联的依赖集合中删除。 也就是说, 我们如果能在每次副作用函数执行前, 将其从相关联的依赖集合中移除, 那么问题就迎刃而解了。
2、解决分支切换问题以及重新设计副作用函数
javascript
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn = ()=> {
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn();
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
effectFn();
}
那么 effectFn.deps 数组中的依赖是从哪里收集的呢?(在 track 函数中)
javascript
function track(target, key) {
// 没有 activeEffect, 直接 return
if (!activeEffect) return target[key];
let depsMap = bucket.get(target);
// 如果不存在 depsMap, 那么新建一个 Map,并与 target 关联
if (!depsMap){
bucket.set(target, ( depsMap= new Map()));
}
// 再根据 key 从 depsMap 中去的 deps,它是一个 Set 类型
// 如果deps 不存在, 同样新建一个 Set 并与 key 关联
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将当前激活的副作用函数添加到桶中
deps.add(activeEffect);
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps);
}
这样就可以 每次在副作用函数执行时,根据 effectFn.deps获取所有相关联的依赖集合, 进而将副作用函数从依赖集合中移除:
javascript
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn = ()=> {
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn();
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
effectFn();
}
function cleanup(){
effectFn.deps.forEach(dep => {
// 将 effectFn 从依赖集合中移除
dep.delete(effectFn);
})
effectFn.deps.length = 0;
}
这样就可以避免副作用函数产生遗留了, 但问题出现了, 目前的实现会导致无限循环执行, 问题出现在 trigger 函数中:
javascript
function trigger (target, key, newVal) {
// 根据 target 从桶中取得 depsMap,它是 key----effects
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
// effects && effects.forEach(fn => fn()); // 问题出现在这里
effectsToRun && effectsToRun.forEach(effectFn => effectFn());
}
循环问题原因:副作用函数执行时, 会调用 cleanup 进行清除, 但副作用函数的执行会导致其重新被收集到集合中,而此时对于 effect集合的编辑扔在进行, 用以下示例来解释: 解决方法: 重新构造另外一个 Set 集合并遍历它;
javascript
const set = new Set([1]);
set.forEach(item => {
set.delete(1); // 删除
set.add(1); // 重新添加, 该值会被重新访问
console.log('遍历中')
})
三、嵌套的 effect 与 effect 栈
effect 是可以发生嵌套的:
javascript
effect(function effectFn1() {
effect(function effectFn2() { /* ... */ })
/* ... */
})
类比 Vue 组件,渲染函数就是一个 effect 中执行的嵌套函数:
javascript
// Bar 组件
const Bar = {
render() { /* ... */ },
}
// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return <Bar /> // jsx 语法
},
}
问题发生 : 之前的effect设计是不支持嵌套的 , 全局变量 activeEffect 存储通过 effect 函数注册的副作用函数, 意味着, 同一时刻 activeEffect 所存储的副作用函数只能有一个,当副作用发生嵌套时, 内层副作用函数的执行会覆盖 activeEffect 的值, 并且永远不会恢复到原来的值。这时如果再 有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,这 就是问题所在。
解决方案:副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中, 待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数, 而不会出现互相影响的情况。
javascript
// 用一个全局变量存储被注册的副作用函数
let activeEffect
const effectStack = [];
function effect(fn) {
const effectFn = ()=> {
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
fn();
// 在当前副作用函数执行完毕后, 将当前副作用函数弹出去 并把activeEffect还原之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length-1];
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
effectFn();
}
function cleanup(){
effectFn.deps.forEach(dep => {
// 将 effectFn 从依赖集合中移除
dep.delete(effectFn);
})
effectFn.deps.length = 0;
}
如下图所示:
四、避免无限递归循环
javascript
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })
effect(() => obj.foo++)
可以看到, 在 effect 注册的副作用函数内有一个自增操作obj.foo++, 该操作会引起栈溢出:
javascript
Uncaught RangeError: Maximum call stack size exceeded
原因:obj.foo++ 拆开来看实际上就是obj.foo = obj.foo + 1; 在这个语句中,既会读取 obj.foo 的值, 又会设置 obj.foo的值, 而这个就是导致问题的根本原因。
代码的执行流程: 首先读取 obj.foo 的值, 触发 track 操作, 将当前副作用函数收集到"桶"中,接着将其加 1 再赋值给obj.foo, 此时会触发 trigger 操作, 即把"桶"中的副作用函数取出并执行。 问题是该副作用函数正在执行中, 还没有执行完毕, 就要开始下一次的执行, 这样就会导致无限递归的调用自己, 于是就产生了栈溢出。
解决方案:在 trigger 动作发生时增加守卫条件: 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同, 则不触发执行。
javascript
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap, 他是 key----effects
const depsMap = bucket.get(target);
// 如果 depsMap 不存在, 那么直接返回
if (!depsMap) return;
// 根据 key 获取所有副作用函数的 effects
const effects = depsMap.get(key);
const effectsToRun = new Set() // 新增
// 执行副作用函数
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
effectsToRun && effectsToRun.forEach(effectFn => effectFn());
}
五、调度执行
可调度:当trigger 动作触发副作用函数重新执行时,有能力决定副作用函数的执行时机、次数以及方式。可调度性是响应系统非常重要的特性。
javascript
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })
effect(() => {
console.log(obj.foo)
})
obj.foo++;
console.log('结束了');
// 输出
// 1
// 2
// 结束了
在下面的例子中: 能够根据代码的执行顺序得到打印的结果,假设现在需求变成输出为:
javascript
// 1
// 结束了
// 2
首先我们可能想到的是obj.foo++ 和 console.log('结束了')换个位置。 那么有没有办法在不调整代码的情况下实现需求呢? 我们可以为 effect 函数设计一个选项参数 options, 允许用户指定调度器,在 effect 函数内部我们需要把 options 选项挂载到对应的副作用函数上。
javascript
function effect(fn,options) {
const effectFn = ()=> {
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
fn();
// 在当前副作用函数执行完毕后, 将当前副作用函数弹出去 并把activeEffect还原之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length-1];
}
// 将 options 挂载到 effectFn 上
effectFn.options = options;
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
effectFn();
}
有了调度函数, 在 trigger 函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数, 从而把控制权交给用户:
javascript
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap, 他是 key----effects
const depsMap = bucket.get(target);
// 如果 depsMap 不存在, 那么直接返回
if (!depsMap) return;
// 根据 key 获取所有副作用函数的 effects
const effects = depsMap.get(key);
const effectsToRun = new Set() // 新增
// 执行副作用函数
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
effectsToRun && effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度起, 则调用该调度起, 并将副作用函数作为参数传递
if (effectFn.options.scheduler){
effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
有了这些基础设施之后, 就可以实现前文的需求了, 如下代码所示:
javascript
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })
effect(() => {
console.log(obj.foo)
},{
scheduler(fn){
// 将副作用函数放到宏任务队列中执行
setTimeout(fn);
}
})
obj.foo++;
console.log('结束了');
// 1
// '结束了'
// 2
除了控制副作用函数的执行顺序, 通过调度器还可以做到控制它的次数。
javascript
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })
effect(() => {
console.log(obj.foo)
})
obj.foo++;
obj.foo++;
// 1
// 2
// 3
从上面的例子来看, obj.foo 会从 1 自增到 3, 2 只是它的过度状态。 如果我们只关心最终结果而不关心过程, 那么执行三次打印操作是多余的, 我们期望的打印结果是:
javascript
// 1
// 3
其中不包含过度状态, 基于调度器我们可以很容易地实现此功能:
javascript
// 定义一个任务队列
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(() => {
// 刷新完成后,将标志设为 false
isFlushing = false;
})
}
effect(() => {
console.log(obj.foo);
}, {
scheduler(fn) {
// 每次调度时, 将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn);
// 刷新队列
flushJob();
}
})
- 定义一个任务队列 jobQueue,它是一个 Set 数据结构, 目的是利用 Set 数据结构的自动去重能力。
- 调度器scheduler的实现, 在每次调度执行时, 先将当前副作用函数添加到 jobQueue 队列中, 再调用 flushJob 函数刷新队列。
- flushJob 函数, 该函数通过 isFlushing 标志判断是否需要执行,只有当其为 false 时才需要执行,在 flushJob 内通过 p.then 将一个函数添加到微任务队列中,在微任务队列内完成对 jobQueue 的遍历执行
六、计算属性 computed 与 lazy
在讲解计算属性之前,需要先了解关于懒执行的 effect, 即 lazy 的 effect, 我们现在所实现的 effect 函数会立即执行传递给它的副作用函数, 例如:
javascript
effect(()=>{
console.log('obj.foo')
})
但在有些场景下, 我们并不希望它执行, 而是希望它在需要的时候才执行, 例如: 计算属性。 我们可以通过在 options 中添加 lazy 属性来达到目的
javascript
effect(()=>{
console.log(obj.foo)
},{
lazy:true
})
当 lazy 为 true 时, 则不立即执行副作用函数
javascript
function effect(fn,options) {
const effectFn = ()=> {
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
fn();
// 在当前副作用函数执行完毕后, 将当前副作用函数弹出去 并把activeEffect还原之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length-1];
}
// 将 options 挂载到 effectFn 上
effectFn.options = options;
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 只有非 lazy 的时候,才执行
if(!options.lazy){
// 执行副作用函数
effectFn();
}
// 将副作用函数作为返回值返回
return effectFn;
}
问题:什么情况下执行?----- 副作用函数 effectFn 作为 effect 函数的返回值, 当调用 effect 函数时, 通过其返回值能够拿到对应的副作用函数, 这样就能手动执行该副作用函数了。
javascript
const effectFn = effect(()=>{
console.log(obj.foo);
},{
lazy:true
});
effectFn();
如果仅仅手动执行副作用函数, 意义其实并不大, 但如果我们把传递给 effect 的函数看做一个 getter, 那么这个 getter 函数可以返回任何值, 例如:
javascript
const effectFn = effect(()=>obj.foo+obj.bar,{lazy:true})
// value 是 getter 的返回值
const value = effectFn();
可以将上述代码再修改一下:
javascript
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;
}
effectFn.options = options;
effectFn.deps = [];
if (!options.lazy){
effectFn();
}
return effectFn;
}
function computed(getter){
const effectFn = effect(getter,{lazy:true});
const obj = {
get value(){
return effectFn();
}
}
return obj;
}
// 测试
const sumRes = computed(()=>obj.foo+obj.bar);
首先我们定义一个 computed 函数, 它接受一个 getter 函数作为参数, 我们把 getter 函数作为副作用函数, 用它创建一个 lazy 的 effect。 computed 的执行会返回一个对象, 该对象的 value 属性是一个访问器属性, 只有当读取 value 的值时, 才会执行 effectFn 并将其结果作为返回值返回。
出现问题:对值做不到缓存, 假设多次访问 sumRes.value的值, 会导致 effectFn 进行多次计算, 即使对象本身并没有发生变化。
解决:添加对值进行缓存的功能。
javascript
function computed(getter){
// value 用来缓存上一次计算的值
let value;
// 标识是否需要重新计算值, 为 true 意味着, 需要重新计算
let dirty = true;
const effectFn = effect(getter,{lazy:true});
const obj = {
get value(){
if (dirty) {
value = effectFn();
dirty = false;
}
return value;
}
}
return obj;
}
问题:上述代码, 修改 obj 的值, 并没有重新计算。
解决办法:值发生变化时, dirty 的值进行重置----用 scheduler
javascript
function computed(getter){
// value 用来缓存上一次计算的值
let value;
// 标识是否需要重新计算值, 为 true 意味着, 需要重新计算
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 函数中所依赖的响应式数据变化时重新执行, 这样我们在 scheduler 函数内将 dirty 重置为 true, 那么下次访问的时候就会重新调用 effectFn 的计算值, 这样就能够得到预期的结果了。
javascript
const sumRes = computed(()=>obj.foo+obj.bar);
effec(()=>{
console.log(sumRes.value);
});
obj.foo++
出现问题: 计算属性在 effect 函数中使用的时候发生更改, 未重新执行。
问题原因: effect 嵌套, 一个计算属性内部拥有自己的 effect 函数, 并且他是懒执行的, 只有当真正读取计算属性的值时才会执行。 对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部的 effect 收集依赖。而当把计算属性用于另外一个 effect 时,就会发生 effect 嵌套,外层 effect 不会被内层的 effect 中的响应数据收集。
解决办法:当读取计算属性的值时, 我们可以手动调用 track 函数进行追踪, 当计算属性依赖的响应式数据发生变化时,我们可以手动调用 trigger 函数触发响应。
javascript
function computed(getter){
let value;
let dirty = true;
const effectFn = effect(getter,{
lazy:true,
scheduler(){
dirty = true;
trigger(obj, 'value')
}
})
const obj = {
get value(){
if(dirty){
value = effectFn();
dirty = false;
}
track(obj,'value');
return value;
}
}
}
当读取一个计算属性的 value 时, 我们手动调用 track 函数, 把计算属性返回的对象 obj 作为 target, 同时作为第一个参数传递给 track 函数。 当计算属性所依赖的响应式数据变化时,会执行调度起函数, 在调度起函数内手动调用 trigger 函数触发响应即可。建立的关系如下:
七、watch 的实现原理
所谓 watch, 就是观测一个响应式数据, 当数据发生变化时通知并执行相应的回调函数。
javascript
watch(obj,()=>{
console.log("数据变了");
})
// 修改响应数据的值, 会导致回调函数执行
obj.foo++;
实际上, watch 的本质就是利用了 effect 以及 options.scheduler选项
javascript
effect(()=>{
console.log(obj.foo);
},{
scheduler(){
// 当 obj.foo 的值变化时, 会执行 scheduler 调度函数
}
})
javascript
function watch(source, cb){
effect(()=> traverse(source),{
scheduler(){
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 函数
javascript
watch(()=>obj.foo,()=>{
console.log('obj.foo 的值变了');
})
修改代码如下:
javascript
function watch(source, cb){
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = ()=> traverse(source);
}
effect(getter,{
scheduler(){
cb();
}
})
}
那么在回调函数中如何拿到旧值和新值呢?可以利用 lazy 选项:
javascript
function watch(source, cb){
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = ()=> tranverse(source);
}
// 定义旧值和新值
let oldValue, newValue;
// 使用 effect 注册副作用函数, 开启 lazy 选项, 并把返回值存储到 effectFn中以便后续手动调用
const effectFn = effect(()=>getter(),{
lazy:true,
scheduler(){
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn();
将旧值和新值作为回调函数的参数
cb(newValue, oldValue);
// 更新旧值, 不然下一次会得到错误的旧值
oldValue = newValue;
}
})
// 手动调用副作用函数, 拿到的值就是旧值
oldValue = effectFn();
}
1、立即执行的 watch
立即执行的回调函数: 默认情况下, 一个 watch的毁掉只会在响应式数据发生变化时才会执行
javascript
watch(obj,()=>{
console.log("变化了");
})
在 Vue.js 中可以通过选项参数 immediate 来指定回调是否需要立即执行:
javascript
watch(()=>obj,()=>{
console.log("变化了")
},{
// 回调函数会在 watch 创建时立即执行一次
immediate:true
});
想一下, 回调函数的立即执行与后续执行本质上没有任何差别, 所以我们可以把 scheduler 调度函数封装为一个通用函数, 分别在初始化和变更时执行它:
javascript
function watch(source, cb, options={}){
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = ()=> tranverse(source);
}
// 定义旧值和新值
let oldValue, newValue;
const job = ()=>{
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn();
将旧值和新值作为回调函数的参数
cb(newValue, oldValue);
// 更新旧值, 不然下一次会得到错误的旧值
oldValue = newValue;
}
// 使用 effect 注册副作用函数, 开启 lazy 选项, 并把返回值存储到 effectFn中以便后续手动调用
const effectFn = effect(()=>getter(),{
lazy:true,
scheduler:job
})
if (options.immediate) {
job();
} else {
// 手动调用副作用函数, 拿到的值就是旧值
oldValue = effectFn();
}
}
2、回调执行时机
例如在 Vue.js3 中使用 flush 选项来指定:
javascript
watch(()=>obj,()=>{
console.log("变化了")
}, {
flush: 'pre' // 此外还有 'post' | 'sync'
})
flush 本质上是在指定调度函数的执行时机,前文讲解过如何在微任务队列中执行调度函数 scheduler, 这与 flush 的功能相同。当 flush 的值为'post'时,代表调度函数需要将副作用函数放到一个微任务队列中, 并等待 DOM 更新结束后再执行,我们可以用如下代码进行模拟:
javascript
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,{
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();
}
}
如以上代码所示,我们修改了调度器函数 scheduler 的实现方 式,在调度器函数内检测 options.flush 的值是否为 post,如果 是,则将 job 函数放到微任务队列 中,从而实现异步延迟执行 ;否则 直接执行 job 函数,这本质上相当于 'sync ' 的实现机制,即同步执 行。对于 options.flush 的值为 'pre' 的情况,我们暂时还没有办 法模拟,因为这涉及组件的更新时机,其中 'pre' 和 'post' 原本的 语义指的就是组件更新前和更新后,不过这并不影响我们理解如何控 制回调函数的更新时机。
八、过期的副作用
态问题通常在多进程或多线程编程中被提及,前端工程师可能 很少讨论它,但在日常工作中你可能早就遇到过与竞态问题相似的场景, 举个例子:
javascript
let finalData;
watch(obj,async ()=>{
const res = await fetch('path/to/request');
finalData = res;
})
在这段代码中,我们使用 watch 观测 obj 对象的变化,每次 obj 对象发生变化都会发送网络请求,例如请求接口数据,等数据请求成 功之后,将结果赋值给 finalData 变量。
竞态问题 : 假设我们第一次修改 obj 对象的某个字段 值,这会导致回调函数执行,同时发送了第一次请求 A,随着时间的 推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行 了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都 在进行中,那么哪一个请求会先返回结果呢? 如果请求 B 先于请求 A 返回结果,就会导致最终 finalData 中存储的是 A 请 求的结果, 如下图所示: 但由于请求 B 是后发送的,因此我们认为请求 B 返回的数据才是 "最新"的,而请求 A 则应该被视为"过期"的,所以我们希望变量 finalData 存储的值应该是由请求 B 返回的结果,而非请求 A 返回 的结果。 请求 A 是副作用函 数第一次执行所产生的副作用,请求 B 是副作用函数第二次执行所产 生的副作用。由于请求 B 后发生,所以请求 B 的结果应该被视为"最 新"的,而请求 A 已经"过期"了,其产生的结果应被视为无效。通过这 种方式,就可以避免竞态问题导致的错误结果 。 归根结底,我们需要的是一个让副作用过期的手段。 在 Vue.js中, watch 函数的回调函数,接受第三个参数onInvalidate, 它是一个函数, 类似于事件监听器, 我们可以使用 onInvalidate 函数注册一个回调, 这个回调函数会在当前副作用函数过期时执行:
javascript
watch(obj,async(newValue, oldValue, onInvalidate)=>{
let expired = false;
onInvalidate(()=>{
expired = true;
});
const res = await fetch('/path/to/request');
if(!expired){
finalData = res;
}
})
onInvalidate的原理: 在 watch 内部每次检测到变更后, 在副作用函数重新执行之前, 会先调用我们通过 onInvalidate 函数注册的过期回调。
javascript
function watch(source,cb,options={}) {
let getter;
if (typeof source === 'function') {
getter = source;
} else {
getter = traverse(source);
}
let newValue, oldValue;
let cleanup;
function onInvalidate(fn){
cleanup = fn;
}
const job = () => {
newValue = effectFn();
if (cleanup) {
cleanup();
}
cb(newValue, oldValue, onInvalidate);
oldValue = newValue;
}
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 {
oldValue = effectFn();
}
}
总结
响应系统的根本实现原理: 一个响应式数据最基本的实现依赖于对"读取"和 "设置"操作的拦截,从而在副作用函数与响应式数据之间建立联系。当"读取"操作发生时,我们将当前执行的副作用函数存储到"桶"中;当 "设置"操作发生时,再将副作用函数从"桶"里取出并执行。