前端異步專題 | 從Promise開始聊異步(附Node和瀏覽器中Promise的差別)


‘異步’ 這個概念若是放到十年前的08,09年的時候,你們會以爲: 哇~ 這是一個新鮮的概念,不用再把全部Web頁面同步處理了,節省了服務資源的同時也提高了用戶體驗。也正是從那個時候開始,咱們開始關注先後端分離這個概念。
通過10年的努力,咱們如今很高興的看到,前端已經快速的成長爲一門有着獨立發展方向的技術。這一切也就是從異步這個關鍵的點開始的,所以可見 異步對於前端來講意味着什麼?大概就是意味着基石和根本吧。javascript

這篇文章將不侷限於上述的Http異步網絡請求這個獨立的場景,將細數一下前端發展過程當中對於異步這個概念是如何逐步落實的。html

什麼是 ‘承諾’

可能咱們的思惟固定化了,畢竟在漫長的JS腳本語言發展的過程當中,回調函數就曾經是異步編程的標準解決方案,直到如今WebAPI和NodeJs中還保留着大量的APi使用回調函數做爲異步結束的處理。爲了解決回調函數這個解決方案在開發體驗上的弱勢。ES6支持了Promise這樣使用同步編程的方式來開發異步程序前端

Promise 就是那個承諾

在以前咱們開發回調函數的時候,沒有人知道哪一個函數先執行,哪一個隨之執行,除非咱們把要逐次進行的函數進行嵌套,讓程序依照回調的層級從深層到淺層的執行java

咱們可能常常會據說這樣的一句話Promise 是一個表現爲狀態機的異步容器。怎麼理解這句話呢:node

  • 狀態機: Promise 能夠感知到程序狀態發生了變化
    • 從正在執行 -> 成功
    • 從正在執行 -> 失敗
  • 異步容器: 他是一個容器,裏面存儲着異步程序執行的過程。
    • 可是從容器來說,他不關心程序是如何執行的,只關心狀態是怎麼變化的。
    • 容器的另外一個特性就是會屏蔽掉外部的影響,外部的操做不會改變異步程序的發生狀態。

都在用的Promise

咱們在看完 # ES6-Promise 的文檔介紹的時候,都會躍躍欲試的使用其提供的API方法來進行開發和升級,並大呼過癮。在興奮之餘,咱們也一塊兒來盤點一下那些年咱們使用Promise。es6

01 狀態機

1. Promise 狀態變化

咱們以前嘗試着理解了--狀態機這個概念。也清晰的知道了這個Promise 的一個重要特性。web

new Promise((resolve, reject) => {
    resolve('程序執行成功');
    setTimeout(() => reject('程序執行失敗'), 5000);
}).then(console.log, console.log);
複製代碼

結論:

  • 那麼,從咱們的已知來看,無論咱們去等待多久,程序在resolve以後都不會再進行reject。

2. 觸發狀態後的代碼

咱們會想到第二個問題:
Promise 執行函數中,在resolve或者reject觸發以後的代碼會不會執行呢?ajax

new Promise((resolve, reject) => {
    resolve('程序執行成功');
    console.log('後面的程序會不會執行呢');
    
    setTimeout(() => reject('程序執行失敗'), 5000);
}).then(console.log, console.log);
複製代碼

OUTPUT:chrome

後面的程序會不會執行呢
程序執行成功
複製代碼

結論:

  • 在觸發Promise狀態改變的方法(resolve 或 reject)以後的代碼會照常執行
  • then方法會在異步函數執行體所有代碼執行完成以後再執行。

3.執行順序

在任何程序中,代碼的執行順序都是很重要的。既然說Promise是一個異步容器,容器中或外的代碼是同步的仍是異步的,甄別他們的執行順序也是須要有明確認識的。編程

console.log('A');

new Promise((resolve, reject) => {
    console.log('B');
    resolve('C');
    console.log('D');
}).then(console.log, console.error);

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

首先要區分一下,哪些代碼是同步的,那些代碼是異步執行的。

  • Promise 以外的代碼是同步的。
  • Promise 參數函數中的代碼是同步的。
  • 狀態改變以後的代碼是異步的。

OUTPUT:

A B D E C 
複製代碼

結論:

  • Promise中只有then以後的代碼纔是異步執行的。

思考: 在Promise構造函數的參數函數中,代碼是同步執行的。若是在函數體中存在異步方法,好比 setTimeout() 執行順序會發生什麼變化?
這部份內容會在瀏覽器異步機制部分提到

4.最佳實踐

Promise 做爲一個異步容器,他存在的意義就是爲了改變Promise的狀態。那麼在狀態已經觸發以後的代碼就變得沒有意義了。若是你已經判定 resolve 或 reject 後的代碼無心義。可使用 return resolve() / return reject() 避免發生沒必要要的錯誤。

new Promise((resolve, reject) => {
    return resolve('程序執行成功');
    console.log('後面的程序會不會執行呢');
    setTimeout(() => reject('程序執行失敗'), 5000);
}).then(console.log, console.log);
複製代碼

做爲一個狀態機,咱們只須要關注Promise的狀態變化便可。狀態變化纔會觸發異步執行。峯迴路轉,狀況變幻無窮。仍是要關注他狀態機的本質。


02 Promise API

說到Promise的API,就到了你們比較熟悉的內容了。在ES6發展以前社區就有對Promise的社區實現,凡是被大規模承認的實現官方也很快就會給出支持,這也是JavaScript語言得以不斷進步的緣由。

1. Promise.prototype.then()

從Api的使用來看,then有兩個接受參數分別對應着的是 Promise 構造函數的參數函數的成功結果和失敗結果,也就是狀態機將狀態變爲了 成功 或者是 失敗

其實對於Promise.prototype.then 這個api很容易理解,總結來看

  • Part01: .then能夠用於鏈式調用,也能夠不用。
  • Part02:.then的本質是建立了一個新的隱形的Promise,所以能夠繼續鏈式調用。
  • Part03:.then的參數函數(回調函數)在觸發以前,Promise的狀態已經發生了變化。
  • Part04:.then只有在Promise的參數函數中,有錯誤發生的時候纔會有reject。
const p1= new Promise((resolve, reject) => resolve('hello'));

const p2 = p1.then(value => {
    console.log(value); // hello
    return value;
});

const p3 = p2.then(console.log); // hello
複製代碼

從上邊的總結來看,.then 是在Promise原型上的Promise.prototype.then。要想讓p2和p3的then可以成功執行,必須保證前面調用then的那個對象是一個Promise

...
const p2 = p1.then(value => {
    // 替換這裏
    return Promise.resolve(value);
});
const p3 = p2.then(console.log); // hello
複製代碼

結論:

  • 使用 Promise.resolve() 和在 .then裏面直接用return返回能夠獲得一樣的結果。
  • .then函數(方法)的執行結果是一個新的 Promise。
  • .then若是要是返回空值,至關於 Promise.resolve();

來繼續看.then 的最後一個Part,咱們知道.then有兩個回調函數,第一個是在成功時候觸發的,第二個是在失敗時候觸發的。

const p1 = new Promise((resolve, reject) => resolve('hello'));

const p2 = p1.then(value => {
    // return abcd; // ReferenceError: x is not defined
    return Promise.reject('手動錯誤');
    // VM258:4 Uncaught (in promise) 手動錯誤

});

const p3 = promiseB.then(console.log, console.error);

複製代碼

以上的DEMO是觸發第二個回調函數的兩種方法:程序錯誤OR手動拋錯(邏輯錯誤)。這兩種的側重點可能不由相同,所以能夠區別來使用。

2. Promise.prototype.catch()

這個Api從某些角度來看是.then方法的一個小變種或者說是語法糖。怎麼來理解這個呢,在then的回調函數中,已經有對於err的處理,只不過在鏈式調用的過程當中,若是每一步都進行err的處理會嚴重的阻塞咱們的開發的流暢性。所以也就誕生了 .catch來捕獲異常。

除了.catch的Api以外,有如下常見總結:

  • Part01:.catch會捕獲整個Promise鏈路上的異常。
  • Part02:.catch捕獲的異常包括 程序錯誤 && 手動拋錯(邏輯錯誤)
  • Part03:Promise 會將全部的內部錯誤內部處理,不會影響外部的邏輯。

詳細來講:

// 捕獲異常
new Promise((resolve, reject) => {
    console.log(x);
    resolve('hello Mr.ZA');
}).then(res => {
    console.log(y);
}).catch(err => {
    console.log('err', err);
})
setTimeout(() => { console.log('log: 後續程序') }, 1000);

// err VM8513:7 ReferenceError: x is not defined
// at <anonymous>:2:14
// at new Promise (<anonymous>)
// at <anonymous>:1:1
// log: 後續程序

new Promise((resolve, reject) => {
    resolve(1);
    console.log(x);   // 區別在這裏
}).then(res => {
    console.log(y);
}).catch(err => {
    console.log(err);
})

ReferenceError: y is not defined
    at <anonymous>:6:14
複製代碼

結論:

  • .catch捕獲的異常不是全部的異常,而是捕獲第一個影響狀態變化的異常。
  • 也就是說,在整個Promise中,狀態一旦變化了,後續的錯誤也就不那麼重要了,你既不能捕獲,也不能處理。上面的例子就運用了這個細節。
  • 全部Promise中出現的異常,無論你捕獲與否或是處理與否都不會影響Promise以外的程序繼續執行。【瀏覽器事件機制會說明緣由】
  • Promise 中的錯誤會依次捕獲和傳遞,若是以前捕獲了異常就不會繼續傳遞。
new Promise((resolve, reject) => {
    console.log(x);
    resolve(1);
}).catch(err => {
    if(err) { console.log('異常捕獲')};
    return 'continue progress';
}).then(res => {
    console.log('res: ', res);
})

// 異常捕獲
// res: continue progress 
複製代碼
  • .catch的位置不必定是在最後面,它和其餘的api同樣都會返回一個新的Promise爲鏈式調用提供服務。寫在最後面符合咱們對開發流程的認知。

3. Promise.prototype.finally()

finally在英語上來說是最終的意思,放在Promise的Api中,它會被咱們理解爲無論狀態如何變化,都會發生的事情

對於.finally,有如下常見總結:

  • .finally是Promise狀態機狀態變化的兜底方案,也是不管如何都能執行的。
  • .finally這個Api的回調函數沒有參數
  • .finally不必定放在鏈式調用的最後面,若是他在鏈式調用的中間,他前面的resolve或者reject傳出的值會跳過finally傳入下面的鏈式調用中。
// 僞代碼,模擬發送請求處理loading的問題。
new Promise((resolve, reject) => {
    this.loading = true;
    $.ajax(url, data, function(res) {
        resolve('res');
    })    
}).finally(() => {
    this.loading = false;
    return '嘗試更改';
}).then(value => {
    // handle value
    console.log(value),  // res
}).catch(
    // handle error
    error => console.error(error),
);
複製代碼

結論:

  • .finally 會更關注於狀態的變化過程而不是狀態變化帶來的影響。
  • .finally 若是在其中的回調中嘗試更改以前的流轉的值的時候,不能得到成功,可是若是有拋錯產生,會被錯誤處理程序依次捕獲。

接下來關注一下這個Api的兼容性問題,我想之因此你尚未使用這種方法來減小重複的工做,頗有多是由於這個Api出世的時間比較晚。

MDN 的資料顯示,這個API是ES2018引入TC39規範的也就是 ES9。

下面來看看這個Api的兼容性問題有如下關注點,根據本身狀況食用吧。


  • Chrome 63: 2018-04
  • Node: 10+: 2018-10

4. Promise.resolve() && Promise.reject()

  • 這兩個Api不是在Promise的原型上,不用 new Promise()來建立實例。
  • 這兩個Api能夠認爲是Promise 提供的兩個語法糖。
Promise.resolve = new Promise((resolve,reject)=>resolve('xx'));

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

5. Promise.all() && Promise.race()

這兩個Api應該是Promise裏面比較難理解的Api了,可是在使用上他們其實很簡單。咱們仍是要追求一下實現的原理,這樣咱們在使用Api的時候也不會那麼迷惑何時應該有什麼樣的結果。這兩個Api也常常會放到一塊作一些對比。

對於他們來說,有如下常見總結:

  • .all().race()不是Promise的原型方法,所以在使用他們的時候不用new Promise()
  • .all().race()都接收一個數組爲參數,返回的也是一個數組。若是參數數組中的值不是一個Promise實例,那麼會被轉換成直接返回。
  • .all()中若是有一個Promise執行出錯,將中止執行返回錯誤。所有成功以後才返回值數組。
  • .race()中若是第一個Promise完成了就直接返回,不等待其他執行完畢。

對於這些官方的Api及其用法,有人曾提出一個結論,使用Promise.all能夠併發的執行異步動做,獲得性能的提高。那麼其中的原理是什麼呢?爲何單線程的JavaScript會有異步性能提高呢?咱們來看下其中的緣由。

若是你有關於【微任務和宏任務】的理解,下面的內容會更加容易理解。

Promise.all([
    new Promise((resolve)=>{
        setTimeout(() => {
            console.log('-', 0)
            resolve(0);
        }, 1000)
    }),
    new Promise((resolve) => {
        setTimeout(()=>{
            console.log('-',1)
            resolve(1);
        }, 2000)
    })
]).then(res=>console.log(res))
複製代碼

執行的過程:

  1. Promise.all 會依次先把兩個 pending 狀態的Promise實例放入棧中,並記錄下他們的順序編號。
  2. 而後判斷他們的是否是一個能夠執行的程序,若是不是說明能夠返回了。
  3. 返回的過程就是把執行以後的值放到跟原來對應的順序的位置上,等着其餘程序執行完畢。
  4. 是能夠執行的Promise實例,就去重複2 -> 3的過程。
  5. 前文說到.then是會建立一個新的Promise執行,所以在執行數組中實例的時候會建立新的 Promise(新的微任務)
  6. 即便新的Promise 中有異步執行的內容,也要等全部的微任務完成纔會執行。所以全部的新的Promise的建立過程會優先於其餘異步任務。
  7. 以後的異步任務(不管是IO,Http,定時器)都屬於宏任務,被微任務加入以後會在同一個EventLoop中執行,也就完成了Promise的併發。

下面來看一下Promise.all 的源碼實現。

Promise.all = function (arr) {
    // ... Step0: 返回新的Promise
    return new Promise(function (resolve, reject) {
        
        var args = Array.prototype.slice.call(arr);
        if (args.length === 0) return resolve([]);
        var remaining = args.length;
        function res(i, val) {
            //...
        };
        // Step 1. 對數組進行同步循環
        for (var i = 0; i < args.length; i++) {
            // Step 2. 執行這些個Promise實例。
            res(i, args[i]);
        }
    });
};
複製代碼
  • Step0: 返回全新的 Promise 實例,擁有Promise 原型的方法。
  • Step1: 數組同步循環,這裏操做是按照順序執行的,數組內容是傳入Promise實例等異步處理方法(不是結果,此時實例沒有執行過)。
  • Step2: 執行傳入的Promise實例拿到結果。

到目前爲止:
程序都是同步執行的, 前後順序之分。也並無體現出併發。接着看 res這個方法。

function res(i, val) {
    // Step 3: 確認要執行的那個Promise實例
    if (val && (typeof val === 'object' || typeof val === 'function')) {
        // Step 4: 建立.then 也就是一個新的Promise微任務
        var then = val.then;
        if (typeof then === 'function') {
            then.call(
                val,
                function (val) {
                    res(i, val);
                },
                reject
            );
            return;
        }
    } 
    
    args[i] = val;
    
    if (--remaining === 0) {
        resolve(args);
    }
// race
// for (; i < len; i++) {
// promises[i].then(resolver,rejecter);
// } 
}
複製代碼
  • Step 3: 確認要執行的那個Promise實例。
  • Step 4: 建立.then 也就是一個新的Promise微任務。

結果也就大概簡化成了:

setTimeout(() => {
    console.log(1)
}, 1000)

setTimeout(() => {
    console.log(2)
}, 1500)

複製代碼
  • 這樣打印1,2總用時 約等於1500ms
  • 由於他們是同時 加入本身的異步線程中執行,回調相差500ms進入任務隊列的。
複製代碼

結論:瞭解宏任務和微任務能夠有效緩解焦慮。

附件:這是一篇測試Promise.all執行的文章


區分進程和線程

也許咱們在講JavaScript的時候,都會去說Js是一個單線程的擁有異步特性的事件驅動的解釋型腳本語言。雖然它是單線程的,可是在保證流暢性和性能優化方便擁有各類各樣的異步任務和主線程進行通訊,異步能夠說是Js的一大難點和重點。不少時候初學者們都在爲這個異步任務什麼時候執行而感到迷茫。在瞭解異步機制以前,咱們仍是須要在一下基礎的概念或者理論上達成一個有效共識,這樣會很大程度上幫助咱們。

01 什麼是進程,線程

如今咱們就從Chrome瀏覽器的主要進程入手,瞭解一下咱們經常使用的工具是如何切分這些線程和進程的。這裏有一些關於進程、線程、多線程相關總結。

從通俗易懂的角度來理解:

  • 進程 像是一個工廠,進程擁有獨立的資源 -- 獨立的內存空間。
  • 進程 之間相互獨立,沒有更大型的內存空間包裹。
  • 進程(工廠)之間想要通訊,能夠藉助第三方進程 -- 管理進程。
  • 線程和進程相比是低一級的概念,能夠理解成一個工人。
  • 完成一個獨立的任務,須要線程之間互相合做。
  • 在同一個進程內的多個線程,共享進程的資源 -- 共享內存空間。

用偏官方的話術來表示一下:

  1. 進程 是cpu資源分配的最小單位(是能擁有資源和獨立運行的最小單位)
  2. 線程 是cpu調度的最小單位(線程是創建在進程的基礎上的一次程序運行單位,一個進程中能夠有多個線程)

02 瀏覽器是多進程的

根據上面的知識,咱們很容易就能發現瀏覽器做爲不少程序的集合體,它的設計必定是一個多進程的,若是他只有一個進程那麼體驗會差到爆炸。咱們也常常會聽到這樣的一個說法,說Google Chrome瀏覽器是一個性能怪獸,每每打開它內存就會飆升。那麼一個瀏覽器中,有哪些進程呢:

  • Browser進程。這是瀏覽器的主進程。
  • 第三方插件進程。
  • GPU進程。
  • Renderer進程。

從Chrome的任務管理器中,能夠清晰的看到那些正在運行在咱們瀏覽器上的進程。


擴展一下:關於Chrome有好多種內存管理的機制,這也是Chrome強大的地方,能夠在瀏覽器裏面輸入 chrome://flags 進行設置。

  • Process-per-site-instance。每打開一個網站,而後從這個網站鏈開的一系列網站都屬於一個進程。這也是Chrome的默認進程模型。
  • Process-per-site。同域名範疇的網站屬於一個進程。
  • Process-per-tab。每個頁面都是一個獨立的進程。這就是外界盛傳的進程模型。
  • Single Process。傳統瀏覽器的單進程模型。

03 瀏覽器內核

對於整個瀏覽器來說,咱們上文說到了瀏覽器有自身的瀏覽器進程。可是這個進程對於每一個標籤頁中顯示的網頁內容來說,幫助不大。它只負責一個調度管理的做用。真正在瀏覽器大部分窗口內工做的仍是Renderer進程。所以咱們把 Renderer進程稱之爲瀏覽器內核。

來了解一下Renderer進程包含哪些線程:

  • GUI渲染線程。
  • JavaScript引擎線程。對於Chrome瀏覽器而言,這個線程上跑的就是威震海內的V8引擎。
  • 事件觸發線程。
  • 定時器線程。
  • 異步HTTP請求線程。

他們在Renderer進程下,各司其職。關於他們的詳細工做,估計又是一篇系列長文。待我寫完以後,會補充一個連接到這裏。

從這些共識中,咱們能夠理解以前的那句對JavaScript的描述了把。JavaScrit是一個單線程( JS引擎是單線程的 )的擁有異步特性( 擁有獨特的異步線程 )的事件驅動( 事件也是一個單獨的線程處理 )的解釋型腳本語言。


事件循環 && 異步機制

在這一章中,咱們不先不關心瀏覽器渲染進程中的其餘線程,也不關心具體的JS代碼上下文,做用域等細節問題。把注意力集中在JS引擎上,從宏觀上觀察一下瀏覽器內核的一些特性。這將在很長一段時間內有助於咱們梳理咱們所寫代碼執行流程,避免意外的發生。

在繼續深刻研究以前,咱們先來回憶一些知識點,避免有疏漏對下面的內容難以理解:

  • Renderer進程 是俗稱的瀏覽器核心,包含 JS引擎線程,事件觸發線程,定時觸發器線程等
  • JS引擎 是單線程的。
  • JS引擎 在執行代碼的時候分同步任務和異步任務。

01 調用(執行)過程

先來看段簡單的代碼理解一下調用關係。

console.log('1');
function a() {
    console.log('2');
    b();
    console.log('3');
}
function b() {
    console.log('4');
}
a(); 
// output: 1 2 4 3
複製代碼

相信你已經很快就獲得了答案,由於這段代碼中是純同步執行的,也沒有事件,IO等異步方法。因此咱們知道他的調用數序,可是程序是如何知道調用數序的呢?或者說程序執行的時候有什麼很牛的辦法麼?
你可能懷疑這樣的一個事情發生,就像剛剛學習這門技術時候的我同樣認爲程序會不會作下面的事情呢?

  • 偷偷的把我寫在單獨函數(function b)中內容在調用它的地方展開了?
  • 而後依次執行代碼呢?

程序設計的時候可能沒有那麼的粗暴,由於這樣會致使一系列的問題好比函數做用域如何處理呢?那它可能有它做爲程序來說的辦法來實現這種調用 -- 執行棧(調用棧)

  • 幾乎全部的計算機程序在執行的時候都是依賴於它的。
  • 既然是一個棧的結構,他就要符合棧的基本性質 -- 後進先出
  • 每調用一層函數,JS引擎就會生成它的棧幀,棧幀裏保存了執行上下文。
  • 而後把棧針放入到執行棧中。等待程序的執行。
  • 在執行棧中,直到最裏層的函數調用完,引擎纔開始將最後進入的棧幀從棧中彈出。

在上面的程序執行的時候,調用棧的工做順序爲:


注:

  • 表格中的每一列,表明着當時的執行棧狀態。
  • 只有方法和函數的調用會使用調用棧,函數聲明,變量聲明不會用到。

再來看下這個不通常的程序:

function hello() {
    hello();
}
hello();
複製代碼

這個程序的獨特之處在於,它一直在像執行棧中插入 hello() 這個棧楨,沒一會咱們的執行棧就會溢出,(內存溢出)。這個時候瀏覽器就會假死掉,報出溢出的錯誤。

02 分別說說那些 異步線程

咱們在書寫代碼的時候,其實運用的 大部分是 JS這門高級語言封裝的各類API,剩下的一部分Api不是JS引擎封裝的,而是跟JS這麼門語言處於的環境有關係的。好比在瀏覽器中咱們直接使用的Navigator就是瀏覽器環境決定的,在Node.js中就不能用,同理 Node.js中的 process 瀏覽器也是不能用的。

1. 網絡請求的異步線程

JS引擎是一個單線程的設計,可是在Web應用中少不了發送網絡請求的場景,JS引擎不能徹底靜止的等待網絡請求結束在進行下面的工做,所以咱們有理由懷疑網絡請求有本身的單獨的線程來處理,不和主線程搶資源。

const url = 'https://xxx.com/api/getName';
fetch(url).then(res => res.json()).then(console.log);
複製代碼

2. 定時器線程

首先須要知道的是,定時器線程也是脫離JS引擎的獨立線程,爲何會給他這種特殊的待遇呢?道理我想很好理解:

  • JS引擎在忙着入棧和出棧呢(執行棧)若是讓它來去進行時間控制,顯然會常常出現時間不許確的狀況。或者阻塞其餘的執行。
setTimeout(function(){
    console.log('1');
}, 0);

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

由於是異步線程執行的,那麼結果應該是 2 1

3. 事件觸發線程

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

const timeoutId = setTimeout(() => {
    for (let i = 0; i < 10000; i++) {
        console.log('hello');
    }
    clearTimeout(timeoutId);
}, 5000);
複製代碼

看上面代碼的執行過程:在5s以後開啓一個事件循環,使JS引擎處於阻塞狀態,講道理的話若是事件的觸發不在單獨線程上解決,那麼在這5s以後JS處理循環的時候,事件都不會被感知和觸發(由於JS引擎阻塞了,你的入棧不會執行)。

可是事實結果確實: 在循環的開始的時候,你點擊按鈕也會獲得響應,只不過這個響應會在循環執行完成以後發生,可是已經說明了事件被觸發了。至於爲何在以後執行,咱們看下一章事件循環的時候會說起。

03 任務隊列

如今咱們知道了,無論是JS引擎實現的仍是瀏覽器等運行環境實現的一些Api,他們擁有特權 -- 專門處理本身事務的線程。這解決了不少問題,那麼如今新問題的關鍵出現了,獨立的線程是如何和JS引擎通訊的呢。搞懂了這個問題,那麼JS的異步運行的機制也就清晰了。

這應該就是咱們這個章節的主角 -- 大名鼎鼎的Task Queue。咱們先看一張圖找找Task Queue的位置。


  • 當JS引擎從上到下將程序入棧出棧的過程當中,遇到那些有異步能力的WebApi,會選擇把他們放入他們本身的線程中,短暫的忽略他們。繼續執行那些同步代碼。
  • 在各自的線程裏完成處理以後,會將這些異步結果以回調的形式放入 Task Queue中。
  • 等待再次回到JS引擎中。
  • 想回到JS引擎的執行序列中,須要必定等到JS引擎空閒(這就是爲啥DOM事件例子中,按鈕觸發的事件在JS大循環結束以後才能觸發的緣由了)

對於任務隊列來講,上面所列就是通用規則,就是在不斷進步的過程當中總會對這些規則進行不斷修正。正因如此,在ES6的Promise和HTML5的MutationObserver出現以後,任務隊列就變得複雜了,主要體如今:將任務隊列中的任務按照等級從新肯定順序,等待Event—Loop的調用。咱們接下來的任務就是對這個順序進行研究。

04 事件循環

事件循環,就是咱們常常說的那個 Event-Loop,想必你們應該都會對它有所耳聞。事件循環是任務隊列和JS主引擎之間的橋樑。EventLoop觸發也是有時機的,它被設計出來的目的也就是爲了保證JS引擎線程的安全和穩定的。全部只有等到JS引擎空閒的時候纔會經過EventLoop來取這時候在任務隊列中排隊等待的任務。

  • 我理解,這實際上是在把異步轉換成同步的過程。
  • 如此往復,即便任務隊列中的方法內包含了異步方法,引擎就會按照一樣的規則再給WebApi進行處理循環。這就是EventLoop的優秀設計之處。

05 任務隊列的順序問題 - 宏任務和微任務

由於宏任務和微任務既設計任務隊列又跟EventLoop有關係,又是異步中很關鍵的一個概念,因此單獨來談談關於宏任務和微任務的問題。本章將從HTML規範 - Event Loop入手。來看看EventLoop是怎麼區分宏任務和微任務的。

  • 注意這裏,Event-loop是HTML 的 Standard 而不是 ECMAScript的。
  • 由於規範和實現不一樣,在瀏覽器裏和Node裏,Event-Loop 有略微差異。

1. 從事實出發

首先,若是咱們按照任務隊列章節的內容來進行理解,隊列作爲一個數據結構應該是先進先出的結構,若是任務是一樣存在於一個隊列裏的,那應該按照順序執行。來看一個例子:

setTimeout(() => {
   console.log(1) 
},0)
Promise.resolve().then(() => {
   console.log(2) 
})
console.log(3) 

複製代碼

輸出: 3 2 1 確定是大多數人都知道的結局,那麼這就直接和咱們對於任務隊列的理解是相悖的。也就是說 任務隊列 有點不同。帶着這個問題,咱們去翻翻規範。

2. EventLoop 的定義

爲了協調事件、用戶交互、腳本、渲染、網絡等,用戶代理必須使用這一小節描述的事件循環

從上面的例子中,咱們產生了一個問題,並懷疑隊列中任務仍然是有優先級的。可是按照這個思路繼續想的話很容易產生矛盾的地方:

  • 爲何任務隊列會有優先級,隊列這個數據結構不就應該是先進先出的麼。
  • 在一個隊列裏要對任務進行優先級運行,這樣的性能成本開銷是很大的。由於要保持原來的上下文等關係。

若是從性能的角度考慮,應該會設計成兩個獨立的列表,分別存聽任務。咱們仍是去看規範中,怎麼定義EventLoop,對於規範來說,着實是很是詳細的,總結來看有如下重要的不容錯過的點:

  • 事件循環EventLoop不必定只有一個,並且不少狀況下是不止一個的。
  • 事件循環是跟user agents 綁定的,每個user agents均可以有一個事件循環(這裏的User agents 能夠理解成用戶代理,也就是觸發用戶交互,腳本,網絡等行爲)。
  • 事件循環EventLoop能夠對應一個或者多個隊列。
  • 檢查是否有 Microtask 微任務,若是有就執行到底。

果真從規範中,咱們瞭解到EventLoop能夠對應多個隊列。流程也就變成了這樣。


對於 Task Queue 和 Microtask Queue 常有這樣的總結:

  • task主要包含:setTimeout、setInterval、setImmediate、I/O、UI交互事件
  • microtask主要包含:Promiseprocess.nextTickMutaionObserver

因此通過理論的驗證咱們的出這樣的

👨‍💻 結論:

  • 隊列有細微區分,大致分爲 Task 和 Micro。
  • MicroTask 會在兩個Task之間(一次EventLoop)所有執行完。
  • 目前來說執行順序能夠大體分爲。同步任務 -> Micro -> Task -> Mic1, Mic2, ... -> Task -> Mic1, Mic2

能夠根據上面的結論來看一個DEMO

console.log(1)
// Part A
setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
})
// Part B
new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})
// Part C
setTimeout(() => {
    console.log(9)
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    })
})
複製代碼

首先:

  1. 先執行同步代碼:輸出 1, 7(Promise 的參數函數是同步的)
  2. 同步的執行中,會將兩個PartAPartC的兩個setTimeout放入Task列表中,把PartB中的 .then產生的新的Promise放入到 Micro中。
  3. 在執行Task以前,清空Micro。輸出: 8
  4. EventLoop 取一個Task。 Part A
  5. 執行PartA,輸出 2,4, 把 .then 放入 Mico 而後清空它 ,輸出 5
  6. EventLoop 取一個Task。 Part C
  7. 執行PartC,輸出 9, 11, 把 .then 放入 Mico 而後清空它 ,輸出 12
  8. 運行一下結果發現沒有問題: 1, 7, 8, 2, 4, 5, 9, 11, 12
  9. Tips: 在某些瀏覽器上不支持原生Promise,是用基於setTimeout的方式pollyfill 的,好比 safafi 10-

3. Node中的執行順序

在明白了Task和MicroTask的順序以後,基本上在瀏覽器中就不會有應用上的問題。
注意:若是你不想在Node中使用的話這部分能夠繞行避免發生混淆。接下來咱們在Node環境下運行代碼看看有沒有什麼'異常'發生。

console.log(1)
// Part A
setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(3)
        resolve()
    }).then(() => {
        console.log(4)
    })
})
// Part B
setTimeout(() => {
    console.log(5)
    new Promise(resolve => {
        console.log(6)
        resolve()
    }).then(() => {
        console.log(7)
    })
})
複製代碼

根據咱們以前的經驗,會很快得出結果。

  • 在瀏覽器中輸出的結果是:1,2,3,4,5,6,7
  • 在Node中的輸出結果是: 1,2,3,5,6,4,7

詳細的說明這個問題,咱們能夠先提出這樣的一個懷疑

在 Node.js 中,setTimeout 和 Promise 用了一樣的方法實現。經過咱們以前的經驗來說,可能Node 用了和以前ES6-Promsie出現以前的方案同樣,使用了setTimeout進行僞實現,也就是說Node的Promise不是微任務

帶着這個疑問我翻看了Node的源碼,源碼(V12.3.1)在下方的連接裏,這裏直接來看得出這個結論,結論可能跟咱們想的不太同樣,又差不太多:

  • 在Node中,setTimeout的回調,和 Promise 是在 Node-api中實現的,而非V8引擎。
  • Node中的任務隊列是一個鏈表的數據結構。
  • Promise 和 setTimeout 生成的任務隊列是用的同一個 node_task_queue,都是在下一個事件循環的時候放入異步隊列。
  • node_task_queue 是一個微任務隊列,process.nextTick也是在這個隊列中。
  • 也就是說 setTimeout 在 Node 的Timer實現中和process.nextTick 和Promise是用一個隊列的。
  • 鏈表的順序是 nextTick -> promise -> timer

這確實出乎咱們的意料,咱們用這個結論去跑一個示例,來看看能不能解釋的通:

console.log(1)
// Part A
setTimeout(() => {
    console.log(2)
    process.nextTick(() => {
        console.log(3)
    })
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
    
})
// Part B
new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})
// Part C
process.nextTick(() => {
    console.log(6)
})
// Part D
setTimeout(() => {
    console.log(9)
    process.nextTick(() => {
        console.log(10)
    })
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    })
})
複製代碼

來分析一下Node執行的步驟:

  • 先執行同步代碼,輸出: 1,7
  • 分析一下這個時候的微任務隊列:
    • 按照鏈表的數序放入: nextTick(Part C) -> Promise(Part B 的then) -> timer(Part A , Part D)
    • Part C 輸出: 6
    • Part B 輸出: 8
    • Part A 輸出: 2, 4
    • Part D 輸出: 9, 11
  • 而後作下一個事件循環:
    • 按照鏈表的數序執行輸出: 3, 10, 5, 2

總結輸出: 1 7 6 8 2 4 9 11 3 10 5 2

這應該就是 node 與 Chrome 瀏覽器中, 異步機制的不一樣之處把。,對於有一些研究文章說不會存在穩定的輸出結果,致使timer執行的不穩定,我以爲多是Node版本的問題,12中的結果是會穩定輸出的,多是數據結構進行了升級,這個部分仍是有待詳細研究。


實踐:Promise的異步串聯

new Promise((resolve) => {
    console.log(1);
    setTimeout(() => {
        console.log(2);        
        resolve();
    }, 1000)
}).then(() => {
    console.log(3)
    setTimeout(() => {
        console.log(4)
    }, 1000)
}).then(()=>{
    console.log(5);
    setTimeout(() => {
        console.log(6)
    }, 1000)
})
複製代碼

這個代碼是不會獲得理想輸出的。輸出結果爲: 1 -> 2 3 5 -> 4 6

new Promise((resolve) => {
    console.log(1);
    setTimeout(() => {
        console.log(2);        
        resolve();
    }, 1000)
}).then(() => {
    console.log(3)
    return  new Promise((resolve)=>{
        setTimeout(() => {
            console.log(4)
            resolve()
        }, 1000)
    })
}).then(()=>{
    console.log(5);
    new Promise(()=>{
        setTimeout(() => {
            console.log(6)
        }, 1000)
    })
})
複製代碼
相關文章
相關標籤/搜索