马蹄疾 | 详解 JavaScript 异步机制及发展历程(万字长文)

本文从Event LoopPromiseGeneratorasync await入手,系统的回顾 JavaScript 的异步机制及发展历程。javascript

须要提醒的是,文本没有讨论 nodejs 的异步机制。html

本文是『horseshoe·Async专题』系列文章之一,后续会有更多专题推出java

GitHub地址(持续更新):horseshoenode

博客地址(文章排版真的很漂亮):matiji.cngit

若是以为对你有帮助,欢迎来 GitHub 点 Star 或者来个人博客亲口告诉我github

🌖🌗🌘 事件循环 🌒🌓🌔web

也许咱们都据说过JavaScript是事件驱动的这种说法。各类异步任务经过事件的形式和主线程通讯,保证网页流畅的用户体验。而异步能够说是JavaScript最伟大的特性之一(也许没有之一)。算法

如今咱们就从Chrome浏览器的主要进程入手,深刻的理解这个机制是如何运行的。npm

Chrome浏览器的主要进程

咱们看一下Chrome浏览器都有哪些主要进程。编程

  • Browser进程。这是浏览器的主进程。

  • 第三方插件进程。

  • GPU进程。

  • Renderer进程。

你们都说Chrome浏览器是内存怪兽,由于它的每个页面都是一个Renderer进程,其实这种说法是不对的。实际上,Chrome支持好几种进程模型。

  • Process-per-site-instance。每打开一个网站,而后从这个网站链开的一系列网站都属于一个进程。这也是Chrome的默认进程模型。

  • Process-per-site。同域名范畴的网站属于一个进程。

  • Process-per-tab。每个页面都是一个独立的进程。这就是外界盛传的进程模型。

  • Single Process。传统浏览器的单进程模型。

浏览器内核

如今咱们知道,除了相关联的页面可能会合并为一个进程外,咱们能够简单的认为每一个页面都会开启一个新的Renderer进程。那么这个进程里跑的程序又是什么呢?就是咱们经常说的浏览器内核,或者说渲染引擎。确切的说,是浏览器内核的一个实例。Chrome浏览器的渲染引擎叫Blink

因为浏览器主要是用来浏览网页的,因此虽然Browser进程是浏览器的主进程,但它充当的只是一个管家的角色,真正的一线业务大拿还得看Renderer进程。这也是跑在Renderer进程里的程序被称为浏览器内核(实例)的缘由。

介绍Chrome浏览器的进程系统只是为了引出Renderer进程,接下来咱们只须要关注浏览器内核与Renderer进程就能够了。

Renderer进程的主要线程

Renderer进程手下又有好多线程,它们各司其职。

  • GUI渲染线程。

  • JavaScript引擎线程。对于Chrome浏览器而言,这个线程上跑的就是威震海内的V8引擎。

  • 事件触发线程。

  • 定时器线程。

  • 异步HTTP请求线程。

调用栈

进入主题以前,咱们先引入调用栈(call stack)的概念,调用栈是JavaScript引擎执行程序的一种机制。为何要有调用栈呢?咱们举个例子。

const str = 'biu';

console.log('1');

function a() {
    console.log('2');
    b();
    console.log('3');
}

function b() {
    console.log('4');
}

a();
复制代码

咱们都知道打印的顺序是1 2 4 3

问题在于,当执行到b函数的时候,我须要记住b函数的调用位置信息,也就是执行上下文。不然执行完b函数以后,引擎可能就忘了执行console.log('3')了。调用栈就是用来干这个的,每调用一层函数,引擎就会生成它的栈帧,栈帧里保存了执行上下文,而后将它压入调用栈中。栈是一个后进先出的结构,直到最里层的函数调用完,引擎才开始将最后进入的栈帧从栈中弹出。

1 2 3 4 5 6 7 8
- - - - console.log('4') - - -
- - console.log('2') b() b() b() console.log('3') -
console.log('1') a() a() a() a() a() a() a()

能够看到,当有嵌套函数调用的时候,栈帧会经历逐渐叠加又逐渐消失的过程,这就是所谓的后进先出。

同时也要注意,诸如const str = 'biu'的变量声明是不会入栈的。

调用栈也要占用内存,因此若是调用栈过深,浏览器会报Uncaught RangeError: Maximum call stack size exceeded错误。

webAPI

如今咱们进入主题。

JavaScript引擎将代码从头执行到尾,不断的进行压栈和出栈操做。除了ECMAScript语法组成的代码以外,咱们还会写哪些代码呢?不错,还有JavaScript运行时给咱们提供的各类webAPI。运行时(runtime)简单讲就是JavaScript运行所在的环境。

咱们重点讨论三种webAPI。

const url = 'https://api.github.com/users/veedrin/repos';
fetch(url).then(res => res.json()).then(console.log);
复制代码
const url = 'https://api.github.com/users/veedrin/repos';
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = () => {
    if (xhr.status === 200) {
        console.log(xhr.response);
    }
}
xhr.send();
复制代码

发起异步的HTTP请求,这几乎是一个网页必要的模块。咱们知道HTTP请求的速度和结果取决于当前网络环境和服务器的状态,JavaScript引擎没法原地等待,因此浏览器得另开一个线程来处理HTTP请求,这就是以前提到的异步HTTP请求线程

const timeoutId = setTimeout(() => {
    console.log(Date.now());
    clearTimeout(timeoutId);
}, 5000);
复制代码
const intervalId = setInterval(() => {
    console.log(Date.now());
}, 1000);
复制代码
const immediateId = setImmediate(() => {
    console.log(Date.now());
    clearImmediate(immediateId);
});
复制代码

定时器也是一个棘手的问题。首先,JavaScript引擎一样没法原地等待;其次,即使不等待,JavaScript引擎也得执行后面的代码,根本无暇给定时器定时。因此于情于理,都得为定时器单独开一个线程,这就是以前提到的定时器线程

const $btn = document.getElementById('btn');
$btn.addEventListener('click', console.log);
复制代码

按道理来说,DOM事件没什么异步动做,直接绑定就好了,不会影响后面代码的执行。

别急,咱们来看一个例子。

const $btn = document.getElementById('btn');
$btn.addEventListener('click', console.log);
const timeoutId = setTimeout(() => {
    for (let i = 0; i < 10000; i++) {
        console.log('biu');
    }
    clearTimeout(timeoutId);
}, 5000);
复制代码

运行代码,先绑定DOM事件,大约5秒钟后开启一个循环。注意,若是在循环结束以前点击按钮,浏览器控制台会打印什么呢?

结果是先打印10000个biu,接着会打印Event对象。

试想一下,你点击按钮的时候,JavaScript引擎还在处理该死的循环,根本没空理你。那为何点击事件可以被响应呢(虽然有延时)?确定是有另一个线程在监听DOM事件。这就是以前提到的事件触发线程

任务队列

好的,如今咱们知道有几类webAPI是单独的线程在处理。可是,处理完以后的回调总归是要由JavaScript引擎线程来执行的吧?这些线程是如何与JavaScript引擎线程通讯的呢?

这就要提到大名鼎鼎的任务队列(Task Queue)。

其实不管是HTTP请求仍是定时器仍是DOM事件,咱们均可以统称它们为事件。很好,各自的线程把各自的webAPI处理完,完成以后怎么办呢?它要把相应的回调函数放入一个叫作任务队列的数据结构里。队列和栈不同,队列是先进先出的,讲究一个先来后到的顺序。

有不少文章认为任务队列是由JavaScript引擎线程维护的,也有不少文章认为任务队列是由事件触发线程维护的。

根据上文的描述,事件触发线程是专门用来处理DOM事件的。

而后咱们来论证,为何任务队列不是由JavaScript引擎线程维护的。假如JavaScript引擎线程在执行代码的同时,其余线程要给任务队列添加事件,这时候它哪忙得过来呢?

因此根据个人理解,任务队列应该是由一个专门的线程维护的。咱们就叫它任务队列线程吧。

事件循环

JavaScript引擎线程把全部的代码执行完了一遍,如今它能够歇着了吗?也许吧,接下来它还有一个任务,就是不停的去轮询任务队列,若是任务队列是空的,它就能够歇一会,若是任务队列中有回调,它就要当即执行这些回调。

这个过程会一直进行,它就是事件循环(Event Loop)。

咱们总结一下这个过程:

  • 第一阶段,JavaScript引擎线程从头至尾把脚本代码执行一遍,碰到须要其余线程处理的代码则交给其余线程处理。
  • 第二阶段,JavaScript引擎线程专一于处理事件。它会不断的去轮询任务队列,执行任务队列中的事件。这个过程又能够分解为轮询任务队列-执行任务队列中的事件-更新页面视图的无限往复。对,别忘了更新页面视图(若是须要的话),虽然更新页面视图是GUI渲染线程 处理的。

这些事件,在任务队列里面也被称为任务。可是事情没这么简单,任务还分优先级,这就是咱们常据说的宏任务和微任务。

宏任务

既然任务分为宏任务和微任务,那是否是得有两个任务队列呢?

此言差矣。

首先咱们得知道,事件循环可不止一个。除了window event loop以外,还有worker event loop。而且同源的页面会共享一个window event loop。

A window event loop is the event loop used by similar-origin window agents. User agents may share an event loop across similar-origin window agents.

其次咱们要区分任务和任务源。什么叫任务源呢?就是这个任务是从哪里来的。是从addEventListener来的呢,仍是从setTimeout来的。为何要这么区分呢?好比键盘和鼠标事件,就要把它的响应优先级提升,以便尽量的提升网页浏览的用户体验。虽然都是任务,命可分贵贱呢!

因此不一样任务源的任务会放入不一样的任务队列里,浏览器根据本身的算法来决定先取哪一个队列里的任务。

总结起来,宏任务有至少一个任务队列,微任务只有一个任务队列。

微任务

哪些异步事件是微任务?Promise的回调、MutationObserver的回调以及nodejs中process.nextTick的回调。

<div id="outer">
    <div id="inner">请点击</div>
</div>
复制代码
const $outer = document.getElementById('outer');
const $inner = document.getElementById('inner');

new MutationObserver(() => {
    console.log('mutate');
}).observe($inner, {
    childList: true,
});

function onClick() {
    console.log('click');
    setTimeout(() => console.log('timeout'), 0);
    Promise.resolve().then(() => console.log('promise'));
    $inner.innerHTML = '已点击';
}

$inner.addEventListener('click', onClick);
$outer.addEventListener('click', onClick);
复制代码

咱们先来看执行顺序。

click
promise
mutate
click
promise
mutate
timeout
timeout
复制代码

整个执行过程是怎样的呢?

  • 从头至尾初始执行脚本代码。给DOM元素添加事件监听。
  • 用户触发内元素的DOM事件,同时冒泡触发外元素的DOM事件。将内元素和外元素的DOM事件回调添加到宏任务队列中。
  • 由于此时调用栈中是空闲的,因此将内元素的DOM事件回调放入调用栈。
  • 执行回调,此时打印click。同时将setTimeout的回调放入宏任务队列,将Promise的回调放入微任务队列。由于修改了DOM元素,触发MutationObserver事件,将MutationObserver的回调放入微任务队列。回顾一下,如今宏任务队列里有两个回调,分别是外元素的DOM事件回调setTimeout的回调;微任务队列里也有两个回调,分别是Promise的回调MutationObserver的回调
  • 依次将微任务队列中的回调放入调用栈,此时打印promisemutate
  • 将外元素的DOM事件回调放入调用栈。执行回调,此时打印click。由于两个DOM事件回调是同样的,过程再也不重复。再次回顾一下,如今宏任务队列里有两个回调,分别是两个setTimeout的回调;微任务队列里也有两个回调,分别是Promise的回调MutationObserver的回调
  • 依次将微任务队列中的回调放入调用栈,此时打印promisemutate
  • 最后依次将setTimeout的回调放入调用栈执行,此时打印两次timeout

规律是什么呢?宏任务与宏任务之间,积压的全部微任务会一次性执行完毕。这就比如超市排队结帐,轮到你结帐的时候,你忽然想顺手买一盒冈本。难道超市会要求你先把以前的帐结完,而后从新排队吗?不会,超市会顺便帮你把冈本的帐也结了。这样效率更高不是么?虽然不知道内部的处理细节,可是我以为标准区分两种任务类型也是出于性能的考虑吧。

$inner.click();
复制代码

若是DOM事件不是用户触发的,而是程序触发的,会有什么不同吗?

click
click
promise
mutate
promise
timeout
timeout
复制代码

严格的说,这时候并无触发事件,而是直接执行onClick函数。翻译一下就是下面这样的效果。

onClick();
onClick();
复制代码

这样就解释了为何会先打印两次click。而MutationObserver会合并多个事件,因此只打印一次mutate。全部微任务依然会在下一个宏任务以前执行,因此最后才打印两次timeout

更新页面视图

咱们再来看一个例子。

const $btn = document.getElementById('btn');

function onClick() {
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 1');
        $btn.style.color = '#f00';
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 2');
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 3');
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 4');
        // alert(1);
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 5');
        // alert(1);
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 6');
    }, 1000);
    new MutationObserver(() => {
        console.log('mutate');
    }).observe($btn, {
        attributes: true,
    });
}

$btn.addEventListener('click', onClick);
复制代码

当我在第4个setTimeout添加alert,浏览器被阻断时,样式尚未生效。

有不少人说,每个宏任务执行完并附带执行完累计的微任务(咱们称它为一个宏任务周期),这时会有一个更新页面视图的窗口期,给更新页面视图预留一段时间。

可是咱们的例子也看到了,每个setTimeout都是一个宏任务,浏览器被阻断时事件循环都好几轮了,但样式依然没有生效。可见这种说法是不许确的。

而当我在第5个setTimeout添加alert,浏览器被阻断时,有很大的几率(并非必定)样式会生效。这说明何时更新页面视图是由浏览器决定的,并无一个准确的时机。

总结

JavaScript引擎首先从头至尾初始执行脚本代码,没必要多言。

若是初始执行完毕后有微任务,则执行微任务(为何这里不属于事件循环?后面会讲到)。

以后就是不断的事件循环。

首先到宏任务队列里找宏任务,宏任务队列又分好多种,浏览器本身决定优先级。

被放入调用栈的某个宏任务,若是它的代码中又包含微任务,则执行全部微任务。

更新页面视图没有一个准确的时机,是每一个宏任务周期后更新仍是几个宏任务周期后更新,由浏览器决定。

也有一种说法认为:从头至尾初始执行脚本代码也是一个任务。

若是咱们承认这种说法,则整个代码执行过程都属于事件循环。

初始执行就是一个宏任务,这个宏任务里面若是有微任务,则执行全部微任务。

浏览器本身决定更新页面视图的时机。

不断的往复这个过程,只不过以后的宏任务是事件回调。

第二种解释好像更说得通。由于第一种解释会有一段微任务的执行不在事件循环里,这显然是不对的。

🌖🌗🌘 迟到的承诺 🌒🌓🌔

Promise是一个表现为状态机的异步容器。

它有如下几个特色:

  • 状态不受外界影响。Promise只有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。状态只能经过Promise内部提供的resolve()reject()函数改变。
  • 状态只能从pending变为fulfilled或者从pending变为rejected。而且一旦状态改变,状态就会被冻结,没法再次改变。
new Promise((resolve, reject) => {
    reject('reject');
    setTimeout(() => resolve('resolve'), 5000);
}).then(console.log, console.error);

// 不要等了,它只会打印一个 reject
复制代码
  • 若是状态发生改变,任什么时候候均可以得到最终的状态,即使改变发生在前。这与事件监听彻底不同,事件监听只能监听以后发生的事件。
const promise = new Promise(resolve => resolve('biu'));
promise.then(console.log);
setTimeout(() => promise.then(console.log), 5000);

// 打印 biu,相隔大约 5 秒钟后又打印 biu
复制代码

正是源于这些特色,Promise才勇于称本身为一个承诺

同步代码与异步代码

Promise是一个异步容器,那哪些部分是同步执行的,哪些部分是异步执行的呢?

console.log('kiu');

new Promise((resolve, reject) => {
    console.log('miu');
    resolve('biu');
    console.log('niu');
}).then(console.log, console.error);

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

咱们看执行结果。

kiu
miu
niu
piu
biu
复制代码

能够看到,Promise构造函数的参数函数是完彻底全的同步代码,只有状态改变触发的then回调才是异步代码。为啥说Promise是一个异步容器?它不关心你给它装的是啥,它只关心状态改变后的异步执行,而且承诺给你一个稳定的结果。

从这点来看,Promise真的只是一个异步容器而已。

Promise.prototype.then()

then方法接受两个回调做为参数,状态变成fulfilled时会触发第一个回调,状态变成rejected时会触发第二个回调。你能够认为then回调是Promise这个异步容器的界面和输出,在这里你能够得到你想要的结果。

then函数能够实现链式调用吗?能够的。

但你想一下,then回调触发的时候,Promise的状态已经冻结了。这时候它就是被打开盒子的薛定谔的猫,它要么是死的,要么是活的。也就是说,它不可能再次触发then回调。

那then函数是如何实现链式调用的呢?

原理就是then函数自身返回的是一个新的Promise实例。再次调用then函数的时候,实际上调用的是这个新的Promise实例的then函数。

既然Promise只是一个异步容器而已,换一个容器也不会有什么影响。

const promiseA = new Promise((resolve, reject) => resolve('biu'));

const promiseB = promiseA.then(value => {
    console.log(value);
    return value;
});

const promiseC = promiseB.then(console.log);
复制代码

结果是打印了两个 biu。

const promiseA = new Promise((resolve, reject) => resolve('biu'));

const promiseB = promiseA.then(value => {
    console.log(value);
    return Promise.resolve(value);
});

const promiseC = promiseB.then(console.log);
复制代码

Promise.resolve()咱们后面会讲到,它返回一个状态是fulfilled的Promise实例。

此次咱们手动返回了一个状态是fulfilled的新的Promise实例,能够发现结果和上一次如出一辙。说明then函数悄悄的将return 'biu'转成了return Promise.resolve('biu')。若是没有返回值呢?那就是转成return Promise.resolve(),反正得转成一个新的状态是fulfilled的Promise实例返回。

这就是then函数返回的老是一个新的Promise实例的内部原理。

想要让新Promise实例的状态从pending变成rejected,有什么办法吗?毕竟then方法也没给咱们提供reject方法。

const promiseA = new Promise((resolve, reject) => resolve('biu'));

const promiseB = promiseA.then(value => {
    console.log(value);
    return x;
});

const promiseC = promiseB.then(console.log, console.error);
复制代码

查看这里的输出结果。

biu
ReferenceError: x is not defined
    at <anonymous>:6:5
复制代码

只有程序自己发生了错误,新Promise实例才会捕获这个错误,并把错误暗地里传给reject方法。因而状态从pending变成rejected

Promise.prototype.catch()

catch方法,顾名思义是用来捕获错误的。它实际上是then方法某种方式的语法糖,因此下面两种写法的效果是同样的。

new Promise((resolve, reject) => {
    reject('biu');
}).then(
    undefined,
    error => console.error(error),
);
复制代码
new Promise((resolve, reject) => {
    reject('biu');
}).catch(
    error => console.error(error),
);
复制代码

Promise内部的错误会静默处理。你能够捕获到它,但错误自己已经变成了一个消息,并不会致使外部程序的崩溃和中止执行。

下面的代码运行中发生了错误,因此容器中后面的代码不会再执行,状态变成rejected。可是容器外面的代码不受影响,依然正常执行。

new Promise((resolve, reject) => {
    console.log(x);
    console.log('kiu');
    resolve('biu');
}).then(console.log, console.error);

setTimeout(() => console.log('piu'), 5000);
复制代码

因此你们经常说"Promise会吃掉错误"。

若是状态已经冻结,即使运行中发生了错误,Promise也会忽视它。

new Promise((resolve, reject) => {
    resolve('biu');
    console.log(x);
}).then(console.log, console.error);

setTimeout(() => console.log('piu'), 5000);
复制代码

Promise的错误若是没有被及时捕获,它会往下传递,直到被捕获。中间没有捕获代码的then函数就被忽略了。

new Promise((resolve, reject) => {
    console.log(x);
    resolve('biu');
}).then(
    value => console.log(value),
).then(
    value => console.log(value),
).then(
    value => console.log(value),
).catch(
    error => console.error(error),
);
复制代码

Promise.prototype.finally()

所谓finally就是必定会执行的方法。它和then或者catch不同的地方在于,finally方法的回调函数不接受任何参数。也就是说,它不关心容器的状态,它只是一个兜底的。

new Promise((resolve, reject) => {
    // 逻辑
}).then(
    value => {
        // 逻辑
        console.log(value);
    },
    error => {
        // 逻辑
        console.error(error);
    }
);
复制代码
new Promise((resolve, reject) => {
    // 逻辑
}).finally(
    () => {
        // 逻辑
    }
);
复制代码

若是有一段逻辑,不管状态是fulfilled仍是rejected都要执行,那放在then函数中就要写两遍,而放在finally函数中就只须要写一遍。

另外,别被finally这个名字带偏了,它不必定要定义在最后的。

new Promise((resolve, reject) => {
    resolve('biu');
}).finally(
    () => console.log('piu'),
).then(
    value => console.log(value),
).catch(
    error => console.error(error),
);
复制代码

finally函数在链条中的哪一个位置定义,就会在哪一个位置执行。从语义化的角度讲,finally不如叫anyway

Promise.all()

它接受一个由Promise实例组成的数组,而后生成一个新的Promise实例。这个新Promise实例的状态由数组的总体状态决定,只有数组的总体状态都是fulfilled时,新Promise实例的状态才是fulfilled,不然就是rejected。这就是all的含义。

Promise.all([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
复制代码
Promise.all([Promise.resolve(1), Promise.reject(2), Promise.resolve(3)]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
复制代码

数组中的项目若是不是一个Promise实例,all函数会将它封装成一个Promise实例。

Promise.all([1, 2, 3]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
复制代码

Promise.race()

它的使用方式和Promise.all()相似,可是效果不同。

Promise.all()是只有数组中的全部Promise实例的状态都是fulfilled时,它的状态才是fulfilled,不然状态就是rejected

Promise.race()则只要数组中有一个Promise实例的状态是fulfilled,它的状态就会变成fulfilled,不然状态就是rejected

就是&&||的区别是吧。

它们的返回值也不同。

Promise.all()若是成功会返回一个数组,里面是对应Promise实例的返回值。

Promise.race()若是成功会返回最早成功的那一个Promise实例的返回值。

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

const timingPromise = new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('网络请求超时')), 5000);
});

Promise.race([fetchByName('veedrin'), timingPromise]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
复制代码

上面这个例子能够实现网络超时触发指定操做。

Promise.resolve()

它的做用是接受一个值,返回一个状态是fulfilled 的Promise实例。

Promise.resolve('biu');
复制代码
new Promise(resolve => resolve('biu'));
复制代码

它是以上写法的语法糖。

Promise.reject()

它的做用是接受一个值,返回一个状态是rejected的Promise实例。

Promise.reject('biu');
复制代码
new Promise((resolve, reject) => reject('biu'));
复制代码

它是以上写法的语法糖。

嵌套Promise

若是Promise有嵌套,它们的状态又是如何变化的呢?

const promise = Promise.resolve(
    (() => {
        console.log('a');
        return Promise.resolve(
            (() => {
                console.log('b');
                return Promise.resolve(
                    (() => {
                        console.log('c');
                        return new Promise(resolve => {
                            setTimeout(() => resolve('biu'), 3000);
                        });
                    })()
                )
            })()
        );
    })()
);

promise.then(console.log);
复制代码

能够看到,例子中嵌套了四层Promise。别急,咱们先回顾一下没有嵌套的状况。

const promise = Promise.resolve('biu');

promise.then(console.log);
复制代码

咱们都知道,它会在微任务时机执行,肉眼几乎看不到等待。

可是嵌套了四层Promise的例子,由于最里层的Promise须要等待几秒才resolve,因此最外层的Promise返回的实例也要等待几秒才会打印日志。也就是说,只有最里层的Promise状态变成fulfilled,最外层的Promise状态才会变成fulfilled

若是你眼尖的话,你就会发现这个特性就是Koa中间件机制的精髓。

Koa中间件机制也是必须得等最后一个中间件resolve(若是它返回的是一个Promise实例的话)以后,才会执行洋葱圈另一半的代码。

function compose(middleware) {
    return function(context, next) {
        let index = -1;
        return dispatch(0);
        function dispatch(i) {
            if (i <= index) return Promise.reject(new Error('next() called multiple times'));
            index = i;
            let fn = middleware[i];
            if (i === middleware.length) fn = next;
            if (!fn) return Promise.resolve();
            try {
                return Promise.resolve(fn(context, function next() {
                    return dispatch(i + 1);
                }));
            } catch (err) {
                return Promise.reject(err);
            }
        }
    }
}
复制代码

🌖🌗🌘 状态机 🌒🌓🌔

Generator简单讲就是一个状态机。但它和Promise不同,它能够维持无限个状态,而且提出它的初衷并非为了解决异步编程的某些问题。

一个线程一次只能作一件任务,而且任务与任务之间不能间断。而Generator开了挂,它能够暂停手头的任务,先干别的,而后在恰当的时机手动切换回来。

这是一种纤程或者协程的概念,相比线程切换更加轻量化的切换方式。

Iterator

在讲Generator以前,咱们要先和Iterator遍历器打个照面。

Iterator对象是一个指针对象,它是一种相似于单向链表的数据结构。JavaScript经过Iterator对象来统一数组和类数组的遍历方式。

const arr = [1, 2, 3];
const iteratorConstructor = arr[Symbol.iterator];
console.log(iteratorConstructor);

// ƒ values() { [native code] }
复制代码
const obj = { a: 1, b: 2, c: 3 };
const iteratorConstructor = obj[Symbol.iterator];
console.log(iteratorConstructor);

// undefined
复制代码
const set = new Set([1, 2, 3]);
const iteratorConstructor = set[Symbol.iterator];
console.log(iteratorConstructor);

// ƒ values() { [native code] }
复制代码

咱们已经见到了Iterator对象的构造器,它藏在Symbol.iterator下面。接下来咱们生成一个Iterator对象来了解它的工做方式吧。

const arr = [1, 2, 3];
const it = arr[Symbol.iterator]();

console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: undefined, done: true }
console.log(it.next()); // { value: undefined, done: true }
复制代码

既然它是一个指针对象,调用next()的意思就是把指针日后挪一位。挪到最后一位,再日后挪,它就会一直重复我已经到头了,只能给你一个空值

Generator

Generator是一个生成器,它生成的究竟是什么呢?

对咯,他生成的就是一个Iterator对象。

function *gen() {
    yield 1;
    yield 2;
    return 3;
}

const it = gen();

console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: undefined, done: true }
console.log(it.next()); // { value: undefined, done: true }
复制代码

Generator有什么意义呢?普通函数的执行会造成一个调用栈,入栈和出栈是一口气完成的。而Generator必须得手动调用next()才能往下执行,至关于把执行的控制权从引擎交给了开发者。

因此Generator解决的是流程控制的问题。

它能够在执行过程暂时中断,先执行别的程序,可是它的执行上下文并无销毁,仍然能够在须要的时候切换回来,继续往下执行。

最重要的优点在于,它看起来是同步的语法,可是却能够异步执行。

yield

对于一个Generator函数来讲,何时该暂停呢?就是在碰到yield关键字的时候。

function *gen() {
    console.log('a');
    yield 13 * 15;
    console.log('b');
    yield 15 - 13;
    console.log('c');
    return 3;
}

const it = gen();
复制代码

看上面的例子,第一次调用it.next()的时候,碰到了第一个yield关键字,而后开始计算yield后面表达式的值,而后这个值就成了it.next()返回值中value的值,而后停在这。这一步会打印a,但不会打印b

以此类推。return的值做为最后一个状态传递出去,而后返回值的done属性就变成true,一旦它变成true,以后继续执行的返回值都是没有意义的。

这里面有一个状态传递的过程。yield把它暂停以前得到的状态传递给执行器。

那么有没有可能执行器传递状态给状态机内部呢?

function *gen() {
    const a = yield 1;
    console.log(a);
    const b = yield 2;
    console.log(b);
    return 3;
}

const it = gen();
复制代码

固然是能够的。

默认状况下,第二次执行的时候变量a的打印结果是undefined,由于yield关键字就没有返回值。

可是若是给next()传递参数,这个参数就会做为上一个yield的返回值。

it.next('biu');
复制代码

别急,第一次执行没有所谓的上一个yield,因此这个参数是没有意义的。

it.next('piu');

// 打印 piu。这个 piu 是 console.log(a) 打印出来的。
复制代码

第二次执行就不一样了。a变量接收到了next()传递进去的参数。

这有什么用?若是能在执行过程当中给状态机传值,咱们就能够改变状态机的执行条件。你能够发现,Generator是能够实现值的双向传递的。

为何要做为上一个yield的返回值?你想啊,做为上一个yield的返回值,才能改变当前代码的执行条件,这样才有价值不是嘛。这地方有点绕,仔细想想。

自动执行

好吧,既然引擎把Generator的控制权交给了开发者,那咱们就要探索出一种方法,让Generator的遍历器对象能够自动执行。

function* gen() {
    yield 1;
    yield 2;
    return 3;
}

function run(gen) {
    const it = gen();
    let state = { done: false };
    while (!state.done) {
        state = it.next();
        console.log(state);
    }
}

run(gen);
复制代码

不错,居然这么简单。

但想一想咱们是来干什么的,咱们是来探讨JavaScript异步的呀。这个简陋的run函数可以执行异步操做吗?

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    fetch(url).then(res => res.json()).then(res => console.log(res));
}

function *gen() {
    yield fetchByName('veedrin');
    yield fetchByName('tj');
}

function run(gen) {
    const it = gen();
    let state = { done: false };
    while (!state.done) {
        state = it.next();
    }
}

run(gen);
复制代码

事实证实,Generator会把fetchByName当作一个同步函数来执行,没等请求触发回调,它已经将指针指向了下一个yield。咱们的目的是让上一个异步任务完成之后才开始下一个异步任务,显然这种方式作不到。

咱们已经让Generator自动化了,可是在面对异步任务的时候,交还控制权的时机依然不对。

什么才是正确的时机呢?

在回调中交还控制权

哪一个时间点代表某个异步任务已经完成?固然是在回调中咯。

咱们来拆解一下思路。

  • 首先咱们要把异步任务的其余参数和回调参数拆分开来,由于咱们须要单独在回调中扣一下扳机。
  • 而后yield asyncTask()的返回值得是一个函数,它接受异步任务的回调做为参数。由于Generator只有yield的返回值是暴露在外面的,方便咱们控制。
  • 最后在回调中移动指针。
function thunkify(fn) {
    return (...args) => {
        return (done) => {
            args.push(done);
            fn(...args);
        }
    }
}
复制代码

这就是把异步任务的其余参数和回调参数拆分开来的法宝。是否是很简单?它经过两层闭包将原过程变成三次函数调用,第一次传入原函数,第二次传入回调以前的参数,第三次传入回调,并在最里一层闭包中又把参数整合起来传入原函数。

是的,这就是大名鼎鼎的thunkify

如下是暖男版。

function thunkify(fn) {
    return (...args) => {
        return (done) => {
            let called = false;
            args.push((...innerArgs) => {
                if (called) return;
                called = true;
                done(...innerArgs);
            });
            try {
                fn(...args);
            } catch (err) {
                done(err);
            }
        }
    }
}
复制代码

宝刀已经有了,我们去屠龙吧。

const fs = require('fs');
const thunkify = require('./thunkify');

const readFileThunk = thunkify(fs.readFile);

function *gen() {
    const valueA = yield readFileThunk('/Users/veedrin/a.md');
    console.log('a.md 的内容是:\n', valueA.toString());
    const valueB = yield readFileThunk('/Users/veedrin/b.md');
    console.log('b.md 的内容是:\n', valueB.toString());
}

function run(gen) {
    const it = gen();
    const state1 = it.next();
    state1.value((err, data) => {
        if (err) throw err;
        const state2 = it.next(data);
        state2.value((err, data) => {
            if (err) throw err;
            it.next(data);
        });
    });
}

run(gen);
复制代码

卧槽,老夫宝刀都提起来了,你让我切豆腐?

这他妈不就是把回调嵌套提到外面来了么!我为啥还要用Generator,感受默认的回调嵌套挺好的呀,有一种黑洞般的简洁和性感...

别急,这只是Thunk解决方案的PPT版本,接下来我们真的要造车并开车了哟,此处@贾跃亭。

const fs = require('fs');
const thunkify = require('./thunkify');

const readFileThunk = thunkify(fs.readFile);

function *gen() {
    const valueA = yield readFileThunk('/Users/veedrin/a.md');
    console.log('a.md 的内容是:\n', valueA.toString());
    const valueB = yield readFileThunk('/Users/veedrin/b.md');
    console.log('b.md 的内容是:\n', valueB.toString());
}

function run(gen) {
    const it = gen();
    function next(err, data) {
        const state = it.next(data);
        if (state.done) return;
        state.value(next);
    }
    next();
}

run(gen);
复制代码

咱们彻底能够把回调函数抽象出来,每移动一次指针就递归一次,而后在回调函数内部加一个中止递归的逻辑,一个通用版的run函数就写好啦。上例中的next()其实就是callback()呢。

在Promise中交还控制权

处理异步操做除了回调以外,咱们还有异步容器Promise。

和在回调中交还控制权差很少,于Promise中,咱们在then函数的函数参数中扣动扳机。

咱们来看看威震海内的co

function co(gen) {
    const it = gen();
    const state = it.next();
    function next(state) {
        if (state.done) return;
        state.value.then(res => {
            const state = it.next(res);
            next(state);
        });
    }
    next(state);
}
复制代码

其实也不复杂,就是在then函数的回调中(其实也是回调啦)移动Generator的指针,而后递归调用,继续移动指针。固然,须要有一个中止递归的逻辑。

如下是暖男版。

function isObject(value) {
    return Object === value.constructor;
}

function isGenerator(obj) {
    return typeof obj.next === 'function' && typeof obj.throw === 'function';
}

function isGeneratorFunction(obj) {
    const constructor = obj.constructor;
    if (!constructor) return false;
    if (constructor.name === GeneratorFunction || constructor.displayName === 'GeneratorFunction') return true;
    return isGenerator(constructor.prototype);
}

function isPromise(obj) {
    return typeof obj.then === 'function';
}

function toPromise(obj) {
    if (!obj) return obj;
    if (isPromise(obj)) return obj;
    if (isGenerator(obj) || isGeneratorFunction(obj)) {
        return co.call(this, obj);
    }
    if (typeof obj === 'function') {
        return thunkToPromise.call(this, obj);
    }
    if (Array.isArray(obj)) {
        return arrayToPromise.call(this, obj);
    }
    if (isObject(obj)) {
        return objectToPromise.call(this, obj);
    }
    return obj;
}

function typeError(value) {
    return new TypeError(`You may only yield a function, promise, generator, array, or object, but the following object was passed: "${String(value)}"`);
}

function co(gen) {
    const ctx = this;
    return new Promise((resolve, reject) => {
        let it;
        if (typeof gen === 'function') {
            it = gen.call(ctx);
        }
        if (!it || typeof it.next !== 'function') {
            return resolve(it);
        }
        onFulfilled();
        function onFulfilled(res) {
            let ret;
            try {
                ret = it.next(res);
            } catch (err) {
                return reject(err);
            }
            next(ret);
        }

        function onRejected(res) {
            let ret;
            try {
                ret = it.throw(res);
            } catch (err) {
                return reject(err);
            }
            next(ret);
        }
        function next(ret) {
            if (ret.done) {
                return resolve(ret.value);
            }
            const value = toPromise.call(ctx, ret.value);
            if (value && isPromise(value)) {
                return value.then(onFulfilled, onRejected);
            }
            return onRejected(typeError(ret.value));
        }
    });
}
复制代码

co是一个真正的异步解决方案,由于它暴露的接口足够简单。

import co from './co';

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

function *gen() {
    const value1 = yield fetchByName('veedrin');
    console.log(value1);
    const value2 = yield fetchByName('tj');
    console.log(value2);
}

co(gen);
复制代码

直接把Generator函数传入co函数便可,太优雅了。

🌖🌗🌘 也许是终极异步解决方案 🌒🌓🌔

上一章咱们了解了co与Generator结合的异步编程解决方案。

我知道你想说什么,写一个异步调用还得引入一个npm包(虽然是大神TJ写的包)。

妈卖批的npm!

固然是不存在的。若是一个特性足够重要,社区的呼声足够高,它就必定会被归入标准的。立刻咱们要介绍的就是血统纯正的异步编程家族终极继承人——爱新觉罗·async。

import co from 'co';

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

co(function *gen() {
    const value1 = yield fetchByName('veedrin');
    console.log(value1);
    const value2 = yield fetchByName('tj');
    console.log(value2);
});
复制代码
function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

async function fetchData() {
    const value1 = await fetchByName('veedrin');
    console.log(value1);
    const value2 = await fetchByName('tj');
    console.log(value2);
}

fetchData();
复制代码

看看这无缝升级的体验,啧啧。

灵活

别被新的关键字吓到了,它其实很是灵活。

async function noop() {
    console.log('Easy, nothing happened.');
}
复制代码

这家伙能执行吗?固然能,老伙计仍是你的老伙计。

async function noop() {
    const msg = await 'Easy, nothing happened.';
    console.log(msg);
}
复制代码

一样别慌,仍是预期的表现。

只有当await关键字后面是一个Promise的时候,它才会显现它异步控制的威力,其他时候人畜无害。

function fetchByName(name) {
    const url = `https://api.github.com/users/${name}/repos`;
    return fetch(url).then(res => res.json());
}

async function fetchData() {
    const name = await 'veedrin';
    const repos = await fetchByName(name);
    console.log(repos);
}
复制代码

虽说await关键字后面跟Promise或者非Promise均可以处理,但对它们的处理方式是不同的。非Promise表达式直接返回它的值就是了,而Promise表达式则会等待它的状态从pending变为fulfilled,而后返回resolve的参数。它隐式的作了一下处理。

注意看,fetchByName('veedrin')按道理返回的是一个Promise实例,可是咱们获得的repos值倒是一个数组,这里就是await关键字隐式处理的地方。

另外须要注意什么呢?await关键字只能定义在async函数里面。

const then = Date.now();

function sleep(duration) {
    return new Promise((resolve, reject) => {
        const id = setTimeout(() => {
            resolve(Date.now() - then);
            clearTimeout(id);
        }, duration * 1000);
    });
}

async function work() {
    [1, 2, 3].forEach(v => {
        const rest = await sleep(3);
        console.log(rest);
        return '睡醒了';
    });
}

work();

// Uncaught SyntaxError: await is only valid in async function
复制代码

行吧,那咱们把它弄到一个做用域里去。

import sleep from './sleep';

function work() {
    [1, 2, 3].forEach(async v => {
        const rest = await sleep(3);
        console.log(rest);
    });
    return '睡醒了';
}

work();
复制代码

很差意思,return '睡醒了'没等异步操做完就执行了,这应该也不是你要的效果吧。

因此这种状况,只能用for循环来代替,async和await就能长相厮守了。

import sleep from './sleep';

async function work() {
    const things = [1, 2, 3];
    for (let thing of things) {
        const rest = await sleep(3);
        console.log(rest);
    }
    return '睡醒了';
}

work();
复制代码

返回Promise实例

有人说async是Generator的语法糖。

naive,朋友们。

async可不止一颗糖哦。它是Generator、co、Promise三者的封装。若是说Generator只是一个状态机的话,那async天生就是为异步而生的。

import sleep from './sleep';

async function work() {
    const needRest = await sleep(6);
    const anotherRest = await sleep(3);
    console.log(needRest);
    console.log(anotherRest);
    return '睡醒了';
}

work().then(res => console.log('🙂', res), res => console.error('😡', res));
复制代码

由于async函数返回一个Promise实例,那它自己return的值跑哪去了呢?它成了返回的Promise实例resolve时传递的参数。也就是说return '睡醒了'在内部会转成resolve('睡醒了')

我能够保证,返回的是一个真正的Promise实例,因此其余特性向Promise看齐就行了。

并发

也许你发现了,上一节的例子大概要等9秒多才能最终结束执行。但是两个sleep之间并无依赖关系,你跟我说说我凭什么要等9秒多?

以前跟老子说要异步流程控制是否是!如今又跟老子说要并发是否是!

我…知足你。

import sleep from './sleep';

async function work() {
    const needRest = await Promise.all([sleep(6), sleep(3)]);
    console.log(needRest);
    return '睡醒了';
}

work().then(res => console.log('🙂', res), res => console.error('😡', res));
复制代码
import sleep from './sleep';

async function work() {
    const onePromise = sleep(6);
    const anotherPromise = sleep(3);
    const needRest = await onePromise;
    const anotherRest = await anotherPromise;
    console.log(needRest);
    console.log(anotherRest);
    return '睡醒了';
}

work().then(res => console.log('🙂', res), res => console.error('😡', res));
复制代码

办法也是有的,还不止一种。手段都差很少,就是把await日后挪,这样既能搂的住,又能实现并发。

大总结

关于异步的知识大致上能够分红两大块:异步机制与异步编程。

异步机制的精髓就是事件循环。

经过控制权反转(从事件通知主线程,到主线程去轮询事件),完美的解决了一个线程忙不过来的问题。

异步编程经历了从回调Promiseasync的伟大探索。异步编程的本质就是用尽量接近同步的语法去处理异步机制。

async目前来看是一种比较完美的同步化异步编程的解决方案。

但其实async是深度集成Promise的,能够说Promiseasync的底层依赖。不只如此,不少API,诸如fetch也是将Promise做为底层依赖的。

因此说一千道一万,异步编程的底色是Promise

Promise是经过什么方式来异步编程的呢?经过then函数,then函数又是经过回调来解决的。

因此呀,回调才是刻在异步编程基因里的东西。你大爷仍是你大爷!

回调换一种说法也叫事件。

这下你理解了为何说JavaScript是事件驱动的吧?

本文是『horseshoe·Async专题』系列文章之一,后续会有更多专题推出

GitHub地址(持续更新):horseshoe

博客地址(文章排版真的很漂亮):matiji.cn

若是以为对你有帮助,欢迎来 GitHub 点 Star 或者来个人博客亲口告诉我

相关文章
相关标签/搜索