深度学习响应式系统

学习本章要尝试解决的问题:

  • 如何避免无限递归
  • 为什么需要嵌套副作用函数
  • 两个副作用函数之间会产生哪些影响

什么是响应式系统?

正常情况下一个函数执行完以后,就不会再次执行。

例如:

ini 复制代码
//副作用函数
const obj = {text:"hello world"}
const effect=()=>{
   document.body.innerText = obj.text;
}

我们在代码执行完毕以后,如果我们的数据再次发生了变化,就需要再执行一次函数。

如果我不想自己调用,应该怎么办呢?

这里让我想到了观察者模式

你的副作用函数的执行,无非就是读取这个数据,然后把这个数据赋值或者操作或者异步等。

都是基于读取然后衍生出来的操作。

那么我们就在你读取的时候 ,把你想要执行的副作用函数存储起来。

等我设置赋值的时候,在把你的函数重新执行一遍,通知你,不就完成了。

这个就是响应式的概念,但是它并不完善。

01 - 实现一个基础的响应式

首先我们实现一个基础的响应式实现。

它的原理是

  • 当我们读取的时候,把读取操作的副作用函数存储到桶里
  • 当我们设置的时候,把存储到桶里的副作用函数拿出来挨个执行一遍。

实际执行时发现的问题:

如果想要触发获取和设置的监听,我们的操作就需要使用代理对象,通过代理对象拿数据,以及设置数据,才能正常执行。

vue3使用为什么要升级Object.defineProperty 为proxy

javascript 复制代码
//原始数据
const data = {text:"hello world"}
//存储副作用的桶
const bucket = new Set()
//对原始数据进行代理
const obj = new Proxy(data,{
   //对读取操作进行拦截
   get(target,key){
      //将副作用函数添加到桶里
      bucket.add(effect)
      return target[key]
   },
   //对设置操作进行拦截
   set(target,key,value){
      //设置操作
      target[key] = value
      //把副作用函数从桶里取出来 并进行执行
      bucket.forEach(fn=>fn())
      return true
   }
})
//副作用函数
const effect=()=>{
   //这里需要使用 代理的对象
   document.body.innerText = obj.text;
}
//执行
effect()
setTimeout(()=>{
   //设置的时候 也是使用代理对象 才能正确的触发
   obj.text = "hello 你好"
},1000)

这里我们实现了一个非常基础的响应式实现。它还有以下其他的问题,才能够逐步完善

  • 注册副作用函数的方式直接写死
  • 我们监听了所有的属性,但是我们只想监听对象的一个属性怎么办呢?
  • 如果我想注册一个副作用函数应该怎么做呢?

下面我们一步一步 来完善吧。

02-完善注册函数机制,实现可以注册匿名函数

我们需要一个注册函数的工具,通过它来进行函数的注册。

  • 设计一个全局变量,临时存储 要注册的副作用函数
  • 注册函数的时候 先把这个函数赋给全局临时变量,然后立刻执行
  • 立即执行会触发 对象的获取机制,然后被拦截,我们在拦截函数中 判断 全局 变量是否存在,如果存在,就把该变量保存到桶里。

它是怎么知道需要监听谁的呢?

答案是:咱们先执行一遍,看看它会触发谁的拦截,然后再触发拦截那里,把刚刚执行的函数,存放到合适的桶里就可以了

javascript 复制代码
//原始数据
const data = {text:"hello world"}
//存储副作用的桶
const bucket = new Set()
//全局变量存储 临时存储 需要注册的函数
let activeEffect;
//注册函数
const registerEffect=(fn)=>{
   if (!fn) return ;
   //把注册函数交给全局变量
   activeEffect = fn;
   //调用该函数 触发代理对象的get函数 添加到桶里面
   fn()
}
//对原始数据进行代理
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
   }
})
registerEffect(()=>{
   //这里需要使用 代理的对象
   document.body.innerText = obj.text;
})
    setTimeout(()=>{
   //设置的时候 也是使用代理对象 才能正确的触发
   obj.text = "hello 你好"
},1000)

我们改进了函数注册机制, 现在我们通过activeEffect和 effect 的搭配使用,可以实现直接注册匿名函数,而不在关注函数的具体名称。

03-完善注册函数的桶,实现基于属性的监听调用

我们虽然实现了可以注册匿名函数的注册机制,但是当我们监听的属性是a ,但是我们执行修改的是b的时候,同样会调用副作用函数,这并不是我们想要的,那我们应该如何优化呢?

实际上我们监听的是谁呢 ? 是不是目标对象?我们的执行函数和谁挂钩呢?目标对象的目标属性。所以 它的关心应该是这样的

css 复制代码
|--target
|--|--key
|--|--|--effectFn

我们可以把这个桶抽取出来,做成一个全局的数据存在。

scss 复制代码
//存储全局的桶
/**
|--target 
|--|--key
|--|--|--effectFn
 */
const bucket = new WeakMap()
//原始数据
const data = {text:"hello world"}
//用一个全局变量  来临时保存 注册函数
let activeEffect ;
function effect(fn){
    activeEffect = fn;
    fn()
}
//重写拦截的逻辑
const obj = new Proxy(data,{
    //拦截读取的操作
    get(target,key){
        //00 - 判断activeEffect是否存在,如果不存在 直接返回
        if(!activeEffect) return 
        //01- 根据目标对象获取 对应的 key-->effects
        let depsMap = bucket.get(target)
        //如果不存在 就创建
        if(!depsMap){
            depsMap = new Map()
            bucket.set(target,depsMap)
        }
        //02-根据属性获取 对应的 副作用函数队列 它是一个Set
        let deps = depsMap.get(key)
        if(!deps){
            deps = new Set()
            depsMap.set(key,deps)
        }
        //03- 把副作用函数添加到拿到的set队列里面
        deps.add(activeEffect)
        //返回属性的值
        return target[key]
    },
    //拦截设置的操作
    set(target,key,newValue){
        //设置属性值
        target[key] = newValue
        //根据target拿到对应的桶 进行执行
        let depsMap = bucket.get(target)
        if(!depsMap) return ;
        //拿到属性对应的副作用函数队列
       const effects = depsMap.get(key)
       //判罚是否存在 并按个执行
       effects && effects.forEach(fn=>fn());
    },
})

构建的数据结构,分别使用了weakMap,map,set

  • weakMaptarget-->Map 构成
  • Mapkey--Set构成

可以看到我们这里使用了weakMap和Map ,为什么这里使用weakMap 而不是Map呢?

WeakMap 的使用说明

我们看一下他们两个的区别吧:

  • 首先,WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名。

  • 其次,WeakMap的键名所指向的对象,不计入垃圾回收机制。

  • 它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内

WeakMap 的键名只能使用对象,而它引用的对象又不会计入到垃圾回收机制里面,那么它的作用就是两个方面。

  • 一、如果weakMap的引用的对象被其他对象引用,那么它属于强引用,垃圾回收机制不会触发
  • 二、如果weakMap的引用的对象已经没有其他的引用了,也就是说它已经不被任何东西强关联,那么垃圾回收机制就可以正常的触发了。

所以weakMap经常用于存储那些只有当key所引用的对象存在时(没有被回收)才有价值的信息。

04-分支切换与cleanup

观察下面的代码,它的执行逻辑分两种情况:

  • obj.ok为 true , 获取obj.text的属性 ,也就是说,我们会触发两次代理对象的get对象,分别为 ok和text 两个属性添加副作用函数。也就是副作用函数会依赖两个属性执行
  • obj.ok为false的时候,只会添加一次,副作用函数依赖一个函数。
javascript 复制代码
//原始数据
const data = {text:"hello world",ok:true}
const obj = new Proxy()

registerEffect(()=>{
    document.body.innerText = obj.ok ? obj.text :'not'
})

假如我们的代码是这样执行的,我们设置

ini 复制代码
obj.ok = false

它的执行过程是这样:

  • 首先会默认执行,在注册函数中触发了两次获取代理对象的数据,那么就在这个代理对象的两个属性下面分别添加了对应的监听函数
  • 接着我们设置了 obj.ok= false
  • 当我们设置了这个以后,我们触发了obj.ok下面的监听函数,监听函数就会执行

document.body.innerText = obj.ok ? obj.text :'not'

  • 由于obj.ok是false 所以 这一次不是给两个属性添加监听函数,而是只给obj.ok添加。

但是由于我们 之前的监听函数还在 ,所以我们计时只需要触发一次 obj.ok等于false的监听函数

还是会触发两次 ,一次是旧的。

这个问题就是分支!解决办法其实也很简单,就是在添加监听函数之前 ,先把旧的清空掉就可以了。

如果按照我的想法 就会想到 我在再次记录副作用之前清除旧的依赖,就需要挨个遍历,但是挨个遍历也是成本!

  • 那么我们就需要知道,都有谁保存了当前的副作用的依赖,我们需要把这些依赖都去掉,这里直接在当前副作用函数上面加工一层,保存在相关联的副作用函数上面
  • 在我们注册函数之前,把之前的依赖都清掉。
scss 复制代码
//原始数据
const data = {text:"hello world",ok:true}

//清理旧的副作用函数
const cleanup =(effectFn)=>{
    //遍历effectFn.deps数组
   for(const deps of effectFn.deps){
      //这里的deps实际上是set---这里删除的实际上是它自己 其他的副作用函数没有删除
      deps.delete(effectFn)
   }
    //重置effectFn.deps数组
   effectFn.deps.length = 0
}

//存储副作用的桶
const bucket = new WeakMap()
//全局变量存储 临时存储 需要注册的函数
let activeEffect;
//注册函数
const registerEffect=(fn)=>{
    if (!fn) return ;
    const effectFn=()=>{
      //调用clean 清除 关于effectFn 的相关联的依赖
      cleanup(effectFn)
      //当effectFn执行时,将其设置为当前激活的副作用函数
      activeEffect = effectFn;
      //调用该函数 触发代理对象的get函数 添加到桶里面
      fn()
    }
   //用来存储所有与该副作用函数相关联的依赖集合
   effectFn.deps = []
    //执行该函数
   effectFn()
}
//完成target-->key-->effect 的关联注册
//在get函数中追踪属性的变化
const track=(target,key)=>{
   //00 - 判断activeEffect是否存在,如果不存在 直接返回
   if(!activeEffect) return
   //01- 根据目标对象获取 对应的 key-->effects
   let depsMap = bucket.get(target)
   //如果不存在 就创建
   if(!depsMap){
      depsMap = new Map()
      bucket.set(target,depsMap)
   }
   //02-根据属性获取 对应的 副作用函数队列 它是一个Set
   let deps = depsMap.get(key)
   if(!deps){
      deps = new Set()
      depsMap.set(key,deps)
   }
   //03- 把副作用函数添加到拿到的set队列里面
   deps.add(activeEffect)
   //04- add-- deps就是一个与当前副作用函数存在关联关系的集合
   activeEffect.deps.push(deps)
}
//在set函数中追踪属性的变化
const trigger=(target,key)=>{
   //根据target拿到对应的桶 进行执行
   let depsMap = bucket.get(target)
   if(!depsMap) return ;
   //拿到属性对应的副作用函数队列
   const effects = depsMap.get(key)
   //判罚是否存在 并按个执行
   // effects && effects.forEach(fn=>fn());

   //使用一个新的set方法 避免无限循环执行
   const effectToRun = new Set(effects)
   effectToRun.forEach(fn=>fn());
}

//重写拦截的逻辑
const obj = new Proxy(data,{
   //拦截读取的操作
   get(target,key){
      track(target,key)
      //返回属性的值
      return target[key]
   },
   //拦截设置的操作
   set(target,key,newValue){
      //设置属性值
      target[key] = newValue
      trigger(target,key)
   },
})

registerEffect(()=>{
   console.log("执行了几次")
   //这里需要使用 代理的对象
   document.body.innerText = obj.ok ? obj.text:'not'
})
setTimeout(()=>{
   //设置的时候 也是使用代理对象 才能正确的触发
   obj.ok = false
},1000)

set 的无限循环

如果我们在一次set的循环遍历里面既删除,又添加同一个元素,就会造成set的无限循环遍历。

所以我们在trgger函数中,修改了一下的代码

ini 复制代码
   //判罚是否存在 并按个执行
   // effects && effects.forEach(fn=>fn());

   //使用一个新的set方法 避免无限循环执行
   const effectToRun = new Set(effects)
   effectToRun.forEach(fn=>fn());

05-嵌套的effect与effect栈

如果我们的函数发生了嵌套 ,能不能正常执行呢?

ini 复制代码
const data = {bar:true,foo:true}
const obj = new Proxy()
registerEffect(()=>{
   console.log("effectFn1 执行")
   registerEffect(()=>{
      console.log("effectFn2 执行")
      temp2 = obj.bar
   })
   temp1 = obj.foo
})
obj.bar = false

按照我们的理解 它会建立如下的响应式结构关系

css 复制代码
obj
|--bar
|--|--effectFn2
|--foo
|--|--effectFn1

我们在执行obj.bar = false的时候,应该执行 effectFn1

但是实际上它的执行却是:

它默认执行的时候 顺序是对的,错误的是我们修改了bar的值,居然执行地effectFn 2 ,而不是 1 ,为什么?

原因其实就藏在我们的activeEffect上面

scss 复制代码
//全局变量存储 临时存储 需要注册的函数
let activeEffect;
//注册函数
const registerEffect=(fn)=>{
    if (!fn) return ;
    const effectFn=()=>{
      //调用clean 清除 关于effectFn 的相关联的依赖
      cleanup(effectFn)
      //当effectFn执行时,将其设置为当前激活的副作用函数
      activeEffect = effectFn;
      //调用该函数 触发代理对象的get函数 添加到桶里面
      fn()
       }
    //用来存储所有与该副作用函数相关联的依赖集合
    effectFn.deps = []
    //执行该函数
    effectFn()
}

由于我们是使用activeEffect来临时保存当前的副作用函数,而且唯一的一个,一旦嵌套函数的内层函数执行,就会把内层函数覆盖外层函数的activeEffect的值,而且永远无法恢复,而这个时候我们收集外层的副作用函数到依赖中,就会错误的添加上内层函数的地址,这就是嵌套函数的问题所在!

解决办法就是:利用栈的特性,每一次函数的调用,就会添加一层栈。执行之前push 执行之后pop

scss 复制代码
//add---effect 栈
const effectStack = []
//注册函数
   const registerEffect=(fn)=>{
   if (!fn) return ;
   const effectFn=()=>{
      //调用clean 清除 关于effectFn 的相关联的依赖
      cleanup(effectFn)
      //当effectFn执行时,将其设置为当前激活的副作用函数
      activeEffect = effectFn;
      //add---在调用副作用函数执行,把当前副作用函数添加到栈中
      effectStack.push(activeEffect)
      //调用该函数 触发代理对象的get函数 添加到桶里面
      fn()
       //add--在副作用函数执行完毕之后,把当前副作用函数弹出栈
       effectStack.pop()
       //取出压在栈底的值
       activeEffect = effectStack[effectStack.length-1]
       }
   //用来存储所有与该副作用函数相关联的依赖集合
   effectFn.deps = []
    //执行该函数
   effectFn()
   }

理解偏差的地方:

我一直在想,为什么fn()执行完毕后, activeEffect的恢复不是使用effectStack.pop(),在这一层执行的时候,它应该是两两相对的,我的子函数fn()执行完毕,执行的时机到了父这里,在父的这一层,栈是两两相对的,

我直接就可以通过effectStack.pop() 拿到这一层的呀。


但是实际上activeEffect的使用并不是这样执行的。

在fn()执行的时候,如果里面有触发代理对象的get,它会立刻执行,立刻就会触发收集依赖的函数。那我们的函数刚刚执行到了activeEffect=effectFn 就会派上用场,它会被正确的保存到对应的 set 中。

当 fn 执行完毕以后,我们需要把这一层对应的effectFn排除掉,正式我们知道它是两两相对的,所以我们在这里一进一出,把我们自己的影响消除掉,然后 把activeEffect恢复到上一层。

当恢复到上一层之后,实际上函数的执行会继续回到父的fn中,继续进行副作用函数的绑定,那这个时候,我们恢复的activeEffect就派上了用场。

看 AI 的回答:

effectStack的作用在于管理嵌套的副作用函数执行上下文,确保在嵌套调用中activeEffect的正确性。它是作为一个栈(stack)数据结构实现的,遵循"后进先出"(LIFO, Last In First Out)原则。让我们一步步分析它是如何工作的:

  1. 入副作用函数执行: 当调用registerEffect时,内部定义的副作用函数effectFn被执行。在执行前,通过activeEffect = effectFn;设置当前激活的副作用函数,并将该函数压入effectStack(effectStack.push(activeEffect)), 这一步是为了记录函数的执行上下文,特别是当有嵌套的副作用函数调用时。
  2. 嵌套调用管理: 如果在执行某个副作用函数期间又调用了其他副作用函数(例如,在effectFn1内部注册并执行effectFn2),这个新副作用函数也会按照同样的流程设置自己为activeEffect并压入栈顶。这样,即使在嵌套调用中,每个层级的副作用函数都能准确知道在其执行期间谁是当前的"活跃"副作用函数。
  3. 退出副作用函数执行: 当一个副作用函数执行完毕,通过effectStack.pop()将其从栈中移除,然后activeEffect = effectStack[effectStack.length - 1];将activeEffect恢复为上一层的副作用函数。这样做确保了当嵌套调用结束时,外部副作用函数能再次成为当前激活的副作用函数。

通过这样的管理,effectStack保证了在任何时候,activeEffect都指向了当前正在执行的最外层的副作用函数。这对于依赖收集(在track函数中)和依赖触发(在trigger函数中)至关重要,因为它让系统能够准确地知道哪些函数需要在数据变化时重新执行,从而实现了响应式数据绑定的逻辑。

06-避免无限递归循环

为什么会出现无限递归循环的问题呢?

javascript 复制代码
const data = {foo:1}
const obj = new Proxy()
registerEffect(()=>{
    obj.foo++
})

当我们运行如上代码的时候,就相当于开启了无限递归模式。

我们不断在注册的副作用函数中执行

ini 复制代码
obj.foo = obj.foo+1

它既读取又设置。

那么应该怎么避免这种情况呢?

我们知道 activeEffect 实际就是当前激活的要添加到依赖序列中的副作用函数,我们判断一下,当前执行的函数是否和activeEffect是否相同 就可以实现 避免无限递归循环。

dart 复制代码
//在set函数中追踪属性的变化
const trigger=(target,key)=>{
   //根据target拿到对应的桶 进行执行
   let depsMap = bucket.get(target)
   if(!depsMap) return ;
   //拿到属性对应的副作用函数队列
   const effects = depsMap.get(key)
       //add - 使用一个新的set方法 避免无限循环执行
       const effectToRun = new Set()
       effects.forEach(effectFn=>{
          if(effectFn !== activeEffect){
             effectToRun.add(effectFn)
           }
        })
       effectToRun.forEach(effectFn=>effectFn())
}

07-调度执行

什么是调度执行?

就是trigger 触发 副作用函数重新执行时,有能力控制 执行的时机,次数和方式

执行的时机

假如我们想要实现如下的效果:

javascript 复制代码
const data = {foo:1}
const obj = new Proxy(...)
registerEffect(()=>{
    console.log(obj.foo)
})
obj.foo++
console.log('结束了')

它的打印顺序是

复制代码
1
2
结束了

现在我们需要的效果是:

复制代码
1
结束了
2

应该如何实现呢?

实际它就是在执行的副作用函数的时候添加了一个宏任务,让它延迟执行,问题是我应该怎么添加呢?

我需要传递一个参数进去,在函数执行的时候判断,我传入的这个参数在不在,如果存在就执行我的函数,

然后我在我的函数里面加上延迟的功能,就实现了。

scss 复制代码
//注册函数
   const registerEffect=(fn,options = {})=>{
   if (!fn) return ;
   const effectFn=()=>{
      //调用clean 清除 关于effectFn 的相关联的依赖
      cleanup(effectFn)
      //当effectFn执行时,将其设置为当前激活的副作用函数
      activeEffect = effectFn;
      //在调用副作用函数执行,把当前副作用函数添加到栈中
      effectStack.push(activeEffect)
      //调用该函数 触发代理对象的get函数 添加到桶里面
      fn()
           //在副作用函数执行完毕之后,把当前副作用函数弹出栈
           effectStack.pop()
           //取出压在栈底的值
           activeEffect = effectStack[effectStack.length-1]
       }
   //add--将options挂在到对应的effectFn上面
       effectFn.options = options
   //用来存储所有与该副作用函数相关联的依赖集合
   effectFn.deps = []
    //执行该函数
   effectFn()
   }

我们在注册函数的时候添加一个参数options ,把它挂在到对应的副作用函数上面 effectFn.

然后再执行的时候判断 是否存在对应的调度器scheduler ,如果存在 就执行,并把当前的副作用函数传入进去作为参数

scss 复制代码
//在set函数中追踪属性的变化
const trigger=(target,key)=>{
   //根据target拿到对应的桶 进行执行
   let depsMap = bucket.get(target)
   if(!depsMap) return ;
   //拿到属性对应的副作用函数队列
   const effects = depsMap.get(key)
   //add - 使用一个新的set方法 避免无限循环执行
   const effectToRun = new Set()
   effects.forEach(effectFn=>{
      if(effectFn !== activeEffect){
         effectToRun.add(effectFn)
      }
   })
   effectToRun.forEach(effectFn=>{
      //add--判断当前的副作用函数上面是否存在调度器
      if(effectFn.options.scheduler){
         effectFn.options.scheduler(effectFn)
           }else{
         effectFn()
           }
       })
}

接着看一下效果:

scss 复制代码
registerEffect(()=>{
console.log(obj.foo)
  },{
scheduler(fn){
   setTimeout(fn)
      }
  })
  obj.foo++
  console.log('结束了')

1
结束了
2

完美实现。

调度器与控制执行多次

需求:

复制代码
obj.foo++
obj.foo++

结果虽然是从 1 加到 2 加到 3

但是 我们只想要最后的一次 中间的状态不想要,该怎么办呢?

javascript 复制代码
  //定义一个任务队列
  const jobQueue = new Set()
  //使用Promise.resolve创建一个promise实例,我们用它将一个任务添加到微任务队列里面
  const p = Promise.resolve()
  //定义一个标识 是否正在刷新队列
  let isFlushing = false
  function flushJob(){
//如果任务队列正在刷新 则什么都不做
      if(isFlushing){
           return
          }
        isFlushing = true
        p.then(()=>{
        //在微任务中执行 会延迟
           jobQueue.forEach(job=>job())
              }).finally(()=>{
           isFlushing = false
              })
  }

  registerEffect(()=>{
        console.log(obj.foo)
      },
      {
        scheduler(fn){
           //每次调度的时候 把副作用函数添加到jobQueue队列中
           jobQueue.add(fn)
          flushJob()
      }
  })
  obj.foo++
  obj.foo++
  obj.foo++
  obj.foo++
  
 执行结果:
 1
 5

原理:

  • 利用set的特性 重复的fn只会添加一次
  • 利用微服务的执行时机,微服务它的执行是在当前宏任务执行完毕以后才会查找判断微任务队列。

在我们上面的代码中,我们虽然添加了多次fn 并且刷新了多次flushJob(),实际上它并没有多次执行。

我们在执行isFlushing 的时候进行了拦截 不会多次调用,另外一个我们虽然添加了多次,由于set的特性,里面始终只有一个,所以并不会多次执行,而是只会拿到它的最终太。

有一点巧妙啊 居然还用到了微服务的特性。

书上原文的说明:

08-计算属性computed 与 lazy

我们现在的代码是会立即执行传递给它的副作用函数

scss 复制代码
registerEffect(()=>{
    //...
})

为什么会立即执行呢?

因为我们在注册函数的时候,需要先触发一次函数,把对应的副作用函数添加到触发的拦截对象的 对应的属性上,把副作用函数挂载上去。

现在我们希望能够懒执行,在需要的时候执行,那么应该怎么办呢?

可以参考我们上面的调度器,我们传递一个lazy的参数给options,在执行的时候判断,如果有这个属性,我们就不执行

javascript 复制代码
registerEffect(()=>{
    //...
},{
    lazy:true
})

如何实现懒执行呢?

javascript 复制代码
  registerEffect(()=>{
    console.log(obj.foo)
      },{
    lazy:true
  })

注册函数的实现

scss 复制代码
//注册函数
   const registerEffect=(fn,options = {})=>{
       if (!fn) return ;
       const effectFn=()=>{
          //调用clean 清除 关于effectFn 的相关联的依赖
          cleanup(effectFn)
          //当effectFn执行时,将其设置为当前激活的副作用函数
          activeEffect = effectFn;
          //在调用副作用函数执行,把当前副作用函数添加到栈中
          effectStack.push(activeEffect)
          //调用该函数 触发代理对象的get函数 添加到桶里面
          fn()
           //在副作用函数执行完毕之后,把当前副作用函数弹出栈
           effectStack.pop()
           //取出压在栈底的值
           activeEffect = effectStack[effectStack.length-1]
           }
          //将options挂在到对应的effectFn上面
           effectFn.options = options
          //用来存储所有与该副作用函数相关联的依赖集合
           effectFn.deps = []
           //add--执行该函数
           if(!options.lazy){
               effectFn()
           }
           //add--将副作用函数作为返回值返回
           return effectFn
       }
   }

我们现在代码是懒执行了,但是我们具体什么时候执行呢?

可以看到我们实际上返回了副作用函数,我们可以自己手动执行,但是这样做有什么意义呢?

如果我们把这个返回的值作为一个getter函数呢?这样就可以返回任何值了

AI:这句话的意思是在构建响应式系统时,我们可以将副作用函数设计为一个访问器属性(getter)的一部分,这样每次访问该属性时,实际上就是在执行副作用函数,进而可以动态地"获取"或计算出某个值。这里的"返回任何值"意味着副作用函数内部可以进行复杂的逻辑处理,最终计算并返回所需的数据或结果。

具体到Vue或类似的框架中,当提到"副作用函数作为一个getter函数",通常是指在实现计算属性(如Vue中的computed)的场景。计算属性本质上是一个依赖其他数据的getter函数,它返回的值是基于其他数据计算得出的。例如,你可能有依赖于多个状态值的计算逻辑,这个计算逻辑封装在getter中,每当依赖的数据发生变化时,getter就会重新执行,计算出新的结果并返回。

简而言之,这种方式允许我们封装数据处理逻辑,并且这个逻辑的执行(即副作用的发生)是由对某个属性的访问自动触发的,而这个属性仿佛具有了"动态计算"的能力,可以根据底层数据的变化实时更新其值。因此,"把副作用函数作为一个getter函数"是一种实现动态数据绑定和自动更新的有效策略。

例如下面的代码:

ini 复制代码
const effectFn = effect(()=>obj.foo+obj.bar,{lazy:true})

这样我们手动执行函数的时候 就能拿到返回值了

ini 复制代码
const res = effectFn()

为此,我们需要把我们的注册函数进行改造一下,既然需要把执行结果的数据返回,那么我们需要在我们真正的副作用函数执行的地方动手脚。

scss 复制代码
//注册函数
   const registerEffect=(fn,options = {})=>{
   if (!fn) return ;
   const effectFn=()=>{
          //调用clean 清除 关于effectFn 的相关联的依赖
          cleanup(effectFn)
          //当effectFn执行时,将其设置为当前激活的副作用函数
          activeEffect = effectFn;
          //在调用副作用函数执行,把当前副作用函数添加到栈中
          effectStack.push(activeEffect)
          //调用该函数 触发代理对象的get函数 添加到桶里面
          //add--将fn的执行结果存储到res中
          const res = fn()
           //在副作用函数执行完毕之后,把当前副作用函数弹出栈
           effectStack.pop()
           //取出压在栈底的值
           activeEffect = effectStack[effectStack.length-1]
           //add将res 作为 effectFn的返回值。
           return res;
       }
       //将options挂在到对应的effectFn上面
       effectFn.options = options
       //用来存储所有与该副作用函数相关联的依赖集合
       effectFn.deps = []
       //add--懒执行该函数
       if(!options.lazy){
              effectFn()
       }
       //add--将副作用函数作为返回值返回
        return effectFn
   }

我们真正的副作用函数其实是fn ,而effectFn是我们的包装的,所以这里使用res临时保存执行结果,并在最后返回。

这样我们就可以去真正的实现计算函数了

计算函数 computed

它的参数是一个是一个getter函数,我们用getter函数作为副作用参数,用它创建一个lazy 的effect ,computed函数会返回一个对象,该对象的value属性是一个访问器属性,只有当读取value值的时候,才会执行effectFn 并将其结果返回

javascript 复制代码
 /**
* 实现一个计算函数
*  @param  getter
*  @return  { {readonly value: * }|*}
*/
function computed(getter){
   //把getter作为副作用函数,创建一个lazy的effect
       const effectFn = registerEffect(getter,{lazy:true})
       //当读取value时才执行effectFn
       const obj = {
       get value(){
         return effectFn()
           }
       }
       return obj
   }

执行结果:

javascript 复制代码
const sunRes = computed(()=>obj.foo+obj.bar)
console.log(sunRes.value)
//3

当我们读取sunRes.value的时候,触发该对象的getter函数,它的getter函数就是我们注册的并返回的懒执行函数effectFn,我们在其内部已经实现了,包装的effectFn的执行会返回其内部真正的副作用函数的执行结果。

所以我们就实现了计算函数,哈哈哈哈!😄😄😄😄😄

对值进行缓冲

我们可以设置一个哨兵和缓冲来实现该效果。

当我们的哨兵dirty为true的时候 我们就重新获取数据,如果是false就不执行,value则对上次的数据进行缓冲。

如果属性没有发生更新 ,我们直接返回上次的值,如果有则更新。

那么属性更新的时候,怎么通知我们呢? 我们可以通过注调度器来实现该效果。

当属性发生变化时,如果我们实现了调度器,会执行调度器,我们正好在这里来重置哨兵dirty.

javascript 复制代码
function computed(getter){
   //缓冲值
   let value ;
   //用来标识是否需要重新计算 当为true的时候 就是需要重新计算
   let dirty = true;
   //把getter作为副作用函数,创建一个lazy的effect
       const effectFn = registerEffect(getter,{
      lazy:true,
      scheduler(){
               //在调度器里面 重置 dirty
               //当依赖的属性发生变化的时候 会触发调度器的执行。
               dirty = true
           }
       })
       //当读取value时才执行effectFn
       const obj = {
       get value(){
         if(dirty){
            value = effectFn()
                   dirty = false
               }
         return value
           }
       }
       return obj
   }

在注册函数中读取计算属性时

好头疼 ,感觉要长脑子!

当我们在一个eff ect 中读取计算属性的时候:

scss 复制代码
const sunRes = computed(()=>obj.foo+obj.bar)
effect(()=>{
    //在该副作用函数中读取sumRes.value
    console.log(sunRes.value)
})
//修改obj.foo的值
obj.foo++
javascript 复制代码
function computed(getter){
   //缓冲值
   let value ;
   //用来标识是否需要重新计算 当为true的时候 就是需要重新计算
   let dirty = true;
   //把getter作为副作用函数,创建一个lazy的effect
       const effectFn = registerEffect(getter,{
      lazy:true,
      scheduler(){
         //在调度器里面 重置 dirty
               //当依赖的属性发生变化的时候 会触发调度器的执行。
         dirty = true
               //当计算属性依赖的响应式数据变化时,手动调用的trigger函数触发响应
               trigger(obj,'value')
           }
       })
       //当读取value时才执行effectFn
       const obj = {
      get value(){
         if(dirty){
            value = effectFn()
                   dirty = false
               }
         //当读取value时,手动调用track函数进行追踪
         return value
           }
       }
   return obj
   }

09 - watch的实现原理

watch的实现原理

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

javascript 复制代码
watch(obj,()=>{
    console.log('数据变了')
})
//修改响应数据值,会导致回调函数执行。

那我们可以利用注册副作用函数effect和 options.scheduler。

effect的注册副作用不就是 观察一个响应式的数据吗,但是它是自动执行副作用函数的,我们这里是想要执行我们自己的,所以我们需要手动注册一个函数,让其能够直接回调。

那么我们之前实现的effect中,的副作用函数执行时机的实现机制,scheduler就正好满足我们的需求。

所以 watch 原理的本质实际上其实是利用副作用函数以及它的执行时机scheduler完成的。

其他的反而是对其进行的进一步优化

scss 复制代码
 /**
* 简单的实现 使用effect 以及options.scheduler
*/
function watch(source,callback){
       effect(()=>source.foo,{
       scheduler(){
         //当数据发生变化的时候,执行回调
           }
       })
   }

观察上面的代码,发现我们的注册代码是直接写死的。

watch的本质是 观察一个可响应式对象,然后执行相应的操作。所以这里我们需要封装一个通用的读取操作的函数。

通用的读取操作的函数

  • 读取操作,我只需要读 并不需要其他的操作
  • 需要读取所有的属性 所以我们需要递归的遍历观察对象上的所有属性
  • 需要防止循环引用的问题出现,所以需要一个set来进行减支(树枝)的处理
scss 复制代码
function watch(source,callback){
       effect(()=>traverse(source),{
      scheduler(){
         //当数据发生变化的时候,执行回调
              callback()
           }
       })
   }
function traverse(value,seen = new Set()){
      //判断递归的边界 如果数据是原始值或者已经读取过了
      if(typeof value !== 'object' || value === null || seen.has(value)){
          return
       }
      //将数据添加到seen 已进行数据的读取操作
       seen.add(value)
       //暂不考虑其他的数据类型
       for(const key in value){
          traverse(value[key],seen)
       }
       return value
   }

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

我们在scheduler中添加上执行的回调。使用**traverse** 读取函数进行通用的读取操作来触发对响应式数据的读取。

其他可响应数据的观测

这里我们还可以添加对其他响应式数据的观测 这里留个口子

watch 支持getter函数

watch 函数既可以接收一个响应式对象,也可以接收一个getter函数。

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

scss 复制代码
function watch(source,callback){
   //定义getter
       let getter;
       if(typeof source === 'function'){
      getter = source
       }else{
      //否则按照原来的实现调用traverse递归地读取
           getter = ()=>traverse(source)
       }
       effect(()=> getter(),{
      scheduler(){
         //当数据发生变化的时候,执行回调
         callback()
           }
       })
   }

现在我们既可以使用响应式对象,也可以使用getter函数。现在我们还缺最重要的一个能力,新旧值的比较传递。

支持新旧值的传递

这里要如何获取新值与旧值呢?

这需要充分利用eff ect 函数的lazy选项,如以下代码所示:

scss 复制代码
function watch(source,callback){
   //定义getter
       let getter;
       if(typeof source === 'function'){
      getter = source
       }else{
      //否则按照原来的实现调用traverse递归地读取
           getter = ()=>traverse(source)
       }

   //定义旧值与新值
      let oldValue,newValue
       //使用effect注册副作用函数,开启lazy选项,并把返回值存储到effectFn中一遍后续手动调用
      const effectFn =  effect(()=> getter(),{
      lazy:true,
      scheduler(){
         //在 scheduler 中重新执行副作用函数,得到的是新值
         newValue = effectFn()
         //将旧值与新值作为回调函数的参数
         callback(newValue,oldValue)
               //更新旧值,
               oldValue = newValue
           }
       })
       //手动调用 先那一下初始化的值
       oldValue = effectFn()
   }

在这段代码中,最核心的改动是使用了lazy选项创建了一个懒执行的effect,注意上面代码中最下面的部分,我们手动调用了effectFn函数得到的返回值就是旧值,这是一次初始化的调用,是在观测响应式数据时直接拿到的初始值。当变化发生并触发了scheduler调度函数并执行的时候,会重新调用effectFn函数并得到新的值,这样我们就同时拿到了新值和旧值,我们就可以在回调函数中同时返回了。

最后一件非常重要的是不要忘记更新旧的值。这样 新值变旧值,就可以一直跑起来了。

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

watch 的两个特性:

立即执行的回调函数

默认情况下,watch的回调函数只会在响应式数据发生变化的时候才会执行,那么如何让其立即执行呢?

Vue.js中可以通过选项参数的immediate来指定回调是否需要立即执行。

当immediate为true的时候 我们抽取的scheduler 公共函数就执行一次,它里面包含了watch的回调函数callback,

如果为false的时候,就还是默认执行我们的effectFn 它只是单纯的返回的副作用回调函数,我们通过它拿到一次默认的值。

scss 复制代码
function watch(source, callback, options = {}) {
   //定义getter
   let getter;
   if (typeof source === 'function') {
      getter = source
   } else {
      //否则按照原来的实现调用traverse递归地读取
      getter = () => traverse(source)
   }
   //定义旧值与新值
   let oldValue, newValue
   //提取scheduler调度函数为一个独立的job函数
   const job = () => {
      //在 scheduler 中重新执行副作用函数,得到的是新值
      newValue = effectFn()
      //将旧值与新值作为回调函数的参数
      callback(newValue, oldValue)
      //更新旧值,
      oldValue = newValue
   }
   //使用effect注册副作用函数,开启lazy选项,并把返回值存储到effectFn中一遍后续手动调用
   const effectFn = effect(() => getter(), {
      lazy: true,
      scheduler: job
   })
   //当 immediate 为 true的时候 立即执行一次
   //如果不是 则我们就拿一下初始值就可以。
   if (options.immediate) {
      job()
   } else {
      //手动调用 先那一下初始化的值
      //这里的关键是 这里的副作用函数调用 并不会执行 watch的回调函数 所以它没有立即执行。
      oldValue = effectFn()
   }
}

回调函数的执行时机

这里使用**flush:'pre'** //'post' |'sync' 来控制执行的时机,如果设置为 Post ,就把任务放在微任务队列中,从而实现异步延迟执行,否则就立即执行。

这里有一个疑问是:当flush为post时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后执行 ?

微任务队列的执行不是在 DOM 更新之前吗 为什么这里说的是 之后呢 ?

scss 复制代码
function watch(source, callback, options = {}) {
   //定义getter
   let getter;
   if (typeof source === 'function') {
      getter = source
   } else {
      //否则按照原来的实现调用traverse递归地读取
      getter = () => traverse(source)
   }
   //定义旧值与新值
   let oldValue, newValue
   //提取scheduler调度函数为一个独立的job函数
   const job = () => {
      //在 scheduler 中重新执行副作用函数,得到的是新值
      newValue = effectFn()
      //将旧值与新值作为回调函数的参数
      callback(newValue, oldValue)
      //更新旧值,
      oldValue = newValue
   }
   //使用effect注册副作用函数,开启lazy选项,并把返回值存储到effectFn中一遍后续手动调用
   const effectFn = effect(() => getter(), {
      lazy: true,
      scheduler: ()=>{
         //在调度函数中判断 flush是否为'post',如果是,将其放到微任务队列中执行
               if(options.flush === 'post'){
            const p = Promise.resolve()
                   p.then(job)
               }else{
            job()
               }
           }
   })
   //当 immediate 为 true的时候 立即执行一次
   //如果不是 则我们就拿一下初始值就可以。
   if (options.immediate) {
      job()
   } else {
      //手动调用 先那一下初始化的值
      //这里的关键是 这里的副作用函数调用 并不会执行 watch的回调函数 所以它没有立即执行。
      oldValue = effectFn()
   }
}

11-过期的副作用

问题所在:副作用的过期

这一节讲了一个很有意思的问题,就是过期的副作用?

副作用怎么会过期呢?

ini 复制代码
let finalData;
watch(obj,async ()=>{
    const res = await fetch('///')
    finalData = res
})

当我们的 Obj修改的时候,触发了回调,执行了 fetch ,进行异步调用。

在执行异步执行的时候,我们又一次触发了回调函数,又一次发起了异步请求。

我们的的 B 先返回了,A后返回。

这个时候 我们应该使用B 的结果,而抛弃 A 的结果,因为 A 是第一次的时候发起的,后来我们更新了

A 的副作用已经过期了。

这个就是问题所在。

如何解决呢?

这里我们可以参考Vue提供的方式:

javascript 复制代码
let finalData;
watch(() => obj.foo, async (newValue, oldValue, onInvalidate) => {
      //定义一个标识,代表当前副作用函数是否过期,默认为false 代表没有过期
      let expired = false
      onInvalidate(() => {
         //当过期时,将expired 设置为true
         expired = true
      })
      //发送网络请求
      const res = await fetch('/path/to/request')
      if (!res) {
         finalData = res
      }
   }
)

它的原理是什么:在watch内部每次检查到变更后,在副作用函数重新执行之前,会先调用我们通过onInvalidate函数注册的过期回调。

scss 复制代码
function watch(source, callback, options = {}) {
   //定义getter
   let getter;
   if (typeof source === 'function') {
      getter = source
   } else {
      //否则按照原来的实现调用traverse递归地读取
      getter = () => traverse(source)
   }
   //定义旧值与新值
   let oldValue, newValue
       //cleanup 用来存储用户注册的过期回调
       let cleanup;
       function onInvalidate(fn){
      cleanup = fn
       }
       
   //提取scheduler调度函数为一个独立的job函数
   const job = () => {
      //在调用回调cb之前,先调用过期回调
           if(cleanup){
         cleanup()
           }
      //在 scheduler 中重新执行副作用函数,得到的是新值
      newValue = effectFn()
      //将旧值与新值作为回调函数的参数
      callback(newValue, oldValue,onInvalidate)
      //更新旧值,
      oldValue = newValue
   }
   //使用effect注册副作用函数,开启lazy选项,并把返回值存储到effectFn中一遍后续手动调用
   const effectFn = effect(() => getter(), {
      lazy: true,
      scheduler: () => {
         //在调度函数中判断 flush是否为'post',如果是,将其放到微任务队列中执行
         if (options.flush === 'post') {
            const p = Promise.resolve()
            p.then(job)
         } else {
            job()
         }
      }
   })
   //当 immediate 为 true的时候 立即执行一次
   //如果不是 则我们就拿一下初始值就可以。
   if (options.immediate) {
      job()
   } else {
      //手动调用 先那一下初始化的值
      //这里的关键是 这里的副作用函数调用 并不会执行 watch的回调函数 所以它没有立即执行。
      oldValue = effectFn()
   }
}
相关推荐
大布布将军2 小时前
⚡后端安全基石:JWT 原理与身份验证实战
前端·javascript·学习·程序人生·安全·node.js·aigc
ybc46522 小时前
React、Next安全漏洞问题修复和自测
前端·安全·next.js
huali2 小时前
社区划分:让AI理解你的代码重构意图
前端·javascript·vue.js
掘金安东尼2 小时前
⏰前端周刊第446期(2025年12月22日–12月27日)
前端
不老刘2 小时前
前端面试八股文:单线程的JavaScript是如何实现异步的
前端·javascript·面试
J总裁的小芒果2 小时前
后端返回参数不一致 前端手动处理key
前端·vue.js·elementui
闲云一鹤2 小时前
【工具篇】使用 nvm 进行 node 版本管理
前端·npm·node.js
指尖跳动的光2 小时前
web网页如何禁止别人移除水印
前端·javascript·css