本文从Event Loop
、Promise
、Generator
、async await
入手,系统的回顾 JavaScript 的异步机制及发展历程。
需要提醒的是,文本没有讨论 nodejs 的异步机制。
本文是『horseshoe·Async专题』系列文章之一,后续会有更多专题推出
GitHub地址(持续更新):horseshoe
博客地址(文章排版真的很漂亮):matiji.cn
如果觉得对你有帮助,欢迎来 GitHub 点 Star 或者来我的博客亲口告诉我
??? 事件循环 ???
也许我们都听说过JavaScript是事件驱动的这种说法。各种异步任务通过事件的形式和主线程通信,保证网页流畅的用户体验。而异步可以说是JavaScript最伟大的特性之一(也许没有之一)。
现在我们就从Chrome浏览器的主要进程入手,深入的理解这个机制是如何运行的。
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的回调
。 - 依次将微任务队列中的回调放入调用栈,此时打印
promise
和mutate
。 - 将外元素的DOM事件回调放入调用栈。执行回调,此时打印
click
。因为两个DOM事件回调是一样的,过程不再重复。再次回顾一下,现在宏任务队列里有两个回调,分别是两个setTimeout的回调
;微任务队列里也有两个回调,分别是Promise的回调
和MutationObserver的回调
。 - 依次将微任务队列中的回调放入调用栈,此时打印
promise
和mutate
。 - 最后依次将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
往后挪,这样既能搂的住,又能实现并发。
大总结
关于异步的知识大体上可以分成两大块:异步机制与异步编程。
异步机制的精髓就是事件循环。
通过控制权反转(从事件通知主线程,到主线程去轮询事件),完美的解决了一个线程忙不过来的问题。
异步编程经历了从回调
到Promise
到async
的伟大探索。异步编程的本质就是用尽可能接近同步的语法去处理异步机制。
async
目前来看是一种比较完美的同步化异步编程的解决方案。
但其实async
是深度集成Promise
的,可以说Promise
是async
的底层依赖。不仅如此,很多API,诸如fetch
也是将Promise
作为底层依赖的。
所以说一千道一万,异步编程的底色是Promise
。
而Promise
是通过什么方式来异步编程的呢?通过then
函数,then
函数又是通过回调来解决的。
所以呀,回调才是刻在异步编程基因里的东西。你大爷还是你大爷!
回调换一种说法也叫事件。
这下你理解了为什么说JavaScript是事件驱动的
吧?
本文是『horseshoe·Async专题』系列文章之一,后续会有更多专题推出
GitHub地址(持续更新):horseshoe
博客地址(文章排版真的很漂亮):matiji.cn
如果觉得对你有帮助,欢迎来 GitHub 点 Star 或者来我的博客亲口告诉我