那么,让我们先从我的一些背景开始。我是一名十年左右经验的软件开发者。最初使用 PHP,后来转到了 JavaScript。
我开始在 5 年前使用 TypeScript,从那时起,我便再也没有回到过 JavaScript。在我使用 TS 的那一刻,我认为它是世界上最好的编程语言。每个人都喜欢它;每个人都会用它……它就是最好的,不是吗?是这样吗?真的是这样的吗?
在那之后我开始学习其它更现代的语言。首先是 Go,然后我慢慢地把 Rust 也加了进来(感谢你,Prime)。
当你不知道有其他事物存在时,就很难错过它们。
这是在说什么呢?Go 和 Rust 的共同点是什么?错误处理。这对令我印象深刻。具体来说,是这些语言的处理方式。
JavaScript 通过抛出异常来处理错误,而 Go 和 Rust 则把错误当成值。你或许觉得这没有什么……但,孩子,这可能听上去微不足道,但,确实改变了游戏规则。
让我来看一下。我们不会深入研究每个语言,只想知道一般的处理方式。
先让我们从 JavaScript/TypeScript 和一个小游戏开始。
请给自己 5 秒钟来审查下面的代码,然后回答为什么要把代码放在 try/catch
里面。
try {
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}
// handle response
} catch (e) {
// handle error
return;
}
所以,我假设大部分人已经猜到即使我们检查了 response.ok
,fetch
方法仍然会抛出错误。response.ok
仅“捕获” 4xx 或者 5xx 的错误。但当网络本身发生错误时,就会被抛出。
但我想知道,又有多少人猜到 JSON.stringify
也会抛出错误。原因就是在请求对象中包含了 bigint(2n)
的变量。这会让 JSON 不知道如何字符串化。
所以,就我个人而言的第一个问题是,我相信也是 JavaScript 有史以来最大的问题,即:我们不知道什么会抛出错误。从 JavsScript 的角度看,和下面这个错误是一样的。
try {
let data = “Hello”;
} catch (err) {
console.error(err);
}
JavaScript 不知道;JavaScript 也不在乎。但你应该知道。
第二件事就是,这是一段可以运行的代码。
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}
没有错误,没有提示,即便如此它仍可以破坏你的程序。
现在,在我的脑海中可以听到“这有什么问题?只要我们用 try/catch
处理就可以了。” 于是便有了第三个问题:我们不知道抛出错误的是哪一个。当然,我们可以通过错误信息来推测,但对于那些有很多地方可能抛出错误的复杂服务/功能呢?你确定只用一个 try/catch
就能合适地处理吗?
好吧,是时候停止对 JS 的挑刺了。让我们从这段 Go 代码开始:
f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
我们正在尝试打开一个文件,它会返回文件内容或者一个错误。你经常会看到这种代码,因为我们知道哪些方法会返回错误。你不会错过任何一个。这个是将错误作为返回值的第一个例子。你可以指定哪个方法会返回它们,你返回它们,然后赋给变量,你对它们检查然后使用。
这并没有丰富多彩,并且这也是 Go 被诟病的地方之一——“错误检查“,其中 if err != nil
有时会比其他代码占用更多行数。
if err != nil {
…
if err != nil {
…
if err != nil {
…
}
}
}
if err != nil {
…
}
…
if err != nil {
…
}
但相信我,这很值得。
最后再看一下 Rust:
let greeting_file_result = File::open(“hello.txt”);
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
这是三个中最冗长的一个,但讽刺的是也是最好的一个。首先,Rust 通过它惊人的枚举类型来处理错误(它们和 TypeScript 的枚举不同!)。先不深入细节,这里最重要的就是名为 Result
的枚举,包含两个变量 Ok
和 Err
。和你想的一样,Ok
包含返回值,Err
包含……错误信息。:D
还有多种方式能更方便地处理它们,以缓解 Go 的问题。其中最知名的是 ?
运算符。
let greeting_file_result = File::open(“hello.txt”)?;
在这里总结一下,Go 和 Rust 总能知道哪里会出现错误。并且会迫使你在它(大部分)出现的地方处理它。没有隐藏、没有猜测,没有令人惊讶的破坏。
并且这种方式更好。更好一点。
好吧,是时候说实话了;我撒了一点慌。我们并不能让 TypeScript 的错误像 Go/Rust 那样。这是因为语言本身的限制;它并没有合适的工具来做到这一点。
但我们可以尽可能地让它相似,并让它变得简单。
从这个例子:
export type Safe<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: string;
};
这里并没什么特别之处,只是一个简单的泛型。但这个小可爱会完全改变代码。正如你注意到的,这里最大的区别是我们要么返回数据,要么返回错误。是不是有点耳熟?
另外……第二个谎言是,我们确实学药一些 try/catch
。好消息是,我们只需要 2 个,而非 100,000 个。
export function safe<T>(promise: Promise<T>, err?: string): Promise<Safe<T>>;
export function safe<T>(func: () => T, err?: string): Safe<T>;
export function safe<T>(
promiseOrFunc: Promise<T> | (() => T),
err?: string,
): Promise<Safe<T>> | Safe<T> {
if (promiseOrFunc instanceof Promise) {
return safeAsync(promiseOrFunc, err);
}
return safeSync(promiseOrFunc, err);
}
async function safeAsync<T>(
promise: Promise<T>,
err?: string
): Promise<Safe<T>> {
try {
const data = await promise;
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}
function safeSync<T>(
func: () => T,
err?: string
): Safe<T> {
try {
const data = func();
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}
“哇哦,真是个天才。他封装了 try/catch
。“ 是的,如你所见。这只是一层封装,将 Safe
类型作为返回结果。但有时你需要的只是简单的东西。让我将它与前面的例子结合起来。
旧代码(16 行):
try {
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
// handle network error
return;
}
// handle response
} catch (e) {
// handle error
return;
}
新代码(20 行):
const request = { name: “test”, value: 2n };
const body = safe(
() => JSON.stringify(request),
“Failed to serialize request”,
);
if (!body.success) {
// handle error (body.error)
return;
}
const response = await safe(
fetch("https://example.com", {
method: “POST”,
body: body.data,
}),
);
if (!response.success) {
// handle error (response.error)
return;
}
if (!response.data.ok) {
// handle network error
return;
}
// handle response (body.data)
所以是的,新的方法更长但性能更好。原因如下:
- 没有
try/catch
- 我们处理了每一个错误
- 我们可以为特定的方法指定特定的错误信息
- 我们有一个很好自上而下的逻辑,所有错误都在上面,返回值在下面
关键点来了。如果我们忘记检查会发生什么?
if (!body.success) {
// handle error (body.error)
return;
}
问题是……我们并不能。是的,我们必须要做检查。如果不这么做,那么 body.data
将不会存在。语法检测会通过抛出“属性 'data' 在 'Safe' 上不存在“的错误来提醒我们。这都要感谢我们之前创建的简单的 Safe
类型。同时它也适用于处理错误信息。在处理 !body.success
之前,我们将无法访问 body.error
。
在这里我们要感谢 TypeScript 以及它是如何改变了 JavsScript 的世界。
下面的内容也一样:
if (!response.success) {
// handle error (response.error)
return;
}
我们不能删除 !response.success
,否则 response.data
也将不存在。
当然,我们的方式也并非没有问题。最重要的一点是,你必须要用我们的 safe
方法类包装那些会抛错的异步方法。这种“必知“来自语言的限制,并非我们所能克服。
这听上去有些困难,但事实并非如此。你很快就会意识到,代码中几乎所有的异步方法都可能会抛错,同步方法也会,你知道它们,但可能并不多。
不过,你仍可能会问,这么做值得吗?我们认为是的,并且在我们的团队中工作得很好 :)。当你查看一个复杂的业务逻辑时,没有 try/catch
,每个错误都在出现的地方被处理,并且有一个良好的逻辑流程……看起来很棒。
这是一个使用 SvelteKit FormAction
的实际例子:
export const actions = {
createEmail: async ({ locals, request }) => {
const end = perf(“CreateEmail”);
const form = await safe(request.formData());
if (!form.success) {
return fail(400, { error: form.error });
}
const schema = z
.object({
emailTo: z.string().email(),
emailName: z.string().min(1),
emailSubject: z.string().min(1),
emailHtml: z.string().min(1),
})
.safeParse({
emailTo: form.data.get("emailTo"),
emailName: form.data.get("emailName"),
emailSubject: form.data.get("emailSubject"),
emailHtml: form.data.get("emailHtml"),
});
if (!schema.success) {
console.error(schema.error.flatten());
return fail(400, { form: schema.error.flatten().fieldErrors });
}
const metadata = createMetadata(URI_GRPC, locals.user.key)
if (!metadata.success) {
return fail(400, { error: metadata.error });
}
const response = await new Promise<Safe<Email__Output>>((res) => {
usersClient.createEmail(schema.data, metadata.data, grpcSafe(res));
});
if (!response.success) {
return fail(400, { error: response.error });
}
end();
return {
email: response.data,
};
},
} satisfies Actions;
这里需要指出几点:
- 我们自定义了
grpcSafe
来帮助处理 gRPC 的回调。 createMetadata
在其内部返回了Safe
因此不需要再对其封装。zod
库使用了相同的模式 :)。如果我们不对schema.success
做处理,我们就无法访问schema.data
。
是不是看起来很干净呢?不妨尝试一下吧!或许它也非常适合你 :)
感谢阅读。
PS:是不是很像?
f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
const response = await safe(fetch(“https://example.com"));
if (!response.success) {
console.error(response.error);
return;
}
// do something with the response.data