12 异步编程

这部分着重要理解 Promiseasync awaitevent loop

12.1 浏览器中的Event loop

事件循环:执行一个宏任务→清空所有微任务→(必要时渲染)→再取下一个宏任务,循环往复

简版总结

JavaScript 最早是用于写网页交互逻辑的,为了避免多线程同时修改 dom 的同步问题,设计成了单线程,又为了解决单线程的阻塞问题,加了一层调度逻辑,也就是 Loop 循环和 Task 队列,把阻塞的逻辑放到其他线程跑,从而支持了异步。然后为了支持高优先级的任务调度,又引入了微任务队列,这就是浏览器的 Event Loop 机制:每次执行一个宏任务,然后执行所有微任务

正确的一次 Event loop 顺序是这样的

  • 执行同步代码,这属于宏任务
  • 执行栈为空,查询是否有微任务需要执行
  • 执行所有微任务
  • 必要的话渲染 UI
  • 然后开始下一轮 Event loop,执行宏任务中的异步代码

通过 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的响应界面响应,我们可以把操作 DOM 放入微任务中

推荐一个可以在线看代码流程的网站:loupe (opens new window),看一下这个学习视频 (opens new window)

JavaScript 是用于实现网页交互逻辑的,涉及到 dom 操作,如果多个线程同时操作需要做同步互斥的处理,为了简化就设计成了单线程,但是如果单线程的话,遇到定时逻辑、网络请求又会阻塞住。怎么办呢?可以加一层调度逻辑。把 JS 代码封装成一个个的任务,放在一个任务队列中,主线程就不断的取任务执行就好了。

每次取任务执行,都会创建新的调用栈。

其中,定时器网络请求其实都是在别的线程执行的,执行完了之后在任务队列里放个任务,告诉主线程可以继续往下执行了

  • 因为这些异步任务是在别的线程执行完,然后通过任务队列通知下主线程,是一种事件机制,所以这个循环叫做 Event Loop
  • 这些在其他线程执行的异步任务包括定时器(setTimeoutsetInterval),UI 渲染、网络请求(XHRfetch)。
  • 但是,现在的 Event Loop 有个严重的问题,没有优先级的概念,只是按照先后顺序来执行,那如果有高优先级的任务就得不到及时的执行了。所以,得设计一套插队机制。
  • 那就搞一个高优先级的任务队列就好了,每执行完一个普通任务,都去把所有高优先级的任务给执行完,之后再去执行普通任务。

有了插队机制之后,高优任务就能得到及时的执行。这就是现在浏览器的 Event Loop,其中普通任务叫做 MacroTask(宏任务),高优任务叫做 MicroTask(微任务)

  • 宏任务包括setTimeoutsetIntervalrequestAnimationFrameAjaxfetchscript(浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务) 标签的代码
  • 微任务包括Promise.thenMutationObserverObject.observe

怎么理解宏微任务的划分呢?

  • 定时器网络请求这种都是在别的线程跑完之后通知主线程的普通异步逻辑,所以都是宏任务
  • 高优任务的这三种也很好理解,MutationObserverObject.observe 都是监听某个对象的变化的,变化是很瞬时的事情,肯定要马上响应,不然可能又变了,Promise 是组织异步流程的,异步结束调用 then 也是很高优的

这就是浏览器里的 Event Loop 的设计 :设计 Loop 机制和 Task 队列是为了支持异步,解决逻辑执行阻塞主线程的问题,设计 MicroTask 队列的插队机制是为了解决高优任务尽早执行的问题

但是后来,JS 的执行环境不只是浏览器一种了,还有了 Node.js,它同样也要解决这些问题,但是它设计出来的 Event Loop 更细致一些

例子1

    setTimeout(function() {
      console.log(1)
    }, 0);
    new Promise(function(resolve, reject) {
      console.log(2);
      resolve()
    }).then(function() {
      console.log(3)
    });
    process.nextTick(function () {
      console.log(4)
    })
    console.log(5)
  • 第一轮:主线程开始执行,遇到setTimeout,将setTimeout的回调函数丢到宏任务队列中,在往下执行new Promise立即执行,输出2then的回调函数丢到微任务队列中,再继续执行,遇到process.nextTick,同样将回调函数扔到微任务队列,再继续执行,输出5,当所有同步任务执行完成后看有没有可以执行的微任务,发现有then函数和nextTick两个微任务,先执行哪个呢?process.nextTick指定的异步任务总是发生在所有异步任务之前,因此先执行process.nextTick输出4然后执行then函数输出3,第一轮执行结束。
  • 第二轮:从宏任务队列开始,发现setTimeout回调,输出1执行完毕,因此结果是25431

例子2

    console.log('script start');
    
    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
    
    new Promise((resolve) => {
        console.log('Promise')
        resolve()
    }).then(function() {
      console.log('promise1');
    }).then(function() {
      console.log('promise2');
    });
    
    console.log('script end');
    // script start => Promise => script end => promise1 => promise2 => setTimeout

以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务

12.2 Node 中的 Event loop

首先从优先级出发理解Node EventLoop

Node.js 是一个新的 JS 运行环境,它同样要支持异步逻辑,包括定时器IO网络请求,很明显,也可以用 Event Loop 那一套来跑

但是呢,浏览器那套 ·Event Loop· 就是为浏览器设计的,对于做高性能服务器来说,那种设计还是有点粗糙了

哪里粗糙呢?

  • 浏览器的 Event Loop 只分了两层优先级,一层是宏任务,一层是微任务。但是宏任务之间没有再划分优先级,微任务之间也没有再划分优先级。
  • Node.js 任务宏任务之间也是有优先级的,比如定时器 Timer 的逻辑就比 IO 的逻辑优先级高,因为涉及到时间,越早越准确;而 close 资源的处理逻辑优先级就很低,因为不 close 最多多占点内存等资源,影响不大。
  • 于是就把宏任务队列拆成了五个优先级:TimersPendingPollCheckClose

解释一下这五种宏任务:

  • Timers Callback: 涉及到时间,肯定越早执行越准确,所以这个优先级最高很容易理解。
  • Pending Callback:处理网络、IO 等异常时的回调,有的 *nix 系统会等待发生错误的上报,所以得处理下。
  • Poll Callback:处理 IOdata,网络的 connection,服务器主要处理的就是这个。
  • Check Callback:执行 setImmediate 的回调,特点是刚执行完 IO 之后就能回调这个。
  • Close Callback:关闭资源的回调,晚点执行影响也不到,优先级最低。

所以呢,Node.jsEvent Loop 就是这样跑的了:

还有一点不同要特别注意:

Node.jsEvent Loop 并不是浏览器那种一次执行一个宏任务,然后执行所有的微任务,而是执行完一定数量的 Timers 宏任务,再去执行所有微任务,然后再执行一定数量的 Pending 的宏任务,然后再去执行所有微任务,剩余的 PollCheckClose 的宏任务也是这样。(订正:node 11 之前是这样,node 11 之后改为了每个宏任务都执行所有微任务了)

为什么这样呢?

其实按照优先级来看很容易理解:假设浏览器里面的宏任务优先级是 1,所以是按照先后顺序依次执行,也就是一个宏任务,所有的微任务,再一个宏任务,再所有的微任务。而 Node.js 的 宏任务之间也是有优先级的,所以 Node.jsEvent Loop 每次都是把当前优先级的所有宏任务跑完再去跑微任务,然后再跑下一个优先级的宏任务

  • 也就是是一定数量的 Timers 宏任务,再所有微任务,再一定数量的 Pending Callback 宏任务,再所有微任务这样。
  • 为什么说是一定数量呢 ?因为如果某个阶段宏任务太多,下个阶段就一直执行不到了,所以有个上限的限制,剩余的下个 Event Loop 再继续执行。 除了宏任务有优先级,微任务也划分了优先级,多了一个 process.nextTick 的高优先级微任务,在所有的普通微任务之前来跑。

Node.js 的 Event Loop 的完整流程就是这样的

  • Timers 阶段:执行一定数量的定时器,也就是 setTimeoutsetIntervalcallback,太多的话留到下次执行
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
  • Pending 阶段:执行一定数量的 IO 和网络的异常回调,太多的话留到下次执行
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
  • Idle/Prepare 阶段:内部用的一个阶段
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
  • Poll 阶段:执行一定数量的文件的 data 回调、网络的 connection 回调,太多的话留到下次执行。如果没有 IO 回调并且也没有 timerscheck 阶段的回调要处理,就阻塞在这里等待 IO 事件
  • 微任务:执行所有 nextTick的微任务,再执行其他的普通微任务
  • Check 阶段:执行一定数量的 setImmediatecallback,太多的话留到下次执行。
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务
  • Close 阶段:执行一定数量的 close 事件的 callback,太多的话留到下次执行。
  • 微任务:执行所有 nextTick 的微任务,再执行其他的普通微任务

比起浏览器里的 Event Loop,明显复杂了很多

Node.js 对宏任务做了优先级划分,从高到低分别是 TimersPendingPollCheckClose5 种,也对微任务做了划分,也就是 nextTick 的微任务和其他微任务。执行流程是先执行完当前优先级的一定数量的宏任务(剩下的留到下次循环),然后执行 process.nextTick 的微任务,再执行普通微任务,之后再执行下个优先级的一定数量的宏任务。这样不断循环。其中还有一个 Idle/Prepare 阶段是给 Node.js 内部逻辑用的,不需要关心

  • 改变了浏览器 Event Loop 里那种一次执行一个宏任务的方式,可以让高优先级的宏任务更早的得到执行,但是也设置了个上限,避免下个阶段一直得不到执行。
  • 还有一个特别要注意的点,就是 poll 阶段:如果执行到 poll 阶段,发现 poll 队列为空并且 timers 队列、check 队列都没有任务要执行,那么就阻塞的等在这里等 IO 事件,而不是空转。 这点设计也是因为服务器主要是处理 IO 的,阻塞在这里可以更早的响应 IO

完整的 Node.js 的 Event Loop 是这样的

对比下浏览器的 Event Loop

两个 JS 运行环境的 Event Loop 整体设计思路是差不多的,只不过 Node.jsEvent Loop 对宏任务和微任务做了更细粒度的划分,也很容易理解,毕竟 Node.js 面向的环境和浏览器不同,更重要的是服务端对性能的要求会更高

总结

  • Node.js也是一个 JS 运行环境,想支持异步同样要用 Event Loop,只不过服务端环境更复杂,对性能要求更高,所以 Node.js 对宏微任务都做了更细粒度的优先级划分
  • Node.js 里划分了 5 种宏任务,分别是 TimersPendingPollCheckClose。又划分了 2 种微任务,分别是 process.nextTick 的微任务和其他的微任务。
  • Node.jsEvent Loop 流程是执行当前阶段的一定数量的宏任务(剩余的到下个循环执行),然后执行所有微任务,一共有 TimersPendingIdle/PreparePollCheckClose 6 个阶段。(订正:node 11 之前是这样,node 11 之后改为了每个宏任务都执行所有微任务了)其中 Idle/Prepare 阶段是 Node.js 内部用的,不用关心。
  • 特别要注意的是 Poll 阶段,如果执行到这里,poll 队列为空并且 timerscheck 队列也为空,就一直阻塞在这里等待 IO,直到 timerscheck 队列有回调再继续 loop
  • Event LoopJS 为了支持异步和任务优先级而设计的一套调度逻辑,针对浏览器、Node.js 等不同环境有不同的设计(主要是任务优先级的划分粒度不同),Node.js 面对的环境更复杂、对性能要求更高,所以 Event Loop 设计的更复杂一些。

Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,这些脚本会进行 API 异步调用,process.nextTick() 方法会开始处理事件循环。下面就是 Node.js 官网提供的 Eventloop 事件循环参考流程

  • Node 中的 Event loop 和浏览器中的不相同。
  • NodeEvent loop 分为6个阶段,它们会按照顺序反复运行

  • 每次执行执行一个宏任务后会清空微任务(执行顺序和浏览器一致,在node11版本以上)
  • process.nextTick node中的微任务,当前执行栈的底部,优先级比promise要高

整个流程分为六个阶段,当这六个阶段执行完一次之后,才可以算得上执行了一次 Eventloop 的循环过程。我们来分别看下这六个阶段都做了哪些事情。

  • Timers 阶段 :这个阶段执行 setTimeoutsetInterval的回调函数,简单理解就是由这两个函数启动的回调函数。
  • I/O callbacks 阶段 :这个阶段主要执行系统级别的回调函数,比如 TCP 连接失败的回调。
  • idle,prepare 阶段 :仅系统内部使用,你只需要知道有这 2 个阶段就可以。
  • poll 阶段poll 阶段是一个重要且复杂的阶段,几乎所有 I/O 相关的回调,都在这个阶段执行(除了setTimeoutsetIntervalsetImmediate 以及一些因为 exception 意外关闭产生的回调)。检索新的 I/O 事件,执行与 I/O 相关的回调,其他情况Node.js` 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行。这个阶段的主要流程如下图所示。

  • check 阶段setImmediate() 回调函数在这里执行,setImmediate 并不是立马执行,而是当事件循环 poll 中没有新的事件处理时就执行该部分,如下代码所示。
    const fs = require('fs');
    setTimeout(() => { // 新的事件循环的起点
        console.log('1'); 
    }, 0);
    setImmediate( () => {
        console.log('setImmediate 1');
    });
    /// fs.readFile 将会在 poll 阶段执行
    fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
        if (err) throw err;
        console.log('read file success');
    });
    /// 该部分将会在首次事件循环中执行
    Promise.resolve().then(()=>{
        console.log('poll callback');
    });
    // 首次事件循环执行
    console.log('2');

在这一代码中有一个非常奇特的地方,就是 setImmediate 会在 setTimeout 之后输出。有以下几点原因:

  • setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms
  • 主流程执行完成后,超过 1ms 时,会将 setTimeout 回调函数逻辑插入到待执行回调函数 poll 队列中;
  • 由于当前 poll 队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate

因此这也验证了这句话,先执行回调函数,再执行 setImmediate

  • close callbacks 阶段 :执行一些关闭的回调函数,如 socket.on('close', ...)

除了把 Eventloop 的宏任务细分到不同阶段外。node 还引入了一个新的任务队列 Process.nextTick()

可以认为,Process.nextTick() 会在上述各个阶段结束时,在进入下一个阶段之前立即执行(优先级甚至超过 microtask 队列)

事件循环的主要包含微任务和宏任务。具体是怎么进行循环的呢

  • 微任务 :在 Node.js 中微任务包含 2 种——process.nextTickPromise微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且process.nextTick 和 Promise也存在优先级,process.nextTick 高于 Promise
  • 宏任务 :在 Node.js 中宏任务包含 4 种——setTimeoutsetIntervalsetImmediateI/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列

我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。

  • 同步代码。
  • 将异步任务插入到微任务队列或者宏任务队列中。
  • 执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。
    const fs = require('fs');
    // 首次事件循环执行
    console.log('start');
    /// 将会在新的事件循环中的阶段执行
    fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
        if (err) throw err;
        console.log('read file success');
    });
    setTimeout(() => { // 新的事件循环的起点
        console.log('setTimeout'); 
    }, 0);
    /// 该部分将会在首次事件循环中执行
    Promise.resolve().then(()=>{
        console.log('Promise callback');
    });
    /// 执行 process.nextTick
    process.nextTick(() => {
        console.log('nextTick callback');
    });
    // 首次事件循环执行
    console.log('end');

分析下上面代码的执行过程

  • 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end
  • 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;
  • 再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout
  • 先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback
  • 再执行宏任务队列,根据宏任务插入先后顺序执行 setTimeout 再执行 fs.readFile,这里需要注意,先执行 setTimeout 由于其回调时间较短,因此回调也先执行,并非是 setTimeout 先执行所以才先执行回调函数,但是它执行需要时间肯定大于 1ms,所以虽然 fs.readFile 先于setTimeout 执行,但是 setTimeout 执行更快,所以先输出 setTimeout ,最后输出 read file success
    // 输出结果
    start
    end
    nextTick callback
    Promise callback
    setTimeout
    read file success

当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:

    const fs = require('fs');
    setTimeout(() => { // 新的事件循环的起点
        console.log('1'); 
        fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
            if (err) throw err;
            console.log('read file sync success');
        });
    }, 0);
    /// 回调将会在新的事件循环之前
    fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
        if (err) throw err;
        console.log('read file success');
    });
    /// 该部分将会在首次事件循环中执行
    Promise.resolve().then(()=>{
        console.log('poll callback');
    });
    // 首次事件循环执行
    console.log('2');

在上面代码中,有 2 个宏任务和 1 个微任务,宏任务是 setTimeout 和 fs.readFile,微任务是 Promise.resolve

  • 整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。
  • 接下来执行微任务,输出 poll callback
  • 再执行宏任务中的 fs.readFile 和 setTimeout,由于 fs.readFile 优先级高,先执行 fs.readFile。但是处理时间长于 1ms,因此会先执行 setTimeout 的回调函数,输出 1。这个阶段在执行过程中又会产生新的宏任务 fs.readFile,因此又将该 fs.readFile 插入宏任务队列
  • 最后由于只剩下宏任务了 fs.readFile,因此执行该宏任务,并等待处理完成后的回调,输出 read file sync success
    // 结果
    2
    poll callback
    1
    read file success
    read file sync success

process.nextTick() 和 Vue 的 nextTick

Node.js 和浏览器端宏任务队列的另一个很重要的不同点是,浏览器端任务队列每轮事件循环仅出队一个回调函数接着去执行微任务队列;而 Node.js 端只要轮到执行某个宏任务队列,则会执行完队列中所有的当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行。

    setTimeout(() => {
        console.log('setTimeout');
    }, 0);
    setImmediate(() => {
        console.log('setImmediate');
    })
    // 这里可能会输出 setTimeout,setImmediate
    // 可能也会相反的输出,这取决于性能
    // 因为可能进入 event loop 用了不到 1 毫秒,这时候会执行 setImmediate
    // 否则会执行 setTimeout

上面介绍的都是 macrotask 的执行情况,microtask 会在以上每个阶段完成后立即执行

    setTimeout(()=>{
        console.log('timer1')
    
        Promise.resolve().then(function() {
            console.log('promise1')
        })
    }, 0)
    
    setTimeout(()=>{
        console.log('timer2')
    
        Promise.resolve().then(function() {
            console.log('promise2')
        })
    }, 0)
    
    // 以上代码在浏览器和 node 中打印情况是不同的
    // 浏览器中一定打印 timer1, promise1, timer2, promise2
    // node 中可能打印 timer1, timer2, promise1, promise2
    // 也可能打印 timer1, promise1, timer2, promise2

Node 中的 process.nextTick 会先于其他 microtask 执行

    setTimeout(() => {
     console.log("timer1");
    
     Promise.resolve().then(function() {
       console.log("promise1");
     });
    }, 0);
    
    // poll阶段执行
    fs.readFile('./test',()=>{
      // 在poll阶段里面 如果有setImmediate优先执行,setTimeout处于事件循环顶端 poll下面就是setImmediate
      setTimeout(()=>console.log('setTimeout'),0)
      setImmediate(()=>console.log('setImmediate'),0)
    })
    
    process.nextTick(() => {
     console.log("nextTick");
    });
    // nextTick, timer1, promise1,setImmediate,setTimeout

对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask

谁来启动这个循环过程,循环条件是什么?

当 Node.js 启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的 API、调度定时器,或者 process.nextTick(),然后再开始处理事件循环。因此可以这样理解,Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。

总结来说,Node.js 事件循环的发起点有 4 个:

  • Node.js 启动后;
  • setTimeout 回调函数;
  • setInterval 回调函数;
  • 也可能是一次 I/O 后的回调函数。

无限循环有没有终点

当所有的微任务和宏任务都清空的时候,虽然当前没有任务可执行了,但是也并不能代表循环结束了。因为可能存在当前还未回调的异步 I/O,所以这个循环是没有终点的,只要进程在,并且有新的任务存在,就会去执行

Node.js 是单线程的还是多线程的?

主线程是单线程执行的,但是 Node.js 存在多线程执行,多线程包括 setTimeout 和异步 I/O 事件。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化

EventLoop 对渲染的影响

  • 想必你之前在业务开发中也遇到过 requestIdlecallback 和 requestAnimationFrame,这两个函数在我们之前的内容中没有讲过,但是当你开始考虑它们在 Eventloop 的生命周期的哪一步触发,或者这两个方法的回调会在微任务队列还是宏任务队列执行的时候,才发现好像没有想象中那么简单。这两个方法其实也并不属于 JS 的原生方法,而是浏览器宿主环境提供的方法,因为它们牵扯到另一个问题:渲染。
  • 我们知道浏览器作为一个复杂的应用是多线程工作的,除了运行 JS 的线程外,还有渲染线程、定时器触发线程、HTTP 请求线程,等等。JS 线程可以读取并且修改 DOM,而渲染线程也需要读取 DOM,这是一个典型的多线程竞争临界资源的问题。所以浏览器就把这两个线程设计成互斥的,即同时只能有一个线程在执行
  • 渲染原本就不应该出现在 Eventloop 相关的知识体系里,但是因为 Eventloop 显然是在讨论 JS 如何运行的问题,而渲染则是浏览器另外一个线程的工作。但是 requestAnimationFrame的出现却把这两件事情给关联起来
  • 通过调用 requestAnimationFrame 我们可以在下次渲染之前执行回调函数。那下次渲染具体是哪个时间点呢?渲染和 Eventloop 有什么关系呢?
    • 简单来说,就是在每一次 Eventloop 的末尾,判断当前页面是否处于渲染时机,就是重新渲染
  • 有屏幕的硬件限制,比如 60Hz 刷新率,简而言之就是 1 秒刷新了 60 次,16.6ms 刷新一次。这个时候浏览器的渲染间隔时间就没必要小于 16.6ms,因为就算渲染了屏幕上也看不到。当然浏览器也不能保证一定会每 16.6ms 会渲染一次,因为还会受到处理器的性能、JavaScript 执行效率等其他因素影响。
  • 回到 requestAnimationFrame,这个 API 保证在下次浏览器渲染之前一定会被调用,实际上我们完全可以把它看成是一个高级版的 setInterval。它们都是在一段时间后执行回调,但是前者的间隔时间是由浏览器自己不断调整的,而后者只能由用户指定。这样的特性也决定了 requestAnimationFrame 更适合用来做针对每一帧来修改的动画效果
  • 当然 requestAnimationFrame 不是 Eventloop 里的宏任务,或者说它并不在 Eventloop 的生命周期里,只是浏览器又开放的一个在渲染之前发生的新的 hook。另外需要注意的是微任务的认知概念也需要更新,在执行 animation callback 时也有可能产生微任务(比如 promise 的 callback),会放到 animation queue 处理完后再执行。所以微任务并不是像之前说的那样在每一轮 Eventloop 后处理,而是在 JS 的函数调用栈清空后处理

但是 requestIdlecallback 却是一个更好理解的概念。当宏任务队列中没有任务可以处理时,浏览器可能存在“空闲状态”。这段空闲时间可以被 requestIdlecallback 利用起来执行一些优先级不高、不必立即执行的任务,如下图所示:

12.3 实现一个Promise A+ 规范

最好是实现一遍 Promise A+ 规范,多少有点印象,当然面试官也不会叫你默写一个完整的出来,但是你起码要知道实现原理

    /**
     * Promises/A+规范 实现一个promise
     * https://promisesaplus.com/
    */
    
    const EMUM = {
      PENDING: 'PENDING',
      FULFILLED: 'FULFILLED',
      REJECTED: 'REJECTED'
    }
    
    // x 返回值
    // promise2 then的时候new的promise
    // promise2的resolve, reject
    const resolvePromise = (x, promise2, resolve, reject)=>{
      // 解析promise的值解析promise2是成功还是失败 传递到下层then
      if(x === promise2) {
        reject(new TypeError('类型错误'))
      }
      // 这里的x如果是一个promise的话 可能是其他的promise,可能调用了成功 又调用了失败
      // 防止resolve的时候 又throw err抛出异常到reject了
      let called
      // 如果x是promise 那么就采用他的状态
      // 有then方法是promise
      if(typeof x === 'object' && typeof x!== null || typeof x === 'function') {
        // x是对象或函数
        try {
          let then = x.then // 缓存,不用多次取值
          if(typeof then === 'function') {
            // 是promise,调用then方法里面有this,需要传入this为x才能取到then方法里面的值this.value
            then.call(x, y=>{// 成功
              // y值可能也是一个promise 如resolve(new Promise()) 此时的y==new Promise()
              // 递归解析y,直到拿到普通的值resolve(x出去)
              if(called) return;
              called = true;
    
              resolvePromise(y, promise2, resolve, reject)
            },r=>{// 一旦失败直接失败
              if(called) return;
              called = true;
              reject(r)
            })
          } else {
            // 普通对象不是promise
            resolve(x)
          }
        } catch (e) {
          // 对象取值可能报错,用defineProperty定义get 抛出异常
          if(called) return;
          called = true;
          reject(e)
        }
      } else {
        // x是普通值
        resolve(x) // 直接成功
      }
      
    }
    class myPromise {
      constructor(executor) {
        this.status = EMUM.PENDING // 当前状态
        this.value = undefined // resolve接收值
        this.reason = undefined // reject失败返回值
    
        /**
         * 同一个promise可以then多次(发布订阅模式)
         * 调用then时 当前状态是等待态,需要将当前成功或失败的回调存放起来(订阅)
         * 调用resolve时 将订阅函数进行执行(发布)
        */
        // 成功队列
        this.onResolvedCallbacks = []
        // 失败队列
        this.onRejectedCallbacks = []
        const resolve = value =>{
          // 如果value是一个promise,需要递归解析
          // 如 myPromise.resolve(new myPromise()) 需要解析value
          if(value instanceof myPromise) {
            // 不停的解析 直到值不是promise
            return value.then(resolve,reject)
          }
    
          if(this.status === EMUM.PENDING) {
            this.status = EMUM.FULFILLED
            this.value = value
    
            this.onResolvedCallbacks.forEach(fn=>fn())
          }
        }
        const reject = reason =>{
          if(this.status === EMUM.PENDING) {
            this.status = EMUM.REJECTED
            this.reason = reason
    
            this.onRejectedCallbacks.forEach(fn=>fn())
          }
        }
        try {
          executor(resolve,reject)
        } catch(e) {
          reject(e)
        }
      }
      then(onFulFilled, onRejected) {
        // 透传 处理默认不传的情况
        // new Promise((resolve,reject)=>{
        //   resolve(1)
        // }).then().then().then(d=>{})
        // new Promise((resolve,reject)=>{
        //   resolve(1)
        // }).then(v=>v).then(v=>v).then(d=>{})
        // new Promise((resolve,reject)=>{
        //   reject(1)
        // }).then().then().then(null, e=>{console.log(e)})
        // new Promise((resolve,reject)=>{
        //   reject(1)
        // }).then(null,e=>{throw e}).then(null,e=>{throw e}).then(null,e=>{console.log(e)})
        onFulFilled = typeof onFulFilled === 'function' ? onFulFilled : v => v
        onRejected = typeof onRejected === 'function' ? onRejected : err => {throw err}
    
        // 调用then 创建一个新的promise
        let promise2 = new myPromise((resolve,reject)=>{
          // 根据value判断是resolve 还是reject value也可能是promise
          if(this.status === EMUM.FULFILLED) {
            setTimeout(() => {
              try {
                // 成功回调结果
                let x = onFulFilled(this.value)
                // 解析promise
                resolvePromise(x, promise2,resolve,reject)
              } catch (error) {
                reject(error)
              }
            }, 0);
          }
          if(this.status === EMUM.REJECTED) {
            setTimeout(() => {
              try {
                let x = onRejected(this.reason)
                // 解析promise
                resolvePromise(x, promise2,resolve,reject)
              } catch (error) {
                reject(error)
              }
            }, 0);
          }
          // 用户还未调用resolve或reject方法
          if(this.status === EMUM.PENDING) {
            this.onResolvedCallbacks.push(()=>{
              try {
                let x = onFulFilled(this.value)
                // 解析promise
                resolvePromise(x, promise2,resolve,reject)
              } catch (error) {
                reject(error)
              }
            })
            this.onRejectedCallbacks.push(()=>{
              try {
                let x = onRejected(this.reason)
                // 解析promise
                resolvePromise(x, promise2,resolve,reject)
              } catch (error) {
                reject(error)
              }
            })
          }
        })
        
        return promise2
      }
      catch(errCallback) {
        // 等同于没有成功,把失败放进去而已
        return this.then(null, errCallback)
      }
      // myPromise.resolve 具备等待功能的 如果参数的promise会等待promise解析完毕在向下执行
      static resolve(val) {
        return new myPromise((resolve,reject)=>{
          resolve(val)
        })
      }
      // myPromise.reject 直接将值返回
      static reject(reason) {
        return new myPromise((resolve,reject)=>{
          reject(reason)
        })
      }
      // finally传入的函数 无论成功或失败都执行
      // Promise.reject(100).finally(()=>{console.log(1)}).then(d=>console.log('success',d)).catch(er=>console.log('faild',er))
      // Promise.reject(100).finally(()=>new Promise()).then(d=>console.log(d)).catch(er=>)
      finally(callback) {
        return this.then((val)=>{
          return myPromise.resolve(callback()).then(()=>val)
        },(err)=>{
          return myPromise.resolve(callback()).then(()=>{throw err})
        })
      }
      // Promise.all
      static all(values) {
        return new myPromise((resolve,reject)=>{
          let resultArr = []
          let orderIndex = 0
          const processResultByKey = (value,index)=>{
            resultArr[index] = value 
            // 处理完全部
            if(++orderIndex === values.length) {
              resolve(resultArr) // 处理完成的结果返回去
            }
          }
          for (let i = 0; i < values.length; i++) {
            const value = values[i];
            // 是promise
            if(value && typeof value.then === 'function') {
              value.then((val)=>{
                processResultByKey(val,i)
              },reject)
            } else {
              // 不是promise情况
              processResultByKey(value,i)
            }
          }
        })
      }
      static race(promises) {
        // 采用最新成功或失败的作为结果
        return new myPromise((resolve,reject)=>{
          for (let i = 0; i < promises.length; i++) {
            let val = promises[i]
            if(val && typeof val.then === 'function') {
              // 任何一个promise先调用resolve或reject就返回结果了 也就是返回执行最快的那个promise的结果
              val.then(resolve,reject)
            }else{
              // 普通值
              resolve(val)
            }
          }
        })
      }
    }
    
    module.exports = myPromise

测试

    /**
     * =====测试用例-====
     */
    // let promise1 = new myPromise((resolve,reject)=>{
    //   setTimeout(() => {
    //     resolve('成功')
    //   }, 900);
    // })
    
    // promise1.then(val=>{
    //   console.log('success', val)
    // },reason=>{
    //   console.log('fail', reason)
    // })
    
    /**
     * then的使用方式 普通值意味不是promise
     * 
     * 1、then中的回调有两个方法 成功或失败 他们的结果返回(普通值)会传递给外层的下一个then中
     * 2、可以在成功或失败中抛出异常,走到下一次then的失败中
     * 3、返回的是一个promsie,那么会用这个promise的状态作为结果,会用promise的结果向下传递
     * 4、错误处理,会默认先找离自己最新的错误处理,找不到就向下查找,找打了就执行
     */
    
    // read('./name.txt').then(data=>{
    //   return '123'
    // }).then(data=>{
      
    // }).then(null,err=>{
    
    // })
    // // .catch(err=>{ // catch就是没有成功的promise
    
    // // })
    
    /**
     * promise.then实现原理:通过每次返回一个新的promise来实现(promise一旦成功就不能失败,失败就不能成功)
     * 
     */
    
    // function read(data) {
    //   return new myPromise((resolve,reject)=>{
    //     setTimeout(() => {
    //       resolve(new myPromise((resolve,reject)=>resolve(data)))
    //     }, 1000);
    //   })
    // }
    
    // let promise2 = read({name: 'poetry'}).then(data=>{
    //   return data
    // }).then().then().then(data=>{
    //   console.log(data,'-data-')
    // },(err)=>{
    //   console.log(err,'-err-')
    // })
    
    // finally测试
    // myPromise
    //   .resolve(100)
    //   .finally(()=>{
    //     return new myPromise((resolve,reject)=>setTimeout(() => {
    //       resolve(100)
    //     }, 100))
    //   })
    //   .then(d=>console.log('finally success',d))
    //   .catch(er=>console.log(er, 'finally err'))
    
    
    /**
     * promise.all 测试
     * 
     * myPromise.all 解决并发问题 多个异步并发获取最终的结果
    */
    
    // myPromise.all([1,2,3,4,new myPromise((resolve,reject)=>{
    //   setTimeout(() => {
    //     resolve('ok1')
    //   }, 1000);
    // }),new myPromise((resolve,reject)=>{
    //   setTimeout(() => {
    //     resolve('ok2')
    //   }, 1000);
    // })]).then(d=>{
    //   console.log(d,'myPromise.all.resolve')
    // }).catch(err=>{
    //   console.log(err,'myPromise.all.reject')
    // })
    
    
    // 实现promise中断请求
    let promise = new Promise((resolve,reject)=>{
      setTimeout(() => {
        // 模拟接口调用 ajax调用超时
        resolve('成功') 
      }, 10000);
    })
    
    function promiseWrap(promise) {
      // 包装一个promise 可以控制原来的promise是成功 还是失败
      let abort
      let newPromsie = new myPromise((resolve,reject)=>{
        abort = reject
      })
      // 只要控制newPromsie失败,就可以控制被包装的promise走向失败
      // Promise.race 任何一个先成功或者失败 就可以获得结果
      let p = myPromise.race([promise, newPromsie])
      p.abort = abort
    
      return p
    }
    
    let newPromise = promiseWrap(promise)
    
    setTimeout(() => {
      // 超过3秒超时
      newPromise.abort('请求超时')
    }, 3000);
    
    newPromise.then(d=>{
      console.log('d',d)
    }).catch(err=>{
      console.log('err',err)
    })
    
    
    // 使用promises-aplus-tests 测试写的promise是否规范
    // 全局安装 cnpm i -g promises-aplus-tests
    // 命令行执行 promises-aplus-tests promise.js
    // 测试入口 产生延迟对象
    myPromise.defer = myPromise.deferred = function () {
      let dfd = {}
      dfd.promise = new myPromise((resolve,reject)=>{
        dfd.resolve = resolve
        dfd.reject = reject
      })
      return dfd
    }
    
    // 延迟对象用户
    // ![](https://s.poetries.work/images/20210509172817.png)
    // promise解决嵌套问题
    // function readData(url) {
    //   let dfd = myPromise.defer()
    //   fs.readFile(url, 'utf8', function (err,data) {
    //     if(err) {
    //       dfd.reject()
    //     }
    //     dfd.resolve(data)
    //   })
    //   return dfd.promise
    // }
    // readData().then(d=>{
    //   return d
    // })

12.4 setTimeout、Promise、Async / Await 的区别

  • 首先,我们先来了解一下基本概念:
    • js EventLoop 事件循环机制:
    • JavaScript的事件分两种,宏任务(macro-task)和微任务(micro-task)
  • 宏任务:包括整体代码scriptsetTimeoutsetInterval
  • 微任务:Promise.then(非new Promise),process.nextTick(node中)
  • 事件的执行顺序,是先执行宏任务,然后执行微任务,这个是基础,任务可以有同步任务和异步任务,同步的进入主线程,异步的进入Event Table并注册函数,异步事件完成后,会将回调函数放入Event Queue中(宏任务和微任务是不同的Event Queue),同步任务执行完成后,会从Event Queue中读取事件放入主线程执行,回调函数中可能还会包含不同的任务,因此会循环执行上述操作。
  • 注意: setTimeOut并不是直接的把你的回掉函数放进上述的异步队列中去,而是在定时器的时间到了之后,把回掉函数放到执行异步队列中去。如果此时这个队列已经有很多任务了,那就排在他们的后面。这也就解释了为什么setTimeOut为什么不能精准的执行的问题了。
  • setTimeout执行需要满足两个条件:
    • 主进程必须是空闲的状态,如果到时间了,主进程不空闲也不会执行你的回掉函数
    • 这个回掉函数需要等到插入异步队列时前面的异步函数都执行完了,才会执行
  • 上面是比较官方的解释,说一下自己的理解吧:
    • 了解了什么是宏任务和微任务,就好理解多了,首先执行 宏任务 => 微任务的Event Queue => 宏任务的Event Queue
  • promiseasync/await
    • 首先,new Promise是同步的任务,会被放到主进程中去立即执行。而.then()函数是异步任务会放到异步队列中去,那什么时候放到异步队列中去呢?当你的promise状态结束的时候,就会立即放进异步队列中去了。
    • async关键字的函数会返回一个promise对象,如果里面没有await,执行起来等同于普通函数;如果没有awaitasync函数并没有很厉害是不是
    • await 关键字要在 async 关键字函数的内部,await 写在外面会报错;await如同他的语意,就是在等待,等待右侧的表达式完成。此时的await会让出线程,阻塞async内后续的代码,先去执行async外的代码。等外面的同步代码执行完毕,才会执行里面的后续代码。就算await的不是promise对象,是一个同步函数,也会等这样操作

根据图片显示我们来整理一下流程:

  • 执行console.log('script start'),输出script start
  • 执行setTimeout,是一个异步动作,放入宏任务异步队列中;
  • 执行async1(),输出async1 start,继续向下执行;
  • 执行async2(),输出async2,并返回了一个promise对象,await让出了线程,把返回的promise加入了微任务异步队列,所以async1()下面的代码也要等待上面完成后继续执行;
  • 执行 new Promise,输出promise1,然后将resolve放入微任务异步队列;
  • 执行console.log('script end'),输出script end
  • 到此同步的代码就都执行完成了,然后去微任务异步队列里去获取任务
  • 接下来执行resolveasync2返回的promise返回的),输出了async1 end
  • 然后执行resolvenew Promise的),输出了promise2
  • 最后执行setTimeout,输出了settimeout

12.5 setTimeout(fn, 0)多久才执行,Event Loop

setTimeout 按照顺序放到队列里面,然后等待函数调用栈清空之后才开始执行,而这些操作进入队列的顺序,则由设定的延迟时间来决定

12.6 async原理

async/await语法糖就是使用Generator函数+自动执行器来运作的

    // 定义了一个promise,用来模拟异步请求,作用是传入参数++
    function getNum(num){
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(num+1)
            }, 1000)
        })
    }
    
    //自动执行器,如果一个Generator函数没有执行完,则递归调用
    function asyncFun(func){
      var gen = func();
    
      function next(data){
        var result = gen.next(data);
        if (result.done) return result.value;
        result.value.then(function(data){
          next(data);
        });
      }
    
      next();
    }
    
    // 所需要执行的Generator函数,内部的数据在执行完成一步的promise之后,再调用下一步
    var func = function* (){
      var f1 = yield getNum(1);
      var f2 = yield getNum(f1);
      console.log(f2) ;
    };
    asyncFun(func);
  • 在执行的过程中,判断一个函数的promise是否完成,如果已经完成,将结果传入下一个函数,继续重复此步骤
  • 每一个 next() 方法返回值的 value 属性为一个 Promise 对象,所以我们为其添加 then 方法, 在 then 方法里面接着运行 next 方法挪移遍历器指针,直到 Generator函数运行完成
Last Updated:
Contributors: leeguooooo