那么,让我们从我的一些背景故事开始。 我是一名拥有大约十年经验的软件开发人员,最初使用 PHP,然后逐渐过渡到 JavaScript。
大约五年前,我开始使用 TypeScript,从那时起,我就再也没有回到过 JavaScript。 当我开始使用它的那一刻,我认为它是有史以来最好的编程语言。 每个人都喜欢它; 每个人都用它……这只是最好的,对吧? 正确的? 正确的?
是的,然后我开始尝试其他语言,更现代的语言。 首先是 Go,然后我慢慢地将 Rust 添加到我的列表中(感谢 Prime)。
当您不知道不同事物的存在时,就很难错过事物。
我在说什么? Go 和 Rust 的共同点是什么? 错误。 对我来说最突出的事情。 更具体地说,这些语言如何处理它们。
JavaScript 依靠抛出异常来处理错误,而 Go 和 Rust 将它们视为值。 你可能认为这没什么大不了的……但是,孩子,这可能听起来微不足道; 然而,它改变了游戏规则。
让我们来看看它们。 我们不会深入研究每种语言; 我们想知道一般方法。
让我们从 JavaScript/TypeScript 和一个小游戏开始。
给自己五秒钟的时间来查看下面的代码并回答为什么我们需要将其包装在 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 问题:我们不知道什么会引发错误。 从 JavaScript 错误的角度来看,它与以下内容相同:
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 {
…
}
仍然完全值得付出努力,相信我。
最后,铁锈:
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 =
| {
success: true;
data: T;
}
| {
success: false;
error: string;
};
这里没什么特别的,只是一个简单的泛型类型。 但这个小宝贝可以完全改变代码。 您可能会注意到,这里最大的区别是我们要么返回数据,要么返回错误。 听起来很熟悉?
另外……第二个谎言,我们确实需要一些尝试/捕获。 好消息是我们只需要大约两个,而不是 100,000 个。
export function safe(promise: Promise, err?: string): Promise;
export function safe(func: () => T, err?: string): Safe;
export function safe(
promiseOrFunc: Promise | (() => T),
err?: string,
): Promise | Safe {
if (promiseOrFunc instanceof Promise) {
return safeAsync(promiseOrFunc, err);
}
return safeSync(promiseOrFunc, err);
}
async function safeAsync(
promise: Promise,
err?: string
): Promise {
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(
func: () => T,
err?: string
): Safe {
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 将不存在。 LSP 将通过抛出“‘Safe’类型上不存在属性‘data’”错误来提醒我们。 这一切都归功于我们创建的简单 Safe 类型。 它也适用于错误消息。 在检查 !body.success 之前,我们无法访问 body.error。
现在我们应该欣赏 TypeScript 以及它如何改变 JavaScript 世界。
以下内容也是如此:
if (!response.success) {
// handle error (response.error)
return;
}
我们不能删除 !response.success 检查,因为否则,response.data 将不存在。
当然,我们的解决方案并非没有问题。 最重要的一点是,您必须记住使用我们的安全包装器来包装可能引发错误的 Promise/函数。 这种“我们需要知道”是我们无法克服的语言限制。
听起来可能很难,但事实并非如此。 您很快就会开始意识到,代码中几乎所有的 Promise 都可能会抛出错误,而同步函数也会抛出错误,您知道它们,但它们并不多。
不过,您可能会问,值得吗? 我们认为是的,而且它在我们的团队中运行得很好:)。 当您查看更大的服务文件时,任何地方都没有 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((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 帮助我们处理 gGRPC 回调。
- createMetadata 在内部返回 Safe,所以我们不需要包装它。
- zod 库使用相同的模式:) 如果我们不进行 schema.success 检查,我们就无法访问 schema.data。
是不是看起来很干净呢? 所以尝试一下吧! 也许它也非常适合您:)
谢谢阅读。
附: 看起来很相似?
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