深入理解Vue3.js响应式系统设计之调度执行

如果您觉得这篇文章有帮助的话!给个点赞和评论支持下吧,感谢~

作者:前端小王hs

阿里云社区博客专家/清华大学出版社签约作者/csdn百万访问前端博主/B站千粉前端up主

此篇文章是博主于2022年学习《Vue.js设计与实现》时的笔记整理而来

书籍:《Vue.js设计与实现》 作者:霍春阳

本篇博文将在书第1.7节 的基础上进一步解析,附加了测试 的代码运行示例 ,以及对书籍中提到的ES6 中的数据结构及其特点进行阐述,方便正在学习Vue3想分析Vue3源码的朋友快速阅读

如有帮助,不胜荣幸

前置章节:

  1. 深入理解Vue3.js响应式系统基础逻辑
  2. 深入理解Vue3.js响应式系统设计之栈结构和循环问题

调度执行

在前面的章节我们学习到,只要修改了代理对象obj的属性就会触发trigger()执行,那么在4.7节,作者向我们介绍了如何去实现可控制的去执行trigger(),也就是决定副作用函数执行的时机、次数以及方式(原文)

那么在这一节,将会涉及到宏任务微任务 知识,这是JavaScript高阶知识,我们先来介绍下

JavaScript中,异步任务被分为两种主要的类别:宏任务(MacroTask)和微任务(MicroTask)

这两种任务类型在事件循环(Event Loop) 中被处理,但它们有不同的执行优先级和时机

宏任务包括:

  1. script(整体代码)
  2. setTimeout
  3. setInterval
  4. setImmediate(Node.js环境)
  5. I/O
  6. UI渲染(浏览器)
  7. MessageChannel(消息通道)
  8. postMessage
  9. requestAnimationFrame(浏览器)

微任务包括:

  1. Promise.then() 或 .catch()
  2. MutationObserver(浏览器)
  3. process.nextTick(Node.js环境)
  4. queueMicrotask()(较新的API)

执行的过程是,在每次宏任务 执行完后,会检查微任务队列 是否有微任务 ,如果有,那么执行(且执行完所有微任务),如果没有,则从宏任务队列 中继续执行下一个宏任务 。也就是说,微任务 是在两个宏任务之间执行的

而如果存在同步代码 ,则宏任务队列异步函数 会在同步代码执行后再执行,可看下面这个例子:

现在,我们来看一个需求

js 复制代码
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });

effect(() => {
  console.log(obj.foo);
});

obj.foo++;

console.log('结束了');

这段代码中,正常的输出顺序是12 、最后是结束了 ,这点非常简单,但如果想把2 放在结束了 之后呢?那么很明显,就需要控制副作用函数的执行时机

结合上面我们说的宏任务 ,是不是思路就来了?把结束了 这个script语句放到执行完fn()之后即可,下面我们来看看是如何实现的

第一步:给effect函数涉及一个选项参数options,其实就是传入一个对象,对象内是一个可执行函数,参数为fn(),代码如下:

js 复制代码
// 书中源代码
effect(
  () => {
    console.log(obj.foo);
  },
  // options
  {
    // 调度器 scheduler 是一个函数  
    scheduler(fn) {
      // ... 这里是调度器函数的实现
    }
  }
);

第二步:在effect内部将options挂载到effectFn上,代码如下:

js 复制代码
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 = []
  effectFn()
}

第三步:在trigger中进行判断,如果effectFn.options存在scheduler,那么执行scheduler,代码如下:

js 复制代码
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()
    }
  })
}

那么现在,当我们传入scheduler(fn)之后,就会被保存在effectFn.options中,当再次触发trigger时,就会执行effectFn.optionsfn(),关键是scheduler里应该怎么处理呢?其实非常简单,只需添加一个setTimeout,然后在定时器里执行fn(),还记得我们在上面说过,这是一个宏任务,代码如下:

js 复制代码
// effect()内
{
  scheduler(fn) {  
    // 将副作用函数放到宏任务队列中执行  
    setTimeout(fn);
  }
}

现在,我们来分析一下代码的执行顺序:

  1. 执行effect,那么会输出1,以及把scheduler(fn){}传给options
  2. 执行obj.foo++,那么先会涉及到一个读取,但这里我们无需关心读取,只看trigger
  3. trigger中执行effectFn.optionsfn(),那么这是一个宏任务 ,会被送进队列 中等待当前宏任务执行完成
  4. 由于setTimeout是一个异步函数 ,而console.log('结束了')是一个同步代码 ,所以会先输出结束了 ,然后再输出完成了++之后的2

下面,我们再来看一个更加进阶的操作,我们直接分析需求和实现过程。首先是需求,代码如下:

js 复制代码
const data = { foo: 1 };
const obj = new Proxy(data, { /* ... */ });  

effect(() => {
  console.log(obj.foo);
});

obj.foo++;  
obj.foo++;

通过逻辑,我们知道会按顺序输出123,现在的需求是直接输出13,也就是跳过21我们知道,直接由fn()初次执行就会输出,那3呢?

我们来看一下解决的实现过程,代码如下:

js 复制代码
// 定义一个任务队列
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 队列中
        jobQueue.add(fn);
        // 调用 flushJob 刷新队列
        flushJob();
    }
});

obj.foo++;
obj.foo++;

我们分析一下实现的过程:

  1. 执行effect,那么首先输出当前的1,并且把scheduler里的函数放进effectFn.options
  2. 执行obj.foo++,读取我们还是不分析,我们直接看触发时执行scheduler里的函数
  3. 执行jobQueue.add(fn),这是一个Set数据结构,也就是里面的值是唯一的
  4. 执行flushJob,将isFlushing置为true,然后把jobQueue里的每一项fn()添加到微任务 队列中,并且在执行完后会将isFlushing置为false,不过此时并没有执行
  5. 我们知道,微任务 之后在宏任务 执行完后才会执行,那么此时flushJob执行完了,obj.foo++也执行完成了,foo变为2
  6. 注意!flushJob同步代码obj.foo++也是同步代码 ,所以先执行obj.foo++,那么在这里的时候,jobQueue.add(fn);是无效的,因为Set里的都是唯一值,那么他会执行flushJob
  7. 执行flushJob时,由于前面我们已经置为了ture,所以直接return
  8. 那么当所有的同步代码 执行完后,就查看微任务队列 了,就把p.then里的代码拿出来执行
  9. 执行console.log(obj.foo);,此时就已经了是3了!

如何设计只执行一次?结合Set只保存唯一值、微任务 的特性和函数开关(布尔值

分析的时候是不是觉得有点绕?只要搞清楚了同步和异步 以及宏任务和微任务 就清晰了,但步骤确实是有点多,也不得不佩服高级前端工程师的设计思路,一环套一环

如果你看到这里,那么可以和hr夸夸其谈:

  • 什么是宏任务和微任务
  • 有同步函数和异步函数、异步函数有微任务时的执行顺序
  • 如何设计只执行一次的逻辑

结语

那么到这里,我们就跟着书籍解决了如何实现调度 的问题,下一节笔记我们来探讨如何实现computed ,这是实现响应式核心 的关键API

谢谢大家的阅读,如有错误的地方请私信笔者

笔者会在近期整理后续章节的笔记发布至博客中,希望大家能多多关注前端小王hs

相关推荐
早起解决80%人生问题13 分钟前
使用CSS生成小三角
前端·css·css3
Eric⠀20 分钟前
【02问:前端常见的设计模式】
前端·javascript·vue.js·设计模式·js
陈琦鹏27 分钟前
css 实现背景图和背景色正片叠底
前端·css
abments40 分钟前
使用 Selenium 获取 Web 页面信息的全指南
前端·selenium·测试工具
Play_Sai42 分钟前
Vue.js 中的API接口封装实战与详解
前端·javascript·vue.js
读心悦1 小时前
【CSS】什么是CSS的columns属性
前端·css
Wang's Blog1 小时前
Webpack: 构建微前端应用
前端·webpack
软件编程工程师1 小时前
基于vue脚手架创建的图书商城
前端·javascript·css·vue.js·html·网站·图书商城
I like Code?1 小时前
Vue图片路径问题分析
javascript·vue.js·ecmascript
柯基的小屁墩1 小时前
Mac|install vue
前端·vue.js·macos