深刻淺出JavaScript異步編程

 


隨着移動互聯網基礎網速的飛速提高和各類設備硬件的革命性升級,人們對web應用功能的期待愈來愈高,瀏覽器性能因瀏覽器內核的革命性升級獲得飛速提高,受瀏覽器性能制約的前端技術也迎來飛速發展。正如Atwood定律所言:「凡是能夠用 JavaScript 來寫的應用,最終都會用 JavaScript 來寫。」的確,如今的前端技術涉足領域普遍,有web應用開發、服務端開發、PC桌面程序開發、移動APP開發、IDE開發、CLI工具開發及工程化流程工具開發等。但隨着前端技術突飛猛進的發展,JavaScript中的異步編程弊病問題也愈來愈明顯地暴露出來,異步編程問題的解決方案也在快速的迭代優化。前端

 

本文將爲你們解答如下疑問:什麼是異步編程?爲何瀏覽器下會有異步編程?異步回調有哪些問題?如何解決異步回調問題?瀏覽器支撐的新方案的原理?web

1.什麼是異步編程

異步和同步對應,異步編程即處理異步邏輯的代碼,JavaScript中最原始的就是使用回調函數。因此,咱們只要理清同步回調和異步回調的區別,就能夠理解什麼是異步編程了。ajax

請先看同步回調示例:編程

在這裏插入圖片描述
執行順序二、一、3,先輸出1後輸出3,可見,同步回調:回調函數callback是在主函數dowork返回以前執行的。promise

再看異步回調示例:瀏覽器

在這裏插入圖片描述
先輸出3後輸出1,可見,異步回調:回調函數並無在主函數內部被調用,而是在主函數外部執行,主函數返回後才執行。babel

2. 爲何瀏覽器下有異步編程

Chrome下的異步編程模型,以下圖:架構

在這裏插入圖片描述
瀏覽器渲染進程中的渲染流水線主線程是單線程的,主線程發起耗時任務,交給其餘進程執行,等處理完後,會將該任務添加到渲染進程的消息隊列中,並排隊等待循環系統的處理。排隊結束以後,循環系統會取出消息隊列中的任務進行處理,觸發相關的回調操做,並將任務交給另外一個進程去處理,這時頁面主線程會繼續執行消息隊列中的任務。併發

瀏覽器設計時,最初選擇了單線程架構,結合事件循環和消息隊列的實現方式,咱們在JavaScript開發中,也會常常遇到異步回調。框架

而異步回調,影響了咱們的編碼方式,咱們必須直面異步回調中的一些問題。

3. 異步回調有什麼問題

若是咱們一直選擇使用異步回調編寫代碼,當面臨複雜的應用需求,如遇到有依賴關係的異步邏輯或者發送ajax請求時,則會較爲麻煩。

看個示例:

在這裏插入圖片描述
這段代碼能夠正常執行,可是裏面卻執行了5次回調。

這麼多的回調會致使代碼的邏輯不連貫、不線性,很是不符合人的常規思惟,也即異步回調影響到了咱們的編碼方式。

遇到這種狀況,咱們一般能夠封裝異步代碼,下降處理異步回調次數,讓處理流程變得線性,如jQuery的$.ajax就是這麼作的。

這樣作,雖然在一些簡單的場景下運行效果也很是好,但遇到很是複雜的場景時,嵌套了太多的回調函數就很容易使本身陷入回調地獄。

好比:

在這裏插入圖片描述
這是一個典型的多層嵌套ajax請求的場景,這時回調地獄問題就暴露無疑了,由於這段代碼邏輯不連續,讓人感到凌亂。

此時,總結異步回調問題,以下:

  1. 嵌套調用,層層嵌套,層次多了代碼可讀性差了。
  2. 任務的不肯定性,如上方ajax請求,總會有成功或者失敗,每一層的任務都有判斷邏輯和錯誤處理邏輯,這樣就讓代碼更加混亂了。

4. 解決異步回調問題的方案

想解決異步編程問題,要考慮的是:一是消滅回調,二是合併錯誤判斷和處理。

目前較好的解決方案有:Promise和Async/await

Promise示例:

在這裏插入圖片描述
代碼清晰了,Promise 使用回調函數延遲綁定解決了回調函數嵌套的問題,如p1.then,p2.then等,這即是同步編碼的風格了。

Promise的回調函數返回值有穿透到最外層的性質,具體到錯誤處理的場景,就是說對象的錯誤具備「冒泡」的性質,會一直向後傳遞,直到被 onReject 函數處理或 catch 語句捕獲爲止,這樣就把錯誤判斷和處理邏輯合併了。

Promise方案,雖然實現了同步風格編程,可是裏面包含了大量的then函數,讓代碼仍是不太容易閱讀。

如下爲Async/await示例:

在這裏插入圖片描述
咱們想要輸出2之後再輸出3,雖然xs函數是異步的,可是咱們的寫法是同步的,代碼邏輯是連續的,這樣代碼就更加清晰可讀了。

5. 從瀏覽器原理分析Promise原理

Promise是V8引擎提供的,因此暫時看不到 Promise 構造函數的細節。V8 在Promise 中使用微任務,來實現回調函數的延遲綁定。

微任務是V8提供的,當前宏任務執行的時候,V8會爲其建立一個全局執行上下文,V8引擎也會在內部建立一個微任務隊列,宏任務執行過程當中產生的微任務都會放入微任務隊列。

當前宏任務中的 JavaScript 快執行完成時,也即在 JavaScript 引擎準備退出全局執行上下文並清空調用棧的時候,JavaScript 引擎會檢查全局執行上下文中的微任務隊列,而後按照順序執行隊列中的微任務。

若是在執行微任務的過程當中,產生了新的微任務,一樣會將該微任務添加到微任務隊列中,V8 引擎一直循環執行微任務隊列中的任務,直到隊列爲空纔算執行結束。

瀏覽器執行宏任務、微任務和渲染的循環順序是,宏任務、該宏任務的微任務隊列、渲染,再執行消息隊列中下個宏任務、該宏任務的微任務隊列、渲染,如此循環執行。

綜上可知,從本質和瀏覽器原理來講, js實現異步回調的方式能夠有兩種:

  1. 把回調函數添加到(消息隊列)宏任務隊列內,當執行完當前宏任務和它的微任務隊列後,等合適的時機或者可執行代碼容器空閒時執行。如setTimeout延遲任務和ajax異步請求任務。
  2. 把回調函數添加到當前宏任務的微任務隊列,等待當前宏任務執行結束前,依次執行。
    咱們猜想模擬實現個Promise,說明爲什麼要用微任務。

在這裏插入圖片描述
這裏,咱們沒有用異步回調,而是同步回調,可是回調函數仍是延遲綁定,這樣執行時就會報錯,由於咱們同步調用回調時,回調函數還沒綁定。

若是此時resolve改成使用宏任務隊列的異步回調setTimeout,雖然能夠實現功能,可是執行回調的時機會被延遲,代碼執行效率則被下降。

在這裏插入圖片描述
因此,v8採用微任務實現promise,是爲了在方便開發與執行效之間尋找到一個完美的平衡。

6. 生成器與協程

生成器Generator是v8提供的,生成器函數是一個帶星號函數,並且是能夠暫停執行和恢復執行的。底層實現機制是協程(Coroutine)。

看個生成器的例子:

在這裏插入圖片描述
執行結果爲:

在這裏插入圖片描述
執行生成器函數,並不執行函數內代碼,而是返回一個對象引用,可賦值給外部函數的變量。外部函數經過變量對象的next 方法開始執行生成器函數的內部代碼;在生成器函數內部執行一段代碼時,若是遇到 yield 關鍵字,那麼 JavaScript 引擎將返回關鍵字後面的內容給外部,並暫停該生成器函數的執行;外部函數經過next().value得到生成器函數的返回值。外部函數能夠經過 next 方法再次恢復生成器函數的執行。以此類推執行。

沒有 yield時,遇到return時,也暫停生成器函數的執行,這裏應該說是回收調用棧,結束函數更準確,而不是暫停。

V8 是如何實現一個函數的暫停和恢復的?

這裏涉及到協程的概念,協程比線程更輕量,協程當作是跑在線程上的任務,一個線程上能夠存在多個協程,可是在一個線程上同時只能執行一個協程。可是,協程不是被操做系統內核所管理的,而徹底是由程序所控制。這樣,性能就有了很大的提高,不會像線程切換那樣消耗資源。

yield 和 .next切換生成器函數的暫停和恢復,其實就是在關閉和開啓生成器函數對應的子協程,子協程和父協程在主線程上交互執行,並不是併發執行的。在切換父子協程時,關閉前都會先保存當前協程的調用棧信息,以便再次開啓時,繼續執行。因此,從瀏覽器角度看,生成器的底層實現是協程。

7. co框架的原理,Promise與生成器的結合

生成器函數能夠理解成一個異步操做的容器,它裝着一些異步操做,但並不會在實例化後當即執行。而co的思想是在恰當的時候執行這些異步操做。在一個異步操做執行完畢之後通知下一個異步操做開始執行,須要依靠回調函數或者promise來實現。因此,co要求生成器函數裏yield的是thunk(回調機制)或者promise。

咱們把執行生成器的代碼封裝成一個函數,並把這個執行生成器代碼的函數稱爲執行器。co框架就是個執行器。

promise結合生成器函數的實現示例:

在這裏插入圖片描述
run2是執行器,也是co框架的源碼裏面的promise回調機制實現的原理。

8. 從協程和微任務看Async/await

async/await 技術背後的祕密就是 Promise 和生成器應用,往低層說就是微任務和協程應用。MDN 定義,async 是一個經過異步執行並隱式返回 Promise 做爲結果的函數。

在這裏插入圖片描述
可見async 執行完,v8讓它返回的是一個Promise。

Async/await在一塊兒會發生什麼?

在這裏插入圖片描述
先執行3,後輸出a和2,這就體現了異步。

上面的await "我會被放入await返回proimse的executor內"會被v8處理爲:

在這裏插入圖片描述
可見,其實await 代碼,會默認返回promise,若是await後面的是個值,值會直接做爲resolve函數的參數內容並調用resolve,返回promise;若是在await後面加個函數,則須要返回promise,如接個async function(){},這就對應了前文,async函數執行默認返回了promise。

同時,把後續代碼,做爲await 返回promise的回調函數延遲綁定了,由於使用了微任務實現了延遲綁定,因此回調也就是後續代碼被放到了微任務隊列,因此會異步執行。

咱們從協程角度,分析上面代碼的執行原理。

調用hai函數,開啓hai函數的子協程;

執行輸出1;

遇到await,把後續代碼加入promise的回調函數,其實進入了微任務隊列。同時,把resolve函數結果值返回給a;

這時,暫停子協程,控制權給主線程;

主線程執行輸出3;

主線程執行結束前,查看微任務隊列,發現有微任務,也就是上面加入的,執行微任務;

執行微任務,立馬恢復子協程,執行輸出a和2。

執行完畢,關閉子協程,控制權交給主線程。

因此,纔有了上面的執行結果。

綜合分析async/await:

在這裏插入圖片描述
輸出順序是:

在這裏插入圖片描述
這裏關鍵點是:

4是在主線程上,屬於宏任務內,按順序先執行;

二、1都是在字協程內執行,其中2所在的協程是1所在協程的父協程,可是都是在當前宏任務階段執行;這裏涉及了主線程、父協程、子協程的關閉交互。

bar 內的await把3加入了微任務隊列,因此在當前宏任務執行完後才執行;

6和8 是在主線程上,屬於宏任務內,按順序執行,7在6所在的Promise內的延遲迴調內,這時加入了微任務隊列,比3加入的晚,因此7晚於3。

執行3時,處於微任務階段,開啓了子協程;

執行7時,處於微任務階段,又關閉了子協程,控制權在主線程。

5是延遲函數,延遲任務,屬於下一個宏任務,因此會在當前微任務執行完,才執行寫個宏任務。

9. 總結

瀏覽器是基於單線程架構實現的,JavaScript編程中常常遇到異步回調,異步回調函數存在回調地獄問題,讓代碼混亂,可維護性差。

ES新標準推出了Promise來讓咱們方便的編寫異步回調代碼,讓代碼保持線性同步的風格。

瀏覽器基於微任務實現了Promise,基於協程實現了生成器。

爲更好地優化異步回調的可讀性,開發者們嘗試了Promise與生成器結合使用的方式。爲方便使用這種結合方式,開發者們把執行生成器的代碼封裝起來做爲執行器,著名的co框架就是在這個思路下產生的。

後來,ES7標準規範化了Promise與生成器結合使用的方式,並優化爲async/await標準,現代瀏覽器也陸續按這個規範實現async/await。

目前,來自ES7的標準的async/await是處理JavaScript異步編程的最佳實踐,未來會受到全部瀏覽器的支持,對於不支持async/await的瀏覽器,可使用babel處理兼容。

async/await是編程領域很是大的一個革新,也是將來的一個主流的編程風格,它能讓代碼美觀整潔,又必定返回promise,其餘語言如Python也引入了async/await。

做者:李鑫海
指導老師:楊朋飛

參考書目:
1. Babel · The compiler for next generation JavaScript
2. 極客時間,李兵《瀏覽器工做原理與實踐》
3. Async-Await ≈ Generators + Promises – Hacker Noon
4. Co-實現原理分析 - 柒青衿的博客 - CSDN博客
5. 從協程到狀態機–regenerator源碼解析(1、二) - 知乎

 

版權歸做者全部,任何形式轉載請聯繫做者。
it_hr@zybank.com.cn

相關文章
相關標籤/搜索