1、副作用函数
#1.1 副作用函数
function effect() {
document.body.innerText='hello vue3'
}
effect()
复制代码
effect函数设置了body的内容,但除了effect函数外的任何函数都可以读取或设置body的文本内容,所以,effect函数的执行会直接或间接的影响其他函数的执行,这时我们就会说effect函数产生了副作用。
4.2响应式数据
假设我们在一个副作用函数中读取了某个对象的属性
const obj={text:"hello word"}
function effect() {
// effect函数的执行会读取obj.text
document.body.innerText=obj.text
}
obj.text='hello vue3' //修改obj.text的值,同时希望副作用函数会重新执行
复制代码
当我们修改了obj.text的值后,我们希望当值变化话,effect函数会自动重新执行,如果能实现这个目标,那么对象obj就是响应式数据。
#1.2响应式数据的基本实现
- effect函数执行时,会触发obj.text的读取操作
- 修改obj.text的值时,会触发字段obj.text的设置操作
- 那么当我们读取obj.tetx时,我们把副作用函数effect函数存储起来
- 当我们设置obj.text时,再把副作用函数effect函数取出来执行,这样便完成了响应式
vue3.js采用代理对象proxy来实现响应式数据
// 副作用函数
function effect () {
// effect函数的执行会读取obj.text
document.body.innerText = obj.text
}
// 存储副作用函数
const bucket = new Set()
const data = { text: 'hello word' }
const obj = new Proxy(data, {
get (target, key){
bucket.add(effect)
return target[key]
},
set (target, key,newVal){
bucket.forEach(fn => fn());
return true
}
})
// 执行副作用函数
effect()
// 一秒后修改响应式数据
setTimeout(() =>{
obj.text = '悟能'
}, 1000)
复制代码
首先上面代码中,我们创建了一个存储副作用函数的存储痛bucket,他是set类型。接着我们定义了原始数据data,obj是原属数据的代理对象。我们分别对他设置了get和set拦截函数。从而读取和设置操作
#1.3 完善的响应式系统
4.2中我们写死了副作用函数的名字为effect,无法修改,修改会导致函数不能正确的执行,而我们希望的是函数哪怕是一个匿名函数,也可以正确的被执行。那么我来修改上面的代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect函数用于注册副作用函数
function effect (fn) {
// 当我们调用effect注册副作用函数时,将副作用函数fn复制给activeEffect
activeEffect = fn
fn()
}
// 存储副作用函数
const bucket = new Set()
const data = { text: 'hello word' }
const obj = new Proxy(data, {
get (target, key){
// 将activeEffect 中存储的副作用函数收集到桶中
if(activeEffect){
bucket.add(activeEffect)
}
return target[key]
},
set (target, key, newVal){
console.log(target,key,newVal);
target[key]=newVal
bucket.forEach(fn => fn());
return true
}
})
// 使用effect函数
effect(() => { document.body.innerText = obj.text })
// 一秒后修改响应式数据
setTimeout(() =>
{
obj.text = '悟能'
}, 1000)
复制代码
如上代码展示我们已经解决了副作用函数名称的问题,但是我们还没有在副作用函数与被操作的目标字段之间建立联系,也就是说即使修改了 obj 上面的其他字段,副作用函数也会被执行,需要将 obj.text字段和调用过它的副作用函数联系起来。
#1.4 将副作用函数与被操作的目标字段之间建立联系
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect函数用于注册副作用函数
function effect (fn) {
// 当我们调用effect注册副作用函数时,将副作用函数fn复制给activeEffect
activeEffect = fn
fn()
}
const data = { text: 'hello word' }
// 存储副作用函数
const bucket = new WeakMap()
const obj = new Proxy(data, {
get (target, key){
// 没有activeEffect 直接return
if(!activeEffect) return target[key]
// 根据target从“桶”中取得depsMap 它也是一个map类型: key=>effects
let depsMap=bucket.get(target)
console.log(depsMap,'1');
if(!depsMap){
bucket.set(target, (depsMap = new Map()))
}
console.log(depsMap,'2');
// 在根据key从depsMap中取得deps 他是一个set类型
// 里面存储着所有与当前key 相关联的副作用函数:effects
let deps = depsMap.get(key)
console.log(deps);
// 如果deps不存在 同样我们要新建立一个Set并与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)
console.log(effects);
effects && effects.forEach(fn => fn() )
}
})
// 使用effect函数
effect(() => { document.body.innerText = obj.text })
// 一秒后修改响应式数据
setTimeout(() =>{
console.log(1);
obj.text = '悟能'
// 修改不相关的值副作用函数不会执行
// obj.asd = 'hello vue3'
}, 1000)
复制代码
#1.5 WeakMap和Map的区别
const map = new Map()
const weakMap = new WeakMap()
(function () {
const foo = { foo: 1 }
const bar = { bar: 2 }
map.set(foo, 1)
weakMap.set(bar, 2)
})()
复制代码
由于WeakMap的key是弱引用,他不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就回吧对象bar从内存中移除,我们也就无法获取weakMap的key值,也就无法通过weakMap取得对象bar
简单来说WeakMap的key是弱引用,不影响垃圾回收机器的工作,根据这个特性,如果我们使用Map,那么即使用户侧的代码对target没有任何引用,但target也不会被回收,最终会导致内存溢出。
根据WeakMap的特性,我们对上面代码进行封装处理,提取一下 get 和 set 操作中操作副作用函数的逻辑,提高灵活性。
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect函数用于注册副作用函数
function effect (fn) {
// 当我们调用effect注册副作用函数时,将副作用函数fn复制给activeEffect
activeEffect = fn
fn()
}
const data = { text: 'hello word' }
// 存储副作用函数
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)
}
})
function track (target, key){
// 没有activeEffect 直接return
if (!activeEffect) return target[key]
// 根据target从“桶”中取得depsMap 它也是一个map类型: key=>effects
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 在根据key从depsMap中取得deps 他是一个set类型
// 里面存储着所有与当前key 相关联的副作用函数:effects
let deps = depsMap.get(key)
console.log(deps);
// 如果deps不存在 同样我们要新建立一个Set并与key关联
if (!deps) {
depsMap.set(key, (deps = new Set()))//这样我们就与目标字段建立了联系
}
// 最后我们将当前激活的副作用函数添加到存储痛里
deps.add(activeEffect)
}
function trigger(target,key) {
// 根据target 从桶中取得 depsMap 他是key-->effects
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
// 使用effect函数
effect(() => { document.body.innerText = obj.text })
// 一秒后修改响应式数据
setTimeout(() =>{
console.log(1);
obj.text = '悟能'
}, 1000)
复制代码
#1.6 分支切换与celeanup
日常工作,读取代码可能会出现不同分支的情况,如下面的代码所示:
effect(() => {
// 当obj.ok为false时,并不会触发obj.text的读取,也就不需要对副作用函数做收集。
document.body.innerText = obj.ok ? obj.text : 'not'
})
// 修改obj.ok为false
obj.ok = false
// 再修改obj.text的值,理论上副作用函数不需要执行,但是现在依然会执行
obj.text = 'hello vue3'
复制代码
原因:分支切换可能会产生遗留的副作用函数
当读取字段值时,会触发副作用函数,此时副作用函数与响应式数据之间建立的联系如下图:
副作用函数与响应式数据之间的联系描述图(opens new window)
如上图我们可以很清楚的明白,副作用函数分别被字段data.ok和data.tex所对应的依赖所收集。当obj.ok字段被修改为false时,会触发副作用函数并重新执行。但obj.text字段不会被读取,只会触发obj.ok字段的读取。所以这个时候副作用函数不应该被字段obj.text对应的依赖手机
解决思路:副作用的每次执行,先把它从所有与之关联的依赖集合中删除,如下图所示:
断开副作用函数与响应式数据之间的联系(opens new window)
这里,我们要重新设计副作用函数,在effect内部定义一个effectFn函数,并为其添加effectFn.deps属性。用来存储所有包含当前副作用函数的依赖集合
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect函数用于注册副作用函数
function effect (fn) {
const effectFn=()=>{
//当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps用来存书所有与该副作用函数相关联的依赖集合
effectFn.deps=[]
effectFn()
}
复制代码
effectFn.deps数组中的依赖集合在tarck函数中如何收集?
œfunction track (target, key){
// 没有activeEffect 直接return
if (!activeEffect) return target[key]
// 根据target从“桶”中取得depsMap 它也是一个map类型: key=>effects
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 在根据key从depsMap中取得deps 他是一个set类型
// 里面存储着所有与当前key 相关联的副作用函数:effects
let deps = depsMap.get(key)
// 如果deps不存在 同样我们要新建立一个Set并与key关联
if (!deps) {
depsMap.set(key, (deps = new Set()))//这样我们就与目标字段建立了联系
}
// 最后我们将当前激活的副作用函数添加到存储桶里
deps.add(activeEffect)
// 将deps添加到activeEffect.deps数组中。
activeEffect.deps.push(deps)
}
function trigger(target,key) {
// 根据target 从桶中取得 depsMap 他是key-->effects
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
复制代码
关系图如下:
有了以上的联系后,我们就可以在副作用函数每次执行的时候,获取所有相关依赖,然后从依赖集合中删除
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect函数用于注册副作用函数
function effect (fn) {
const effectFn=()=>{
cleanUp(effectFn)
//当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps用来存书所有与该副作用函数相关联的依赖集合
effectFn.deps=[]
effectFn()
}
//cleanUp函数的实现
//cleanUp函数的实现
function cleanUp(effectFn){
// 首先便利effectFn.deps数组
for(let i=0;i<effectFn.deps.length;i++){
//deps是依赖集合
const deps=effectFn.deps(i)
//将effectFn从依赖集合中删除
deps.delete(effectFn)
}
//最后重置deps数组
effectFn.deps.length = 0
}
复制代码
cleanUp会遍历effectFn.deps数组,从而将副作用函数从依赖集合中删除。最后重置effectFn.deps数组
那么现在,我们已经可以避免副作用函数产生遗留了,但是现在我们尝试运行代码。发现会导致无限循环执行,问题出现在了trigger函数中
原因:因为trigger函数内部会遍历effects集合,如果遍历集合时,一个值已经被访问过,然后该值被删除并重新添加到集合,此时如果遍历未结束,那么该值会重新被访问,就会导致无限循环
解决办法如下:我们构造一个新的Set集合effectsToRun,代替直接遍历effects集合
function trigger(target,key) {
// 根据target 从桶中取得 depsMap 他是key-->effects
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun=new Set(effects)
effectsToRun.forEach(effectFn=>effectFn())
}
复制代码
#1.7 嵌套的effect与effect栈
effect嵌套。
let temp1,temp2
// effectFn1 嵌套effectFn2
effect(function effectFn1() {
console.log('effectFn1执行了')
effect(function effectFn2() {
console.log('effectFn2执行了')
// 在effectFn2读取bar的值
temp2=obj.effectFn2
})
temp1=obj.foo
console.log(temp1,temp2)
})
// // 一秒后修改响应式数据
setTimeout(() =>{
obj.foo='帅涛'
// console.log('des',des)
}, 1000)
复制代码
在上面这段代码中 ,effectFn1嵌套了effectFn2,当我们一秒后修改obj.foo值时,会发现输出跟我想像的不太一样:
effectFn1执行了
effectFn2执行了
effectFn2执行了
复制代码
我们会发现effectFn1函数并没有重新执行,这显然跟我们想的不太一样的。问题其实出现在了我们的effect函数与全局变量activeEffect
原因:我们使用全局变量activeEffect存储effect注册过的副作用函数,所以也就意味着activeEffect只能存储一个副作用函数,当发生嵌套时,内部的副作用函数执行会覆盖activeEffect的值。且不会恢复。这时如果我们在此修改响应式数据,即使数据实在外层副作用,但是因为activeEffect还是原来的内部副作用函数,所以此时收集到的还会是内部副作用函数。
解决办法:我们可以定义一个effectStack副作用函数栈,副作用函数执行时压入函数栈。执行完毕再从栈中弹出,并始终让activeEffect指向最顶层的副作用函数。
// 用一个全局变量存储被注册的副作用函数
let activeEffect
//副作用函数栈
let effectStack=[]
// effect函数用于注册副作用函数
function effect (fn) {
const effectFn=()=>{
cleanUp(effectFn)
//当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 调用副作用函数之前压入栈
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect=effectStack[effectStack.length-1]
}
// activeEffect.deps用来存书所有与该副作用函数相关联的依赖集合
effectFn.deps=[]
effectFn()
}
复制代码
#1.8 如何避免无限递归循环
我们假设在effect副作用函数中增加一个自增操作obj.foo++ 该操作就会引起栈溢出
const data = {foo:1}
const obj= new Proxy(data,{/*...*/})
// 使用effect函数
effect(() => { obj.foo++ })
复制代码
运行后,改操作就会引起爆栈
Uncaught RangeError: Maximum call stack size exceeded
复制代码
原因:首先 我们把上述代码拆开,那么相当于如下代码:
effect(()=>{
obj.foo = obj.foo+1
})
复制代码
那么当运行effect副作用函数的时候,既会读取obj.foo的值,又会设置obj.foo的值。当读取值的时候会触发track函数,将副作用函数添加到桶中,然后设置值会触发trigger函数,会将副作用函数取出执行,那么此时此刻,当前副作用函数还未执行完毕的时候,就要开始下一次的执行,从而形成了无限循环递归,导致了爆栈
解决办法:在上述问题中,我们会发现读取和设置都是在同一个副作用函数内执行的。那么我们则可以在trigger函数内增加条件判断。如果trigger函数执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
function trigger(target,key) {
// 根据target 从桶中取得 depsMap 他是key-->effects
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=>effectFn())
}
复制代码
我们修改完trigger函数后,这样就可以避免无限递归调用。从而避免爆栈
#1.8 调度执行
所谓调度,则是在trigger执行时,我们可以决定副作用函数的执行时间,所谓执行顺序。我门还是举列来说明:
const data = {foo:1}
const obj= new Proxy(data,{/*...*/})
// 使用effect函数
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')
复制代码
那么以上代码执行后,输出结果如下:
1
2
'结束了'
复制代码
那么如果现在我们想把输出顺序调整为如下,该怎么做呢
1
'结束了'
2
复制代码
那么按照我们正常的编码思路,我们可以为effect函数增加一个选项参数options,用参数来控制。
// 使用effect函数
effect(() =>
{
console.log(obj.foo)
},
{
// 调度器 scheduler 是一个函数
scheduler(fn){
}
}
)
复制代码
然后我们把options选项参数挂载到对应的副作用函数上
// effect函数用于注册副作用函数
function effect (fn,options={}) {
const effectFn=()=>{
cleanUp(effectFn)
//当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 调用副作用函数之前压入栈
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect=effectStack[effectStack.length-1]
}
// 将options挂载到effect上
effectFn.options=options
// activeEffect.deps用来存书所有与该副作用函数相关联的依赖集合
effectFn.deps=[]
effectFn()
}
复制代码
最后,我们在trigger函数触发副作用函数时,既可以直接调用用户传递的调度器函数,从而实现调度执行
function trigger(target,key) {
// 根据target 从桶中取得 depsMap 他是key-->effects
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=>effectFn())
// 对effectsToRun集合进行循环
effectsToRun.forEach(effectFn=>{
// 如果调度器存在,则调用该调度器 并将副作用函数当作参数传递
if(effectFn.options.scheduler){
effectFn.options.scheduler(effectFn)
}else{
// 如果调度器不存在 则直接执行副作用函数
effectFn()
}
})
}
复制代码
现在我们将副作用函数放到宏任务队列
// 使用effect函数
effect(() =>
{
console.log(obj.foo)
},
{
// 调度器 scheduler 是一个函数
scheduler(fn){
setTimeout(fn);
}
}
)
复制代码
在看打印,可以发现我们的需求已经实现了。如上,我们已经实现了执行顺序的控制,我们还可以控制执行的次数。假如我们有如下需求:
// 使用effect函数
effect(() =>
{
console.log(obj.foo)
})
obj.foo++
obj.foo++
复制代码
上述代码执行后,在我们没用调度器控制的情况下,输出肯定是1,2,3
1
2
3
复制代码
但是其实我们值2 只是自增重的过渡状态,我们并不关心这个过程,所以执行三次有点多余,我们所期望的是1,3,那么我可以通过调度器来实现此功能
// 我们定义一个任务队列
const jobQueue = new Set()
// 我们可以使用promise.resolve() 创建一个promise实列,我们用这个实列将一个任务添加到微任务队列
const p =Promise.resolve()
// 一个标志代码是否正在刷新队列
let isFlushJob = false
function FlushJob() {
// 首先如果队列在刷新 则什么都不做
if(isFlushJob) return
// 设置为true,代表正在刷新
isFlushJob = true
// 在微任务队列中刷新 jobQueue 毒烈
p.then(()=>{
jobQueue.forEach(job=>job())
}).finally(()=>{
// 结束后重置 isFlushJob
isFlushJob = false
})
}
// 使用effect函数
effect(() =>
{
console.log(obj.foo)
},
{
// 调度器 scheduler 是一个函数
scheduler(fn){
// 每次调度时,我们将副作用函数添加到任务队列jobQueue中
jobQueue.add(fn)
// 调用FlushJob刷新队列
FlushJob()
// setTimeout(fn);
}
})
obj.foo++
obj.foo++
复制代码
再次打印我们会发现,只打印出了1,2