本文從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 或者來個人博客親口告訴我