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


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

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

什么是 ‘承诺’

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

Promise 就是那个承诺

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

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

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

都在用的Promise

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

01 状态机

1. Promise 状态变化

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

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

结论:

  • 那么,从咱们的已知来看,无论咱们去等待多久,程序在resolve以后都不会再进行reject。

2. 触发状态后的代码

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

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

OUTPUT:chrome

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

结论:

  • 在触发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)
    })
})
复制代码
相关文章
相关标签/搜索