本文从Event Loop
、Promise
、Generator
、async await
入手,系统的回顾 JavaScript 的异步机制及发展历程。javascript
须要提醒的是,文本没有讨论 nodejs 的异步机制。html
本文是『horseshoe·Async专题』系列文章之一,后续会有更多专题推出java
GitHub地址(持续更新):horseshoenode
博客地址(文章排版真的很漂亮):matiji.cngit
若是以为对你有帮助,欢迎来 GitHub 点 Star 或者来个人博客亲口告诉我github
🌖🌗🌘 事件循环 🌒🌓🌔web
也许咱们都据说过JavaScript是事件驱动的这种说法。各类异步任务经过事件的形式和主线程通讯,保证网页流畅的用户体验。而异步能够说是JavaScript最伟大的特性之一(也许没有之一)。算法
如今咱们就从Chrome浏览器的主要进程入手,深刻的理解这个机制是如何运行的。npm
咱们看一下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进程手下又有好多线程,它们各司其职。
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
错误。
如今咱们进入主题。
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
复制代码
整个执行过程是怎样的呢?
click
。同时将setTimeout的回调放入宏任务队列,将Promise的回调放入微任务队列。由于修改了DOM元素,触发MutationObserver事件,将MutationObserver的回调放入微任务队列。回顾一下,如今宏任务队列里有两个回调,分别是外元素的DOM事件回调
和setTimeout的回调
;微任务队列里也有两个回调,分别是Promise的回调
和MutationObserver的回调
。promise
和mutate
。click
。由于两个DOM事件回调是同样的,过程再也不重复。再次回顾一下,如今宏任务队列里有两个回调,分别是两个setTimeout的回调
;微任务队列里也有两个回调,分别是Promise的回调
和MutationObserver的回调
。promise
和mutate
。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是一个表现为状态机的异步容器。
它有如下几个特色:
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真的只是一个异步容器而已。
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
。
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),
);
复制代码
所谓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实例组成的数组,而后生成一个新的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.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),
);
复制代码
上面这个例子能够实现网络超时触发指定操做。
它的做用是接受一个值,返回一个状态是fulfilled
的Promise实例。
Promise.resolve('biu');
复制代码
new Promise(resolve => resolve('biu'));
复制代码
它是以上写法的语法糖。
它的做用是接受一个值,返回一个状态是rejected
的Promise实例。
Promise.reject('biu');
复制代码
new Promise((resolve, reject) => reject('biu'));
复制代码
它是以上写法的语法糖。
若是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开了挂,它能够暂停手头的任务,先干别的,而后在恰当的时机手动切换回来。
这是一种纤程或者协程的概念,相比线程切换更加轻量化的切换方式。
在讲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是一个生成器,它生成的究竟是什么呢?
对咯,他生成的就是一个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解决的是流程控制的问题。
它能够在执行过程暂时中断,先执行别的程序,可是它的执行上下文并无销毁,仍然能够在须要的时候切换回来,继续往下执行。
最重要的优点在于,它看起来是同步的语法,可是却能够异步执行。
对于一个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中,咱们在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();
复制代码
有人说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 或者来个人博客亲口告诉我