再探Vue3响应式系统

1,042 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第4天,点击查看活动详情

上一篇我们初探了响应式系统 👉 初探响应式系统 - 掘金 (juejin.cn)

欲看懂这一篇还是建议先看上一篇,这一篇我们继续往下走

一、嵌套问题

🖖先看背景

  • 在这段代码里面,question1里面嵌套了question2,所以question1的执行会导致question2的执行
let temp1 ,temp2;
function question1() {
  console.log('question1执行')
  effect(question2)
  temp1 = proxy.foo
}
function question2() {
  console.log('question2执行')
  temp2 = proxy.bar
}
effect(question1)
  • 由于question1中使用了proxy.foo question2使用了proxy.bar,那么我们所希望的结构应该是这样
//理想情况
data
    |___foo 
        |__question1
    |___bar
        |__question2
  • 但事实却是:初始化的时候触发question1 , question1内再触发一次question2,当我们修改proxy.foo后本应该再次触发question1再触发question2,但是却只触发了question2 image.png

🖖分析原因

  • 我们只用了一个activeEffect来存储副作用函数,但是这样当副作用函数嵌套的时候,内层的副作用函数会覆盖activeEffect的值
  • 所以即使proxy.foo是在外层副作用函数question1读取的,但是它收集到的副作用函数也是内层的question2,也就解释了为什么上面只执行question2而不执行question1
//实际情况
data
    |___foo 
        |__question2
    |___bar
        |__question2

🖖解决方案

  • 涉及到有序的多个数据存储,嵌套函数就是最里层的最后调用,也最先执行完毕,就纯纯符合栈结构
  • 那么我们可以选择使用一个effectStack来存储副作用函数,当副作用函数执行的时候,让它压入栈,当它执行完毕出栈
  • 所以我们一共增加了四步
    • 新增一个effectStack
    • fn()执行前,将effectFn推进栈
    • fn()执行后,将effectFn推出栈
    • activerEffect重新复制为栈顶元素
//用一个全局变量存储被注册的副作用函数
let activeEffect; 
const effectStack = []  //新增一个栈
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    //当effectFn执行的时候,把它设置为当前激活的副作用函数
    activeEffect = effectFn;
    effectStack.push(effectFn); //新增
    fn();
    effectStack.pop(); //新增
    activeEffect = effectStack[effectStack.length-1];  //新增
  }
  effectFn.deps = [];
  effectFn();
}

🖖测试

  • 依旧还是上面的修改proxy.foo的值: proxy.foo = '123'
  • 现在就达到预期情况了,执行了question1后再执行了question2

image.png

二、递归循环问题

🤚先看背景

当你解决了上面的问题兴致勃勃测试数据的时候,你输入了proxy.bar++,打开控制台一看,坏了,发现它竟然递归调用了导致了栈溢出报错

image.png

🤚分析原因

  • proxy.bar++是一个自增的操作,那么它就会先获取值,再设置值
  • 当我们获取值的时候,就会触发track函数,那么该函数就会加入list,当我们+1后设置值的时候,又会触发trigger函数,把list的事件取出执行,它本身都还没有执行完,就又让调用它,那么就是无限递归调用自己,导致如上图所示的栈溢出
  • 本质上就是在同一个函数先获取值,再设置值,就会导致目前这个代码进入循环状态
  • 可以证实一下我们的想法,即不使用自增,而是先获取proxy.bar的值,再设置值,同样报出了错误;但是如果是先设置值,再获取值即无问题
function question2() {
  console.log('question2执行')
  temp2 = proxy.bar;
  proxy.bar = '123'
}

🤚解决方案

  • 既然我们上述提到了是在同一函数中进行这种操作才有问题,那么我们自然是希望,如果在赋值的时候,发现是触发的副作用函数刚好是当前执行函数的时候就不执行
  • 触发的副作用函数我们可以在forEach循环里面寻找
  • 当前执行函数刚好就是activerEffect
  • 那么就说明我们的effectsToRun不能全盘照收从list取出来的副作用集合,而是需要由一个剔除操作
function trigger(target,key) {
  const desMap = list.get(target);
  if(!desMap) return;
  //从desMap中取中存放副作用事件的Set结构
  const effects = desMap.get(key);
  //不要全盘接受
  const effectsToRun = new Set();
  //剔除操作
  effects && effects.forEach(effectFn => {
  //如果不相等就加进去,如果相等就不处理
    if(effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn=> effectFn());
}

🤚测试

  • 我们直接拿上面分析原因的那段代码进行测试,发现现在已经解决了递归调用问题,即正常调用 image.png

三、扩充调度

🤚背景

  • 当我们问题解决后,自然就想着能不能给他加上一些功能
  • 例如,能不能加上一个调度器,可以用它来控制副作用函数的执行顺序,甚至控制该副作用函数的调用次数

🤚实现方案

  • 要让它可调度,其实就是将它的执行交给我们调度函数去解决,那么只需要走三步:
    • 传入
    • 挂载
    • 使用
  • 传入:我们选择以options作为形参,赋初始值{}options内可以放入scheduler(调度函数)

为什么不是直接传入scheduler呢:因为这个函数不会只有调度这个功能配置功能,所以我们希望传入的结构应该是

//options对象
{
    //调度函数
      scheduler(fn) { }
 }
  • 挂载:人家都传入来了,你总得用吧,难不成丢那里自己就会调用了(Bushi
function effect(fn, options = {} ) {
 .......//这里都不用修改
  effectFn.deps = [];
  //看这里看这里,把它挂载上去即可
  effectFn.options = options;
  effectFn();
}
  • 使用:首先options中可能有传入scheduler,也可能没有传入,那么我们首先就要给它加个判断,其次调度函数影响的是调用过程,那么我们就先去找到trigger函数,再去找到调用它的地方,也就是在forEach那个遍历调用的地方,然后把副作用函数的执行交给调换函数去处理
  // effectsToRun.forEach(effectFn=> effectFn())  ----旧代码,我们不用了
  //使用scheduler
  effectsToRun.forEach(effectFn => {
  //判断
    if(effectFn.options.scheduler) {
    //有则使用
      effectFn.options.scheduler(effectFn)
    } else {
    //没有则则直接调用
      effectFn()
    }
  });

🤚测试

  • 比如我希望这个副作用函数能够一秒钟执行一次
    • scheduler里面使用了setInterval每隔一秒就去执行show函数
function show() {
  document.body.innerText = `现在的数字为${proxy.num}`
  proxy.num++;
}
effect(show,{
  scheduler(fn) {
    setInterval(fn,1000)
  }
})
proxy.num++
  • 结果如图 递增走动.gif

四、继续扩展

再比如我期望我能够去控制它的执行次数,比如说Vue.js连续多次修改响应式数据但是只触发一次更新的效果

👋实现方案

  • 先定义一个jobQueue集合,用来存放不重复的副作用函数
  • 通过isFlushing(表示是否正在刷新),控制是否是否去执行jobQueue内的任务
  • 通过p.then将函数添加到微任务队列,在微任务队列中遍历执行jobQueue内的任务,执行完将isFlushing置为false
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
  })
}

👋使用

  • 副作用函数为dosomething,打印proxy.num
  • 传入调度函数
  • proxy.num递增十次
function doSomething() {
  console.log(proxy.num);
}
effect(doSomething,{
  scheduler(fn) {
    jobQueue.add(fn);
    flushJob()
  }
})
for(let i = 0;i<10;i++) {
  proxy.num++;
}
  • 只打印出了 image.png

👋分析过程

  • 每一次proxy.num递增,就会进去trigger函数里面找到scheduler,然后jobQueue把该函数推入集合;由于我们jobQueue设置的Set结构,所以你无论推进入同一副作用函数doSomething几次其实里面都只有一个
    • 图为截取部分过程中打印的jobQueue image.png
  • 推进去之后就调用flushJob函数,一旦它开始执行,isFulshing标志就会被设置为true,也就是说无论你调用多少次,在一个周期内都只会执行一次,实际上它在一个事件循环中只会执行一次
    • 图为过程中打印isFulshing,可以知道只有第一次是可以进入执行的,后面都被return回来了 image.png
  • 当微任务队列开始执行的时候,就会拿出jobQueue中的函数依次执行,但是我们前面已经说了只有一个函数,辣就是说只去执行一次,此时proxy.num的值已经递增到11了,所以就打印出了11
  • 到这里就把一个简易的响应式的系统完成啦
  • 注:这篇是基于个人学习  《Vue.js设计与实现》  这本书过程的一种梳理和整理输出