重學前端(6)JavaScript執行(一):Promise裡的程式碼為什麼比setTimeout先執行?
阿新 • • 發佈:2022-04-12
首先我們考慮一下,如果我們是瀏覽器或者 Node 的開發者,我們該如何使用 JavaScript 引擎。
當拿到一段 JavaScript 程式碼時,瀏覽器或者 Node 環境首先要做的就是;傳遞給 JavaScript 引擎,並且要求它去執行。
然而,執行 JavaScript 並非一錘子買賣,宿主環境當遇到一些事件時,會繼續把一段程式碼傳遞給 JavaScript 引擎去執行,此外,我們可能還會提供 API 給JavaScript 引擎,比如 setTimeout 這樣的 API,它會允許 JavaScript 在特定的時機執行。
所以,我們首先應該形成一個感性的認知:一個 JavaScript 引擎會常駐於記憶體中,它等待著我們(宿主)把 JavaScript 程式碼或者函式傳遞給它執行。在 ES3 和更早的版本中,JavaScript 本身還沒有非同步執行程式碼的能力,這也就意味著,宿主環境傳遞給 JavaScript 引擎一段程式碼,引擎就把程式碼直接順次執行了,這個任務也就是宿主發起的任務。
但是,在 ES5 之後,JavaScript 引入了 Promise,這樣,不需要瀏覽器的安排,JavaScript 引擎本身也可以發起任務了。
由於我們這裡主要講 JavaScript 語言,那麼採納 JSC 引擎的術語,我們把宿主發起的任務稱為巨集觀任務,把 JavaScript 引擎發起的任務稱為微觀任務。
巨集觀和微觀任務
JavaScript 引擎等待宿主環境分配巨集觀任務,在作業系統中,通常等待的行為都是一個事件迴圈,所以在 Node 術語中,也會把這個部分稱為事件迴圈。
不過,術語本身並非我們需要重點討論的內容,我們在這裡把重點放在事件迴圈的原理上。在底層的 C/C++ 程式碼中,這個事件迴圈是一個跑在獨立執行緒中的迴圈,
我們用虛擬碼來表示,大概是這樣的:
Promise 是 JavaScript 語言提供的一種標準化的非同步管理方式,它的總體思想是,需要進行 io、等待或者其它非同步操作的函式,不返回真實結果,而返回一個“承諾”,函式的呼叫方可以在合適的時機,選擇等待這個承諾兌現(通過 Promise 的 then 方法的回撥)。
Promise 的基本用法示例如下:
這段程式碼定義了一個函式 sleep,它的作用是等候傳入引數指定的時長。
Promise 的 then 回撥是一個非同步的執行過程,下面我們就來研究一下 Promise 函式中的執行順序,我們來看一段程式碼示例:
while(TRUE) { r = wait(); execute(r); }我們可以看到,整個迴圈做的事情基本上就是反覆“等待 - 執行”。當然,實際的程式碼中並沒有這麼簡單,還有要判斷迴圈是否結束、巨集觀任務佇列等邏輯,這裡為了方便理解,就把這些都省略掉了。 這裡每次的執行過程,其實都是一個巨集觀任務。我們可以大概理解:巨集觀任務的佇列就相當於事件迴圈。 在巨集觀任務中,JavaScript 的 Promise 還會產生非同步程式碼,JavaScript 必須保證這些非同步程式碼在一個巨集觀任務中完成,因此,每個巨集觀任務中又包含了一個微觀任務佇列 有了巨集觀任務和微觀任務機制,我們就可以實現 JS 引擎級和宿主級的任務了,例如:Promise 永遠在佇列尾部新增微觀任務。setTimeout 等宿主 API,則會新增巨集觀任務。 Promise
function sleep(duration) { return new Promise(function(resolve, reject) { setTimeout(resolve,duration); }) } sleep(1000).then( ()=> console.log("finished"));
var r = new Promise(function(resolve, reject){ console.log("a"); resolve() }); r.then(() => console.log("c")); console.log("b")我們執行這段程式碼後,注意輸出的順序是 a b c。在進入 console.log(“b”) 之前,毫無疑問 r 已經得到了 resolve,但是 Promise 的 resolve 始終是非同步操作,所以 c 無法出現在 b 之前。 接下來我們試試跟 setTimeout 混用的 Promise。 在這段程式碼中,我設定了兩段互不相干的非同步操作:通過 setTimeout 執行 console.log(“d”),通過 Promise 執行 console.log(“c”)
var我們發現,不論程式碼順序如何,d 必定發生在 c 之後,因為 Promise 產生的是 JavaScript 引擎內部的微任務,而 setTimeout 是瀏覽器 API,它產生巨集任務。 為了理解微任務始終先於巨集任務,我們設計一個實驗:執行一個耗時 1 秒的 Promise。r = new Promise(function(resolve, reject){ console.log("a"); resolve() }); setTimeout(()=>console.log("d"), 0) r.then(() => console.log("c")); console.log("b")
setTimeout(()=>console.log("d"), 0) var r1 = new Promise(function(resolve, reject){ resolve() }); r.then(() => { var begin = Date.now(); while(Date.now() - begin < 1000); console.log("c1") new Promise(function(resolve, reject){ resolve() }).then(() => console.log("c2")) });這裡我們強制了 1 秒的執行耗時,這樣,我們可以確保任務 c2 是在 d 之後被新增到任務佇列。 我們可以看到,即使耗時一秒的 c1 執行完畢,再 enque 的 c2,仍然先於 d 執行了,這很好地解釋了微任務優先的原理。 總結一下如何分析非同步執行的順序: 首先我們分析有多少個巨集任務; 在每個巨集任務中,分析有多少個微任務; 根據呼叫次序,確定巨集任務中的微任務執行次序; 根據巨集任務的觸發規則和呼叫次序,確定巨集任務的執行次序; 確定整個順序。 我們再來看一個稍微複雜的例子:
function sleep(duration) { return new Promise(function(resolve, reject) { console.log("b"); setTimeout(resolve,duration); }) } console.log("a"); sleep(5000).then(()=>console.log("c"));這是一段非常常用的封裝方法,利用 Promise 把 setTimeout 封裝成可以用於非同步的函式。 我們首先來看,setTimeout 把整個程式碼分割成了 2 個巨集觀任務,這裡不論是 5 秒還是 0 秒,都是一樣的。 第一個巨集觀任務中,包含了先後同步執行的 console.log(“a”); 和 console.log(“b”);。setTimeout 後,第二個巨集觀任務執行呼叫了 resolve,然後 then 中的程式碼非同步得到執行,所以呼叫了 console.log(“c”),最終輸出的順序才是: a b c。 Promise 是 JavaScript 中的一個定義,但是實際編寫程式碼時,它似乎並不比回撥的方式書寫更簡單,但是從 ES6 開始,有了 async/await,這個語法改進跟 Promise 配合,能夠有效地改善程式碼結構. 新特性:async/await async/await 是 ES2016 新加入的特性,它提供了用 for、if 等程式碼結構來編寫非同步的方式。它的執行時基礎是 Promise,面對這種比較新的特性,我們先來看一下基本用法。 async 函式必定返回 Promise,我們把所有返回 Promise 的函式都可以認為是非同步函式。 async 函式是一種特殊語法,特徵是在 function 關鍵字之前加上 async 關鍵字,這樣,就定義了一個 async 函式,我們可以在其中使用 await 來等待一個Promise。
function sleep(duration) { return new Promise(function(resolve, reject) { setTimeout(resolve,duration); }) } async function foo(){ console.log("a") await sleep(2000) console.log("b") }這段程式碼利用了我們之前定義的 sleep 函式。在非同步函式 foo 中,我們呼叫 sleep。 async 函式強大之處在於,它是可以巢狀的。我們在定義了一批原子操作的情況下,可以利用 async 函式組合出新的 async 函式。
function sleep(duration) { return new Promise(function(resolve, reject) { setTimeout(resolve,duration); }) } async function foo(name){ await sleep(2000) console.log(name) } async function foo2(){ await foo("a"); await foo("b"); }這裡 foo2 用 await 呼叫了兩次非同步函式 foo,可以看到,如果我們把 sleep 這樣的非同步操作放入某一個框架或者庫中,使用者幾乎不需要了解 Promise 的概念即可進行非同步程式設計了。 此外,generator/iterator 也常常被跟非同步一起來講,我們必須說明 generator/iterator 並非非同步程式碼,只是在缺少 async/await 的時候,一些框架(最著名的要數co)使用這樣的特性來模擬 async/await。但是 generator 並非被設計成實現非同步,所以有了 async/await 之後,generator/iterator 來模擬非同步的方法應該被廢棄。 總結: 把宿主發起的任務稱為巨集觀任務,把 JavaScript 引擎發起的任務稱為微觀任務。許多的微觀任務的佇列組成了巨集觀任務。 最後,留一個小練習:我們現在要實現一個紅綠燈,把一個圓形 div 按照綠色 3 秒,黃色 1 秒,紅色 2 秒迴圈改變背景色,怎樣編寫這個程式碼呢?