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

本文從Event LoopPromiseGeneratorasync await入手,系統的回顧 JavaScript 的異步機制及發展歷程。javascript

須要提醒的是,文本沒有討論 nodejs 的異步機制。html

本文是『horseshoe·Async專題』系列文章之一,後續會有更多專題推出java

GitHub地址(持續更新):horseshoenode

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

若是以爲對你有幫助,歡迎來 GitHub 點 Star 或者來個人博客親口告訴我github

🌖🌗🌘 事件循環 🌒🌓🌔web

也許咱們都據說過JavaScript是事件驅動的這種說法。各類異步任務經過事件的形式和主線程通訊,保證網頁流暢的用戶體驗。而異步能夠說是JavaScript最偉大的特性之一(也許沒有之一)。算法

如今咱們就從Chrome瀏覽器的主要進程入手,深刻的理解這個機制是如何運行的。npm

Chrome瀏覽器的主要進程

咱們看一下Chrome瀏覽器都有哪些主要進程。編程

  • Browser進程。這是瀏覽器的主進程。

  • 第三方插件進程。

  • GPU進程。

  • Renderer進程。

你們都說Chrome瀏覽器是內存怪獸,由於它的每個頁面都是一個Renderer進程,其實這種說法是不對的。實際上,Chrome支持好幾種進程模型。

  • Process-per-site-instance。每打開一個網站,而後從這個網站鏈開的一系列網站都屬於一個進程。這也是Chrome的默認進程模型。

  • Process-per-site。同域名範疇的網站屬於一個進程。

  • Process-per-tab。每個頁面都是一個獨立的進程。這就是外界盛傳的進程模型。

  • Single Process。傳統瀏覽器的單進程模型。

瀏覽器內核

如今咱們知道,除了相關聯的頁面可能會合併爲一個進程外,咱們能夠簡單的認爲每一個頁面都會開啓一個新的Renderer進程。那麼這個進程裏跑的程序又是什麼呢?就是咱們經常說的瀏覽器內核,或者說渲染引擎。確切的說,是瀏覽器內核的一個實例。Chrome瀏覽器的渲染引擎叫Blink

因爲瀏覽器主要是用來瀏覽網頁的,因此雖然Browser進程是瀏覽器的主進程,但它充當的只是一個管家的角色,真正的一線業務大拿還得看Renderer進程。這也是跑在Renderer進程裏的程序被稱爲瀏覽器內核(實例)的緣由。

介紹Chrome瀏覽器的進程系統只是爲了引出Renderer進程,接下來咱們只須要關注瀏覽器內核與Renderer進程就能夠了。

Renderer進程的主要線程

Renderer進程手下又有好多線程,它們各司其職。

  • GUI渲染線程。

  • JavaScript引擎線程。對於Chrome瀏覽器而言,這個線程上跑的就是威震海內的V8引擎。

  • 事件觸發線程。

  • 定時器線程。

  • 異步HTTP請求線程。

調用棧

進入主題以前,咱們先引入調用棧(call stack)的概念,調用棧是JavaScript引擎執行程序的一種機制。爲何要有調用棧呢?咱們舉個例子。

const str = 'biu';

console.log('1');

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

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

a();
複製代碼

咱們都知道打印的順序是1 2 4 3

問題在於,當執行到b函數的時候,我須要記住b函數的調用位置信息,也就是執行上下文。不然執行完b函數以後,引擎可能就忘了執行console.log('3')了。調用棧就是用來幹這個的,每調用一層函數,引擎就會生成它的棧幀,棧幀裏保存了執行上下文,而後將它壓入調用棧中。棧是一個後進先出的結構,直到最裏層的函數調用完,引擎纔開始將最後進入的棧幀從棧中彈出。

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

能夠看到,當有嵌套函數調用的時候,棧幀會經歷逐漸疊加又逐漸消失的過程,這就是所謂的後進先出。

同時也要注意,諸如const str = 'biu'的變量聲明是不會入棧的。

調用棧也要佔用內存,因此若是調用棧過深,瀏覽器會報Uncaught RangeError: Maximum call stack size exceeded錯誤。

webAPI

如今咱們進入主題。

JavaScript引擎將代碼從頭執行到尾,不斷的進行壓棧和出棧操做。除了ECMAScript語法組成的代碼以外,咱們還會寫哪些代碼呢?不錯,還有JavaScript運行時給咱們提供的各類webAPI。運行時(runtime)簡單講就是JavaScript運行所在的環境。

咱們重點討論三種webAPI。

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

發起異步的HTTP請求,這幾乎是一個網頁必要的模塊。咱們知道HTTP請求的速度和結果取決於當前網絡環境和服務器的狀態,JavaScript引擎沒法原地等待,因此瀏覽器得另開一個線程來處理HTTP請求,這就是以前提到的異步HTTP請求線程

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

定時器也是一個棘手的問題。首先,JavaScript引擎一樣沒法原地等待;其次,即使不等待,JavaScript引擎也得執行後面的代碼,根本無暇給定時器定時。因此於情於理,都得爲定時器單獨開一個線程,這就是以前提到的定時器線程

const $btn = document.getElementById('btn');
$btn.addEventListener('click', console.log);
複製代碼

按道理來說,DOM事件沒什麼異步動做,直接綁定就好了,不會影響後面代碼的執行。

別急,咱們來看一個例子。

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

運行代碼,先綁定DOM事件,大約5秒鐘後開啓一個循環。注意,若是在循環結束以前點擊按鈕,瀏覽器控制檯會打印什麼呢?

結果是先打印10000個biu,接着會打印Event對象。

試想一下,你點擊按鈕的時候,JavaScript引擎還在處理該死的循環,根本沒空理你。那爲何點擊事件可以被響應呢(雖然有延時)?確定是有另一個線程在監聽DOM事件。這就是以前提到的事件觸發線程

任務隊列

好的,如今咱們知道有幾類webAPI是單獨的線程在處理。可是,處理完以後的回調總歸是要由JavaScript引擎線程來執行的吧?這些線程是如何與JavaScript引擎線程通訊的呢?

這就要提到大名鼎鼎的任務隊列(Task Queue)。

其實不管是HTTP請求仍是定時器仍是DOM事件,咱們均可以統稱它們爲事件。很好,各自的線程把各自的webAPI處理完,完成以後怎麼辦呢?它要把相應的回調函數放入一個叫作任務隊列的數據結構裏。隊列和棧不同,隊列是先進先出的,講究一個先來後到的順序。

有不少文章認爲任務隊列是由JavaScript引擎線程維護的,也有不少文章認爲任務隊列是由事件觸發線程維護的。

根據上文的描述,事件觸發線程是專門用來處理DOM事件的。

而後咱們來論證,爲何任務隊列不是由JavaScript引擎線程維護的。假如JavaScript引擎線程在執行代碼的同時,其餘線程要給任務隊列添加事件,這時候它哪忙得過來呢?

因此根據個人理解,任務隊列應該是由一個專門的線程維護的。咱們就叫它任務隊列線程吧。

事件循環

JavaScript引擎線程把全部的代碼執行完了一遍,如今它能夠歇着了嗎?也許吧,接下來它還有一個任務,就是不停的去輪詢任務隊列,若是任務隊列是空的,它就能夠歇一會,若是任務隊列中有回調,它就要當即執行這些回調。

這個過程會一直進行,它就是事件循環(Event Loop)。

咱們總結一下這個過程:

  • 第一階段,JavaScript引擎線程從頭至尾把腳本代碼執行一遍,碰到須要其餘線程處理的代碼則交給其餘線程處理。
  • 第二階段,JavaScript引擎線程專一於處理事件。它會不斷的去輪詢任務隊列,執行任務隊列中的事件。這個過程又能夠分解爲輪詢任務隊列-執行任務隊列中的事件-更新頁面視圖的無限往復。對,別忘了更新頁面視圖(若是須要的話),雖然更新頁面視圖是GUI渲染線程 處理的。

這些事件,在任務隊列裏面也被稱爲任務。可是事情沒這麼簡單,任務還分優先級,這就是咱們常據說的宏任務和微任務。

宏任務

既然任務分爲宏任務和微任務,那是否是得有兩個任務隊列呢?

此言差矣。

首先咱們得知道,事件循環可不止一個。除了window event loop以外,還有worker event loop。而且同源的頁面會共享一個window event loop。

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

其次咱們要區分任務和任務源。什麼叫任務源呢?就是這個任務是從哪裏來的。是從addEventListener來的呢,仍是從setTimeout來的。爲何要這麼區分呢?好比鍵盤和鼠標事件,就要把它的響應優先級提升,以便儘量的提升網頁瀏覽的用戶體驗。雖然都是任務,命可分貴賤呢!

因此不一樣任務源的任務會放入不一樣的任務隊列裏,瀏覽器根據本身的算法來決定先取哪一個隊列裏的任務。

總結起來,宏任務有至少一個任務隊列,微任務只有一個任務隊列。

微任務

哪些異步事件是微任務?Promise的回調、MutationObserver的回調以及nodejs中process.nextTick的回調。

<div id="outer">
    <div id="inner">請點擊</div>
</div>
複製代碼
const $outer = document.getElementById('outer');
const $inner = document.getElementById('inner');

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

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

$inner.addEventListener('click', onClick);
$outer.addEventListener('click', onClick);
複製代碼

咱們先來看執行順序。

click
promise
mutate
click
promise
mutate
timeout
timeout
複製代碼

整個執行過程是怎樣的呢?

  • 從頭至尾初始執行腳本代碼。給DOM元素添加事件監聽。
  • 用戶觸發內元素的DOM事件,同時冒泡觸發外元素的DOM事件。將內元素和外元素的DOM事件回調添加到宏任務隊列中。
  • 由於此時調用棧中是空閒的,因此將內元素的DOM事件回調放入調用棧。
  • 執行回調,此時打印click。同時將setTimeout的回調放入宏任務隊列,將Promise的回調放入微任務隊列。由於修改了DOM元素,觸發MutationObserver事件,將MutationObserver的回調放入微任務隊列。回顧一下,如今宏任務隊列裏有兩個回調,分別是外元素的DOM事件回調setTimeout的回調;微任務隊列裏也有兩個回調,分別是Promise的回調MutationObserver的回調
  • 依次將微任務隊列中的回調放入調用棧,此時打印promisemutate
  • 將外元素的DOM事件回調放入調用棧。執行回調,此時打印click。由於兩個DOM事件回調是同樣的,過程再也不重複。再次回顧一下,如今宏任務隊列裏有兩個回調,分別是兩個setTimeout的回調;微任務隊列裏也有兩個回調,分別是Promise的回調MutationObserver的回調
  • 依次將微任務隊列中的回調放入調用棧,此時打印promisemutate
  • 最後依次將setTimeout的回調放入調用棧執行,此時打印兩次timeout

規律是什麼呢?宏任務與宏任務之間,積壓的全部微任務會一次性執行完畢。這就比如超市排隊結帳,輪到你結帳的時候,你忽然想順手買一盒岡本。難道超市會要求你先把以前的帳結完,而後從新排隊嗎?不會,超市會順便幫你把岡本的帳也結了。這樣效率更高不是麼?雖然不知道內部的處理細節,可是我以爲標準區分兩種任務類型也是出於性能的考慮吧。

$inner.click();
複製代碼

若是DOM事件不是用戶觸發的,而是程序觸發的,會有什麼不同嗎?

click
click
promise
mutate
promise
timeout
timeout
複製代碼

嚴格的說,這時候並無觸發事件,而是直接執行onClick函數。翻譯一下就是下面這樣的效果。

onClick();
onClick();
複製代碼

這樣就解釋了爲何會先打印兩次click。而MutationObserver會合並多個事件,因此只打印一次mutate。全部微任務依然會在下一個宏任務以前執行,因此最後纔打印兩次timeout

更新頁面視圖

咱們再來看一個例子。

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

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

$btn.addEventListener('click', onClick);
複製代碼

當我在第4個setTimeout添加alert,瀏覽器被阻斷時,樣式尚未生效。

有不少人說,每個宏任務執行完並附帶執行完累計的微任務(咱們稱它爲一個宏任務週期),這時會有一個更新頁面視圖的窗口期,給更新頁面視圖預留一段時間。

可是咱們的例子也看到了,每個setTimeout都是一個宏任務,瀏覽器被阻斷時事件循環都好幾輪了,但樣式依然沒有生效。可見這種說法是不許確的。

而當我在第5個setTimeout添加alert,瀏覽器被阻斷時,有很大的機率(並非必定)樣式會生效。這說明何時更新頁面視圖是由瀏覽器決定的,並無一個準確的時機。

總結

JavaScript引擎首先從頭至尾初始執行腳本代碼,沒必要多言。

若是初始執行完畢後有微任務,則執行微任務(爲何這裏不屬於事件循環?後面會講到)。

以後就是不斷的事件循環。

首先到宏任務隊列裏找宏任務,宏任務隊列又分好多種,瀏覽器本身決定優先級。

被放入調用棧的某個宏任務,若是它的代碼中又包含微任務,則執行全部微任務。

更新頁面視圖沒有一個準確的時機,是每一個宏任務週期後更新仍是幾個宏任務週期後更新,由瀏覽器決定。

也有一種說法認爲:從頭至尾初始執行腳本代碼也是一個任務。

若是咱們承認這種說法,則整個代碼執行過程都屬於事件循環。

初始執行就是一個宏任務,這個宏任務裏面若是有微任務,則執行全部微任務。

瀏覽器本身決定更新頁面視圖的時機。

不斷的往復這個過程,只不過以後的宏任務是事件回調。

第二種解釋好像更說得通。由於第一種解釋會有一段微任務的執行不在事件循環裏,這顯然是不對的。

🌖🌗🌘 遲到的承諾 🌒🌓🌔

Promise是一個表現爲狀態機的異步容器。

它有如下幾個特色:

  • 狀態不受外界影響。Promise只有三種狀態:pending(進行中)、fulfilled(已成功)和rejected(已失敗)。狀態只能經過Promise內部提供的resolve()reject()函數改變。
  • 狀態只能從pending變爲fulfilled或者從pending變爲rejected。而且一旦狀態改變,狀態就會被凍結,沒法再次改變。
new Promise((resolve, reject) => {
    reject('reject');
    setTimeout(() => resolve('resolve'), 5000);
}).then(console.log, console.error);

// 不要等了,它只會打印一個 reject
複製代碼
  • 若是狀態發生改變,任什麼時候候均可以得到最終的狀態,即使改變發生在前。這與事件監聽徹底不同,事件監聽只能監聽以後發生的事件。
const promise = new Promise(resolve => resolve('biu'));
promise.then(console.log);
setTimeout(() => promise.then(console.log), 5000);

// 打印 biu,相隔大約 5 秒鐘後又打印 biu
複製代碼

正是源於這些特色,Promise才勇於稱本身爲一個承諾

同步代碼與異步代碼

Promise是一個異步容器,那哪些部分是同步執行的,哪些部分是異步執行的呢?

console.log('kiu');

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

console.log('piu');
複製代碼

咱們看執行結果。

kiu
miu
niu
piu
biu
複製代碼

能夠看到,Promise構造函數的參數函數是完徹底全的同步代碼,只有狀態改變觸發的then回調纔是異步代碼。爲啥說Promise是一個異步容器?它不關心你給它裝的是啥,它只關心狀態改變後的異步執行,而且承諾給你一個穩定的結果。

從這點來看,Promise真的只是一個異步容器而已。

Promise.prototype.then()

then方法接受兩個回調做爲參數,狀態變成fulfilled時會觸發第一個回調,狀態變成rejected時會觸發第二個回調。你能夠認爲then回調是Promise這個異步容器的界面和輸出,在這裏你能夠得到你想要的結果。

then函數能夠實現鏈式調用嗎?能夠的。

但你想一下,then回調觸發的時候,Promise的狀態已經凍結了。這時候它就是被打開盒子的薛定諤的貓,它要麼是死的,要麼是活的。也就是說,它不可能再次觸發then回調。

那then函數是如何實現鏈式調用的呢?

原理就是then函數自身返回的是一個新的Promise實例。再次調用then函數的時候,實際上調用的是這個新的Promise實例的then函數。

既然Promise只是一個異步容器而已,換一個容器也不會有什麼影響。

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

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

const promiseC = promiseB.then(console.log);
複製代碼

結果是打印了兩個 biu。

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

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

const promiseC = promiseB.then(console.log);
複製代碼

Promise.resolve()咱們後面會講到,它返回一個狀態是fulfilled的Promise實例。

此次咱們手動返回了一個狀態是fulfilled的新的Promise實例,能夠發現結果和上一次如出一轍。說明then函數悄悄的將return 'biu'轉成了return Promise.resolve('biu')。若是沒有返回值呢?那就是轉成return Promise.resolve(),反正得轉成一個新的狀態是fulfilled的Promise實例返回。

這就是then函數返回的老是一個新的Promise實例的內部原理。

想要讓新Promise實例的狀態從pending變成rejected,有什麼辦法嗎?畢竟then方法也沒給咱們提供reject方法。

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

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

const promiseC = promiseB.then(console.log, console.error);
複製代碼

查看這裏的輸出結果。

biu
ReferenceError: x is not defined
    at <anonymous>:6:5
複製代碼

只有程序自己發生了錯誤,新Promise實例纔會捕獲這個錯誤,並把錯誤暗地裏傳給reject方法。因而狀態從pending變成rejected

Promise.prototype.catch()

catch方法,顧名思義是用來捕獲錯誤的。它實際上是then方法某種方式的語法糖,因此下面兩種寫法的效果是同樣的。

new Promise((resolve, reject) => {
    reject('biu');
}).then(
    undefined,
    error => console.error(error),
);
複製代碼
new Promise((resolve, reject) => {
    reject('biu');
}).catch(
    error => console.error(error),
);
複製代碼

Promise內部的錯誤會靜默處理。你能夠捕獲到它,但錯誤自己已經變成了一個消息,並不會致使外部程序的崩潰和中止執行。

下面的代碼運行中發生了錯誤,因此容器中後面的代碼不會再執行,狀態變成rejected。可是容器外面的代碼不受影響,依然正常執行。

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

setTimeout(() => console.log('piu'), 5000);
複製代碼

因此你們經常說"Promise會吃掉錯誤"。

若是狀態已經凍結,即使運行中發生了錯誤,Promise也會忽視它。

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

setTimeout(() => console.log('piu'), 5000);
複製代碼

Promise的錯誤若是沒有被及時捕獲,它會往下傳遞,直到被捕獲。中間沒有捕獲代碼的then函數就被忽略了。

new Promise((resolve, reject) => {
    console.log(x);
    resolve('biu');
}).then(
    value => console.log(value),
).then(
    value => console.log(value),
).then(
    value => console.log(value),
).catch(
    error => console.error(error),
);
複製代碼

Promise.prototype.finally()

所謂finally就是必定會執行的方法。它和then或者catch不同的地方在於,finally方法的回調函數不接受任何參數。也就是說,它不關心容器的狀態,它只是一個兜底的。

new Promise((resolve, reject) => {
    // 邏輯
}).then(
    value => {
        // 邏輯
        console.log(value);
    },
    error => {
        // 邏輯
        console.error(error);
    }
);
複製代碼
new Promise((resolve, reject) => {
    // 邏輯
}).finally(
    () => {
        // 邏輯
    }
);
複製代碼

若是有一段邏輯,不管狀態是fulfilled仍是rejected都要執行,那放在then函數中就要寫兩遍,而放在finally函數中就只須要寫一遍。

另外,別被finally這個名字帶偏了,它不必定要定義在最後的。

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

finally函數在鏈條中的哪一個位置定義,就會在哪一個位置執行。從語義化的角度講,finally不如叫anyway

Promise.all()

它接受一個由Promise實例組成的數組,而後生成一個新的Promise實例。這個新Promise實例的狀態由數組的總體狀態決定,只有數組的總體狀態都是fulfilled時,新Promise實例的狀態纔是fulfilled,不然就是rejected。這就是all的含義。

Promise.all([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
複製代碼
Promise.all([Promise.resolve(1), Promise.reject(2), Promise.resolve(3)]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
複製代碼

數組中的項目若是不是一個Promise實例,all函數會將它封裝成一個Promise實例。

Promise.all([1, 2, 3]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
複製代碼

Promise.race()

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

Promise.all()是隻有數組中的全部Promise實例的狀態都是fulfilled時,它的狀態纔是fulfilled,不然狀態就是rejected

Promise.race()則只要數組中有一個Promise實例的狀態是fulfilled,它的狀態就會變成fulfilled,不然狀態就是rejected

就是&&||的區別是吧。

它們的返回值也不同。

Promise.all()若是成功會返回一個數組,裏面是對應Promise實例的返回值。

Promise.race()若是成功會返回最早成功的那一個Promise實例的返回值。

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

const timingPromise = new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('網絡請求超時')), 5000);
});

Promise.race([fetchByName('veedrin'), timingPromise]).then(
    values => console.log(values),
).catch(
    error => console.error(error),
);
複製代碼

上面這個例子能夠實現網絡超時觸發指定操做。

Promise.resolve()

它的做用是接受一個值,返回一個狀態是fulfilled 的Promise實例。

Promise.resolve('biu');
複製代碼
new Promise(resolve => resolve('biu'));
複製代碼

它是以上寫法的語法糖。

Promise.reject()

它的做用是接受一個值,返回一個狀態是rejected的Promise實例。

Promise.reject('biu');
複製代碼
new Promise((resolve, reject) => reject('biu'));
複製代碼

它是以上寫法的語法糖。

嵌套Promise

若是Promise有嵌套,它們的狀態又是如何變化的呢?

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

promise.then(console.log);
複製代碼

能夠看到,例子中嵌套了四層Promise。別急,咱們先回顧一下沒有嵌套的狀況。

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

promise.then(console.log);
複製代碼

咱們都知道,它會在微任務時機執行,肉眼幾乎看不到等待。

可是嵌套了四層Promise的例子,由於最裏層的Promise須要等待幾秒才resolve,因此最外層的Promise返回的實例也要等待幾秒纔會打印日誌。也就是說,只有最裏層的Promise狀態變成fulfilled,最外層的Promise狀態纔會變成fulfilled

若是你眼尖的話,你就會發現這個特性就是Koa中間件機制的精髓。

Koa中間件機制也是必須得等最後一箇中間件resolve(若是它返回的是一個Promise實例的話)以後,纔會執行洋蔥圈另一半的代碼。

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

🌖🌗🌘 狀態機 🌒🌓🌔

Generator簡單講就是一個狀態機。但它和Promise不同,它能夠維持無限個狀態,而且提出它的初衷並非爲了解決異步編程的某些問題。

一個線程一次只能作一件任務,而且任務與任務之間不能間斷。而Generator開了掛,它能夠暫停手頭的任務,先幹別的,而後在恰當的時機手動切換回來。

這是一種纖程或者協程的概念,相比線程切換更加輕量化的切換方式。

Iterator

在講Generator以前,咱們要先和Iterator遍歷器打個照面。

Iterator對象是一個指針對象,它是一種相似於單向鏈表的數據結構。JavaScript經過Iterator對象來統一數組和類數組的遍歷方式。

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

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

// undefined
複製代碼
const set = new Set([1, 2, 3]);
const iteratorConstructor = set[Symbol.iterator];
console.log(iteratorConstructor);

// ƒ values() { [native code] }
複製代碼

咱們已經見到了Iterator對象的構造器,它藏在Symbol.iterator下面。接下來咱們生成一個Iterator對象來了解它的工做方式吧。

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

console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: undefined, done: true }
console.log(it.next()); // { value: undefined, done: true }
複製代碼

既然它是一個指針對象,調用next()的意思就是把指針日後挪一位。挪到最後一位,再日後挪,它就會一直重複我已經到頭了,只能給你一個空值

Generator

Generator是一個生成器,它生成的究竟是什麼呢?

對咯,他生成的就是一個Iterator對象。

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

const it = gen();

console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: undefined, done: true }
console.log(it.next()); // { value: undefined, done: true }
複製代碼

Generator有什麼意義呢?普通函數的執行會造成一個調用棧,入棧和出棧是一口氣完成的。而Generator必須得手動調用next()才能往下執行,至關於把執行的控制權從引擎交給了開發者。

因此Generator解決的是流程控制的問題。

它能夠在執行過程暫時中斷,先執行別的程序,可是它的執行上下文並無銷燬,仍然能夠在須要的時候切換回來,繼續往下執行。

最重要的優點在於,它看起來是同步的語法,可是卻能夠異步執行。

yield

對於一個Generator函數來講,何時該暫停呢?就是在碰到yield關鍵字的時候。

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

const it = gen();
複製代碼

看上面的例子,第一次調用it.next()的時候,碰到了第一個yield關鍵字,而後開始計算yield後面表達式的值,而後這個值就成了it.next()返回值中value的值,而後停在這。這一步會打印a,但不會打印b

以此類推。return的值做爲最後一個狀態傳遞出去,而後返回值的done屬性就變成true,一旦它變成true,以後繼續執行的返回值都是沒有意義的。

這裏面有一個狀態傳遞的過程。yield把它暫停以前得到的狀態傳遞給執行器。

那麼有沒有可能執行器傳遞狀態給狀態機內部呢?

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

const it = gen();
複製代碼

固然是能夠的。

默認狀況下,第二次執行的時候變量a的打印結果是undefined,由於yield關鍵字就沒有返回值。

可是若是給next()傳遞參數,這個參數就會做爲上一個yield的返回值。

it.next('biu');
複製代碼

別急,第一次執行沒有所謂的上一個yield,因此這個參數是沒有意義的。

it.next('piu');

// 打印 piu。這個 piu 是 console.log(a) 打印出來的。
複製代碼

第二次執行就不一樣了。a變量接收到了next()傳遞進去的參數。

這有什麼用?若是能在執行過程當中給狀態機傳值,咱們就能夠改變狀態機的執行條件。你能夠發現,Generator是能夠實現值的雙向傳遞的。

爲何要做爲上一個yield的返回值?你想啊,做爲上一個yield的返回值,才能改變當前代碼的執行條件,這樣纔有價值不是嘛。這地方有點繞,仔細想想。

自動執行

好吧,既然引擎把Generator的控制權交給了開發者,那咱們就要探索出一種方法,讓Generator的遍歷器對象能夠自動執行。

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

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

run(gen);
複製代碼

不錯,居然這麼簡單。

但想一想咱們是來幹什麼的,咱們是來探討JavaScript異步的呀。這個簡陋的run函數可以執行異步操做嗎?

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

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

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

run(gen);
複製代碼

事實證實,Generator會把fetchByName當作一個同步函數來執行,沒等請求觸發回調,它已經將指針指向了下一個yield。咱們的目的是讓上一個異步任務完成之後纔開始下一個異步任務,顯然這種方式作不到。

咱們已經讓Generator自動化了,可是在面對異步任務的時候,交還控制權的時機依然不對。

什麼纔是正確的時機呢?

在回調中交還控制權

哪一個時間點代表某個異步任務已經完成?固然是在回調中咯。

咱們來拆解一下思路。

  • 首先咱們要把異步任務的其餘參數和回調參數拆分開來,由於咱們須要單獨在回調中扣一下扳機。
  • 而後yield asyncTask()的返回值得是一個函數,它接受異步任務的回調做爲參數。由於Generator只有yield的返回值是暴露在外面的,方便咱們控制。
  • 最後在回調中移動指針。
function thunkify(fn) {
    return (...args) => {
        return (done) => {
            args.push(done);
            fn(...args);
        }
    }
}
複製代碼

這就是把異步任務的其餘參數和回調參數拆分開來的法寶。是否是很簡單?它經過兩層閉包將原過程變成三次函數調用,第一次傳入原函數,第二次傳入回調以前的參數,第三次傳入回調,並在最裏一層閉包中又把參數整合起來傳入原函數。

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

如下是暖男版。

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

寶刀已經有了,我們去屠龍吧。

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

const readFileThunk = thunkify(fs.readFile);

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

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

run(gen);
複製代碼

臥槽,老夫寶刀都提起來了,你讓我切豆腐?

這他媽不就是把回調嵌套提到外面來了麼!我爲啥還要用Generator,感受默認的回調嵌套挺好的呀,有一種黑洞般的簡潔和性感...

別急,這只是Thunk解決方案的PPT版本,接下來我們真的要造車並開車了喲,此處@賈躍亭。

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

const readFileThunk = thunkify(fs.readFile);

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

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

run(gen);
複製代碼

咱們徹底能夠把回調函數抽象出來,每移動一次指針就遞歸一次,而後在回調函數內部加一箇中止遞歸的邏輯,一個通用版的run函數就寫好啦。上例中的next()其實就是callback()呢。

在Promise中交還控制權

處理異步操做除了回調以外,咱們還有異步容器Promise。

和在回調中交還控制權差很少,於Promise中,咱們在then函數的函數參數中扣動扳機。

咱們來看看威震海內的co

function co(gen) {
    const it = gen();
    const state = it.next();
    function next(state) {
        if (state.done) return;
        state.value.then(res => {
            const state = it.next(res);
            next(state);
        });
    }
    next(state);
}
複製代碼

其實也不復雜,就是在then函數的回調中(其實也是回調啦)移動Generator的指針,而後遞歸調用,繼續移動指針。固然,須要有一箇中止遞歸的邏輯。

如下是暖男版。

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

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

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

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

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

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

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

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

co是一個真正的異步解決方案,由於它暴露的接口足夠簡單。

import co from './co';

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

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

co(gen);
複製代碼

直接把Generator函數傳入co函數便可,太優雅了。

🌖🌗🌘 也許是終極異步解決方案 🌒🌓🌔

上一章咱們瞭解了co與Generator結合的異步編程解決方案。

我知道你想說什麼,寫一個異步調用還得引入一個npm包(雖然是大神TJ寫的包)。

媽賣批的npm!

固然是不存在的。若是一個特性足夠重要,社區的呼聲足夠高,它就必定會被歸入標準的。立刻咱們要介紹的就是血統純正的異步編程家族終極繼承人——愛新覺羅·async。

import co from 'co';

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

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

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

fetchData();
複製代碼

看看這無縫升級的體驗,嘖嘖。

靈活

別被新的關鍵字嚇到了,它其實很是靈活。

async function noop() {
    console.log('Easy, nothing happened.');
}
複製代碼

這傢伙能執行嗎?固然能,老夥計仍是你的老夥計。

async function noop() {
    const msg = await 'Easy, nothing happened.';
    console.log(msg);
}
複製代碼

一樣別慌,仍是預期的表現。

只有當await關鍵字後面是一個Promise的時候,它纔會顯現它異步控制的威力,其他時候人畜無害。

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

async function fetchData() {
    const name = await 'veedrin';
    const repos = await fetchByName(name);
    console.log(repos);
}
複製代碼

雖說await關鍵字後面跟Promise或者非Promise均可以處理,但對它們的處理方式是不同的。非Promise表達式直接返回它的值就是了,而Promise表達式則會等待它的狀態從pending變爲fulfilled,而後返回resolve的參數。它隱式的作了一下處理。

注意看,fetchByName('veedrin')按道理返回的是一個Promise實例,可是咱們獲得的repos值倒是一個數組,這裏就是await關鍵字隱式處理的地方。

另外須要注意什麼呢?await關鍵字只能定義在async函數裏面。

const then = Date.now();

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

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

work();

// Uncaught SyntaxError: await is only valid in async function
複製代碼

行吧,那咱們把它弄到一個做用域裏去。

import sleep from './sleep';

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

work();
複製代碼

很差意思,return '睡醒了'沒等異步操做完就執行了,這應該也不是你要的效果吧。

因此這種狀況,只能用for循環來代替,async和await就能長相廝守了。

import sleep from './sleep';

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

work();
複製代碼

返回Promise實例

有人說async是Generator的語法糖。

naive,朋友們。

async可不止一顆糖哦。它是Generator、co、Promise三者的封裝。若是說Generator只是一個狀態機的話,那async天生就是爲異步而生的。

import sleep from './sleep';

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

work().then(res => console.log('🙂', res), res => console.error('😡', res));
複製代碼

由於async函數返回一個Promise實例,那它自己return的值跑哪去了呢?它成了返回的Promise實例resolve時傳遞的參數。也就是說return '睡醒了'在內部會轉成resolve('睡醒了')

我能夠保證,返回的是一個真正的Promise實例,因此其餘特性向Promise看齊就行了。

併發

也許你發現了,上一節的例子大概要等9秒多才能最終結束執行。但是兩個sleep之間並無依賴關係,你跟我說說我憑什麼要等9秒多?

以前跟老子說要異步流程控制是否是!如今又跟老子說要併發是否是!

我…知足你。

import sleep from './sleep';

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

work().then(res => console.log('🙂', res), res => console.error('😡', res));
複製代碼
import sleep from './sleep';

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

work().then(res => console.log('🙂', res), res => console.error('😡', res));
複製代碼

辦法也是有的,還不止一種。手段都差很少,就是把await日後挪,這樣既能摟的住,又能實現併發。

大總結

關於異步的知識大致上能夠分紅兩大塊:異步機制與異步編程。

異步機制的精髓就是事件循環。

經過控制權反轉(從事件通知主線程,到主線程去輪詢事件),完美的解決了一個線程忙不過來的問題。

異步編程經歷了從回調Promiseasync的偉大探索。異步編程的本質就是用盡量接近同步的語法去處理異步機制。

async目前來看是一種比較完美的同步化異步編程的解決方案。

但其實async是深度集成Promise的,能夠說Promiseasync的底層依賴。不只如此,不少API,諸如fetch也是將Promise做爲底層依賴的。

因此說一千道一萬,異步編程的底色是Promise

Promise是經過什麼方式來異步編程的呢?經過then函數,then函數又是經過回調來解決的。

因此呀,回調纔是刻在異步編程基因裏的東西。你大爺仍是你大爺!

回調換一種說法也叫事件。

這下你理解了爲何說JavaScript是事件驅動的吧?

本文是『horseshoe·Async專題』系列文章之一,後續會有更多專題推出

GitHub地址(持續更新):horseshoe

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

若是以爲對你有幫助,歡迎來 GitHub 點 Star 或者來個人博客親口告訴我

相關文章
相關標籤/搜索