一文帶你走進 JavaScript 異步編程
陳晨,微醫雲服務團隊前端工程師,一位 “生命在於靜止” 的程序員。
異步的由來
JavaScript 是單線程語言,瀏覽器只分配了一個主線程執行任務,意味着如果有多個任務,則必須按照順序執行,前一個任務執行完成之後才能繼續下一個任務。
這個模式比較清晰,但是當任務耗時較長的時候,比如網絡請求,定時器和事件監聽等,這個時候後續任務繼續等待,效率比較低。我們常見的頁面無響應,有時候就是因爲任務耗時長或者無限循環等造成的。那現在是怎麼解決這個問題呢。。。。
首先維護了一個 “任務隊列”。JavaScript 雖然是單線程的,但運行的宿主環境(瀏覽器)是多線程的,瀏覽器爲這些耗時任務開闢了另外的線程,主要包括 http 請求線程,瀏覽器定時觸發器,瀏覽器事件觸發線程。這些線程主要把任務回調,放在任務隊列裏,等待主線程執行。
簡單介紹如下圖:
這樣就實現了 JavaScript 的單線程異步,任務被分爲同步任務和異步任務兩種:
同步任務:排隊執行的任務,後一個任務等待前一個任務結束。
異步任務:放入任務隊列的任務,未來纔會觸發執行的事件。
異步執行機制
異步任務分爲宏任務和微任務。
宏任務(macroTask)
宏任務,其實就是標準機制下的常規任務,即” 任務隊列中 “等待被主線程執行的事件,是由瀏覽器宿主發起的任務,例如:
-
script (可以理解爲外層主程序同步代碼)。
-
setTimeout,setInterval,requestAnimationFrame。
-
I/O。
-
渲染事件(解析 DOM,佈局,繪製等)。
-
用戶交互事件(鼠標點擊,頁面滾動,放大縮小等)。
宏任務會被放在宏任務隊列裏,先進先出的原則,兩個宏任務中間可能會被插入其他系統任務,間隔時間不定,效率較低 。
微任務(microTask)
由於宏任務間隔不定,時間顆粒大,對於實時性要求比較高的場景就需要更精確地控制,需要把任務插入到當前宏任務執行,從而產生了微任務的概念。
微任務是 JavaScript 引擎發起的,是需要異步執行的函數。例如:
-
Promise:ES6 的異步編程,Promise 的各種 Api 會產生微任務,下面異步實現會做詳細介紹。
-
MutationObserver(瀏覽器):監視 DOM 樹更改,DOM 節點的變化是微任務。
在執行 JavaScript 腳本,創建全局執行上下文的時候,JavaScript 引擎就會創建一個微任務隊列,在執行當前宏任務時,產生的微任務都會保存到微任務隊列裏。在宏任務主函數執行結束之後,宏任務結束之前,清空微任務隊列。
微任務和宏任務是綁定的,每個宏任務都會創建自己的微任務:
事件循環(Event loop)
主線程運行 JavaScript 代碼時,會生成個執行棧 (先進後出),管理主線程上函數調用關係的數據結構。
當執行棧中的所有同步任務執行完畢,系統就會不斷的從 "任務隊列" 中讀取事件,這個過程是循環不斷的,稱爲 Event Loop(事件循環)。
事件循環機制調度宏任務和微任務,機制如下:
-
執行一個宏任務(第一次是最外層同步代碼),執行過程中如果遇到微任務會加入微任務隊列;
-
代碼執行完成後,查看是否有微任務,如果有的執行第 3 步,沒有則執行第 4 步;
-
依次執行所有微任務,在執行微任務的過程中產生的新的微任務也會被事件循環處理,直到隊列清空,宏任務完成,執行第 4 步;
-
查看是否有下一個宏任務,有的話則執行第 1 步,沒有則結束。
因爲微任務自身可以入列更多的微任務,且事件循環會持續處理微任務直至隊列爲空,那麼就存在一種使得事件循環無盡處理微任務的真實風險。如何處理遞歸增加微任務是要謹慎而行的。
異步的實現歷程
回調函數
回調函數是一個函數被當做參數傳遞給另一個函數,另一個函數完成之後執行回調。比如 Ajax 請求、IO 操作、定時器的回調等。
下面是 setTimeout 例子:
console.log('setTimeout 調用之前')
setTimeout(() => {console.log('setTimeout 輸出')}, 0);
console.log('setTimeout 調用之後')
// 結果
setTimeout 調用之前
setTimeout 調用之後
setTimeout 輸出
setTimeout 回調放入任務隊列中,當主線程的同步代碼執行完之後,纔會執行任務隊列的回調,所以是如上的輸出結果。
優缺點
優點:回調函數相對比較簡單、容易理解。
缺點:不利於代碼的閱讀和維護,各個部分之間高度耦合,流程會很混亂,而且每個任務只能指定一個回調函數,易形成回調函數地獄。如下:
setTimeout(function(){
let value1 = step1()
setTimeout(function(){
let value2 = step2(value1)
setTimeout(function(){
step3(value2)
},0);
},0);
},0);
Promise
Promise 是 ES6 新增的異步編程的方式,在一定程度上解決了回調地域的問題。簡單說就是一個容器,裏面保存着某個未來纔會結束的事件(通常是一個異步操作)的結果。從語法上說,Promise 是一個對象,從它可以獲取異步操作的消息。
使用 Promise 首先要明白以下特點:
-
Promise 有三種狀態 pending、rejected、resolved,狀態一旦確定就不能改變,且只能夠由 pending 狀態變成 rejected 或者 resolved 狀態;
-
Promise 實例最主要的方法就是 then 的實現,有兩個參數。Promise 執行成功時,調用 then 方法的第一個回調函數,失敗則調用第二個回調函數,而且 then 方法會返回一個新的 Promise 實例。
-
其次常用的就是 catch 方法,catch 方法實際是 then 方法第一個參數是 null 的情況,用於指定發生錯誤時的回調函數。
-
還有很多其他的 finally、all、race、allSettled、any、resolve、reject 等一系列 Api。
下面的例子就是常見的異步操作,主要是使用的 then 和 catch:
new Promise((resolve) => {
resolve(step1())
}).then(res => {
return step2(res)
}).catch(err => {
console.log(err)
})
step1 和 step2 是異步操作,step1 執行完之後的返回值會透傳給 then 回調,當做 step2 的入參,通過 then 一層層的代替回調地域。其中 then 的回調會加入微任務隊列。
Promise 爲什麼是微任務呢?
當 Promise 入參是同步代碼時:
console.log('start')
new Promise((resolve) => {
console.log('開始 resolve')
resolve('resolve 返回值')
}).then(data => {
console.log(data)
})
console.log('end')
// 原生 promise 輸出結果
start
開始 resolve
end
resolve 返回值
首先看下 Promise 的極簡實現:
class Promise {
constructor (executor) {
// 回調值
this.value = ''
// 成功的回調
this.onResolvedCallbacks = []
executor(this.resolve.bind(this))
}
resolve (value) {
this.value = value
this.onResolvedCallbacks.forEach(callback => callback())
}
then (onResolved, onRejected) {
this.onResolvedCallbacks.push(() => {
onResolved(this.value)
})
}
}
// 此時上面例子執行結果如下
start
開始 resolve
end
由於 Promise 是延遲綁定機制(回調在業務代碼的後面),executor 是同步代碼時,在執行到 resolve 的時候,還沒有執行 then,所以 onResolvedCallbacks 是空數組。這個時候需要讓 resolve 延後執行,可以先加一個定時器。如下:
resolve (value) {
setTimeout(() => {
this.value = value
this.onResolvedCallbacks.forEach(callback => callback())
})
}
輸出結果和預期是一致的,這裏使用 setTimeout 來延遲執行 resolve。但是 setTimeout 是宏任務,效率不高,這裏只是用 setTimeout 代替,在瀏覽器中,JavaScript 引擎會把 Promise 回調映射到微任務,既可以延遲被調用,又提升了代碼的效率。
優缺點
優點:
-
將異步操作以同步操作的流程表達出來,避免了層層嵌套的回調函數。
-
提供統一的接口,使得控制異步操作更加容易。
缺點:
-
無法取消 Promise,一旦新建它就會立即執行,無法中途取消。
-
如果不設置回調函數,Promise 內部拋出的錯誤,不會反應到外面。
-
當處於 pending 狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。
Generator/yield
Generator 是 ES6 提供的異步解決方案,其最大的特點就是可以控制函數的執行。整個 Generator 函數就是一個封裝的異步任務,或者說是異步任務的容器,異步操作需要暫停的地方,都用 yield 語句註明。
Generator 函數的特徵:
-
function 關鍵字與函數名之間有一個星號;
-
函數體內部使用 yield 表達式,定義不同的內部狀態;
-
通過 yield 暫停執行;
-
next 恢復執行,並且返回一個包含 value 和 done 屬性的對象,其中 value 表示 yield 表達式的值,done 表示遍歷器是否完成;
-
next 方法也可以接受參數, 作爲上一次 yield 語句的返回值。
function* getData () {
let value1 = yield 111
let value2 = yield value1 + 111 // 這裏的 value1 就是下面傳入的 val1.value
return value2
}
let meth = getData()
let val1 = meth.next()
console.log(val1) // { value: 111, done: false }
let val2 = meth.next(val1.value)
console.log(val2) // { value: 222, done: false }
let val3 = meth.next(val2.value)
console.log(val3) // { value: 222, done: true }
-
調用 getData 函數,會返回一個內部指針 meth(即遍歷器);
-
調用指針 meth 的 next 方法,移動內部指針,指向第一個遇到的 yield 語句,輸出返回值爲
{value: 111, done: false}
; -
再次調用指針 meth 的 next 方法,入參爲 111,賦值給 value1,移動內部指針,指向下一個 yield 語句,輸出表達式的返回值爲
{value: 222, done: false}
; -
持續調用指針 meth 的 next 方法,入參爲 222,賦值給 value2,遇到 return 結束遍歷器,輸出返回值
{ value: 222, done: true }
。
Generator 是怎麼實現暫停和恢復執行的呢?
Generator 是協程的一種實現方式。
協程:協程是一種比線程更加輕量級的存在,協程處在線程的環境中,一個線程可以存在多個協程,可以將協程理解爲線程中的一個個任務。通過應用程序代碼進行控制。
上面的例子中協程具體流程如下:
-
通過生成器函數 getData 創建一個協程 meth,創建之後沒有立即執行;
-
調用 meth.next() 讓協程執行;
-
協程執行時,通過關鍵字 yield 暫停協程;
-
協程執行時,遇到 return,JavaScript 引擎結束當前協程,並把結果返回給父協程。
meth 協程和父協程在主線程上交替執行,通過 next() 和 yield 進行控制,只有用戶態,切換效率高。
優缺點
優點:Generator 是以一種看似順序、同步的方式實現了異步控制流程,增強了代碼可讀性。
缺點:需要手動 next 執行下一步。
async/await
async/await 將 Generator 函數和自動執行器,封裝在一個函數中,是 Generator 的一種語法糖,簡化了外部執行器的代碼,同時利用 await 替代 yield,async 替代生成器的 (*) 號。
async 和 Generator 相比改進的地方:
-
內置執行器,不需要使用 next() 手動執行。
-
await 命令後面可以是 Promise 對象或原始類型的值,如果是原始值,會 Promise 化。
-
async 返回值是 Promise。返回非 Promise 時,async 函數會把它包裝成 Promise 返回。
下面來看個 sleep 的例子:
function sleep(time) {
return new Promise((resolve, reject) => {
time+=1000
setTimeout(() => {
resolve(time);
}, 1000);
});
}
async function test () {
let time = 0
for(let i = 0; i < 4; i++) {
time = await sleep(time);
console.log(time);
}
}
test()
// 輸出結果
1000
2000
3000
執行結果每隔一秒會輸出 time,await 是等待的意思,等待 sleep 執行完畢後通過 resolve 返回,纔會繼續執行,間隔至少一秒。
把 async/await 轉成 Generator 和 Promise 來實現。
function test () {
let time = 0
// stepGenerator 生成器
function* stepGenerator() {
for (let i = 0; i < 4; i++) {
let result = yield sleep(time);
console.log(result);
}
}
let step = stepGenerator()
let info
return new Promise((resolve) => {
// 自執行 next()
function stepNext () {
info = step.next(time)
// 執行結束則返回 value
if (info.done) {
resolve(info.value)
} else {
// 遍歷沒有結束 ,繼續執行
return Promise.resolve(info.value).then((res) => {
time = res
return stepNext()
})
}
}
stepNext()
})
}
test()
-
首先把 async 包裝成 Promise,async/await 轉換成 stepGenerator 生成器,yield 替換 await;
-
執行 stepNext();
-
stepNext 裏,step 遍歷器會執行 next()。done 爲 false 時,說明遍歷沒有完成,通過 Promise.resolve 等待執行結果,獲取結果之後繼續執行 next(),直到 done 爲 true,async 的 resolve 把最終返回。
優缺點
優點:是 Generator 更簡化的方式,相當於自動執行 Generator,代碼更清晰,更簡單。
缺點:濫用 await 可能會導致性能問題,因爲 await 會阻塞代碼,非依賴代碼失去併發性。
多個異步的執行順序問題
多個異步的執行順序問題是很考驗對異步的理解的。下面我們把 setTimeout、Promise、async/await 放在一起,看下返回結果和預想的是否一致:
console.log('start')
setTimeout(function() {
console.log('setTimeout')
}, 0);
async function test () {
let a = await 'await-result'
console.log(a)
}
test()
new Promise(function(resolve) {
console.log('promise-resolve')
resolve()
}).then(function() {
console.log('promise-then')
})
console.log('end')
//執行結果
start
promise-resolve
end
await-result
promise-then
setTimeout
上述例子中,外層主程序 和 setTimeout 都是宏任務,Promise 和 async/await 是微任務,所以整個流程如下:
-
第一個宏任務(主程序)開始執行 ------ 輸出 start
-
setTimeout 加入宏任務隊列
-
執行 test(),async/await 加入微任務隊列
-
Promise 初始入參是同步代碼,主程序一起執行 ------ 輸出 promise-resolve
-
Promise 的 then 回調加入微任務隊列
-
繼續執行主程序 ------ 輸出 end
-
執行第一個微任務 ------ 輸出 await-result
-
執行第二個微任務 ------ 輸出 promise-then
-
再執行下一個宏任務(setTimeout) ------ 輸出 setTimeout
總結
前端程序員日常代碼經常會用到異步編程,瞭解異步運行的機制和順序有助於更流暢清晰的實現異步代碼,這裏主要分析了異步的由來和異步代碼實現,可結合不同的場景和要求進行選擇。
參考資料
-
https://es6.ruanyifeng.com/
-
https://www.imooc.com/article/287901?block_id=tuijian_wz
-
http://www.ruanyifeng.com/blog/2012/12/asynchronous%EF%BC%BFjavascript.html
-
極客時間 - 李兵 (https://time.geekbang.org/column/intro/216)
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/IW95ZHuxyWKhKOnQjnVQTQ