那么,让我们先从我的一些背景开始。我是一名十年左右经验的软件开发者。最初使用 PHP,后来转到了 JavaScript。
我开始在 5 年前使用 TypeScript,从那时起,我便再也没有回到过 JavaScript。在我使用 TS 的那一刻,我认为它是世界上最好的编程语言。每个人都喜欢它;每个人都会用它……它就是最好的,不是吗?是这样吗?真的是这样的吗?
在那之后我开始学习其它更现代的语言。首先是 Go,然后我慢慢地把 Rust 也加了进来(感谢你,Prime)。
当你不知道有其他事物存在时,就很难错过它们。
这是在说什么呢?Go 和 Rust 的共同点是什么?错误处理。这对令我印象深刻。具体来说,是这些语言的处理方式。
JavaScript 通过抛出异常来处理错误,而 Go 和 Rust 则把错误当成值。你或许觉得这没有什么……但,孩子,这可能听上去微不足道,但,确实改变了游戏规则。
让我来看一下。我们不会深入研究每个语言,只想知道一般的处理方式。
先让我们从 JavaScript/TypeScript 和一个小游戏开始。
请给自己 5 秒钟来审查下面的代码,然后回答为什么要把代码放在 try/catch
里面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 ; } } catch (e) { return ; }
所以,我假设大部分人已经猜到即使我们检查了 response.ok
,fetch
方法仍然会抛出错误。response.ok
仅“捕获” 4xx 或者 5xx 的错误。但当网络本身发生错误时,就会被抛出。
但我想知道,又有多少人猜到 JSON.stringify
也会抛出错误。原因就是在请求对象中包含了 bigint(2n)
的变量。这会让 JSON 不知道如何字符串化。
所以,就我个人而言的第一个问题是,我相信也是 JavaScript 有史以来最大的问题,即:我们不知道什么会抛出错误。从 JavsScript 的角度看,和下面这个错误是一样的。
1 2 3 4 5 try { let data = “Hello ”; } catch (err) { console .error (err); }
JavaScript 不知道;JavaScript 也不在乎。但你应该知道。
第二件事就是,这是一段可以运行的代码。
1 2 3 4 5 6 7 8 9 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 代码开始:
1 2 3 4 5 f, err := os.Open(“filename.ext”)if err != nil { log.Fatal(err) }
我们正在尝试打开一个文件,它会返回文件内容或者一个错误。你经常会看到这种代码,因为我们知道哪些方法会返回错误。你不会错过任何一个。这个是将错误作为返回值的第一个例子。你可以指定哪个方法会返回它们,你返回它们,然后赋给变量,你对它们检查然后使用。
这并没有丰富多彩,并且这也是 Go 被诟病的地方之一——“错误检查“,其中 if err != nil
有时会比其他代码占用更多行数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 if err != nil { … if err != nil { … if err != nil { … } } }if err != nil { … } …if err != nil { … }
但相信我,这很值得。
最后再看一下 Rust:
1 2 3 4 5 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 的问题。其中最知名的是 ?
运算符。
1 let greeting_file_result = File::open (“hello.txt”)?;
在这里总结一下,Go 和 Rust 总能知道哪里会出现错误。并且会迫使你在它(大部分)出现的地方处理它。没有隐藏、没有猜测,没有令人惊讶的破坏。
并且这种方式更好。更好一点。
好吧,是时候说实话了;我撒了一点慌。我们并不能让 TypeScript 的错误像 Go/Rust 那样。这是因为语言本身的限制;它并没有合适的工具来做到这一点。
但我们可以尽可能地让它相似,并让它变得简单。
从这个例子:
1 2 3 4 5 6 7 8 9 export type Safe <T> = | { success : true ; data : T; } | { success : false ; error : string ; };
这里并没什么特别之处,只是一个简单的泛型。但这个小可爱会完全改变代码。正如你注意到的,这里最大的区别是我们要么返回数据,要么返回错误。是不是有点耳熟?
另外……第二个谎言是,我们确实学药一些 try/catch
。好消息是,我们只需要 2 个,而非 100,000 个。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 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 行):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 ) { return ; } } catch (e) { return ; }
新代码(20 行):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const request = { name : “test”, value : 2n };const body = safe ( () => JSON .stringify (request), “Failed to serialize request”, );if (!body.success ) { return ; }const response = await safe ( fetch ("https://example.com" , { method : “POST ”, body : body.data , }), );if (!response.success ) { return ; }if (!response.data .ok ) { return ; }
所以是的,新的方法更长但性能更好。原因如下:
没有 try/catch
我们处理了每一个错误
我们可以为特定的方法指定特定的错误信息
我们有一个很好自上而下的逻辑,所有错误都在上面,返回值在下面
关键点来了。如果我们忘记检查会发生什么?
1 2 3 4 if (!body.success ) { return ; }
问题是……我们并不能。是的,我们必须要做检查。如果不这么做,那么 body.data
将不会存在。语法检测会通过抛出“属性 ‘data’ 在 ‘Safe‘ 上不存在“的错误来提醒我们。这都要感谢我们之前创建的简单的 Safe
类型。同时它也适用于处理错误信息。在处理 !body.success
之前,我们将无法访问 body.error
。
在这里我们要感谢 TypeScript 以及它是如何改变了 JavsScript 的世界。
下面的内容也一样:
1 2 3 4 if (!response.success ) { return ; }
我们不能删除 !response.success
,否则 response.data
也将不存在。
当然,我们的方式也并非没有问题。最重要的一点是,你必须要用我们的 safe
方法类包装那些会抛错的异步方法。这种“必知“来自语言的限制,并非我们所能克服。
这听上去有些困难,但事实并非如此。你很快就会意识到,代码中几乎所有的异步方法都可能会抛错,同步方法也会,你知道它们,但可能并不多。
不过,你仍可能会问,这么做值得吗?我们认为是的,并且在我们的团队中工作得很好 :)。当你查看一个复杂的业务逻辑时,没有 try/catch
,每个错误都在出现的地方被处理,并且有一个良好的逻辑流程……看起来很棒。
这是一个使用 SvelteKit FormAction
的实际例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 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:是不是很像?
1 2 3 4 5 f, err := os.Open(“filename.ext”)if err != nil { log.Fatal(err) }
1 2 3 4 5 6 const response = await safe (fetch (“https :if (!response.success ) { console .error (response.error ); return ; }