前端异步专题 | 从Promise开始聊异步(附Node和浏览器中Promise的差异)


‘异步’ 这个概念如果放到十年前的08,09年的时候,大家会觉得: 哇~ 这是一个新鲜的概念,不用再把所有Web页面同步处理了,节省了服务资源的同时也提升了用户体验。也正是从那个时候开始,我们开始关注前后端分离这个概念。
经过10年的努力,我们现在很高兴的看到,前端已经快速的成长为一门有着独立发展方向的技术。这一切也就是从异步这个关键的点开始的,因此可见 异步对于前端来说意味着什么?大概就是意味着基石和根本吧。

这篇文章将不局限于上述的Http异步网络请求这个独立的场景,将细数一下前端发展过程中对于异步这个概念是如何逐步落实的。

什么是 ‘承诺’

可能我们的思维固定化了,毕竟在漫长的JS脚本语言发展的过程中,回调函数就曾经是异步编程的标准解决方案,直到现在WebAPI和NodeJs中还保留着大量的APi使用回调函数作为异步结束的处理。为了解决回调函数这个解决方案在开发体验上的弱势。ES6支持了Promise这样使用同步编程的方式来开发异步程序

Promise 就是那个承诺

在之前我们开发回调函数的时候,没有人知道哪个函数先执行,哪个随之执行,除非我们把要逐次进行的函数进行嵌套,让程序依照回调的层级从深层到浅层的执行

我们可能经常会听说这样的一句话Promise 是一个表现为状态机的异步容器。怎么理解这句话呢:

  • 状态机: Promise 可以感知到程序状态发生了变化
    • 从正在执行 -> 成功
    • 从正在执行 -> 失败
  • 异步容器: 他是一个容器,里面存储着异步程序执行的过程。
    • 但是从容器来讲,他不关心程序是如何执行的,只关心状态是怎么变化的。
    • 容器的另一个特性就是会屏蔽掉外部的影响,外部的操作不会改变异步程序的发生状态。

都在用的Promise

我们在看完 # ES6-Promise 的文档介绍的时候,都会跃跃欲试的使用其提供的API方法来进行开发和升级,并大呼过瘾。在兴奋之余,我们也一起来盘点一下那些年我们使用Promise。

01 状态机

1. Promise 状态变化

我们之前尝试着理解了--状态机这个概念。也清晰的知道了这个Promise 的一个重要特性。

new Promise((resolve, reject) => {
    resolve('程序执行成功');
    setTimeout(() => reject('程序执行失败'), 5000);
}).then(console.log, console.log);
复制代码

结论:

  • 那么,从我们的已知来看,不管我们去等待多久,程序在resolve之后都不会再进行reject。
2. 触发状态后的代码

我们会想到第二个问题:
Promise 执行函数中,在resolve或者reject触发之后的代码会不会执行呢?

new Promise((resolve, reject) => {
    resolve('程序执行成功');
    console.log('后面的程序会不会执行呢');
    
    setTimeout(() => reject('程序执行失败'), 5000);
}).then(console.log, console.log);
复制代码

OUTPUT:

后面的程序会不会执行呢
程序执行成功
复制代码

结论:

  • 在触发Promise状态改变的方法(resolve 或 reject)之后的代码会照常执行
  • then方法会在异步函数执行体全部代码执行完成之后再执行。
3.执行顺序

在任何程序中,代码的执行顺序都是很重要的。既然说Promise是一个异步容器,容器中或外的代码是同步的还是异步的,甄别他们的执行顺序也是需要有明确认识的。

console.log('A');

new Promise((resolve, reject) => {
    console.log('B');
    resolve('C');
    console.log('D');
}).then(console.log, console.error);

console.log('E');
复制代码

首先要区分一下,哪些代码是同步的,那些代码是异步执行的。

  • Promise 之外的代码是同步的。
  • Promise 参数函数中的代码是同步的。
  • 状态改变之后的代码是异步的。

OUTPUT:

A B D E C 
复制代码

结论:

  • Promise中只有then之后的代码才是异步执行的。

思考: 在Promise构造函数的参数函数中,代码是同步执行的。如果在函数体中存在异步方法,比如 setTimeout() 执行顺序会发生什么变化?
这部分内容会在浏览器异步机制部分提到

4.最佳实践

Promise 作为一个异步容器,他存在的意义就是为了改变Promise的状态。那么在状态已经触发之后的代码就变得没有意义了。如果你已经断定 resolve 或 reject 后的代码无意义。可以使用 return resolve() / return reject() 避免发生不必要的错误。

new Promise((resolve, reject) => {
    return resolve('程序执行成功');
    console.log('后面的程序会不会执行呢');
    setTimeout(() => reject('程序执行失败'), 5000);
}).then(console.log, console.log);
复制代码

作为一个状态机,我们只需要关注Promise的状态变化即可。状态变化才会触发异步执行。峰回路转,情况千变万化。还是要关注他状态机的本质。


02 Promise API

说到Promise的API,就到了大家比较熟悉的内容了。在ES6发展之前社区就有对Promise的社区实现,凡是被大规模认可的实现官方也很快就会给出支持,这也是JavaScript语言得以不断进步的原因。

1. Promise.prototype.then()

从Api的使用来看,then有两个接受参数分别对应着的是 Promise 构造函数的参数函数的成功结果和失败结果,也就是状态机将状态变为了 成功 或者是 失败

其实对于Promise.prototype.then 这个api很容易理解,总结来看

  • Part01: .then可以用于链式调用,也可以不用。
  • Part02:.then的本质是创建了一个新的隐形的Promise,因此可以继续链式调用。
  • Part03:.then的参数函数(回调函数)在触发之前,Promise的状态已经发生了变化。
  • Part04:.then只有在Promise的参数函数中,有错误发生的时候才会有reject。
const p1= new Promise((resolve, reject) => resolve('hello'));

const p2 = p1.then(value => {
    console.log(value); // hello
    return value;
});

const p3 = p2.then(console.log); // hello
复制代码

从上边的总结来看,.then 是在Promise原型上的Promise.prototype.then。要想让p2和p3的then能够成功执行,必须保证前面调用then的那个对象是一个Promise

...
const p2 = p1.then(value => {
    // 替换这里
    return Promise.resolve(value);
});
const p3 = p2.then(console.log); // hello
复制代码

结论:

  • 使用 Promise.resolve() 和在 .then里面直接用return返回可以得到同样的结果。
  • .then函数(方法)的执行结果是一个新的 Promise。
  • .then如果要是返回空值,相当于 Promise.resolve();

来继续看.then 的最后一个Part,我们知道.then有两个回调函数,第一个是在成功时候触发的,第二个是在失败时候触发的。

const p1 = new Promise((resolve, reject) => resolve('hello'));

const p2 = p1.then(value => {
    // return abcd; // ReferenceError: x is not defined
    return Promise.reject('手动错误');
    //  VM258:4 Uncaught (in promise) 手动错误

});

const p3 = promiseB.then(console.log, console.error);

复制代码

以上的DEMO是触发第二个回调函数的两种方法:程序错误OR手动抛错(逻辑错误)。这两种的侧重点可能不禁相同,因此可以区别来使用。

2. Promise.prototype.catch()

这个Api从某些角度来看是.then方法的一个小变种或者说是语法糖。怎么来理解这个呢,在then的回调函数中,已经有对于err的处理,只不过在链式调用的过程中,如果每一步都进行err的处理会严重的阻塞我们的开发的流畅性。因此也就诞生了 .catch来捕获异常。

除了.catch的Api之外,有以下常见总结:

  • Part01:.catch会捕获整个Promise链路上的异常。
  • Part02:.catch捕获的异常包括 程序错误 && 手动抛错(逻辑错误)
  • Part03:Promise 会将所有的内部错误内部处理,不会影响外部的逻辑。

详细来说:

// 捕获异常
new Promise((resolve, reject) => {
    console.log(x);
    resolve('hello Mr.ZA');
}).then(res => {
    console.log(y);
}).catch(err => {
    console.log('err', err);
})
setTimeout(() => { console.log('log: 后续程序') }, 1000);

// err VM8513:7 ReferenceError: x is not defined
//    at <anonymous>:2:14
//    at new Promise (<anonymous>)
//    at <anonymous>:1:1
// log: 后续程序

new Promise((resolve, reject) => {
    resolve(1);
    console.log(x);   // 区别在这里
}).then(res => {
    console.log(y);
}).catch(err => {
    console.log(err);
})

ReferenceError: y is not defined
    at <anonymous>:6:14
复制代码

结论:

  • .catch捕获的异常不是所有的异常,而是捕获第一个影响状态变化的异常。
  • 也就是说,在整个Promise中,状态一旦变化了,后续的错误也就不那么重要了,你既不能捕获,也不能处理。上面的例子就运用了这个细节。
  • 所有Promise中出现的异常,不管你捕获与否或是处理与否都不会影响Promise之外的程序继续执行。【浏览器事件机制会说明原因】
  • Promise 中的错误会依次捕获和传递,如果之前捕获了异常就不会继续传递。
new Promise((resolve, reject) => {
    console.log(x);
    resolve(1);
}).catch(err => {
    if(err) { console.log('异常捕获')};
    return 'continue progress';
}).then(res => {
    console.log('res: ', res);
})

// 异常捕获
// res: continue progress    
复制代码
  • .catch的位置不一定是在最后面,它和其他的api一样都会返回一个新的Promise为链式调用提供服务。写在最后面符合我们对开发流程的认知。
3. Promise.prototype.finally()

finally在英语上来讲是最终的意思,放在Promise的Api中,它会被我们理解为不管状态如何变化,都会发生的事情

对于.finally,有以下常见总结:

  • .finally是Promise状态机状态变化的兜底方案,也是无论如何都能执行的。
  • .finally这个Api的回调函数没有参数
  • .finally不一定放在链式调用的最后面,如果他在链式调用的中间,他前面的resolve或者reject传出的值会跳过finally传入下面的链式调用中。
// 伪代码,模拟发送请求处理loading的问题。
new Promise((resolve, reject) => {
    this.loading = true;
    $.ajax(url, data, function(res) {
        resolve('res');
    })    
}).finally(() => {
    this.loading = false;
    return '尝试更改';
}).then(value => {
    // handle value
    console.log(value),  // res
}).catch(
    // handle error
    error => console.error(error),
);
复制代码

结论:

  • .finally 会更关注于状态的变化过程而不是状态变化带来的影响。
  • .finally 如果在其中的回调中尝试更改之前的流转的值的时候,不能获得成功,但是如果有抛错产生,会被错误处理程序依次捕获。

接下来关注一下这个Api的兼容性问题,我想之所以你还没有使用这种方法来减少重复的工作,很有可能是因为这个Api出世的时间比较晚。

MDN 的资料显示,这个API是ES2018引入TC39规范的也就是 ES9。

下面来看看这个Api的兼容性问题有以下关注点,根据自己情况食用吧。


  • Chrome 63: 2018-04
  • Node: 10+: 2018-10
4. Promise.resolve() && Promise.reject()
  • 这两个Api不是在Promise的原型上,不用 new Promise()来创建实例。
  • 这两个Api可以认为是Promise 提供的两个语法糖。
Promise.resolve = new Promise((resolve,reject)=>resolve('xx'));

Promise.reject = new Promise((resolve, reject)=>reject('xx'));
复制代码
5. Promise.all() && Promise.race()

这两个Api应该是Promise里面比较难理解的Api了,但是在使用上他们其实很简单。我们还是要追求一下实现的原理,这样我们在使用Api的时候也不会那么迷惑什么时候应该有什么样的结果。这两个Api也经常会放到一块做一些对比。

对于他们来讲,有以下常见总结:

  • .all().race()不是Promise的原型方法,因此在使用他们的时候不用new Promise()
  • .all().race()都接收一个数组为参数,返回的也是一个数组。如果参数数组中的值不是一个Promise实例,那么会被转换成直接返回。
  • .all()中如果有一个Promise执行出错,将停止执行返回错误。全部成功之后才返回值数组。
  • .race()中如果第一个Promise完成了就直接返回,不等待其余执行完毕。

对于这些官方的Api及其用法,有人曾提出一个结论,使用Promise.all可以并发的执行异步动作,得到性能的提升。那么其中的原理是什么呢?为什么单线程的JavaScript会有异步性能提升呢?我们来看下其中的原因。

如果你有关于【微任务和宏任务】的理解,下面的内容会更加容易理解。

Promise.all([
    new Promise((resolve)=>{
        setTimeout(() => {
            console.log('-', 0)
            resolve(0);
        }, 1000)
    }),
    new Promise((resolve) => {
        setTimeout(()=>{
            console.log('-',1)
            resolve(1);
        }, 2000)
    })
]).then(res=>console.log(res))
复制代码

执行的过程:

  1. Promise.all 会依次先把两个 pending 状态的Promise实例放入栈中,并记录下他们的顺序编号。
  2. 然后判断他们的是不是一个可以执行的程序,如果不是说明可以返回了。
  3. 返回的过程就是把执行之后的值放到跟原来对应的顺序的位置上,等着其他程序执行完毕。
  4. 是可以执行的Promise实例,就去重复2 -> 3的过程。
  5. 前文说到.then是会创建一个新的Promise执行,因此在执行数组中实例的时候会创建新的 Promise(新的微任务)
  6. 即使新的Promise 中有异步执行的内容,也要等所有的微任务完成才会执行。因此所有的新的Promise的创建过程会优先于其他异步任务。
  7. 之后的异步任务(无论是IO,Http,定时器)都属于宏任务,被微任务加入之后会在同一个EventLoop中执行,也就完成了Promise的并发。

下面来看一下Promise.all 的源码实现。

Promise.all = function (arr) {
    // ... Step0: 返回新的Promise
    return new Promise(function (resolve, reject) {
        
        var args = Array.prototype.slice.call(arr);
        if (args.length === 0) return resolve([]);
        var remaining = args.length;
        function res(i, val) {
            //...
        };
        // Step 1. 对数组进行同步循环
        for (var i = 0; i < args.length; i++) {
            // Step 2. 执行这些个Promise实例。
            res(i, args[i]);
        }
    });
};
复制代码
  • Step0: 返回全新的 Promise 实例,拥有Promise 原型的方法。
  • Step1: 数组同步循环,这里操作是按照顺序执行的,数组内容是传入Promise实例等异步处理方法(不是结果,此时实例没有执行过)。
  • Step2: 执行传入的Promise实例拿到结果。

到目前为止:
程序都是同步执行的, 先后顺序之分。也并没有体现出并发。接着看 res这个方法。

function res(i, val) {
    // Step 3: 确认要执行的那个Promise实例
    if (val && (typeof val === 'object' || typeof val === 'function')) {
        // Step 4: 创建.then 也就是一个新的Promise微任务
        var then = val.then;
        if (typeof then === 'function') {
            then.call(
                val,
                function (val) {
                    res(i, val);
                },
                reject
            );
            return;
        }
    } 
    
    args[i] = val;
    
    if (--remaining === 0) {
        resolve(args);
    }
// race
// for (; i < len; i++) {
//      promises[i].then(resolver,rejecter);
// } 
}
复制代码
  • Step 3: 确认要执行的那个Promise实例。
  • Step 4: 创建.then 也就是一个新的Promise微任务。

结果也就大概简化成了:

setTimeout(() => {
    console.log(1)
}, 1000)

setTimeout(() => {
    console.log(2)
}, 1500)

复制代码
  • 这样打印1,2总用时 约等于1500ms
  • 因为他们是同时 加入自己的异步线程中执行,回调相差500ms进入任务队列的。
复制代码

结论:了解宏任务和微任务可以有效缓解焦虑。

附件:这是一篇测试Promise.all执行的文章


区分进程和线程

也许我们在讲JavaScript的时候,都会去说Js是一个单线程的拥有异步特性的事件驱动的解释型脚本语言。虽然它是单线程的,但是在保证流畅性和性能优化方便拥有各种各样的异步任务和主线程进行通信,异步可以说是Js的一大难点和重点。很多时候初学者们都在为这个异步任务何时执行而感到迷茫。在了解异步机制之前,我们还是需要在一下基础的概念或者理论上达成一个有效共识,这样会很大程度上帮助我们。

01 什么是进程,线程

现在我们就从Chrome浏览器的主要进程入手,了解一下我们常用的工具是如何切分这些线程和进程的。这里有一些关于进程、线程、多线程相关总结。

从通俗易懂的角度来理解:

  • 进程 像是一个工厂,进程拥有独立的资源 -- 独立的内存空间。
  • 进程 之间相互独立,没有更大型的内存空间包裹。
  • 进程(工厂)之间想要通信,可以借助第三方进程 -- 管理进程。
  • 线程和进程相比是低一级的概念,可以理解成一个工人。
  • 完成一个独立的任务,需要线程之间互相合作。
  • 在同一个进程内的多个线程,共享进程的资源 -- 共享内存空间。

用偏官方的话术来表示一下:

  1. 进程 是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位)
  2. 线程 是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)

02 浏览器是多进程的

根据上面的知识,我们很容易就能发现浏览器作为很多程序的集合体,它的设计一定是一个多进程的,如果他只有一个进程那么体验会差到爆炸。我们也经常会听到这样的一个说法,说Google Chrome浏览器是一个性能怪兽,往往打开它内存就会飙升。那么一个浏览器中,有哪些进程呢:

  • Browser进程。这是浏览器的主进程。
  • 第三方插件进程。
  • GPU进程。
  • Renderer进程。

从Chrome的任务管理器中,可以清晰的看到那些正在运行在我们浏览器上的进程。


扩展一下:关于Chrome有好多种内存管理的机制,这也是Chrome强大的地方,可以在浏览器里面输入 chrome://flags 进行设置。

  • Process-per-site-instance。每打开一个网站,然后从这个网站链开的一系列网站都属于一个进程。这也是Chrome的默认进程模型。
  • Process-per-site。同域名范畴的网站属于一个进程。
  • Process-per-tab。每一个页面都是一个独立的进程。这就是外界盛传的进程模型。
  • Single Process。传统浏览器的单进程模型。

03 浏览器内核

对于整个浏览器来讲,我们上文说到了浏览器有自身的浏览器进程。但是这个进程对于每个标签页中显示的网页内容来讲,帮助不大。它只负责一个调度管理的作用。真正在浏览器大部分窗口内工作的还是Renderer进程。因此我们把 Renderer进程称之为浏览器内核。

来了解一下Renderer进程包含哪些线程:

  • GUI渲染线程。
  • JavaScript引擎线程。对于Chrome浏览器而言,这个线程上跑的就是威震海内的V8引擎。
  • 事件触发线程。
  • 定时器线程。
  • 异步HTTP请求线程。

他们在Renderer进程下,各司其职。关于他们的详细工作,估计又是一篇系列长文。待我写完之后,会补充一个链接到这里。

从这些共识中,我们可以理解之前的那句对JavaScript的描述了把。JavaScrit是一个单线程( JS引擎是单线程的 )的拥有异步特性( 拥有独特的异步线程 )的事件驱动( 事件也是一个单独的线程处理 )的解释型脚本语言。


事件循环 && 异步机制

在这一章中,我们不先不关心浏览器渲染进程中的其他线程,也不关心具体的JS代码上下文,作用域等细节问题。把注意力集中在JS引擎上,从宏观上观察一下浏览器内核的一些特性。这将在很长一段时间内有助于我们梳理我们所写代码执行流程,避免意外的发生。

在继续深入研究之前,我们先来回忆一些知识点,避免有疏漏对下面的内容难以理解:

  • Renderer进程 是俗称的浏览器核心,包含 JS引擎线程,事件触发线程,定时触发器线程等
  • JS引擎 是单线程的。
  • JS引擎 在执行代码的时候分同步任务和异步任务。

01 调用(执行)过程

先来看段简单的代码理解一下调用关系。

console.log('1');
function a() {
    console.log('2');
    b();
    console.log('3');
}
function b() {
    console.log('4');
}
a(); 
// output: 1 2 4 3
复制代码

相信你已经很快就得到了答案,因为这段代码中是纯同步执行的,也没有事件,IO等异步方法。所以我们知道他的调用数序,但是程序是如何知道调用数序的呢?或者说程序执行的时候有什么很牛的办法么?
你可能怀疑这样的一个事情发生,就像刚刚学习这门技术时候的我一样认为程序会不会做下面的事情呢?

  • 偷偷的把我写在单独函数(function b)中内容在调用它的地方展开了?
  • 然后依次执行代码呢?

程序设计的时候可能没有那么的粗暴,因为这样会导致一系列的问题比如函数作用域如何处理呢?那它可能有它作为程序来讲的办法来实现这种调用 -- 执行栈(调用栈)

  • 几乎所有的计算机程序在执行的时候都是依赖于它的。
  • 既然是一个栈的结构,他就要符合栈的基本性质 -- 后进先出
  • 每调用一层函数,JS引擎就会生成它的栈帧,栈帧里保存了执行上下文。
  • 然后把栈针放入到执行栈中。等待程序的执行。
  • 在执行栈中,直到最里层的函数调用完,引擎才开始将最后进入的栈帧从栈中弹出。

在上面的程序执行的时候,调用栈的工作顺序为:


注:

  • 表格中的每一列,代表着当时的执行栈状态。
  • 只有方法和函数的调用会使用调用栈,函数声明,变量声明不会用到。

再来看下这个不一般的程序:

function hello() {
    hello();
}
hello();
复制代码

这个程序的独特之处在于,它一直在像执行栈中插入 hello() 这个栈桢,没一会我们的执行栈就会溢出,(内存溢出)。这个时候浏览器就会假死掉,报出溢出的错误。

02 分别说说那些 异步线程

我们在书写代码的时候,其实运用的 大部分是 JS这门高级语言封装的各种API,剩下的一部分Api不是JS引擎封装的,而是跟JS这么门语言处于的环境有关系的。比如在浏览器中我们直接使用的Navigator就是浏览器环境决定的,在Node.js中就不能用,同理 Node.js中的 process 浏览器也是不能用的。

1. 网络请求的异步线程

JS引擎是一个单线程的设计,但是在Web应用中少不了发送网络请求的场景,JS引擎不能完全静止的等待网络请求结束在进行下面的工作,因此我们有理由怀疑网络请求有自己的单独的线程来处理,不和主线程抢资源。

const url = 'https://xxx.com/api/getName';
fetch(url).then(res => res.json()).then(console.log);
复制代码
2. 定时器线程

首先需要知道的是,定时器线程也是脱离JS引擎的独立线程,为什么会给他这种特殊的待遇呢?道理我想很好理解:

  • JS引擎在忙着入栈和出栈呢(执行栈)如果让它来去进行时间控制,显然会经常出现时间不准确的情况。或者阻塞其他的执行。
setTimeout(function(){
    console.log('1');
}, 0);

console.log('2');
复制代码

因为是异步线程执行的,那么结果应该是 2 1

3. 事件触发线程
const $btn = document.getElementById('btn');
$btn.addEventListener('click', console.log);

const timeoutId = setTimeout(() => {
    for (let i = 0; i < 10000; i++) {
        console.log('hello');
    }
    clearTimeout(timeoutId);
}, 5000);
复制代码

看上面代码的执行过程:在5s之后开启一个事件循环,使JS引擎处于阻塞状态,讲道理的话如果事件的触发不在单独线程上解决,那么在这5s之后JS处理循环的时候,事件都不会被感知和触发(因为JS引擎阻塞了,你的入栈不会执行)。

但是事实结果确实: 在循环的开始的时候,你点击按钮也会得到响应,只不过这个响应会在循环执行完成之后发生,但是已经说明了事件被触发了。至于为什么在之后执行,我们看下一章事件循环的时候会提及。

03 任务队列

现在我们知道了,不管是JS引擎实现的还是浏览器等运行环境实现的一些Api,他们拥有特权 -- 专门处理自己事务的线程。这解决了很多问题,那么现在新问题的关键出现了,独立的线程是如何和JS引擎通信的呢。搞懂了这个问题,那么JS的异步运行的机制也就清晰了。

这应该就是我们这个章节的主角 -- 大名鼎鼎的Task Queue。我们先看一张图找找Task Queue的位置。


  • 当JS引擎从上到下将程序入栈出栈的过程中,遇到那些有异步能力的WebApi,会选择把他们放入他们自己的线程中,短暂的忽略他们。继续执行那些同步代码。
  • 在各自的线程里完成处理之后,会将这些异步结果以回调的形式放入 Task Queue中。
  • 等待再次回到JS引擎中。
  • 想回到JS引擎的执行序列中,需要一定等到JS引擎空闲(这就是为啥DOM事件例子中,按钮触发的事件在JS大循环结束之后才能触发的原因了)

对于任务队列来说,上面所列就是通用规则,就是在不断进步的过程中总会对这些规则进行不断修正。正因如此,在ES6的Promise和HTML5的MutationObserver出现之后,任务队列就变得复杂了,主要体现在:将任务队列中的任务按照等级重新确定顺序,等待Event—Loop的调用。我们接下来的任务就是对这个顺序进行研究。

04 事件循环

事件循环,就是我们经常说的那个 Event-Loop,想必大家应该都会对它有所耳闻。事件循环是任务队列和JS主引擎之间的桥梁。EventLoop触发也是有时机的,它被设计出来的目的也就是为了保证JS引擎线程的安全和稳定的。所有只有等到JS引擎空闲的时候才会通过EventLoop来取这时候在任务队列中排队等待的任务。

  • 我理解,这其实是在把异步转换成同步的过程。
  • 如此往复,即使任务队列中的方法内包含了异步方法,引擎就会按照同样的规则再给WebApi进行处理循环。这就是EventLoop的优秀设计之处。

05 任务队列的顺序问题 - 宏任务和微任务

因为宏任务和微任务既设计任务队列又跟EventLoop有关系,又是异步中很关键的一个概念,所以单独来谈谈关于宏任务和微任务的问题。本章将从HTML规范 - Event Loop入手。来看看EventLoop是怎么区分宏任务和微任务的。

  • 注意这里,Event-loop是HTML 的 Standard 而不是 ECMAScript的。
  • 因为规范和实现不同,在浏览器里和Node里,Event-Loop 有略微差别。
1. 从事实出发

首先,如果我们按照任务队列章节的内容来进行理解,队列做为一个数据结构应该是先进先出的结构,如果任务是同样存在于一个队列里的,那应该按照顺序执行。来看一个例子:

setTimeout(() => {
   console.log(1) 
},0)
Promise.resolve().then(() => {
   console.log(2) 
})
console.log(3) 

复制代码

输出: 3 2 1 肯定是大多数人都知道的结局,那么这就直接和我们对于任务队列的理解是相悖的。也就是说 任务队列 有点不一样。带着这个问题,我们去翻翻规范。

2. EventLoop 的定义

为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用这一小节描述的事件循环

从上面的例子中,我们产生了一个问题,并怀疑队列中任务仍然是有优先级的。但是按照这个思路继续想的话很容易产生矛盾的地方:

  • 为什么任务队列会有优先级,队列这个数据结构不就应该是先进先出的么。
  • 在一个队列里要对任务进行优先级运行,这样的性能成本开销是很大的。因为要保持原来的上下文等关系。

如果从性能的角度考虑,应该会设计成两个独立的列表,分别存放任务。我们还是去看规范中,怎么定义EventLoop,对于规范来讲,着实是非常详细的,总结来看有以下重要的不容错过的点:

  • 事件循环EventLoop不一定只有一个,而且很多情况下是不止一个的。
  • 事件循环是跟user agents 绑定的,每一个user agents都可以有一个事件循环(这里的User agents 可以理解成用户代理,也就是触发用户交互,脚本,网络等行为)。
  • 事件循环EventLoop可以对应一个或者多个队列。
  • 检查是否有 Microtask 微任务,如果有就执行到底。

果然从规范中,我们了解到EventLoop可以对应多个队列。流程也就变成了这样。


对于 Task Queue 和 Microtask Queue 常有这样的总结:

  • task主要包含:setTimeout、setInterval、setImmediate、I/O、UI交互事件
  • microtask主要包含:Promiseprocess.nextTickMutaionObserver

所以经过理论的验证我们的出这样的

?‍? 结论:

  • 队列有细微区分,大体分为 Task 和 Micro。
  • MicroTask 会在两个Task之间(一次EventLoop)全部执行完。
  • 目前来讲执行顺序可以大致分为。同步任务 -> Micro -> Task -> Mic1, Mic2, ... -> Task -> Mic1, Mic2

可以根据上面的结论来看一个DEMO

console.log(1)
// Part A
setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
})
// Part B
new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})
// Part C
setTimeout(() => {
    console.log(9)
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    })
})
复制代码

首先:

  1. 先执行同步代码:输出 1, 7(Promise 的参数函数是同步的)
  2. 同步的执行中,会将两个PartAPartC的两个setTimeout放入Task列表中,把PartB中的 .then产生的新的Promise放入到 Micro中。
  3. 在执行Task之前,清空Micro。输出: 8
  4. EventLoop 取一个Task。 Part A
  5. 执行PartA,输出 2,4, 把 .then 放入 Mico 然后清空它 ,输出 5
  6. EventLoop 取一个Task。 Part C
  7. 执行PartC,输出 9, 11, 把 .then 放入 Mico 然后清空它 ,输出 12
  8. 运行一下结果发现没有问题: 1, 7, 8, 2, 4, 5, 9, 11, 12
  9. Tips: 在某些浏览器上不支持原生Promise,是用基于setTimeout的方式pollyfill 的,比如 safafi 10-
3. Node中的执行顺序

在明白了Task和MicroTask的顺序之后,基本上在浏览器中就不会有应用上的问题。
注意:如果你不想在Node中使用的话这部分可以绕行避免发生混淆。接下来我们在Node环境下运行代码看看有没有什么'异常'发生。

console.log(1)
// Part A
setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(3)
        resolve()
    }).then(() => {
        console.log(4)
    })
})
// Part B
setTimeout(() => {
    console.log(5)
    new Promise(resolve => {
        console.log(6)
        resolve()
    }).then(() => {
        console.log(7)
    })
})
复制代码

根据我们之前的经验,会很快得出结果。

  • 在浏览器中输出的结果是:1,2,3,4,5,6,7
  • 在Node中的输出结果是: 1,2,3,5,6,4,7

详细的说明这个问题,我们可以先提出这样的一个怀疑

在 Node.js 中,setTimeout 和 Promise 用了同样的方法实现。通过我们之前的经验来讲,可能Node 用了和之前ES6-Promsie出现之前的方案一样,使用了setTimeout进行伪实现,也就是说Node的Promise不是微任务

带着这个疑问我翻看了Node的源码,源码(V12.3.1)在下方的链接里,这里直接来看得出这个结论,结论可能跟我们想的不太一样,又差不太多:

  • 在Node中,setTimeout的回调,和 Promise 是在 Node-api中实现的,而非V8引擎。
  • Node中的任务队列是一个链表的数据结构。
  • Promise 和 setTimeout 生成的任务队列是用的同一个 node_task_queue,都是在下一个事件循环的时候放入异步队列。
  • node_task_queue 是一个微任务队列,process.nextTick也是在这个队列中。
  • 也就是说 setTimeout 在 Node 的Timer实现中和process.nextTick 和Promise是用一个队列的。
  • 链表的顺序是 nextTick -> promise -> timer

这确实出乎我们的意料,我们用这个结论去跑一个示例,来看看能不能解释的通:

console.log(1)
// Part A
setTimeout(() => {
    console.log(2)
    process.nextTick(() => {
        console.log(3)
    })
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
    
})
// Part B
new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})
// Part C
process.nextTick(() => {
    console.log(6)
})
// Part D
setTimeout(() => {
    console.log(9)
    process.nextTick(() => {
        console.log(10)
    })
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    })
})
复制代码

来分析一下Node执行的步骤:

  • 先执行同步代码,输出: 1,7
  • 分析一下这个时候的微任务队列:
    • 按照链表的数序放入: nextTick(Part C) -> Promise(Part B 的then) -> timer(Part A , Part D)
    • Part C 输出: 6
    • Part B 输出: 8
    • Part A 输出: 2, 4
    • Part D 输出: 9, 11
  • 然后做下一个事件循环:
    • 按照链表的数序执行输出: 3, 10, 5, 2

总结输出: 1 7 6 8 2 4 9 11 3 10 5 2

这应该就是 node 与 Chrome 浏览器中, 异步机制的不同之处把。,对于有一些研究文章说不会存在稳定的输出结果,导致timer执行的不稳定,我觉得可能是Node版本的问题,12中的结果是会稳定输出的,可能是数据结构进行了升级,这个部分还是有待详细研究。


实践:Promise的异步串联

new Promise((resolve) => {
    console.log(1);
    setTimeout(() => {
        console.log(2);        
        resolve();
    }, 1000)
}).then(() => {
    console.log(3)
    setTimeout(() => {
        console.log(4)
    }, 1000)
}).then(()=>{
    console.log(5);
    setTimeout(() => {
        console.log(6)
    }, 1000)
})
复制代码

这个代码是不会得到理想输出的。输出结果为: 1 -> 2 3 5 -> 4 6

new Promise((resolve) => {
    console.log(1);
    setTimeout(() => {
        console.log(2);        
        resolve();
    }, 1000)
}).then(() => {
    console.log(3)
    return  new Promise((resolve)=>{
        setTimeout(() => {
            console.log(4)
            resolve()
        }, 1000)
    })
}).then(()=>{
    console.log(5);
    new Promise(()=>{
        setTimeout(() => {
            console.log(6)
        }, 1000)
    })
})
复制代码


  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值