馬蹄疾 | 詳解 JavaScript 非同步機制及發展歷程(萬字長文)

馬蹄疾發表於2019-05-14

本文從Event LoopPromiseGeneratorasync 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的回撥
  • 依次將微任務佇列中的回撥放入呼叫棧,此時列印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 或者來我的部落格親口告訴我

相關文章