本章說明 JavaScript 中非同步程式設計的基礎。它提供 下一章 ES6 Promises 的背景知識。
當函式 f
呼叫函式 g
時,g
需要知道在完成後要返回到哪裡(在 f
內)。此資訊通常使用堆疊(呼叫堆疊)來管理。我們來看一個範例。
function
h
(
z
)
{
// Print stack trace
console
.
log
(
new
Error
().
stack
);
// (A)
}
function
g
(
y
)
{
h
(
y
+
1
);
// (B)
}
function
f
(
x
)
{
g
(
x
+
1
);
// (C)
}
f
(
3
);
// (D)
return
;
// (E)
最初,當上述程式開始執行時,呼叫堆疊是空的。在 D 行的函式呼叫 f(3)
之後,堆疊有一個項目
在 C 行的函式呼叫 g(x + 1)
之後,堆疊有兩個項目
f
中的位置
在 B 行的函式呼叫 h(y + 1)
之後,堆疊有三個項目
g
中的位置
f
中的位置
在 A 行印出的堆疊追蹤顯示呼叫堆疊的樣子
Error
at h (stack_trace.js:2:17)
at g (stack_trace.js:6:5)
at f (stack_trace.js:9:5)
at <global> (stack_trace.js:11:1)
接下來,每個函式終止,每次都會從堆疊中移除最上方的項目。在函式 f
完成後,我們回到全域範圍,呼叫堆疊為空。在 E 行,我們傳回,堆疊為空,表示程式終止。
簡而言之,每個瀏覽器分頁在單一程序中執行:事件迴圈。此迴圈執行瀏覽器相關事項(稱為工作),這些事項是透過工作佇列提供給迴圈的。工作的範例包括
項目 2-4 是透過瀏覽器內建的引擎執行 JavaScript 程式碼的任務。當程式碼終止時,它們也會終止。然後,可以執行佇列中的下一個任務。以下的圖表(靈感來自 Philip Roberts 的投影片 [1])概述了所有這些機制的連接方式。
事件迴圈周圍有其他平行執行的程序(計時器、輸入處理等)。這些程序透過將任務新增到其佇列與其溝通。
瀏覽器有 計時器。setTimeout()
會建立一個計時器,等到它觸發後,再將一個任務新增到佇列。它的簽章為
setTimeout
(
callback
,
ms
)
在經過 ms
毫秒後,callback
會被新增到任務佇列。請務必注意,ms
僅指定何時新增回呼,而不是實際執行時間。這可能會晚很多,特別是在事件迴圈被封鎖時(如本章稍後所述)。
將 ms
設定為零的 setTimeout()
是立即將某個項目新增到任務佇列的常見解決方法。但是,有些瀏覽器不允許 ms
低於最小值(Firefox 中為 4 毫秒);如果是,它們會將其設定為該最小值。
對於大多數 DOM 變更(特別是涉及重新配置的變更),顯示不會立即更新。「配置每 16 毫秒發生一次更新勾選」(@bz_moz),並且必須透過事件迴圈獲得執行機會。
有一些方法可以協調頻繁的 DOM 更新與瀏覽器,以避免與其配置節奏發生衝突。請參閱 文件,以取得 requestAnimationFrame()
的詳細資訊。
JavaScript 具有所謂的執行至完成語意:目前任務總是在執行下一個任務前完成。這表示每個任務都能完全控制所有目前的狀態,而且不必擔心並發修改。
我們來看一個範例
setTimeout
(
function
()
{
// (A)
console
.
log
(
'Second'
);
},
0
);
console
.
log
(
'First'
);
// (B)
從 A 行開始的函式會立即新增到任務佇列,但只會在目前的程式碼執行完畢後才執行(特別是 B 行!)。這表示此程式碼的輸出將永遠是
First
Second
正如我們所見,每個分頁(在某些瀏覽器中,是整個瀏覽器)都由單一程序管理,包括使用者介面和所有其他運算。這表示你可以透過在該程序中執行長時間運算來凍結使用者介面。以下程式碼示範了這一點。
<
a
id
=
"block"
href
=
""
>
Block for 5 seconds</
a
>
<
p
>
<
button
>
This is a button</
button
>
<
div
id
=
"statusMessage"
></
div
>
<
script
>
document
.
getElementById
(
'block'
)
.
addEventListener
(
'click'
,
onClick
);
function
onClick
(
event
)
{
event
.
preventDefault
();
setStatusMessage
(
'Blocking...'
);
// Call setTimeout(), so that browser has time to display
// status message
setTimeout
(
function
()
{
sleep
(
5000
);
setStatusMessage
(
'Done'
);
},
0
);
}
function
setStatusMessage
(
msg
)
{
document
.
getElementById
(
'statusMessage'
).
textContent
=
msg
;
}
function
sleep
(
milliseconds
)
{
var
start
=
Date
.
now
();
while
((
Date
.
now
()
-
start
)
<
milliseconds
);
}
</
script
>
每當按一下開頭的連結時,就會觸發函式 onClick()
。它使用同步的 sleep()
函式來封鎖事件迴圈五秒鐘。在這幾秒鐘內,使用者介面無法運作。例如,你無法按一下「簡單按鈕」。
你可以透過兩種方式避免封鎖事件迴圈
首先,不要在主程序中執行長時間運算,請將它們移到其他程序。這可以使用 Worker API 來達成。
其次,不要(同步)等待長時間運算(Worker 程序中的自訂演算法、網路要求等)的結果,請繼續執行事件迴圈,並讓運算在完成時通知你。事實上,在瀏覽器中你通常甚至沒有選擇,而且必須這樣做。例如,沒有內建的方法可以同步休眠(例如先前實作的 sleep()
)。相反地,setTimeout()
讓你非同步休眠。
下一節說明了非同步等待結果的技術。
非同步接收結果的兩種常見模式是:事件和回呼。
在此非同步接收結果的模式中,你為每個要求建立一個物件,並向其註冊事件處理常式:一個用於運算成功,另一個用於處理錯誤。以下程式碼顯示了如何使用 XMLHttpRequest
API 執行此操作
var
req
=
new
XMLHttpRequest
();
req
.
open
(
'GET'
,
url
);
req
.
onload
=
function
()
{
if
(
req
.
status
==
200
)
{
processData
(
req
.
response
);
}
else
{
console
.
log
(
'ERROR'
,
req
.
statusText
);
}
};
req
.
onerror
=
function
()
{
console
.
log
(
'Network Error'
);
};
req
.
send
();
// Add request to task queue
請注意,最後一行並未實際執行要求,而是將其新增到工作佇列。因此,你也可以在 open()
之後、設定 onload
和 onerror
之前立即呼叫該方法。由於 JavaScript 的執行至完成語意,因此事情會以相同的方式運作。
瀏覽器 API IndexedDB 具有稍微特殊的事件處理樣式
var
openRequest
=
indexedDB
.
open
(
'test'
,
1
);
openRequest
.
onsuccess
=
function
(
event
)
{
console
.
log
(
'Success!'
);
var
db
=
event
.
target
.
result
;
};
openRequest
.
onerror
=
function
(
error
)
{
console
.
log
(
error
);
};
你首先建立一個要求物件,並向其新增會收到結果通知的事件監聽器。但是,你不需要明確排隊要求,這是由 open()
執行的。它會在目前的任務完成後執行。這就是為什麼你可以在呼叫 open()
之後 註冊事件處理常式(事實上也必須這樣做)。
如果您習慣於多執行緒程式語言,這種處理要求的風格看起來可能很奇怪,好像容易發生競爭狀態。但是,由於執行到完成,所以事情總是安全的。
如果您多次收到結果,這種非同步計算結果的處理風格是沒問題的。然而,如果只有一個結果,那麼冗長就會成為一個問題。對於這種使用案例,回呼已變得流行。
如果您透過回呼處理非同步結果,則將回呼函式傳遞為非同步函式或方法呼叫的尾部參數。
以下是 Node.js 中的範例。我們透過非同步呼叫 `fs.readFile()` 來讀取文字檔的內容
// Node.js
fs
.
readFile
(
'myfile.txt'
,
{
encoding
:
'utf8'
},
function
(
error
,
text
)
{
// (A)
if
(
error
)
{
// ...
}
console
.
log
(
text
);
});
如果 `readFile()` 成功,則 A 行中的回呼會透過參數 `text` 接收結果。如果它不是,則回呼會透過其第一個參數取得錯誤(通常是 `Error` 或子建構函式的執行個體)。
經典函式程式風格中的相同程式碼看起來像這樣
// Functional
readFileFunctional
(
'myfile.txt'
,
{
encoding
:
'utf8'
},
function
(
text
)
{
// success
console
.
log
(
text
);
},
function
(
error
)
{
// failure
// ...
});
使用回呼的程式風格(特別是前面顯示的函式方式)也稱為延續傳遞風格 (CPS),因為下一步(延續)被明確地傳遞為參數。這讓呼叫的函式可以更進一步控制接下來發生的事情以及何時發生。
下列程式碼說明 CPS
console
.
log
(
'A'
);
identity
(
'B'
,
function
step2
(
result2
)
{
console
.
log
(
result2
);
identity
(
'C'
,
function
step3
(
result3
)
{
console
.
log
(
result3
);
});
console
.
log
(
'D'
);
});
console
.
log
(
'E'
);
// Output: A E B D C
function
identity
(
input
,
callback
)
{
setTimeout
(
function
()
{
callback
(
input
);
},
0
);
}
對於每一步驟,程式的控制流程會在回呼中繼續。這會導致巢狀函式,有時稱為回呼地獄。但是,您通常可以避免巢狀,因為 JavaScript 的函式宣告是提升的(其定義會在它們的範圍開始時評估)。這表示您可以提前呼叫並呼叫程式中稍後定義的函式。下列程式碼使用提升來扁平化前一個範例。
console
.
log
(
'A'
);
identity
(
'B'
,
step2
);
function
step2
(
result2
)
{
// The program continues here
console
.
log
(
result2
);
identity
(
'C'
,
step3
);
console
.
log
(
'D'
);
}
function
step3
(
result3
)
{
console
.
log
(
result3
);
}
console
.
log
(
'E'
);
在一般的 JavaScript 風格中,您可以透過以下方式編寫程式碼片段
map()
、filter()
和 forEach()
for
和 while
函式庫 Async.js 提供組合器,讓您可以在 CPS 中執行類似的事情,並使用 Node.js 風格的回呼函式。以下範例使用它來載入三個檔案的內容,其名稱儲存在陣列中。
var
async
=
require
(
'async'
);
var
fileNames
=
[
'foo.txt'
,
'bar.txt'
,
'baz.txt'
];
async
.
map
(
fileNames
,
function
(
fileName
,
callback
)
{
fs
.
readFile
(
fileName
,
{
encoding
:
'utf8'
},
callback
);
},
// Process the result
function
(
error
,
textArray
)
{
if
(
error
)
{
console
.
log
(
error
);
return
;
}
console
.
log
(
'TEXTS:\n'
+
textArray
.
join
(
'\n----\n'
));
});
使用回呼函式會產生截然不同的程式設計風格,即 CPS。CPS 的主要優點是其基本機制容易理解。但它也有缺點
Node.js 風格中的回呼函式有三個缺點(與函式風格相比)
if
陳述式增加了冗長性。下一章節將介紹 Promise 和 ES6 Promise API。Promise 在底層比回呼函式複雜。作為交換,它們帶來幾個重要的優點,並消除了前面提到的回呼函式的大部分缺點。
[1] Philip Roberts 的「救命,我被困在事件迴圈中」(影片)。
[2] HTML 規範中的「事件迴圈」。
[3] Axel Rauschmayer 的「JavaScript 中的非同步程式設計和延續傳遞樣式」。