前不久看到 Dima Grossman 寫的 How to write async await without try-catch blocks in Javascript。看到標題的時候,我感到非常好奇。我知道雖然在非同步程式中可以不使用 try-catch 配合 async/await 來處理錯誤,但是處理方式並不能與 async/await 配合得很好,所以很想知道到底有什麼辦法會比 try-catch 更好用。
Dima 去除 try-catch 的方法
當然套路依舊,Dima 講到了回撥地獄,Promise 鏈並最終引出了 async/await。而在處理錯誤的時候,他並不喜歡 try-catch 的方式,所以寫了一個 to(promise)
來對 Promise 進行封裝,輔以解構語法,實現了同步寫法但類似 Node 錯誤標準的程式碼。摘抄程式碼如下
// to.js
export default function to(promise) {
return promise
.then(data => {
return [null, data];
})
.catch(err => [err]);
}複製程式碼
應用示例:
import to from "./to.js";
async function asyncTask(cb) {
let err, user, savedTask;
[err, user] = await to(UserModel.findById(1));
if (!user) return cb("No user found");
[err, savedTask] = await to(TaskModel({ userId: user.id, name: "Demo Task" }));
if (err) return cb("Error occurred while saving task");
if (user.notificationsEnabled) {
const [err] = await to(NotificationService.sendNotification(user.id, "Task Created"));
if (err) return cb("Error while sending notification");
}
cb(null, savedTask);
}複製程式碼
Dima 的辦法讓人產生的了熟悉的感覺,Node 的回撥中不是經常都這樣寫嗎?
(err, data) => {
if (err) {
// deal with error
} else {
// deal with data
}
}複製程式碼
所以這個方法真的很有意思。不過回過頭來想一想,這段程式碼中每當遇到錯誤,都是將錯誤訊息通過 cb()
呼叫推出去,同時中斷後續過程。像這種中斷式的錯誤處理,其實正適合採用 try-catch。
使用 try-catch 改寫上面的程式碼
要用 try-catch 改寫上面的程式碼,首先要去掉 to()
封裝。這樣,一旦發生錯誤,需要使用 Promise.prototype.catch()
進行捕捉,或者使用 try-catch 對 await promise
語句進行捕捉。捕捉到的,當然是每個業務程式碼裡 reject
出來的 err
。
然而注意,上面的程式碼中並沒有直接使用 err
,而是使用了自定義的錯誤訊息。所以需要對 reject 出來的 err
進一步處理成指定的錯誤訊息。當然這難不到誰,比如
someAsync().catch(err => Project.reject("specified message"));複製程式碼
然後再最外層加上 try-catch 就好。所以改寫之後的程式碼是:
async function asyncTask(cb) {
try {
const user = await UserModel.findById(1)
.catch(err => Promise.reject("No user found"));
const savedTask = await TaskModel({ userId: user.id, name: "Demo Task" })
.catch(err => Promise.reject("Error occurred while saving task"));
if (user.notificationsEnabled) {
await NotificationService.sendNotification(user.id, "Task Created")
.catch(err => Promise.reject("Error while sending notification"));
}
cb(null, savedTask);
} catch (err) {
cb(err);
}
}複製程式碼
上面這段程式碼,從程式碼量上來說,並沒有比 Dima 的程式碼減少了多少工作量,只是去掉了大量 if (err) {}
結構。不習慣使用 try-catch 的程式設計師找找不到中斷點,但習慣了 try-catch 的程式設計師都知道,業務過程中一旦發生錯誤(非同步程式碼裡指 reject),程式碼就會跳到 catch
塊去處理 reject 出來的值。
但是,一般業務程式碼 reject 出來的資訊通常都是有用的。假如上面的每個業務 reject 出來的 err 本身就是錯誤訊息,那麼,用 Dima 的模式,仍然需要寫
if (err) return cb(err);複製程式碼
而用 try-catch 的模式,就簡單多了
async function asyncTask(cb) {
try {
const user = await UserModel.findById(1);
const savedTask = await TaskModel({ userId: user.id, name: "Demo Task" });
if (user.notificationsEnabled) {
await NotificationService.sendNotification(user.id, "Task Created");
}
cb(null, savedTask);
} catch (err) {
cb(err);
}
}複製程式碼
為什麼?因為在 Dima 的模式中,if (err)
實際上處理了兩個業務:一是捕捉會引起中斷的 err
,並將其轉換為錯誤訊息,二是通過 return
中斷業務過程。所以當 err
轉換為錯誤訊息這一過程不再需要的時候,這種捕捉中斷再重新引起中斷的處理主顯得多餘了。
繼續改進
用函式表示式改善 try-catch 邏輯
當然還有改進的空間,比如 try {}
塊中的程式碼比較長,會造成閱讀不太方便,try-catch 的邏輯有被“切斷”的感覺。這種情況下可以使用函式表示式來改善
async function asyncTask(cb) {
async function process() {
const user = await UserModel.findById(1);
const savedTask = await TaskModel({ userId: user.id, name: "Demo Task" });
if (user.notificationsEnabled) {
await NotificationService.sendNotification(user.id, "Task Created");
}
return savedTask;
}
try {
cb(null, await process());
} catch (err) {
cb(err);
}
}複製程式碼
如果對錯誤的處理程式碼比較長,也可以寫成單獨的函式表示式。
如果過程中每一步的錯誤處理邏輯不同怎麼辦
如果發生錯誤,不再轉換為錯誤訊息,而是特定的錯誤處理邏輯,怎麼辦?
思考一下,我們用字串來表示錯誤訊息,以後可以通過 console.log()
來處理處理。而邏輯,最適合的表示當然是函式表示式,最終可以通過呼叫來進行統一處理
async function asyncTask(cb) {
async function process() {
const user = await UserModel.findById(1)
.catch(err => Promise.reject(() => {
// deal with error on looking for the user
return "No user found";
}));
const savedTask = await TaskModel({ userId: user.id, name: "Demo Task" })
.catch(err => Promise.reject(() => {
// making model error
// deal with it
return err === 1
? "Error occurred while saving task"
: "Error occurred while making model";
}));
if (user.notificationsEnabled) {
await NotificationService.sendNotification(user.id, "Task Created")
.catch(err => Promise.reject(() => {
// just print a message
logger.log(err);
return "Error while sending notification";
}));
}
return savedTask;
}
try {
cb(null, await process());
} catch (func) {
cb(func());
}
}複製程式碼
甚至還可以處理更復雜的情況
現在應該都知道 .catch(err => Promise.reject(xx))
,這裡的 xx
就是 try-catch 的 catch 塊捕捉到的物件,所以如果不同的業務 reject 出來不同的物件,比如有些是函式(表示錯誤處理邏輯),有些是字串(表示錯誤訊息),有些是數字(表示錯誤程式碼)——其實只需要改 catch 塊就行
try {
// ...
} catch(something) {
switch (typeof something) {
case "string":
// show message something
break;
case "function":
something();
break;
case "number":
// look up something as code
// and show correlative message
break;
default:
// deal with unknown error
}
}複製程式碼
小結
我沒有批判 Dima 的錯誤處理方式,這個錯誤處理方式很好,很符合 Node 錯誤處理的風格,也一定會受到很多人的喜愛。由於 Dima 的錯誤處理方式給帶靈感,同時也讓我再次審視了一直比較喜歡的 try-catch 方式。
用什麼方式取決於適用場景、團隊約定和個人喜好等多種因素,在不同的情況下需要採用不同的處理方式,並不是說哪一種就一定好於另一種——合適的才是最好的!