唯一比不知道代碼爲什麼崩潰更可怕的事情是,不知道爲什麼一開始它是工作的!
在 ECMA 規範的最近幾次版本里不斷有新成員加入,尤其在處理異步的問題上,更是不斷推陳出新。然而,我們在享受便利的同時,也應該瞭解異步到底是怎麼一回事。
現在與將來
JavaScript 是單線程的,一次只能專注於一件事。如果瀏覽器只靠 JavaScript 引擎線程來完成所有工作,先不說能不能搞定,即使可以,那也會花費很長時間。幸好在瀏覽器裏 JavaScript 引擎並不孤單,還有 GUI 渲染線程、事件觸發線程、定時觸發器線程、異步http請求線程等其它線程。這些線程之間的協作纔有了我們看到的瀏覽器界面效果(遠不止這些)。
(盜了一張圖)
一個程序在執行過程中可能會有等待用戶輸入、從數據庫或文件系統中請求數據、通過網絡發送並等待響應,或是以固定時間間隔執行重複任務(比如動畫)等情況。(這些情況,當下是無法得出結果的,但是一旦有了結果,我們知道需要去做些什麼。)
JavaScript 引擎不是一個人在戰鬥,它把以上的任務交給其它線程,並計劃好任務完成後要做的事,JavaScript 引擎又可以繼續做自己的事了。從這裏可以看出,一個程序的運行包括兩部分,現在運行和將來運行。而現在運行和將來運行的關係正是異步編程的核心。
let params = {type:'asynchronous'}
let response = ajax(params,'http://someURL.com'); // 異步請求
if (!response) throw '無數據!';
以上代碼肯定會拋錯的,異步請求任務交出去之後,程序會繼續運行下去。由於ajax(...) 是異步操作,即使立刻返回結果,當下的 response 也不會被賦值。一個是現在,一個是將來,兩者本就不屬於一個時空的。
事件循環
現在和將來是相對的,等將來的時刻到了,將來也就成爲了現在。
JavaScript 引擎運行在宿主環境中,宿主環境提供了一種機制來處理程序中多個塊的執行,且執行每個塊時調用 JavaScript 引擎,這種機制被稱爲事件循環。即,JavaScript 引擎本身並沒有時間的概念,只是一個按需執行 JavaScript 任意代碼片段的環境。
“事件”(JavaScript 代碼執行)調度總是由包含它的環境進行。
點擊圖片進入或點此進入:
一個 JavaScript 運行時包含了一個待處理的消息隊列。每一個消息都關聯着一個用以處理這個消息的函數。
在事件循環期間的某個時刻,運行時從最先進入隊列的消息開始處理隊列中的消息。爲此,這個消息會被移出隊列,並作爲輸入參數調用與之關聯的函數。
while (queue.waitForMessage()) {
queue.processNextMessage();
}
一旦有事件需要進行,事件循環就會運行,直到隊列清空。事件循環的每一輪稱爲一個 tick。用戶交互,IO 和定時器會向事件隊列中加入事件。
(又盜了一張圖)
任務隊列
任務隊列(job queue)建立在事件循環隊列之上。(Promise 的異步特性就是基於任務。)
最好的理解方式,它是掛在事件循環隊列的每個tick之後的一個隊列。在事件循環的每個tick中,可能出現的異步動作不會導致一個完整的新事件添加到事件循環隊列中,而會在當前 tick 的任務隊列末尾添加一個項目(一個任務)。
即,由 Call Stack 生成的任務隊列會緊隨其後運行。
Promise.resolve().then(function promise1 () {
console.log('promise1');
})
setTimeout(function setTimeout1 (){
console.log('setTimeout1');
Promise.resolve().then(function promise2 () {
console.log('promise2');
})
}, 0)
setTimeout(function setTimeout2 (){
console.log('setTimeout2');
Promise.resolve().then(function promise3 () {
console.log('promise3');
setTimeout(function setTimeout3 () {
console.log('setTimeout3');
})
Promise.resolve().then(function promise4 () {
console.log('promise4');
})
})
}, 0)
// promise1
// setTimeout1
// promise2
// setTimeout2
// promise3
// promise4
// setTimeout3
異步回調
被作爲實參傳入另一函數,並在該外部函數內被調用,用以來完成某些任務的函數,稱爲回調函數。回調函數經常被用於繼續執行一個異步完成後的操作,它們被稱爲異步回調。立即執行的稱之爲同步回調。
回調函數是事件循環“回頭調用”到程序中的目標,隊列處理到這個項目的時候會運行它。
回調是 JavaScript 語言中最基礎的異步模式。
生活中,我們喜歡和有條理的人打交道,因爲我們的大腦習慣了這種思維模式。然而回調的使用打破了這種模式,因爲代碼的嵌套使得我們要在不同塊間切換。嵌套越多,邏輯越複雜,我們也就越難理解和處理代碼,尤其在表達異步的方式上。
(又盜了一張圖)
除了嵌套的問題,異步回調還存在一些信任問題。
- 回調性質的不確定
- 調用回調方式不確定(沒調用,重複調用等)
- ......
針對第一點的建議是:永遠異步調用回調,即使就在事件循環的下一輪,這樣,所有回調都是可預測的異步調用了。
在理解這個建議之前,我們首先了解下控制反轉,控制反轉就是把自己程序一部分的執行控制交個某個第三方。
let a = 0; // A
thirdparty(() => {
console.log('a', a); // B
})
a++; // C
A 和 C 是現在運行的,B 雖然代碼是我們的,但是卻受制於第三方,因爲我們無法確定它是現在運行還是將來運行的。這裏的回調函數可能是同步回調也可能是異步回調。a 是 0 還是 1,都有可能。
// 同步回調
const thirdparty = cb => {
cb();
}
// 異步回調
const thirdparty = cb => {
setTimeout(() => cb(), 0);
}
所以,永遠異步調用回調,可預測。
function asyncify(fn) {
let func = fn;
let t = setTimeout(() => {
t = null;
if (fn) fn();
}, 0);
fn = null;
return () => {
if (t) {
fn = func.bind(this, ...arguments);
} else {
func.apply(this, arguments);
}
}
}
let a = 0;
thirdparty(asyncify(() => {
console.log('a', a);
}))
a++;
// 1