☁️ 2022.03.20 15:30
这一章题序中上来就抛出了如下问题:
花费一个晚上+一个下午读完了这一章,读完的第一感觉是信息量巨大。即使之前自己实现过一遍属于自己的 mini-vue
,看完本章依然觉得填补了我很多知识盲区,收获很多,但一时很难消化。
此刻难消化的原因:很难在头脑中重现源码中针对哪些问题给出了哪些解决方案?
对于看完之后合上书本让人感觉虚空的问题,决定用自己方式再走一遍!整理读书笔记加手动源码实现。
梳理本章解决了哪些问题:
cleanup
,effects 依赖在收集时如何避免无限循环?effect
的如何处理scheduler
是什么?解决了什么问题?post
异步执行抛开响应式系统的细节处理,实现一个基本的响应式系统,应该是比较简单的。
const target = {
text: "Hello World!",
};
const proxy = new Proxy(target, {
get(target, key, receiver) {
return target[key];
},
set(target, key, value, receiver) {
target[key] = value;
},
});
console.log(proxy.text); // Hello World!
proxy.text = "Hello Vue3!"
console.log(proxy.text); // Hello Vue3!
可以看到 proxy
和原来 Vue2 使用的 Object.defineProperty()
差不多,都可以对属性进行劫持。
需要注意的是:
Object.defineProperty
不能监听对象属性新增和删除,在初始化阶段递归执行劫持对象属性,性能损耗较大;
而 Proxy
可以监听到对象属性的增删改,在访问对象属性时才递归执行劫持对象属性,在性能上有所提升。不过 Proxy API 属于 ES6 规范,目前 IE 尚不支持。
上面那段代码无法对如下对象进行劫持:
const target = {
name: "Hello World!",
get alias() {
return this.name;
},
};
通过 Reflect.get(target, key, receiver)
代替 target[key]
就可以解决上述问题。具体看我这篇博文:https://blog.csdn.net/hefeng6500/article/details/123610606?spm=1001.2014.3001.5502
下面是实现一个完善的响应式系统的过程,当然像 effect api 和 对象代理是硬编码,Vue3真实源码不是这样的,这里只做原理讲解,简单明了能说明问题即可!
const target = {
text: "Hello World!",
};
let targetMap = new WeakMap();
let activeEffect;
function effect(fn) {
activeEffect = fn;
fn();
}
const proxy = new Proxy(target, {
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
Reflect.set(target, key, value, receiver);
trigger(target, key);
},
});
function track(target, key) {
if (!activeEffect) {
return;
}
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
}
function trigger(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
}
effect(() => {
console.log(proxy.text);
});
proxy.text = "Hello Vue3!";
运行上述代码,effect 的回调函数执行两次,分别打印 Hello World!
,Hello Vue3!
可以看到上述代码使用 ES6 的 Proxy
对 target
对象进行了代理,在访问对象 target
的属性的时候被 get
函数劫持调用 track
函数进行依赖收集,并且返回 key
对应的值。依赖收集的过程简单说就是将 activeEffect 放进特定的数据结构中
在对 target
的属性进行设置时被 set
函数劫持为 target
属性设置新的值,并且调用 trigger
设置新的值。依赖触发就是将之前收集好的依赖从特定数据结构中取出来执行
这个依赖收集和触发的过程就像是发布订阅模式。
track 依赖收集的过程
将依赖收集设置成如下图所示的数据结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8oOlXahs-1648126903905)(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/64b7d539-d130-463f-9901-6486a82ee014/Untitled.png)]
targetMap
是一个 WeakMap
数据结构:target —> Map()depsMap
是一个 Map
数据结构,其值是一个 Set
数据结构:key —> Set()cleanup
很快我们就发现对应下面这种案例就会出问题
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /** ... */ })
effect(() => {
console.log('effect run')
document.body.innerText = obj.ok ? obj.text : 'not'
})
obj.ok = false;
对于 effect 函数中三目运算分支切换问题,首次执行时,访问 obj.ok 对 ok 属性进行依赖收集,由于 ok 为 true,访问 obj.text ,再对 text 属性进行依赖收集。
如果接下来修改 ok 属性值为 false,将会触发副作用函数重新执行,由于 obj.ok 值为 false,所以 obj.text 不会读取,所以副作用函数不该再被 obj.text 所对应的依赖集合收集。
但是实际上上次收集的依赖还在,obj.ok 和 obj.text 在上次访问时收集的依赖并没有消失,即使这次只对 obj.ok 读取并进行依赖收集,实际上 obj.ok 和 obj.text 所对应的副作用函数还在收集的依赖中。所以我们把之前收集的依赖清除掉不就可以了吗?反正在触发依赖执行时会再次访问副作用函数,会再次进行依赖收集。
所以,我们定义一个 cleanup 函数清除之前收集的依赖。如下:
let targetMap = new WeakMap();
let activeEffect;
const target = { ok: true, text: 'hello world' }
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
const obj = new Proxy(target, {
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
Reflect.set(target, key, value, receiver);
trigger(target, key);
},
});
function track(target, key) {
if (!activeEffect) {
return;
}
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.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 trigger(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
}
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
deps.delete(effectFn);
}
effectFn.deps.length = 0;
}
effect(() => {
console.log("effect run");
document.body.innerText = obj.ok ? obj.text : "not";
});
setTimeout(() => {
obj.ok = false;
setTimeout(() => {
obj.text = "hello vue3";
}, 1000);
}, 1000);
看上去好像越来越完美了,但是运行是你会发现无限循环。
原因是:trigger 函数在执行时,会遍历 fn 进行执行,fn 执行时又会 cleanup 依赖(减少),然后调用副作用函数收集依赖(增加),一减一增,如此循环导致。具体可以查看 Set.prototype.forEach
其实这个问题很好解决!重新造一个 Set 进行遍历
function trigger(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let effects = depsMap.get(key);
const effectToRun = new Set(effects)
effectToRun && effectToRun.forEach((fn) => fn());
}
effect
的如何处理测试用例是这样的
const target = { count: 0, text: "Hello World!" };
const obj = new Proxy(data, { /** ... */ })
effect(() => {
effect(() => {
console.log("inside effect: ", obj.text);
});
console.log("outside effect: ", obj.count);
});
obj.count++;
按理说应该输出
inside effect: Hello World!
outside effect: 0
inside effect: Hello World!
outside effect: 1
实际上
inside effect: Hello World!
outside effect: 0
inside effect: Hello World!
原有代码分析:
当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且不会恢复到原来的值。如果再有响应式数据发生依赖收集,即使是读取外层副作用函数中的数据,收集到的副作用函数也是内层的副作用函数。
如何解决?使用栈结构
const effectStack = [];
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(activeEffect);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
effectFn.deps = [];
effectFn();
}
不得感叹,真聪明!
还没完!上述解决嵌套是 Vue3.2 之前的,来看看 Vue3.2 版本怎么处理嵌套的
run() {
let parent = activeEffect;
try {
this.parent = activeEffect;
activeEffect = this as any;
return this.fn();
} finally {
activeEffect = this.parent;
this.parent = undefined;
}
}
执行外层副作用函数时,parent = undefined,this.parent = undefined; 然后 activeEffect 被赋值为外层副作用函数,再执行外层副作用函数,由于 外层副作用函数中存在 effect ,于是开始执行内层的 effect,同理:parent = 外层的副作用函数,内层的实例的 parent = 外层的 activeEffect,执行内层副作用函数,完成之后 finally,将 activeEffect 复位为原有父级的 activeEffect,再将内层的 parent 赋值为 undefined。
这个步骤让人的感受是:过完河,把桥拆掉。哈哈,开个玩笑!应该叫有始有终
写到这一步是不是已经够完美了,其实还有一个功能需要适配,继续!
什么场景会造成无限递归循环?
const target = { foo: 1 }
const obj = new Proxy(target, { /** ... */ })
effect(() => {
obj.foo++
})
effect 的副作用函数中读取属性并且修改属性,换句话说,副作用函数中收集依赖并且触发依赖。如果按之前的代码,一定会无限循环下去
解决方案:修改一下 trigger 函数
function trigger(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let effects = depsMap.get(key);
const effectToRun = new Set();
effects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectToRun.add(effectFn);
}
});
effectToRun && effectToRun.forEach((fn) => fn());
}
如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
要注意:这里 副作用函数会先执行一次,只是在执行的时候发生 track,trigger,trigger 的时候发现 effectFn !== activeEffect
所以就不会往 effectToRun
中添加 activeEffect
了。但不可否认副作用函数已经执行了一次了。
effect(() => {
obj.foo++
})
这也让我明白了为什么源码中要将 effects 循环一遍再用的原因了,为的就是防止遇到这种使用情况下无限递归循环下去!
先到这,调度执行,computed 和 watch 下次继续总结!
梳理的过程真的非常消耗时间和精力,不过我认为这也是自我消化的最好的方法!加油!!!
scheduler
是什么?解决了什么问题?调度执行有能力决定副作用函数的执行的时机、次数以及方式。
const target = { foo: 1 }
const obj = new Proxy(target, { /** ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log("结束了")
希望输出
1
结束了
2
而不是
1
2
结束了
你可能觉得将 obj.foo++
和 console.log("结束了")
顺序调换一下就可以了,如果不允许调换还有别的解决方案吗
解决方案:
聪明的你肯定会想到异步,那如何设计呢?
effect(() => {
console.log(obj.foo)
},
{
scheduler(fn){
setTimeout(fn) // 进入下一次事件循环
}
}
)
function trigger(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let effects = depsMap.get(key);
const effectToRun = new Set();
effects &&
effects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectToRun.add(effectFn);
}
});
effectToRun.forEach((effectFn) => {
+ if (effectFn.options.scheduler) {
+ effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
这里可以看到
const effectStack = [];
function effect(fn, options) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(activeEffect);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
+ effectFn.options = options
effectFn.deps = [];
effectFn();
}
这个例子就提现了任务调度的时机选择问题。
下面再讲一个任务调度控制的次数问题
const target = { foo: 1 }
const obj = new Proxy(target, { /** ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
希望输出
1
3
而不是
1
2
3
解决方案:还是借助 scheduler
函数
const jobQueue = new Set()
const p = Promise.resolve()
let isFlushing = false
function flushJob() {
if (isFlushing) return
isFlushing = true
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
effect(() => {
console.log(obj.foo)
}, {
scheduler(fn) {
jobQueue.add(fn)
flushJob()
}
})
obj.foo++
obj.foo++
连续运行 obj.foo++
时,借助 Set 数据结构的去重能力,Set 结构里面只会保存一个副作用函数。
obj.foo++
时会触发 trigger
函数,trigger
执行时调用 scheduler
,将 fn
放进 Set,flushJob
刷新任务队列,任务被挂起到微任务队列,再次 obj.foo++
时会触发 trigger 函数,trigger
执行时调用 scheduler
,由于Set的去重能力,就导致 Set 中只有一个副作用函数,flushJob
刷新任务队列,任务被挂起到微任务队列。
接下来,执行微任务,由于 设置了 isFlushing
标志导致第二次 trigger
时调用的 flushJob
无法执行,第一次是执行了。但是 obj.foo++
连续两次增加值在 Proxy set 中已经是被设置了。所以值是修改了两次,但是副作用函数只执行了一次!
需要明确一点的是:scheduler
调度函数是在 trigger
后才开始执行的
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj, 'value')
return value
}
}
return obj
}
还是利用了 effect
函数的功能,在对象 get
时进行依赖收集,在 set
时触发副作用函数更新。采用了 dirty
标识,在值未更新的时候继续使用之前的值。
function effect(fn, options) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(activeEffect);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
effectFn.options = options
effectFn.deps = [];
+ if (!options.lazy) {
+ effectFn()
+ }
+ return effectFn
}
希望通过如下方式运行:
watch(() => obj.foo, (newVal, oldVal) => {
console.log(newVal, oldVal)
}, {
immediate: true,
flush: 'post'
})
解决方案:
function traverse(value, seen = new Set()) {
if (typeof value !== 'object' || value === null || seen.has(value)) return
// 代表已经读取过了,避免循环引用引起的死循环,例如: a.b = a
seen.add(value)
for (const k in value) {
// 访问属性值,收集依赖
traverse(value[k], seen)
}
return value
}
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
// watch 设置 immediate 首次执行时,oldValue 值为 undefined
cb(oldValue, newValue)
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()
}
}
就目前 watch
api 的实现来看,traverse
深度遍历了 value
,进行依赖收集,其实调用了 traverse
等于就是深度监听了。
这里需要明确的是:
effect 中的第一个参数,() => getter()
它是副作用函数,oldValue = effectFn()
时会执行副作用函数。scheduler
函数是在 trigger
时才执行的
试想如下代码,obj.foo
变化两次,触发两次回调函数,由于请求先后的不可确定性,finallyData
就存在这种竞态问题
let finallyData
watch(() => obj.foo, async (newVal, oldVal, onInvalidate) => {
const res = await fetch()
finallyData = res
})
为保证值的确定性 watch
做如下修改
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
let cleanup
function onInvalidate(fn) {
cleanup = fn
}
const job = () => {
newValue = effectFn()
if (cleanup) {
cleanup()
}
cb(oldValue, newValue, 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()
}
}
watch api 可以这样使用
watch(() => obj.foo, async (newVal, oldVal, onInvalidate) => {
let valid = true
onInvalidate(() => {
valid = false
})
const res = await fetch()
if (!valid) return
finallyData = res
console.log(finallyData)
})
obj.foo++
obj.foo++
可以看到 watch
api,第二个参数回调函数提供了第三个参数 onInvalidate
,在失效时修改 valid
值,如果失效函数直接 return
,不再赋值。
再把目光投向 watch
函数的实现,函数内部添加了 cleanup
标识,在执行 job
时如果存在 cleanup
将会调用。再回到上述 watch
示例,如果连续两次修改 obj.foo++
,第一次调用watch 的回调函数 onInvalidate
,onInvalidate
传入的回调函数被注册为 cleanup
,当第二次再次调用 watch
的回调时,存在 cleanup
,将会 cleanup()
,示例中的 valid
值就设置为了 false
。
于是,即便是两次修改 obj.foo
触发 watch 的回调,只要第二次回调被触发,第一次的 valid
将被设置为 false
,就算第一次请求返回了结果,依然不可以赋值给 finallyData