JavaScript異步之從回調函數到Promise
原創(chuàng)【51CTO.com原創(chuàng)稿件】 JavaScript的異步處理是前端工程師必須接觸的一塊內容。ES6在JavaScript異步的處理上引入了新的特性,使得程序員能夠更加優(yōu)雅地處理異步問題。
若您想通過本教程直接上手Promise,那么請按順序閱讀。
若您只是想了解Promise概念,那么請直接閱讀每章的***小節(jié),等需要的時候,再回過頭來看具體的例子,從而不至于浪費您太多時間。
1.基于回調函數
1.1.異步動作與回調函數
在JavaScript中往往需要處理很多異步動作(asynchronous actions),如后臺請求某個dashboard上的顯示數據、響應一條定時信息。異步動作的執(zhí)行不會阻塞其他動作,且在執(zhí)行完成之后,由回調函數(callback)處理異步動作的結果。
假如你想載入一個JavaScript 腳本,并在腳本載入完畢之后調用一個回調函數來完成載入之后的操作。代碼片1.1-1實現(xiàn)了這樣一個異步函數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
- });
***一行l(wèi)oadScript的調用可以讀作:異步函數loadScript載入腳本,并在載入執(zhí)行完畢后調用毀掉函數彈出提示框。
1.2.異步動作的順序執(zhí)行
很多場景下,往往需要依次執(zhí)行多個異步動作(上一個異步動作結束之后才能執(zhí)行下一個)。通過結合回調函數,可以寫成一個“嵌套”的異步函數,如下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.異步動作的異常處理
在實際場景中,還需要根據異步函數的執(zhí)行狀態(tài)(正?;蛘弋惓?來執(zhí)行不同的回調函數。代碼片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載入腳本,并在腳本載入執(zhí)行失敗后調用2,在腳本載入執(zhí)行成功后調用1。
1.4. 異步動作帶來的問題——惡魔金字塔
結合1.2和1.3,可以得到一個包含異常處理和多個異步動作順序執(zhí)行的例子,如代碼片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解決了惡魔金字塔的問題,但也引入了可讀性和命名空間的問題,因此不算一個優(yōu)雅的解決方案。
代碼片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中定義的異步解決方案,它的訂閱模式與鏈式表達式能讓開發(fā)者更加方便的定義自己的異步動作。
為了更好的理解Promise想要解決的問題,可以想象這樣一個場景:想象你是一個知名歌手,你的粉絲問你單曲發(fā)售的消息。你讓他們訂閱你的消息,這樣在你準備好專輯之后,就有專人負責通知你的粉絲,讓他們獲取關于單曲的信息,好讓他們購買專輯并推薦給身邊的朋友。
這里”歌手發(fā)布一首單曲”就是一個異步動作的生產代碼(producing code)(實際中可能是向服務器請求一條數據),“粉絲接受單曲發(fā)售的通知,然后購買專輯并推薦給身邊的朋友”,這一動作就是消費代碼(consuming code)(類似回調函數),而連接兩者的“專人”就是Promise。
Promise是一個JavaScript對象,它將生產代碼和消費代碼聯(lián)系起來,從而在生產代碼完成異步動作后,訂閱異步動作的消費代碼就能獲取結果(假如初次接觸Promise,到這至少已經理解一半了。但想了解如何使用Promise或者想閱讀Promise相關的代碼,你還得繼續(xù))。
2.2.生成一個Promise對象
根據2.1可知,Promise起到的就是“橋接”生產代碼和消費代碼的作用。Promise對象通過傳入一個執(zhí)行器(executor)執(zhí)行生產代碼,消費代碼通過.then和.catch方法訂閱結果(生產代碼的結果可能是正常的返回值也可能是一個異常)。理解了生產代碼的傳入和消費代碼如何訂閱結果,也就明白了Promise的用法。
2.2.1.生產代碼
Promise對象通過傳入一個執(zhí)行器(executor)執(zhí)行生產代碼。執(zhí)行器是形式為function(resolve, reject)的函數,它包含了異步動作的生產代碼。執(zhí)行器會在Promise對象創(chuàng)建的時候自動執(zhí)行。當執(zhí)行器執(zhí)行完成任務之后,會調用resolve(解析)來接受異步動作正常執(zhí)行完畢的結果,調用reject(拒絕)來接受一個在異步動作中拋出的異常(Error)。
這樣可能還是不夠直觀,那就看看代碼片2.2.1-1,它利用Promise改造了代碼片1.3-1。onload(表示腳本正常載入完畢)和onerror(載入過程中拋出異常)兩個異步狀態(tài)分別執(zhí)行了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如何能夠得知一個異步狀態(tài)?這是因為Promise對象維護了兩個重要內部屬性:
- state(狀態(tài)) :初始是“pending”,執(zhí)行完畢之后變化成“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對象發(fā)送的消息,訂閱生產代碼的結果。
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是這兩種的實現(xiàn)方式的例子。
代碼片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需要注意的一些細節(jié)
2.3.1.一個執(zhí)行器只會執(zhí)行一次resolve或者reject
在代碼片2.3.1-1的執(zhí)行器中,除了***個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.立即執(zhí)行resolve/reject
雖然在實際中,執(zhí)行器往往執(zhí)行一些異步操作,但是你也可以在執(zhí)行器中立刻執(zhí)行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立刻執(zhí)行了到了resolve或者reject,handler也必須等待當前的代碼執(zhí)行完畢才能被加載,如代碼片2.3.4-1所示。雖然執(zhí)行器立即執(zhí)行了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鏈
在實際中,很多時候往往需要順序執(zhí)行異步任務,但是用也帶來了”惡魔金字塔”的問題(如1.4節(jié)描述)。引入Promise鏈,我們可以優(yōu)雅的解決這個問題。
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;
- });
執(zhí)行該代碼,結果為1——2——4,這是因為.then返回一個Promise方法,并隱式地把值賦給了Promise對象的result屬性,使得***個Promise的result屬性能夠通過調用鏈不斷傳遞。
倘若想在.then中包含異步操作,則必須返回一個包含異步對象的Promise。在處理異步操作期間,Promise鏈上的handler均不會執(zhí)行,待異步操作完成,才將結果傳遞到鏈的下一個節(jié)點。
代碼片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才會顯示??梢岳斫鉃閞eturn一個Promise阻礙了結果的傳播,必須要等這個異步動作結束,結果才能在Promise鏈中繼續(xù)傳遞。
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的狀態(tài)碼,若不為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對象的狀態(tài)變?yōu)榫芙^(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標準的一部分)監(jiān)聽器,來捕獲未處理異常,如代碼片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對象有四個靜態(tài)方法: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數組)作為輸入,并行地執(zhí)行它們,等待所有Promise執(zhí)行完畢之后返回一個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數組)作為輸入,并行地執(zhí)行它們,將***個返回的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概念、基本用法、靜態(tài)方法以及兩個關鍵字async和await。這里沒有提到的是,Promise仍然有著一些缺點,比如它無法像RxJS一般很好地處理流事件。和所有的教程一樣,本文不可能涵蓋到異步編程的所有細節(jié),但是若能對你有所啟發(fā),那就是再好不過了。
7. 參考鏈接
8.作者簡介
邱仁博,多年運營商商業(yè)分析、數據中心數據庫方向工作經驗,現(xiàn)任職于某地市事業(yè)單位信息技術部。日常關注國內外極客新聞、前后端技術。海外知識搬運工。
【51CTO原創(chuàng)稿件,合作站點轉載請注明原文作者和出處為51CTO.com】