04-响应系统的作用与实现

110 阅读28分钟

响应系统的作用与实现

响应式数据和副作用函数

副作用函数

副作用函数就是会产生副作用效果的函数,例如函数修改了全局变量,导致其他函数读取的时候拿到了修改后的全局变量

响应式数据

 const obj = {text: 'hello world'}
 function effect(){
     //effect函数的执行回读取obj.txt
     document.body,innerText = obj.text
 }

响应式数据的效果就是希望`obj.text的值改变的时候,effect函数重新执行

响应式数据的基本实现

线索

  • 当副作用函数effect执行的时候,会触发字段obj.text的读取操作
  • 当修改obj.text的时候,会触发obj.text的设置操作

思路

拦截一个对象的读取和设置操作

  • 当读取字段obj.text时,就可以把副作用函数effect存储到一个桶中
  • 当设置obj.text的时候,再把副作用函数effect从桶中取出来重新执行

具体实现

在ES6之前,只能通过Object.defineProperty来实现,也就是vue2的实现方式

但是ES6之后,支持使用代理proxy的方式来实现,这也是vue3的实现方式

proxy实现:

 const bucket = new Set()    //存储副作用函数的桶
 const data = {text: 'hello world'}  //原始数据
 const obj = new Proxy(data, {
     //拦截读取操作
     get(target, key){
         bucket.add(effect)  //将副作用函数存入桶中
         return target[key]  //返回属性值
     },
     //拦截设置操作
     set(target, key, newVal){
         target[key] = newVal    //修改属性值
         bucket.forEach(fn => fn())  //将桶中的函数取出并重新执行
         return true //返回true代表设置成功
     }
 })

测试

 function effect(){
     document.body.innerText = obj.text
 }
 effect()    //执行副作用函数,触发读取
 //一秒后修改响应式数据
 setTimeout(() => {
     obj.text = "hello vue3"
 }, 1000)

缺点

采取了硬编码形式,比如写死的effect,扩展性不行

设计一个完整的响应系统

上一节中我们可以看到代码的缺点,所以现在要设计一个完整的响应系统

副作用函数名字不一

在上面的代码实现中,副作用函数的名字我们称为effect,而且将他在响应式实现函数中写死

而在实际开发中,副作用函数的名称是不唯一的,甚至可能是匿名函数

所以现在设计一个用来注册副作用函数的机制让响应系统不依赖副作用函数的名字

 let activeEffect    //定义一个全局变量,作用是存储被注册的副作用函数
 function effect(fn){    //用来注册副作用函数的函数
     activeEffect = fn   //防止副作用函数命名不统一
     fn()
 }

现在代理proxy对象:

 const obj = new Proxy(data, {
     //拦截读取操作
     get(target, key){
         if(activeEffect) bucket.add(activeEffect)   //将activeEffect中存储的副作用函数存入桶中
         return target[key]  //返回属性值
     },
     //拦截设置操作
     set(target, key, newVal){
         target[key] = newVal    //修改属性值
         bucket.forEach(fn => fn())  //将桶中的函数取出并重新执行
         return true //返回true代表设置成功
     }
 })

代理对象变化误调副作用函数

如果依赖上述的proxy代理对象来实现响应式的话,会存在一种情况:如果在响应式对象obj上设置一个不存在的属性时,副作用函数也会被重新调用

因为我们只要去设置这个obj对象的话,肯定都会触发set操作,进而触发桶中的副作用函数,所以不管这个对象怎么改变,副作用函数都会重新被执行,也就是说,我们没有在副作用函数与被操作的目标字段之间建立明确的关系

现在需要重新设计数据结构:

 effect(function effectFn(){
     document.body.innerText = obj.text
 })

这段代码三个角色:

  • target:被操作的代理对象obj
  • key:被操作的字段名text
  • effectFn:副作用函数effectFn

关系:

  • 可能存在多个target
  • target —— key 一对多
  • key —— effectFn 一对多

设计:

  • WeakMap 由 target --> Map构成

  • Map 由 key --> Set构成

    响应系统数据结构.jpg

  • 具体实现

     const obj = new Proxy(data, {
         // 拦截get操作
         get(target, key){
             // 查看有没有副作用函数 activeEffect,没有直接return
             if(!activeEffect) return target[key]
             //在桶中查看有没有该代理对象,没有的话就添加该对象(Map结构,也就是obj)
             let depsMap = bucket.get(target)
             if(!depsMap) bucket.set(target, (depsMap = new Map()))
             //查看桶中该对象存不存在关键字key,没有则添加该关键字(Set结构,也就是text)
             let deps = depsMap.get(key)
             if(!deps) depsMap.set(key, (deps = new Set()))
             //在该关键字对应的Set结构中添加该副作用函数
             deps.add(activeEffect)
             //返回属性值
             return target[key]
         },
         //拦截设置操作
         set(target, key, newVal){
             //设置属性值
             target[key] = newVal
             //查看有没有该代理对象,没有则表示没有副作用函数与其关联,可以直接返回
             const depsMap = bucket.get(target)
             if(!depsMap) return
             //查看有没有该代理对象的key,取得其所有副作用函数
             const effects = depsMap.get(key)
             //有副作用函数则全部重新执行
             effects && effects.forEach(fn => fn())
         }
         
     })
    

使用WeakMap

WeakMap和Map的区别在于:WeakMap不影响垃圾回收机制

如果target对象没有任何引用,说明用户不需要再用他了,这时垃圾回收机制就会回收任务

而如果用的是map则不会被回收,最后有可能导致内存溢出

封装

将get拦截函数使用track函数封装(追踪):

 function track(target, key){
     // 查看有没有副作用函数 activeEffect,没有直接return
     if(!activeEffect) return target[key]
     //在桶中查看有没有该代理对象,没有的话就添加该对象(Map结构,也就是obj)
     let depsMap = bucket.get(target)
     if(!depsMap) bucket.set(target, (depsMap = new Map()))
     //查看桶中该对象存不存在关键字key,没有则添加该关键字(Set结构,也就是text)
     let deps = depsMap.get(key)
     if(!deps) depsMap.set(key, (deps = new Set()))
     //在该关键字对应的Set结构中添加该副作用函数
     deps.add(activeEffect)
 }

将set拦截函数使用trigger函数封装(触发):

 function trigger(target, key){
     //查看有没有该代理对象,没有则表示没有副作用函数与其关联,可以直接返回
     const depsMap = bucket.get(target)
     if(!depsMap) return
     //查看有没有该代理对象的key,取得其所有副作用函数
     const effects = depsMap.get(key)
     //有副作用函数则全部重新执行
     effects && effects.forEach(fn => fn())
 }

最终函数:

 const obj = new Proxy(data, {
     get(target, key){
         track(target, key)
         return target[key]
     },
     set(target, key, newVal){
         target[key] = newVal
         trigger(target, key)
     }
 })

分支切换与cleanup

分支切换定义

比如有如下副作用函数:

 effect(function effectFn(){
     document.body.innerText = obj.ok ? obj.text : 'not'
 })

可以看到,如果obj.ok的值变化的话,那么代码的分支也会跟着变化,这就是分支切换

分支切换产生后果

可能会产生遗留的副作用函数

比如,上述代码中,obj.ok的初始值为trueobj.text的初始值为hello world,依赖关系:

分支切换依赖图.jpg 然后,现在要将obj.ok的值变为false,这必然会触发effectFn这个副作用函数重新执行,理论上应该取消text的依赖(因为obj.ok===false),但是现在的依赖关系仍然没变,还是保持着上图的依赖关系

这样的后果是:我现在修改obj.text的内容,又会触发effectFn副作用函数,但是,现在触发这个副作用函数已经毫无意义,因为无论怎么执行,都不会让obj.text的内容在页面上进行修改

解决问题

思路:

每次副作用函数执行的时候,可以先把他从所有与之关联的依赖集合中删除,然后当副作用函数执行完毕后,才进行重新建立联系

设计:

  • 先将一个副作用函数从所有与之关联的依赖集合中删除

    需要重新设计副作用函数,添加一个属性存储所有包含该副作用函数的依赖集合

     let activeEffect
     function effect(fn){
         const effectFn = () => {
             //当effectFn执行时,将其设置为当前激活的副作用函数
             activeEffect = effectFn
             fn()
         }
         effectFn.deps = []  //存储与该副作用函数相关联的依赖集合
         effectFn()  //执行副作用函数
     }
    
  • 收集依赖需要在track函数中执行

     function track(target, key){
         if(!activeEffect) return target[key]
         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)
         //deps是一个与当前副作用函数存在联系的依赖集合,添加到activeEffect.deps数组中
         activeEffect.deps.push(deps)
     }
    
  • 将副作用函数从依赖集合中移除

     function effect(fn){
         const effectFn = () => {
             //调用cleanup函数完成清除工作
             cleanup(effectFn)
             activeEffect = effectFn
             fn()
         }
         effectFn.deps = [] 
         effectFn() 
     }
     ​
     //清除函数cleanup
     function cleanup(effectFn){
         //遍历effectFn.deps数组
         for(let i = 0; i < effectFn.deps.length; i++){
             //deps是依赖集合
             const deps = effectFn.deps[i]
             //在依赖集合中移除这个副作用函数
             deps.delete(effectFn)
         }
         //重置effectFn.deps数组
         effectFn.deps.length = 0
     }
    
  • 现在的代码会出现无限循环执行的情况

    因为在trigger内的最后一句代码effects && effects.forEach(fn => fn())中,在遍历副作用函数时,调用了副作用函数,他就会将其从effect集合中剔除,然后一执行这个函数,就又会重新收集到集合中

    而语言规范中对forEach做了明确说明:如果一个值已经被访问过,但该值被删除并重新添加到集合,如果此时遍历没有结束,则该值会重新被访问,所以会无限执行

    解决:

     function trigger(target, key){
         const depsMap = bucket.get(target)
         if(!depsMap) return
         const effects = depsMap.get(key)
         //多封装一层set集合就不会无限执行了
         const effectsToRun = new Set(effects)
         //有副作用函数则全部重新执行
         effectsToRun.forEach(effectFn => effectFn())
         // effects && effects.forEach(fn => fn())
     }
    

嵌套的effect与effect栈

effect嵌套场景

常用场景:vue中的组件发生嵌套时,就会出现effect的嵌套

vue组件的渲染函数是在一个effect中执行的:

 //Foo组件
 const Foo = {
     render(){
         return //.....
     }
 }
 ​
 //effect中执行Foo组件的渲染函数
 effect(() => {
     Foo.render()
 })

组件嵌套:

 effect(() => {
     Foo.render()
     //发生嵌套
     effect(() => {
         Bar.render()
     })
 })

effect嵌套后果

模拟代码:

 const data = {foo: true, bar: true} //原始数据
 let temp1, temp2
 const obj = new Proxy(...)
 effect(function effectFn1(){
     console.log('Fn1执行');
 ​
     effect(function effectFn2(){
         console.log('Fn2执行');
         temp2 = obj.bar
     })
     temp1 = obj.foo //模拟时的读取操作要放在effectFn2下,让其覆盖
 })

理想情况的副作用函数与对象属性之间的关系:

 data{{foo: effectFn1},{bar: effectFn2}}

当现在,我们修改obj.foo时,理论上应该触发effectFn1再触发effectFn2,而修改obj.bar时,应该只触发effectFn2

但是,在实际测试中,发现两种情况的输出都是只触发了effectFn2(内层副作用函数)

 //打印结果
 Fn1执行
 Fn2执行
 Fn2执行

原因分析

问题出在activeEffect上,现在我们只是用一个全局变量存储副作用函数,这意味着同一时刻activeEffect只能存储一个副作用函数

副作用函数嵌套时,内层的副作用函数执行会覆盖activeEffect的值,并且不会恢复原来的值

所以这是再有响应式数据进行修改,只能永远收集到内层副作用函数

 function effect(fn){
     const effectFn = () => {
         cleanup(effectFn)
         //问题所在
         activeEffect = effectFn
         fn()
     }
     effectFn.deps = [] 
     effectFn() 
 }

问题解决

设计一个副作用函数栈effectStack

副作用函数执行时,就将其压入栈,执行完毕后弹出栈,并始终让activeEffect指向栈顶的副作用函数

这样就能做到一个响应式数据只会收集直接读取其值的副作用函数

effect栈.jpg

 let activeEffect
 const effectStack = []  //effect的栈
 function effect(fn){
     const effectFn = () => {
         cleanup(effectFn)
         //当effectFn执行时,将其设置为当前激活的副作用函数
         activeEffect = effectFn
         //将副作用函数压入栈
         effectStack.push(effectFn)
         fn()
         //副作用函数执行完毕后,将当前副作用函数弹出栈
         effectStack.pop()
         //还原为之前的副作用函数,此处副作用函数执行完activeEffect会变成空
         activeEffect = effectStack[effectStack.length - 1]
     }
     effectFn.deps = []  //存储与该副作用函数相关联的依赖集合
     effectFn()  //执行副作用函数
 }

避免无限递归循环

无限递归场景

 const data = {foo: 1}
 const obj = new Proxy(data, {代理})
 effect(() => obj.foo++)    

出现原因

自增操作分开:obj.foo = obj.foo + 1

这个操作中会先读取obj.foo,触发track操作,把当前副作用函数收集到桶中

然后在进行加1赋值给obj.foo的操作,这时会触发trigger操作,把桶中的副作用函数去触发执行

而现在副作用函数正在执行,还没执行完就要开始下一次执行,这样就会导致无限递归地调用自己

解决问题

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(effectFn => {
         if(effectFn !== activeEffect){
             effectsToRun.add(effectFn)
         }
     })
     //有副作用函数则全部重新执行
     effectsToRun.forEach(effectFn => effectFn())
 }

调度执行

可调度性

可调度性是响应系统非常重要的特性

可调度性就是当trigger动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机,次数,方式

决定副作用函数的执行方式

例子:

 const data = {foo: 1}
 const obj = new Proxy(data, /* ... */)
 effect(() => {
     console.log(obj.foo)
 })
 obj.foo++
 console.log("结束了")

现在的代码输出为:

 1
 2
 "结束了"

假设现在有需求,要将输出顺序调整为:

 1
 "结束了"
 2

虽然说直接调换代码的6和7行也能实现需求,但是我们要在不调整代码的情况下实现,这就需要响应系统支持调度

设计思路

  • effect函数设计一个选项参数options,允许用户指定调度器

     effect(() => {
         console.log(obj.foo)
     },
     //传入的options
     {
         scheduler(fn){
             //...
         }
     })
    
  • effect函数内部options选项挂载到对应的副作用函数上

     function effect(fn, options = {}){
         const effectFn = () => {
             cleanup(effectFn)
             activeEffect = effectFn
             effectStack.push(effectFn)
             fn()
             effectStack.pop()
             activeEffect = effectStack[effectStack.length - 1]
         }
         effectFn.options = options  //将options挂载到effectFn上
         effectFn.deps = [] 
         effectFn() 
     }
    
  • 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(effectFn => {
             if(effectFn !== activeEffect){
                 effectsToRun.add(effectFn)
             }
         })
         effectsToRun.forEach(effectFn => {
             //查看有没有存在调度器,有的话则调用调度器,并将副作用函数作为参数传递
             if(effectFn.options.scheduler){
                 effectFn.options.scheduler(effectFn)
             }else{
                 //没有则直接执行副作用函数
                 effectFn()
             }
         })
     }
    

测试:

 effect(() => {
     console.log(obj.foo)
 },
 //传入的options
 {
     scheduler(fn){
         setTimeout(fn)  //放入宏任务队列
     }
 })
 obj.foo++
 console.log("结束了");
 //输出:
 1
 '结束了'
 2

控制执行次数

例子:

 const data = {foo: 1}
 const obj = new Proxy(data, /* ... */)
 effect(() => {
     console.log(obj.foo)
 })
 obj.foo++
 obj.foo++

现在的输出:

 1
 2
 3

现在有一个需求,就是只关心输出结果,即不输出第一次自增的2

 1
 3

设计思路

  • 基于调度器实现一个任务队列存储副作用函数

    在此调度器上,副作用函数会存储到微任务队列中,并且去重,在一个周期内都只会执行一次

     //定义一个任务队列,利用Set去重
     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(() => {
             //结束后重置isFlushing
             isFlushing = false
         })
     }
    
  • 调度器写法:

     effect(() => {
         console.log(obj.foo)
     }, {
         scheduler(fn){
             jobQueue.add(fn)    //每次调用将副作用函数添加到jobQueue队列中
             flushJob()  //调用flushJob刷新队列
         }
     })
    

测试:

 1
 3

计算属性computed和lazy

lazy的effect

也就是懒执行的effect

例子:

一般的effect函数会立即执行传递给他的副作用函数,但是现在不希望他立即执行,而是希望在他需要的时候才执行

设计思路:

  • 在options中设计一个lazy属性来达到目的

     effect(() => {
         console.log(obj.foo)
     }, {
         lazy: true
     })
    
  • 修改effect函数的实现逻辑

     function effect(fn, options = {}){
         const effectFn = () => {
             cleanup(effectFn)
             activeEffect = effectFn
             effectStack.push(effectFn)
             fn()
             effectStack.pop()
             activeEffect = effectStack[effectStack.length - 1]
         }
         effectFn.options = options 
         effectFn.deps = [] 
         //只有非lazy的时候才执行
         if(!options.lazy){
             effectFn()
         }
         //将副作用函数作为返回值返回
         return effectFn
     }
    
  • 执行副作用函数(手动)

     const effectFn = effect(() => {
         console.log(obj.foo)
     }, {
         lazy: true
     })
     effectFn()  //手动执行副作用函数
    
  • 只能手动执行副作用函数作用并不大,但是如果把传递给effect的函数看作一个getter,那么这个getter函数可以返回任何值,这样手动执行副作用函数时,就能够拿到其返回值

     const effectFn = effect(
         //getter返回obj.foo + obj.bar
         () => obj.foo + obj.bar, 
         { lazy: true }
     )
     const value = effectFn()
    
  • 想要拿到返回值的话,需要将副作用函数的结果return出来

    而现在effectFn的值是我们包装过的副作用函数,并不会返回值,所以应该将其返回的值保存起来,在effectreturn出来

     function effect(fn, options = {}){
         const effectFn = () => {
             cleanup(effectFn)
             activeEffect = effectFn
             effectStack.push(effectFn)
             //将fn的执行结果存储到res
             const res = fn()
             effectStack.pop()
             activeEffect = effectStack[effectStack.length - 1]
             //将res作为effectFn的返回值
             return res
         }
         effectFn.options = options 
         effectFn.deps = [] 
         //只有非lazy的时候才执行
         if(!options.lazy){
             effectFn()
         }
         //将副作用函数作为返回值返回
         return effectFn
     }
    

计算属性computed

  • 定义一个computed函数,接收一个getter函数作为参数,把getter函数作为一个副作用函数,用它创建一个lazyeffect

     function computed(getter){
         //把getter作为副作用函数,创建一个lazy的effect
         const effectFn = effect(getter, {
             lazy: true
         })
         const obj = {
             //当读取value时执行effectFn
             get value(){
                 return effectFn()
             }
         }
         return obj
     }
    
  • 使用copmuted创建一个计算属性

     const data = {foo: 1, bar: 2}
     const obj = new Proxy(data, {/* ... */})
     //创建一个计算属性
     const sumRes = computed(() => obj.foo + obj.bar)
     //调用value的时候会执行get函数,进而执行副作用函数
     console.log(sumRes.value)
    
  • 上面的computed已经可以正确计算属性值了

    但是有个缺点:我如果连续多次读取sumRes.value的时候,会不断计算effectFn,尽管所依赖的两个值并没有变化

    解决思路:使用一个值进行缓存

     function computed(getter){
         //用来缓存上一次计算的值
         let value   
         //用来表示是否需要重新计算值,为true则意味着脏,需要计算
         let dirty = true    
         const effectFn = effect(getter, {
             lazy: true
         })
         const obj = {
             get value(){
                 //只有脏的时候在计算值,并将得到的值缓存到value中
                 if(dirty){
                     value = effectFn()
                     //将dirty设置为false,下一次访问直接使用缓存到value中的值
                     dirty = false
                 }
                 return value
             }
         }
         return obj
     }
    
  • 上述代码实现了只会在第一次访问时进行真正的计算,后续访问都会直接读取value的值

    但是现在会出现另一种情况,修改obj.foo或者obj.bar的值,sumRes并不会重新进行计算

    原因:第一次访问sumRes.value后,变量dirty会设置为false,代表不需要计算,后面只要dirty的值为false,就不会重新计算,导致得到错误的值

    解决:obj.foo或者obj.bar变化的时候,只要将dirty的值重置为true即可

     function computed(getter){
         let value   
         let dirty = true    
         const effectFn = effect(getter, {
             lazy: true,
             //添加调度器,在调度器中将dirty重置为true
             scheduler() {
                 //下一次所依赖的响应式数据变化的时候,就会调用该调度器让dirty重置为true
                 dirty = true
             }
         })
         const obj = {
             get value(){
                 if(dirty){
                     value = effectFn()
                     dirty = false
                 }
                 return value
             }
         }
         return obj
     }
    
  • 使用effect副作用函数嵌套computed,这时候修改依赖值并不会触发副作用函数重新执行

     effect(() => {  
         //访问的不是proxy代理的数据,不会被收集依赖
         console.log(sumRes.value)
     })
    

    原因:计算属性内部自己拥有一个effect,并且是懒执行的,当真正读取的时候才进行计算,对应计算属性的getter函数来说,它里面访问的响应式数据只会把computed内部的effect收集为依赖,当把计算属性用于另外一个effect的时候,就会发生effect嵌套,外层的effect不会被内层effect中的响应式数据收集

    解决:读取计算属性的值的时候,手动调用track函数进行追踪,计算属性依赖的响应式数据发生变化时,手动调用trigger函数触发响应

    重点理解:下面代码第21行

     function computed(getter){
         let value   
         let dirty = true    
         const effectFn = effect(getter, {
             lazy: true,
             //添加调度器,在调度器中将dirty重置为true
             scheduler() {
                 //下一次所依赖的响应式数据变化的时候,就会调用该调度器让dirty重置为true
                 dirty = true
                 //当计算属性依赖的响应式数据发生变化时,手动触发响应
                 trigger(obj, 'value')
             }
         })
         const obj = {
             get value(){
                 if(dirty){
                     value = effectFn()
                     dirty = false
                 }
                 //读取value的时候,手动调用track函数进行追踪,将外层的effect函数收集进依赖
                 //外层副作用函数在读取value的时候,activeEffect就是副作用函数,这时候要将其收集进依赖
                 track(obj, 'value')
                 return value
             }
         }
         return obj
     }
    

    现在建立的联系:

    computed(obj) --> value --> effectFn

watch实现原理

watch本质

watch本质就是观测一个响应式数据,数据发生变化的时候通知并执行相应的回调函数

 watch(obj, () => {
     console.log("数据变了")
 })
 obj.foo++   //修改响应式数据会导致回调函数执行

实际上,其利用了effectoptions.scheduler选项

watch基础实现

  • watch简单实现

     //source是响应式数据,cb是回调函数
     function watch(source, cb){
         effect(
             //触发读取操作,建立联系
             () => source.foo,
             {
                 scheduler(){
                     //当数据变化时,调用回调函数cb
                     cb()
                 }
             }
         )
     }
    
  • 使用测试

     watch(obj, () => {
         console.log("数据变化了");
     })
     obj.foo++   //数据变化了
    

解决硬编码问题

上述代码中,watch只能监视到obj.foo的变化,因为在定义watch函数的时候,使用了硬编码方式,只建立了obj.foo的联系

所以现在要封装一个更加通用的读取操作:traverse

目的:为了让对象上的每一个属性发生变化时都能触发回调函数的执行

 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函数的定义应该改为:

 function watch(source, cb){
     effect(
         //调用traverse递归读取
         () => traverse(source),
         {
             scheduler(){
                 //当数据变化时,调用回调函数cb
                 cb()
             }
         }
     )
 }

watch接收getter函数

传递给watch函数的第一个参数不一定是要响应式数据,也可以是一个getter函数

getter内部,用户可以指定改watch依赖哪些响应式数据,只有这些数据变化的时候,才会触发回调函数的执行

所以应该修改watch函数,如果source是函数类型,则说明传递了getter函数,否则保留之前的做法:

 function watch(source, cb){
     let getter  //定义getter
     //如果source是函数,说明用户传递的时Getter,所以直接把source赋值给getter
     if(typeof source === 'function'){
         getter = source
     }else{
         //否则按照原来的实现调用traverse函数递归的读取
         getter = () => traverse(source)
     }
     effect(
         //执行getter
         () => getter(),
         {
             scheduler(){
                 //当数据变化时,调用回调函数cb
                 cb()
             }
         }
     )
 }

watch缺少拿新值与旧值

需要充分利用其中的lazy选项:

 function watch(source, cb){
     let getter  //定义getter
     //如果source是函数,说明用户传递的时Getter,所以直接把source赋值给getter
     if(typeof source === 'function'){
         getter = source
     }else{
         //否则按照原来的实现调用traverse函数递归的读取
         getter = () => traverse(source)
     }
     let oldValue, newValue
     const effectFn = effect(
         //执行getter
         () => getter(),
         {
             lazy: true, 
             scheduler(){
                 //在schedule中重新执行辅作用函数,拿到的是新制
                 newValue = effectFn()
                 //当数据变化时,调用回调函数cb,并传入新值和旧值
                 cb(newValue, oldValue)
                 //更新旧值,防止下次数据更新得到错误的旧值!
                 oldValue = newValue
             }
         }
     )
     //先拿到旧值
     oldValue = effectFn()
 }

立即执行的watch与回调执行时机

immediate

watch中还存在immediate选项,为true的时候表示回调函数会在该watch创建的时候立即执行一次

 watch(obj, () => {
     console.log('变化了')
 }, {
     //创建watch回调函数会立即执行一次
     immediate: true
 })

此时需要修改watch函数:

所以此时执行回调函数的oldValue值为undefined也是符合预期的

 function watch(source, cb){
     let getter  //定义getter
     //如果source是函数,说明用户传递的时Getter,所以直接把source赋值给getter
     if(typeof source === 'function'){
         getter = source
     }else{
         //否则按照原来的实现调用traverse函数递归的读取
         getter = () => traverse(source)
     }
     let oldValue, newValue
 ​
     const job = () => {
         newValue = effectFn()
         //当数据变化时,调用回调函数cb
         cb(newValue, oldValue)
         oldValue = newValue
     }
     const effectFn = effect(
         //执行getter
         () => getter(),
         {
             lazy: true, 
             scheduler: job  //使用job函数作为调度器函数
         }
     )
 ​
     if(options.immediate){
         //当immediate为true时立即执行job,总而触发回调执行
         job()
     }else{
         oldValue = effectFn()
     }
 }

flush

在vue3中,除了指定回调函数立即执行以外,还可以通过其他选项参数来指定回调函数的执行实际,也就是flush

flush的可选值有:postpresync

其中,flush的值为post的时候,代表调度函数需要将副作用函数放到一个微任务队列中,等待DOM更新结束后再执行

 function watch(source, cb){
     let getter  //定义getter
     //如果source是函数,说明用户传递的时Getter,所以直接把source赋值给getter
     if(typeof source === 'function'){
         getter = source
     }else{
         //否则按照原来的实现调用traverse函数递归的读取
         getter = () => traverse(source)
     }
     let oldValue, newValue
 ​
     const job = () => {
         newValue = effectFn()
         //当数据变化时,调用回调函数cb
         cb(newValue, oldValue)
         oldValue = newValue
     }
     const effectFn = effect(
         //执行getter
         () => getter(),
         {
             lazy: true, 
             scheduler: () => {
                 if(options.flush === 'post'){
                     //放到微任务队列执行
                     const p = Promise.resolve()
                     p.then(job)
                 }else{
                     //此处相当于sync实现机制
                     job()
                 }
             }
         }
     )
 ​
     if(options.immediate){
         //当immediate为true时立即执行job,总而触发回调执行
         job()
     }else{
         oldValue = effectFn()
     }
 }

过期的副作用

竞态问题

下面这段代码中,如果连续修改两次obj对象的某个字段值,就会发送两次请求,所以现在两次请求的返回结果顺序不确定,可能请求B先于请求A返回结果,那么就会导致finalDate存储了A请求的结果

此处应该是请求B后发送,所以请求B返回的数据应该才是最新的

 let finalData
 watch(obj, async () => {
     const res = await fetch('/patch/to/request')
     finalData = res
 })

解决思路

因为请求A是副作用函数第一次执行所产生的副作用,请求B是副作用函数第二次执行所产生的副作用

由于请求B发生后,请求B的结果应该被视为最新,而请求A应该过期了,其产生的结果应该被无视

vue中,watch可以接收第三个参数onInvalidate,是一个函数,类似于事件监听器,可以使用该函数注册一个回调,这个回调会在当前副作用函数过期的时候执行:

 watch(obj, async(newVal, oldVal, onInvalidate) => {
     let expired = false //过期标志
     onInvalidate(() => {
         expired = true  //过期的时候,会将过期标志设置为true
     })
     //发送网络请求
     const res = await fetch('/path/to/request')
     //只有没过期才能执行后续操作
     if(!expired) finalData = res
 })

watch内部实现onInvalidate

如果连续发送两次请求,在第一次请求还没收到的时候就发送了第二次请求,也就是重新触发了watch的副作用函数,就会执行之前注册的过期回调,这会使得第一次执行的副作用函数内闭包的变量expired的值变为true,即副作用函数执行过期了

 function watch(source, cb, options = {}){
     let getter  //定义getter
     //如果source是函数,说明用户传递的时Getter,所以直接把source赋值给getter
     if(typeof source === 'function'){
         getter = source
     }else{
         //否则按照原来的实现调用traverse函数递归的读取
         getter = () => traverse(source)
     }
     let oldValue, newValue
     
     let cleanup //cleanup用来存储用户注册的过期函数
     function onInvalidate(fn){
         cleanup = fn
     }
     const job = () => {
         newValue = effectFn()
         if(cleanup) cleanup()   //调用回调函数cb之前先调用过期回调
         //当数据变化时,调用回调函数cb
         cb(newValue, oldValue, onInvalidate)
         oldValue = newValue
     }
     const effectFn = effect(
         //执行getter
         () => getter(),
         {
             lazy: true, 
             scheduler: () => {
                 if(options.flush === 'post'){
                     //放到微任务队列执行
                     const p = Promise.resolve()
                     p.then(job)
                 }else{
                     //此处相当于sync实现机制
                     job()
                 }
             }
         }
     )
 ​
     if(options.immediate){
         //当immediate为true时立即执行job,总而触发回调执行
         job()
     }else{
         oldValue = effectFn()
     }
 }

总结

首先了解了响应系统的两大组成部分:副作用函数和响应式数据

  • 副作用函数就是能会产生副作用效果的函数,例如修改了全局变量,而响应式数据就是被副作用函数绑定之后,更改数据能够引起副作用函数的重新执行

接下来,了解了响应式系统的基本实现

  • 切入口:副作用函数执行引起响应式数据的读取操作(此处只是引起读取,并没有引起设置),修改响应式数据会触发响应式数据的设置操作
  • 思路:使用代理proxy的方式,读取数据的时候将副作用函数effect存储到桶bucket中,设置数据的时候再将桶中的数据取出重新执行

上面的基本实现有很多不足的地方,接下来要完善该响应系统

  • 副作用函数名字不一:因为上述的实现中,函数名字采取了硬编码的形式,所以现在需要设计一个不依赖副作用名字的响应系统,定义一个全局变量activeEffect去存储副作用函数,调用effect传入副作用函数即可注册

  • 代理对象的变化误调用副作用函数:在响应式对象上设置一个不存在的属性时,也会触发绑定的副作用函数,因为副作用函数和被操作字段之间没有建立明确的关系,故需要重新设计数据结构:

    可能存在多个target,一个target对应多个key,一个key由多个effectFn构成

    所以设计成:WeakMap 由 target --> Map构成 Map 由 key --> Set构成,设计完需要修改代理对象

  • 将get和set封装,get拦截函数使用track函数封装,set拦截函数只用trigger函数封装

然后,继续完善该响应系统,发现有分支切换问题

  • 分支切换:例如有一个三元表达式,判断条件变化的话,后面的结果也会变化,也就是可能副作用函数可能会出现无效绑定的情况,如document.body.innerText = obj.ok ? obj.text : 'not'中的obj.okfalse时,修改obj.text会导致触发副作用函数无效
  • 思路:既然已经不需要该副作用函数与该该关键字进行绑定,就必须清除,节省资源,所以我们可以在副作用函数执行之前,先将其从所有与之绑定的依赖中删除,然后副作用函数执行完后再重新建立联系
  • 出现死循环:在trigger函数中最后,遍历副作用函数的时候,调用副作用函数就会将其从集合中剔除,一执行这个函数就由收集到集合中,再封装一层Set就能解决

effect的嵌套也是一个未解决的问题,需要解决

  • 嵌套场景:vue中组件发生嵌套时,就会出现effect的嵌套,造成在同一时刻只能存储内层的副作用函数,导致对响应式数据进行修改时,永远只能收集到内层的副作用函数
  • 思路:设计一个副作用函数栈effectStack,副作用函数执行的时候,就让其压入栈,执行完毕后弹出栈,并且始终让activeEffect指向副作用函数栈的栈顶

改代码还有存在缺陷,就是还会出现无限递归循环

  • 递归场景:在副作用函数中出现自增等操作,副作用函数正在执行,还没执行完就又要进行下一次执行
  • 思路:trigger中增加守卫条件,如果trigger函数触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

为了让响应系统有更强大的灵活性,我们要让其可以调度执行

  • 可调度性:可调度性就是有能力决定副作用函数执行的时机、次数、方式等
  • 决定副作用函数的执行方式:可以为effect函数设置一个选项参数options,允许用户指定调度器,并将该选项参数挂载到副作用函数上,在trigger函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,将控制权交给用户
  • 控制执行次数:可以基于调度器实现一个任务队列存储副作用函数,将副作用函数存储到微任务队列中,并且去重,每一周期只会执行一次

实现lazy和计算属性computed

  • lazy属性:让effect函数不立即执行传递给他的副作用函数,希望它执行的时候再执行,所以需要修改effect的逻辑,判断options中的lazy并选择是否立即执行,并且副作用函数是getter的时候,要获取到它的返回值,则需要将副作用函数的结果返回出来
  • 计算属性computed:需要定义一个computed函数,接收一个getter作为参数,将其作为副作用函数,创建一个lazyeffect,创建一个计算属性。并且现在还有一个问题,就是如果连续读取计算属性的值,会不断计算该属性,尽管两个值并没有变化,可以使用一个值去缓存