JavaScript非同步之從回撥函式到Promise
【51CTO.com原創稿件】 JavaScript的非同步處理是前端工程師必須接觸的一塊內容。ES6在JavaScript非同步的處理上引入了新的特性,使得程式員能夠更加優雅地處理非同步問題。
若您想通過本教程直接上手Promise,那麼請按順序閱讀。
若您只是想了解Promise概念,那麼請直接閱讀每章的第一小節,等需要的時候,再回過頭來看具體的例子,從而不至於浪費您太多時間。
1.基於回撥函式
1.1.非同步動作與回撥函式
在JavaScript中往往需要處理很多非同步動作(asynchronous actions),如後臺請求某個dashboard上的顯示資料、響應一條定時資訊。非同步動作的執行不會阻塞其他動作,且在執行完成之後,由回撥函式(callback)處理非同步動作的結果。
假如你想載入一個JavaScript 指令碼,並在指令碼載入完畢之後呼叫一個回撥函式來完成載入之後的操作。程式碼片1.1-1實現了這樣一個非同步函式loadScript。
程式碼片1.1-1
//src代表JavaScript 指令碼的URL,callback代表自定義回撥函式 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.src} is loaded`); alert( _ ); // function declared in the loaded script });
最後一行loadScript的呼叫可以讀作:非同步函式loadScript載入指令碼,並在載入執行完畢後呼叫毀掉函式彈出提示框。
1.2.非同步動作的順序執行
很多場景下,往往需要依次執行多個非同步動作(上一個非同步動作結束之後才能執行下一個)。通過結合回撥函式,可以寫成一個“巢狀”的非同步函式,如下1.2-1。
程式碼片1.2-1
loadScript('/my/script.js', function(script) { loadScript('/my/script2.js', function(script) { loadScript('/my/script3.js', function(script) { // ...continue after all scripts are loaded }); }) });
1.3.非同步動作的異常處理
在實際場景中,還需要根據非同步函式的執行狀態(正常或者異常)來執行不同的回撥函式。程式碼片1.3-1是程式碼片1.1-1中的改進版本,通過增加對onerror事件的響應,非同步動作丟擲的異常能由使用者提供的函式來接管。此時要注意的是,這裡的回撥函式與前面不同,它是形式為function(error,script)的函式。
程式碼片1.3-1
function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; script.onload = () => callback(, script); // 1 script.onerror = () => callback(new Error(`Script load error for ${src}`)); // 2 document.head.append(script); } loadScript('/my/script.js', function(error, script) { if (error) { // handle error } else { // script loaded successfully } });
這裡loadScript的呼叫可以讀作:非同步函式loadScript載入指令碼,並在指令碼載入執行失敗後呼叫2,在指令碼載入執行成功後呼叫1。
1.4. 非同步動作帶來的問題——惡魔金字塔
結合1.2和1.3,可以得到一個包含異常處理和多個非同步動作順序執行的例子,如程式碼片1.4-1所示。
程式碼片1.4-1
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 (*) } }); } }) } });
可以看到,隨著巢狀的深入,從左往右看程式碼就形成了一個金字塔結構的巢狀。這樣得到的程式碼非常不利於維護和拓展,因此也被稱為惡魔金字塔(Pyramid of doom)。
程式碼片1.4-2解決了惡魔金字塔的問題,但也引入了可讀性和名稱空間的問題,因此不算一個優雅的解決方案。
程式碼片1.4-2
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 (*) } };
2.基於Promise
2.1.Promise是什麼
Promise是為了解決回撥函式的一些缺陷而在ES6中定義的非同步解決方案,它的訂閱模式與鏈式表示式能讓開發者更加方便的定義自己的非同步動作。
為了更好的理解Promise想要解決的問題,可以想象這樣一個場景:想象你是一個知名歌手,你的粉絲問你單曲發售的訊息。你讓他們訂閱你的訊息,這樣在你準備好專輯之後,就有專人負責通知你的粉絲,讓他們獲取關於單曲的資訊,好讓他們購買專輯並推薦給身邊的朋友。
這裡”歌手釋出一首單曲”就是一個非同步動作的生產程式碼(producing code)(實際中可能是向伺服器請求一條資料),“粉絲接受單曲發售的通知,然後購買專輯並推薦給身邊的朋友”,這一動作就是消費程式碼(consuming code)(類似回撥函式),而連線兩者的“專人”就是Promise。
Promise是一個JavaScript物件,它將生產程式碼和消費程式碼聯絡起來,從而在生產程式碼完成非同步動作後,訂閱非同步動作的消費程式碼就能獲取結果(假如初次接觸Promise,到這至少已經理解一半了。但想了解如何使用Promise或者想閱讀Promise相關的程式碼,你還得繼續)。
2.2.生成一個Promise物件
根據2.1可知,Promise起到的就是“橋接”生產程式碼和消費程式碼的作用。Promise物件通過傳入一個執行器(executor)執行生產程式碼,消費程式碼通過.then和.catch方法訂閱結果(生產程式碼的結果可能是正常的返回值也可能是一個異常)。理解了生產程式碼的傳入和消費程式碼如何訂閱結果,也就明白了Promise的用法。
2.2.1.生產程式碼
Promise物件通過傳入一個執行器(executor)執行生產程式碼。執行器是形式為function(resolve, reject)的函式,它包含了非同步動作的生產程式碼。執行器會在Promise物件建立的時候自動執行。當執行器執行完成任務之後,會呼叫resolve(解析)來接受非同步動作正常執行完畢的結果,呼叫reject(拒絕)來接受一個在非同步動作中丟擲的異常(Error)。
這樣可能還是不夠直觀,那就看看程式碼片2.2.1-1,它利用Promise改造了程式碼片1.3-1。onload(表示指令碼正常載入完畢)和onerror(載入過程中丟擲異常)兩個非同步狀態分別執行了resolve和reject方法,分別接受一個DOM物件和Error物件。若生產程式碼呼叫resolve解析,則Promise會把DOM物件作為結果通知給消費程式碼;反之若呼叫reject方法,則Promise把Error物件作為結果通知給消費程式碼。
程式碼片2.2.1-1
function loadScript(src) { return new Promise(function(resolve, reject) { let script = document.createElement('script'); script.src = src; script.onload = () => resolve(script); script.onerror = () => reject(new Error("Script load error: " + src)); document.head.append(script); }); }
Promise如何能夠得知一個非同步狀態?這是因為Promise物件維護了兩個重要內部屬性:
- state(狀態) :初始是“pending”,執行完畢之後變化成“fulfilled”或者“rejected”。
- result(結果):非同步動作的結果值。可以任意指定,預設是undefined。
當呼叫resolve時設定state為fulfilled,並把result作為引數傳給resolve;當呼叫reject時設定state為rejected,並把result作為引數傳給rejected。從邏輯上來看,rejected和resolve可以看做是非同步動作結果的”容器”,一旦state改變,Promise就從“容器”中取出result並通知消費程式碼處理。
2.2.2.消費程式碼
.then和.catch方法可以使消費程式碼能夠接受Promise物件傳送的訊息,訂閱生產程式碼的結果。
2.2.2.1..then方法
.then方法的強大之處在於它的靈活性,可以定義兩個函式接受分別接受resolve和reject返回的結果。程式碼片2.2.2.1-1和程式碼片2.2.2.1-2分別反映了.then方法對resolve和reject結果的不同的響應。
程式碼片2.2.2.1-1
let promise = new Promise(function(resolve, reject) { setTimeout(() => resolve("done!"), 1000); }); // resolve runs the first function in .then promise.then( result => alert(result), // shows "done!" after 1 second error => alert(error) // doesn't run );
程式碼片2.2.2.1-2
let promise = new Promise(function(resolve, reject) { setTimeout(() => reject(new Error("Whoops!")), 1000); }); // reject runs the second function in .then promise.then( result => alert(result), // doesn't run error => alert(error) // shows "Error: Whoops!" after 1 second );
若.then方法只傳入了一個引數,那麼預設消費程式碼只訂閱resovle接受的結果,如程式碼片2.2.2.1-3所示。
程式碼片2.2.2.1-3
let promise = new Promise(resolve => { setTimeout(() => resolve("done!"), 1000); }); promise.then(alert); // shows "done!" after 1 second
2.2.2.2..catch方法
若消費程式碼想單獨捕獲異常(訂閱異常結果),可以考慮使用.catch。.catch是.then(null,alert)的一個快捷方式。程式碼片2.2.2.2-1是這兩種的實現方式的例子。
程式碼片2.2.2.2-1
let promise = new Promise((resolve, reject) => { setTimeout(() => reject(new Error("Whoops!")), 1000); }); // .catch(f) is the same as promise.then(null, f) promise.catch(alert); // shows "Error: Whoops!" after 1 second // .catch(f) is the same as promise.then(null, f) promise.then(,alert); // shows "Error: Whoops!" after 1 second
2.3.使用Promise需要注意的一些細節
2.3.1.一個執行器只會執行一次resolve或者reject
在程式碼片2.3.1-1的執行器中,除了第一個resolve之外的其他resolve或者reject都會被忽略。這兩個方法中的額外引數也會被忽略。
程式碼片2.3.1-1
let promise = new Promise(function(resolve, reject) { resolve("done"); reject(new Error("…")); // ignored setTimeout(() => resolve("…")); // ignored });
2.3.2.使用Error物件或者繼承自Error類的物件作為reject的引數
這是一個好的實踐,這樣能對異常進行更好的處理(比如針對不通的異常型別進行不同的操作)。
2.3.3.立即執行resolve/reject
雖然在實際中,執行器往往執行一些非同步操作,但是你也可以在執行器中立刻執行resolve或者reject方法,這完全沒有關係。這樣你的結果會被直接投遞到消費程式碼。如程式碼片2.4.3-1所示。
程式碼片2.3.3-1
let promise = new Promise(function(resolve, reject) { // not taking our time to do the job resolve(123); // immediately give the result: 123 });
2.3.4..then和.catch中定義的handler都是非同步的
.then和.catch中定義的handler都是非同步的,這意味著即使Promise立刻執行了到了resolve或者reject,handler也必須等待當前的程式碼執行完畢才能被載入,如程式碼片2.3.4-1所示。雖然執行器立即執行了resolve得到了結果,但是.then(alert)也在最後被呼叫。如程式碼片2.3.4-1所示。
程式碼片2.3.4-1
// an "immediately" resolved Promise const executor = resolve => resolve("done!"); const promise = new Promise(executor); promise.then(alert); // this alert shows last (*) alert("code finished"); // this alert shows first
3.Promise鏈
在實際中,很多時候往往需要順序執行非同步任務,但是用也帶來了”惡魔金字塔”的問題(如1.4節描述)。引入Promise鏈,我們可以優雅的解決這個問題。
3.1.Promise鏈中的.then
多個.then方法可以構成一條Promise鏈。程式碼片3.1.-1就是一個簡單的例子。
程式碼片3.1.-1
new Promise(function(resolve, reject) { setTimeout(() => resolve(1), 1000); // (*) }).then(function(result) { // (**) alert(result); // 1 return result * 2; }).then(function(result) { // (***) alert(result); // 2 return result * 2; }).then(function(result) { alert(result); // 4 return result * 2; });
執行該程式碼,結果為1——2——4,這是因為.then返回一個Promise方法,並隱式地把值賦給了Promise物件的result屬性,使得第一個Promise的result屬效能夠通過呼叫鏈不斷傳遞。
倘若想在.then中包含非同步操作,則必須返回一個包含非同步物件的Promise。在處理非同步操作期間,Promise鏈上的handler均不會執行,待非同步操作完成,才將結果傳遞到鏈的下一個節點。
程式碼片3.1.-2
new Promise(function(resolve, reject) { setTimeout(() => resolve(1), 1000); }).then(function(result) { alert(result); // 1 return new Promise((resolve, reject) => { // (*) setTimeout(() => resolve(result * 2), 1000); }); }).then(function(result) { // (**) alert(result); // 2 return new Promise((resolve, reject) => { setTimeout(() => resolve(result * 2), 1000); }); }).then(function(result) { alert(result); // 4 });
在程式碼片3.1.-2中,最後的結果也是1——2——4,但是每個alter都相隔1s才會顯示。可以理解為return一個Promise阻礙了結果的傳播,必須要等這個非同步動作結束,結果才能在Promise鏈中繼續傳遞。
3.2.Promise鏈中的.catch
.catch可以對Promise鏈中的異常進行處理。考慮程式碼片3.2.-1。假設我們引入fetch函式(用來獲取json)獲取使用者的頭像(avatar)並顯示,.catch可以捕獲該Promise鏈中丟擲的異常。
程式碼片3.2.-1
fetch('/article/promise-chaining/user.json') .then(response => response.json()) .then(user => fetch(`https://api.github.com/users/${user.name}`)) .then(response => response.json()) .then(githubUser => new Promise(function(resolve, reject) { let img = document.createElement('img'); img.src = githubUser.avatar_url; img.className = "promise-avatar-example"; document.body.append(img); setTimeout(() => { img.remove(); resolve(githubUser); }, 3000); })) .catch(error => alert(error.message));
但是這樣還不夠好,在實際編碼中,常常需要在程式碼中丟擲異常,並根據異常的型別來做相應的處理。幸運的是,Promise鏈預設把在處理鏈中丟擲的異常當reject進行處理,並讓使用者用.catch捕獲。如程式碼片3.2.-2所示,loadJson函式在Promise鏈中會檢測HTTP的狀態碼,若不為200(不成功),就丟擲自定義異常“new HttpError(response)”,並被catch所捕獲。
程式碼片3.2.-2
class HttpError extends Error { // (1) constructor(response) { super(`${response.status} for ${response.url}`); this.name = 'HttpError'; this.response = response; } } function loadJson(url) { // (2) return fetch(url) .then(response => { if (response.status == 200) { return response.json(); } else { throw new HttpError(response); } }) } loadJson('no-such-user.json') // (3) .catch(alert); // HttpError: 404 for .../no-such-user.json
3.3.重新丟擲異常及未處理異常
在一般的try…catch…結構中,若一個異常無法處理,往往可以重新丟擲(Rethrowing)給上一級的異常處理函式處理。Promise鏈也支援這種形式。在Promis中也可以重新丟擲異常,並被最近一個.catch所捕獲。
考慮程式碼片3.3.-1。在Promise物件丟擲一個異常”Whoops!”之後,這個Promise物件的狀態變為拒絕(reject),鏈上最近的一個.catch方法被呼叫,並判斷是否是URI異常,顯然”Whoops!”不屬於這類異常,因此顯示”Can’t handle such error”,並重新丟擲異常。該異常被鏈上的第二個.catch所捕獲,最終顯示”The unknown error has occurred: Error: Whoops!”。
程式碼片3.3.-1
// the execution: catch -> catch -> then new Promise(function(resolve, reject) { throw new Error("Whoops!"); }).catch(function(error) { // (*) if (error instanceof URIError) { // handle it } else { alert("Can't handle such error"); throw error; // throwing this or another error jumps to the next catch } }).then(function() { /* never runs here */ }).catch(error => { // (**) alert(`The unknown error has occurred: ${error}`); // don't return anything => execution goes the normal way });
一般來說Promise鏈底部寫上.catch來捕獲異常是一個非常好的習慣。假如不這樣做,那麼javaScript引擎會捕獲該異常並在控制檯顯示 。當然,也可以在瀏覽器中可以通過註冊一個unhandledrejection事件(unhandledrejection事件是HTML標準的一部分)監聽器,來捕獲未處理異常,如程式碼片3.3.-2所示:
程式碼片3.3.-2
window.addEventListener('unhandledrejection', function(event) { // the event object has two special properties: alert(event.promise); // [object Promise] - the promise that generated the error alert(event.reason); // Error: Whoops! - the unhandled error object }); new Promise(function() { throw new Error("Whoops!"); }); // no catch to handle the er
4.Promise API
Promise物件有四個靜態方法:resolve/reject/all/race,可以在某些場景下讓處理Promise物件的程式碼變得更加簡潔。
4.1.Promise.resolve/Promise.reject
Promise.resolve/Promise.reject直接返回一個已經被resolve/reject的Promise物件。程式碼片4.1-1和程式碼片4.1-2分別顯示了Promise.resolve和Promise.reject的等價形式。值得注意的是,Promise.resolve/Promise.reject返回的是Promise物件,因此也可用.then/.catch構成Promise鏈。
程式碼片4.1-1
let promise = Promise.resolve(value); let promise = new Promise(resolve => resolve(value)); 程式碼片4.1-2
程式碼片4.1-2
let promise = Promise.reject(error); let promise = new Promise((resolve, reject) => reject(error));
4.2.Promise.all
Promise.all接受一個可迭代物件(往往是Promise陣列)作為輸入,並行地執行它們,等待所有Promise執行完畢之後返回一個Promise物件。這個Promise物件的result屬性是包含所有對應結果的一個數組,如程式碼片4.2-1所示。
程式碼片4.2-1
Promise.all([ new Promise((resolve, reject) => setTimeout(() => resolve(1), 3000)), // 1 new Promise((resolve, reject) => setTimeout(() => resolve(2), 2000)), // 2 new Promise((resolve, reject) => setTimeout(() => resolve(3), 1000)) // 3 ]).then(alert); // 1,2,3 when promises are ready: each promise contributes an array member
需要指出的是,當傳入的可迭代物件中包含非Promise物件的元素時,Promise.all會自動呼叫Promise.resolve方法將其包裝成一個Promise物件並返回。如程式碼片4.2-2所示。
程式碼片4.2-2
Promise.all([ new Promise((resolve, reject) => { setTimeout(() => resolve(1), 1000) }), 2, // treated as Promise.resolve(2) 3 // treated as Promise.resolve(3) ]).then(alert); // 1, 2, 3
4.3.Promise.race
Promise.race接受一個可迭代物件(往往是Promise陣列)作為輸入,並行地執行它們,將第一個返回的Promise物件作為結果,如程式碼片4.3-1所示。最後alter的結果是1
程式碼片4.3-1
Promise.race([ new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)), new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 2000)), new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)) ]).then(alert); // 1
5.async/await
假如你是按順序讀完,那麼到這裡理解async/await關鍵字就非常容易。async/await關鍵字作為語法糖,能讓操作Promise的程式碼更加簡潔可讀。
5.1.async
async關鍵詞置於你想修飾的函式前,可以將一個非Promise的結果通過Promise.resolve的封裝變成一個Promise物件,如程式碼片5.1-1所示。
程式碼片5.1-1
async function f() { return 1; } f().then(alert); // 1 5.2.await
5.2.await
await的作用和.then非常相似,用來等待一個Promise物件的非同步返回。await和async密不可分,await必須在async修飾的函式中才能使用。如程式碼片5.2-1所示。
程式碼片5.2-1
async function f() { let promise = new Promise((resolve, reject) => { setTimeout(() => resolve("done!"), 1000) }); let result = await promise; // wait till the promise resolves (*) alert(result); // "done!" } f();
值得注意的是,一旦使用await,就可以使用try…catch來捕獲異常。相比.catch來說,這樣捕獲異常更加方便。
程式碼片5.2-2
async function f() { try { let response = await fetch('http://no-such-url'); } catch(err) { alert(err); // TypeError: failed to fetch } } f();
6.總結
本文參考線上教程並根據個人的實踐經驗有側重的總結了一下ES6的非同步特性:Promise概念、基本用法、靜態方法以及兩個關鍵字async和await。這裡沒有提到的是,Promise仍然有著一些缺點,比如它無法像RxJS一般很好地處理流事件。和所有的教程一樣,本文不可能涵蓋到非同步程式設計的所有細節,但是若能對你有所啟發,那就是再好不過了。
7. 參考連結
-
ofollow,noindex">https://javascript.info/callbacks
8.作者簡介
邱仁博,多年運營商商業分析、資料中心資料庫方向工作經驗,現任職於某地市事業單位資訊科技部。日常關注國內外極客新聞、前後端技術。海外知識搬運工。
【51CTO原創稿件,合作站點轉載請註明原文作者和出處為51CTO.com】
【責任編輯:龐桂玉 TEL:(010)68476606】