我們在這裏的示例中使用了浏覽器方法
爲了演示回調、promise 和其他抽象概念的使用,我們將使用壹些浏覽器方法:具體地說,是加載腳本和執行簡單的文檔操作的方法。
如果妳不熟悉這些方法,並且對它們在這些示例中的用法感到疑惑,那麽妳可能需要閱讀本教程 下壹部分 中的幾章。
但是,我們會盡全力使講解變得更加清晰。在這兒不會有浏覽器方面的真正複雜的東西。
JavaScript 主機(host)環境提供了許多函數,這些函數允許我們計劃 異步 行爲(action)—— 也就是在我們執行壹段時間後才自行完成的行爲。
例如,setTimeout
函數就是壹個這樣的函數。
這兒有壹些實際中的異步行爲的示例,例如加載腳本和模塊(我們將在後面的章節中介紹)。
讓我們看壹下函數 loadScript(src)
,該函數使用給定的 src
加載腳本:
function loadScript(src) { // 創建壹個 <script> 標簽,並將其附加到頁面 // 這將使得具有給定 src 的腳本開始加載,並在加載完成後運行 let script = document.createElement('script'); script.src = src; document.head.append(script); }
它將壹個新的、帶有給定 src
的、動態創建的標簽 <script src="…">
插入到文檔中。浏覽器將自動開始加載它,並在加載完成後執行它。
我們可以像這樣使用這個函數:
// 在給定路徑下加載並執行腳本 loadScript('/my/script.js');
腳本是“異步”調用的,因爲它從現在開始加載,但是在這個加載函數執行完成後才運行。
如果在 loadScript(…)
下面有任何其他代碼,它們不會等到腳本加載完成才執行。
loadScript('/my/script.js'); // loadScript 下面的代碼 // 不會等到腳本加載完成才執行 // ...
假設我們需要在新腳本加載後立即使用它。它聲明了新函數,我們想運行它們。
但如果我們在 loadScript(…)
調用後立即執行此操作,這將不會有效。
loadScript('/my/script.js'); // 這個腳本有 "function newFunction() {…}" newFunction(); // 沒有這個函數!
自然情況下,浏覽器可能沒有時間加載腳本。到目前爲止,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 壹文中有描述,它通常會在腳本加載和執行完成後執行壹個函數。
現在,如果我們想調用該腳本中的新函數,我們應該將其寫在回調函數中:
loadScript('/my/script.js', function() { // 在腳本加載完成後,回調函數才會執行 newFunction(); // 現在它工作了 ... });
這是我們的想法:第二個參數是壹個函數(通常是匿名函數),該函數會在行爲(action)完成時運行。
這是壹個帶有真實腳本的可運行的示例:
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(`酷,腳本 ${script.src} 加載完成`); alert( _ ); // _ 是所加載的腳本中聲明的壹個函數 });
這被稱爲“基于回調”的異步編程風格。異步執行某項功能的函數應該提供壹個 callback
參數用于在相應事件完成時調用。(譯注:上面這個例子中的相應事件是指腳本加載)
這裏我們在 loadScript
中就是這麽做的,但當然這是壹種通用方法。
我們如何依次加載兩個腳本:第壹個,然後是第二個?
自然的解決方案是將第二個 loadScript
調用放入回調中,如下所示:
loadScript('/my/script.js', function(script) { alert(`酷,腳本 ${script.src} 加載完成,讓我們繼續加載另壹個吧`); loadScript('/my/script2.js', function(script) { alert(`酷,第二個腳本加載完成`); }); });
在外部 loadScript
執行完成時,回調就會發起內部的 loadScript
。
如果我們還想要壹個腳本呢?
loadScript('/my/script.js', function(script) { loadScript('/my/script2.js', function(script) { loadScript('/my/script3.js', function(script) { // ...加載完所有腳本後繼續 }); }); });
因此,每壹個新行爲(action)都在回調內部。這對于幾個行爲來說還好,但對于許多行爲來說就不好了,所以我們很快就會看到其他變體。
在上述示例中,我們並沒有考慮出現 error 的情況。如果腳本加載失敗怎麽辦?我們的回調應該能夠對此作出反應。
這是 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) { // 處理 error } else { // 腳本加載成功 } });
再次強調,我們在 loadScript
中所使用的方案其實很普遍。它被稱爲“Error 優先回調(error-first callback)”風格。
約定是:
callback
的第壹個參數是爲 error 而保留的。壹旦出現 error,callback(err)
就會被調用。
第二個參數(和下壹個參數,如果需要的話)用于成功的結果。此時 callback(null, result1, result2…)
就會被調用。
因此,單壹的 callback
函數可以同時具有報告 error 和傳遞返回結果的作用。
乍壹看,它像是壹種可行的異步編程方式。的確如此,對于壹個或兩個嵌套的調用看起來還不錯。
但對于壹個接壹個的多個異步行爲,代碼將會變成這樣:
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 { // ...加載完所有腳本後繼續 (*) } }); } }); } });
在上面這段代碼中:
我們加載 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 { // ...加載完所有腳本後繼續 (*) } }
看到了嗎?它的作用相同,但是沒有深層的嵌套了,因爲我們將每個行爲都編寫成了壹個獨立的頂層函數。
它可以工作,但是代碼看起來就像是壹個被撕裂的表格。妳可能已經注意到了,它的可讀性很差,在閱讀時妳需要在各個代碼塊之間跳轉。這很不方便,特別是如果讀者對代碼不熟悉,他們甚至不知道應該跳轉到什麽地方。
此外,名爲 step*
的函數都是壹次性使用的,創建它們就是爲了避免“厄運金字塔”。沒有人會在行爲鏈之外重用它們。因此,這裏的命名空間有點混亂。
我們希望還有更好的方法。
幸運的是,有其他方法可以避免此類金字塔。最好的方法之壹就是 “promise”,我們將在下壹章中介紹它。