Ajax 和非同步處理
呼叫 API 訪問資料採用的 Ajax 方式,這是一個非同步過程,非同步過程最基本的處理方式是事件或回撥,其實這兩種處理方式實現原理差不多,都需要在呼叫非同步過程的時候傳入一個在非同步過程結束的時候呼叫的介面。比如 jQuery Ajax 的 success
就是典型的回撥引數。不過使用 jQuery 處理非同步推薦使用 Promise 處理方式。
Promise 處理方式也是通過註冊回撥函式來完成的。jQuery 的 Promise 和 ES6 的標準 Promise 有點不一樣,但在 then
上可以相容,通常稱為 thenable。jQuery 的 Promise 沒有提供 .catch()
介面,但它自己定義的 .done()
、.fail()
和 .always()
三個註冊回撥的方式也很有特色,用起來很方便,它是在事件的方式來註冊的(即,可以註冊多個同型別的處理函式,在該觸發的時候都會觸發)。
當然更直觀的一點的處理方式是使用 ES2017 帶來的 async/await 方式,可以用同步程式碼的形式來寫非同步程式碼,當然也有一些坑在裡面。對於前端工程師來說,最大的坑就是有些瀏覽器不支援,需要進行轉譯,所以如果前端程式碼沒有構建過程,一般還是就用 ES5 的語法相容性好一些(jQuery 的 Promise 是支援 ES5 的,但是標準 Promise 要 ES6 以後才可以使用)。
關於 JavaScript 非同步處理相關的內容可以參考
- 從小小題目逐步走進 JavaScript 非同步呼叫
- 閒談非同步呼叫“扁平”化
- 從地獄到天堂,Node 回撥向 async/await 轉變
- 理解 JavaScript 的 async/await
- 從不用 try-catch 實現的 async/await 語法說錯誤處理
自己封裝工具函式
在處理 Ajax 的過程中,雖然有現成的庫(比如 jQuery.ajax,axios 等),它畢竟是為了通用目的設計的,在使用的時候仍然不免繁瑣。而在專案中,對 Api 進行呼叫的過程幾乎都大同小異。如果設計得當,就連錯誤處理的方式都會是一樣的。因此,在專案內的 Ajax 呼叫其實可以進行進一步的封裝,使之在專案內使用起來更方便。如果介面方式發生變化,修改起來也更容易。
比如,當前介面要求使用 POST 方法呼叫(非 RESTful),引數必須包括 action
,返回的資料以 JSON 方式提供,如果出錯,只要不是伺服器異常都會返回特定的 JSON 資料,包括一個不等於 0 的 code
和可選的 message
屬性。
那麼用 jQuery 寫這麼一個 Ajax 呼叫,大概是這樣
const apiUrl = "http://api.some.com/";
jQuery
.ajax(url, {
type: "post",
dataType: "json",
data: {
action: "login",
username: "uname",
password: "passwd"
}
})
.done(function(data) {
if (data.code) {
alert(data.message || "登入失敗!");
} else {
window.location.assign("home");
}
})
.fail(function() {
alert("伺服器錯誤");
});複製程式碼
初步封裝
同一專案中,這樣的 Ajax 呼叫,基本上只有 data
部分和 .done
回撥中的 else
部分不同,所以進行一次封裝會大大減少程式碼量,可以這樣封裝
function appAjax(action, params) {
var deffered = $.Deferred();
jQuery
.ajax(apiUrl, {
type: "post",
dataType: "json",
data: $.extend({
action: action
}, params)
})
.done(function(data) {
// 當 code 為 0 或省略時,表示沒有錯誤,
// 其它值表示錯誤程式碼
if (data.code) {
if (data.message) {
// 如果伺服器返回了訊息,那麼向使用者呈現訊息
// resolve(null),表示不需要後續進行業務處理
alert(data.message);
deffered.resolve();
} else {
// 如果伺服器沒返回訊息,那麼把 data 丟給外面的業務處理
deferred.reject(data);
}
} else {
// 正常返回資料的情況
deffered.resolve(data);
}
})
.fail(function() {
// Ajax 呼叫失敗,向使用者呈現訊息,同時不需要進行後續的業務處理
alert("伺服器錯誤");
deffered.resolve();
});
return deferred.promise();
}複製程式碼
而業務層的呼叫就很簡單了
appAjax("login", {
username: "uname",
password: "passwd"
}).done(function(data) {
if (data) {
window.location.assign("home");
}
}).fail(function() {
alert("登入失敗");
});複製程式碼
更換 API 呼叫介面
上面的封裝對呼叫介面和返回資料進行了統一處理,把大部分專案介面約定的內容都處理掉了,剩下在每次呼叫時需要處理的就是純粹的業務。
現在專案組決定不用 jQuery 的 Ajax,而是採用 axios 來呼叫 API(axios 不見得就比 jQuery 好,這裡只是舉例),那麼只需要修改一下 appAjax()
的實現即可。所有業務呼叫都不需要修改。
假設現在的目標環境仍然是 ES5,那麼需要第三方 Promise 提供,這裡擬用 Bluebird,相容原生 Promise 介面(在 HTML 中引入,未直接出現在 JS 程式碼中)。
function appAjax(action, params) {
var deffered = $.Deferred();
axios
.post(apiUrl, {
data: $.extend({
action: action
}, params)
})
.then(function(data) { ... }, function() { ... });
return deferred.promise();
}複製程式碼
這次的封裝採用了 axios 來實現 Web Api 呼叫。但是為了保持原來的介面(jQuery Promise 物件有提供 .done()
、.fail()
和 .always()
事件處理),appAjax
仍然不得不返回 jQuery Promise。這樣,即使所有地方都不再需要使用 jQuery,這裡仍然得用。
專案中應該用還是不用 jQuery?請閱讀為什麼要用原生 JavaScript 代替 jQuery?
去除 jQuery
就只在這裡使用 jQuery 總讓人感覺如芒在背,想把它去掉。有兩個辦法
- 修改所有業務中的呼叫,去掉
.done()
、.fail()
和.always()
,改成.then()
。這一步工作量較大,但基本無痛,因為 jQuery Promise 本身支援.then()
。但是有一點需要特別注意,這一點稍後說明 - 自己寫個介面卡,相容 jQuery Promise 的介面,工作量也不小,但關鍵是要充分測試,避免差錯。
上面提到第 1 種方法中有一點需要特別注意,那就是 .then()
和 .done()
系列函式在處理方式上有所不同。.then()
是按 Promise 的特性設計的,它返回的是另一個 Promise 物件;而 .done()
系列函式是按事件機制實現的,返回的是原來的 Promise 物件。所以像下面這樣的程式碼在修改時就要注意了
appAjax(url, params)
.done(function(data) { console.log("第 1 處處理", data) })
.done(function(data) { console.log("第 2 處處理", data) });
// 第 1 處處理 {}
// 第 2 處處理 {}複製程式碼
簡單的把 .done()
改成 .then()
之後(注意不需要使用 Bluebird,因為 jQuery Promise 支援 .then()
)
appAjax(url, params)
.then(function(data) { console.log("第 1 處處理", data); })
.then(function(data) { console.log("第 2 處處理", data); });
// 第 1 處處理 {}
// 第 2 處處理 undefined複製程式碼
原因上面已經講了,這裡正確的處理方式是合併多個 done 的程式碼,或者在 .then()
處理函式中返回 data
:
appAjax(url, params)
.then(function(data) {
console.log("第 1 處處理", data);
return data;
})
.then(function(data) {
console.log("第 2 處處理", data);
});複製程式碼
使用 Promise 介面改善設計
我們的 appAjax()
介面部分也可以設計成 Promise 實現,這是一個更通用的介面。既使用不用 ES2015+ 特性,也可以使用像 jQuery Promise 或 Bluebird 這樣的三方庫提供的 Promise。
function appAjax(action, params) {
// axios 依賴於 Promise,ES5 中可以使用 Bluebird 提供的 Promise
return axios
.post(apiUrl, {
data: $.extend({
action: action
}, params)
})
.then(function(data) {
// 這裡調整了判斷順序,會讓程式碼看起來更簡潔
if (!data.code) { return data; }
if (!data.message) { throw data; }
alert(data.message);
}, function() {
alert("伺服器錯誤");
});
}複製程式碼
不過現在前端有構建工具,可以使用 ES2015+ 配置 Babel,也可以使用 TypeScript …… 總之,選擇很多,寫起來也很方便。那麼在設計的時候就不用侷限於 ES5 所支援的內容了。所以可以考慮用 Promise + async/await 來實現
async function appAjax(action, params) {
// axios 依賴於 Promise,ES5 中可以使用 Bluebird 提供的 Promise
const data = await axios
.post(apiUrl, {
data: $.extend({
action: action
}, params)
})
// 這裡模擬一個包含錯誤訊息的結果,以便後面統一處理錯誤
// 這樣就不需要用 try ... catch 了
.catch(() => ({ code: -1, message: "伺服器錯誤" }));
if (!data.code) { return data; }
if (!data.message) { throw data; }
alert(data.message);
}複製程式碼
上面程式碼中使用
.catch()
來避免try ... catch ...
的技巧在從不用 try-catch 實現的 async/await 語法說錯誤處理中提到過。
當然業務層呼叫也可以使用 async/await(記得寫在 async 函式中):
const data = await appAjax("login", {
username: "uname",
password: "passwd"
}).catch(() => {
alert("登入失敗");
});
if (data) {
window.location.assign("home");
}複製程式碼
對於多次 .done()
的改造:
const data = await appAjax(url, params);
console.log("第 1 處處理", data);
console.log("第 2 處處理", data);複製程式碼
小結
本文以封裝 Ajax 呼叫為例,看似在講述非同步呼叫。但實際想告訴大家的東西是:如何將一個常用的功能封裝起來,實現程式碼重用和更簡潔的呼叫;以及在封裝的過程中需要考慮的問題——向前和向後的相容性,在做工具函式封裝的時候,應該儘量避免和某個特定的工具特性繫結,向公共標準靠攏——不知大家是否有所體會。