為了示範回呼函式、Promise 和其他抽象概念的使用方式,我們將使用一些瀏覽器方法:特別是載入腳本和執行簡單的文件操作。
如果你不熟悉這些方法,而且範例中的用法令人困惑,你可以閱讀教學課程下一部分的幾個章節。
儘管如此,我們還是會盡量把事情說清楚。在瀏覽器方面,不會有任何真正複雜的事情。
JavaScript 主機環境提供了許多函式,可讓你排程非同步動作。換句話說,我們現在啟動的動作,但它們會在稍後完成。
例如,其中一個這樣的函式是 setTimeout
函式。
還有其他非同步動作的真實世界範例,例如載入腳本和模組(我們將在後面的章節中介紹它們)。
看看函式 loadScript(src)
,它會載入具有給定 src
的腳本
function loadScript(src) {
// creates a <script> tag and append it to the page
// this causes the script with given src to start loading and run when complete
let script = document.createElement('script');
script.src = src;
document.head.append(script);
}
它會將一個新的動態建立的標籤 <script src="…">
插入文件,其中具有給定的 src
。瀏覽器會自動開始載入它,並在完成時執行。
我們可以像這樣使用這個函式
// load and execute the script at the given path
loadScript('/my/script.js');
腳本會「非同步」執行,因為它現在開始載入,但會在函式已完成後才執行。
如果 loadScript(…)
下方有任何程式碼,它不會等到腳本載入完成。
loadScript('/my/script.js');
// the code below loadScript
// doesn't wait for the script loading to finish
// ...
假設我們需要在腳本載入後立即使用它。它會宣告新的函式,而我們想要執行它們。
但如果我們在 loadScript(…)
呼叫後立即執行,那將無法運作
loadScript('/my/script.js'); // the script has "function newFunction() {…}"
newFunction(); // no such function!
當然,瀏覽器可能沒有時間載入腳本。目前,loadScript
函式不提供追蹤載入完成的方式。腳本會載入並最終執行,就這樣。但我們希望知道何時發生這種情況,以使用該腳本中的新函式和變數。
讓我們新增一個 callback
函式作為 loadScript
的第二個引數,它應該在腳本載入時執行
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script);
document.head.append(script);
}
onload
事件在文章 資源載入:onload 和 onerror 中有說明,它基本上會在腳本載入並執行後執行一個函式。
現在,如果我們想要呼叫腳本中的新函式,我們應該在 callback 中撰寫它
loadScript('/my/script.js', function() {
// the callback runs after the script is loaded
newFunction(); // so now it works
...
});
這就是這個概念:第二個引數是一個函式(通常是匿名的),它會在動作完成時執行。
以下是使用實際腳本的可執行範例
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script);
document.head.append(script);
}
loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
alert(`Cool, the script ${script.src} is loaded`);
alert( _ ); // _ is a function declared in the loaded script
});
這稱為非同步程式設計的「基於 callback」樣式。非同步執行某項作業的函式應該提供一個 callback
引數,我們可以在其中放置在它完成後執行的函式。
我們在 loadScript
中執行這項作業,但這當然是一種一般性的方法。
callback 中的 callback
我們如何依序載入兩個腳本:第一個,然後是第二個腳本?
自然而然的解決方法是將第二個 loadScript
呼叫放入 callback 中,如下所示
loadScript('/my/script.js', function(script) {
alert(`Cool, the ${script.src} is loaded, let's load one more`);
loadScript('/my/script2.js', function(script) {
alert(`Cool, the second script is loaded`);
});
});
在外層 loadScript
完成後,callback 會啟動內層 loadScript
。
如果我們想要多一個腳本…?
loadScript('/my/script.js', function(script) {
loadScript('/my/script2.js', function(script) {
loadScript('/my/script3.js', function(script) {
// ...continue after all scripts are loaded
});
});
});
因此,每個新動作都在回呼函式中。對於少數動作來說沒問題,但對於許多動作來說並不好,所以我們很快就會看到其他變體。
處理錯誤
在上述範例中,我們沒有考慮錯誤。如果腳本載入失敗怎麼辦?我們的回呼函式應該能夠對此做出反應。
以下是追蹤載入錯誤的 loadScript
改良版本
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(null, script);
script.onerror = () => callback(new Error(`Script load error for ${src}`));
document.head.append(script);
}
它會在載入成功時呼叫 callback(null, script)
,否則呼叫 callback(error)
。
用法
loadScript('/my/script.js', function(error, script) {
if (error) {
// handle error
} else {
// script loaded successfully
}
});
再次說明,我們用於 loadScript
的食譜實際上很常見。它稱為「錯誤優先回呼函式」樣式。
慣例是
callback
的第一個引數保留給錯誤(如果發生錯誤)。然後呼叫callback(err)
。- 第二個引數(以及後續引數,如果需要的話)是成功結果。然後呼叫
callback(null, result1, result2…)
。
因此,單一 callback
函式用於回報錯誤和傳回結果。
厄運金字塔
乍看之下,這看起來像是可行的非同步編碼方法。事實上也的確如此。對於一個或兩個巢狀呼叫來說,看起來沒問題。
但是對於多個連續發生的非同步動作,我們的程式碼會像這樣
loadScript('1.js', function(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('2.js', function(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('3.js', function(error, script) {
if (error) {
handleError(error);
} else {
// ...continue after all scripts are loaded (*)
}
});
}
});
}
});
在上述程式碼中
- 我們載入
1.js
,然後如果沒有錯誤… - 我們載入
2.js
,然後如果沒有錯誤… - 我們載入
3.js
,然後如果沒有錯誤 – 執行其他動作(*)
。
隨著呼叫越來越巢狀,程式碼變得越來越深,也越來越難管理,特別是如果我們有實際程式碼而不是 ...
,其中可能包含更多迴圈、條件式陳述式等。
這有時稱為「回呼函式地獄」或「厄運金字塔」。
巢狀呼叫的「金字塔」會隨著每個非同步動作向右方擴展。很快就會失控。
因此,這種編碼方式並不好。
我們可以透過讓每個動作成為獨立函式來嘗試緩解這個問題,如下所示
loadScript('1.js', step1);
function step1(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('2.js', step2);
}
}
function step2(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('3.js', step3);
}
}
function step3(error, script) {
if (error) {
handleError(error);
} else {
// ...continue after all scripts are loaded (*)
}
}
看到了嗎?它執行相同的動作,而且現在沒有深層巢狀,因為我們讓每個動作成為獨立的頂層函式。
它可以運作,但程式碼看起來像被撕開的試算表。它很難閱讀,而且你可能注意到在閱讀時需要在各個部分之間跳動。這很不方便,特別是如果讀者不熟悉程式碼,也不知道該跳到哪裡。
此外,名為 step*
的函式都是單一用途,它們只建立來避免「厄運金字塔」。沒有人會在動作鏈之外重複使用它們。因此,這裡有點名稱空間混亂。
我們想要有更好的方法。
幸運的是,還有其他方法可以避免這種金字塔。其中最好的方法之一是使用「承諾」,這將在下一章中說明。
留言
<code>
標籤,要插入多行程式碼 - 請將它們包覆在<pre>
標籤中,要插入 10 行以上的程式碼 - 請使用沙盒 (plnkr、jsbin、codepen…)