如何捕获 JS 异常?

解读

在国内前端与 Rust 混合开发的面试场景里,面试官问“如何捕获 JS 异常”并不是想听一段纯前端代码,而是考察候选人能否在 Rust 侧安全、零成本地接管 JavaScript 运行时错误,并给出可维护、可扩展、可测试的解决方案。核心诉求有三点:

  1. 不 panic:Rust 线程绝不能因为 JS 抛错而崩溃;
  2. 不丢信息:堆栈、行列号、SourceMap 都要完整回传 Rust;
  3. 不阻塞性能:异常路径不能引入额外 GC 压力或锁竞争。

知识点

  1. wasm-bindgen 异常 ABI:js_sys::Error、wasm_bindgen::JsValue、JsCast trait
  2. Closure::wrap 与 catch_unwind 边界:FFI 安全栅栏,防止 Rust panic 穿透到 JS
  3. std::panic::AssertUnwindSafeSend+Sync 约束:在异步或并发场景下保证 unwind 安全
  4. source-map-mappings crate:在 Rust 侧解析 .map 文件,实现行列号回译
  5. Window::set_error_handlerqueueMicrotask:捕获异步宏任务错误
  6. #[wasm_bindgen(start)] 初始化钩子:注册全局错误监听器,做到“编译通过即正确”

答案

分三层回答,先给最小可运行示例,再讲原理,最后给出工程化封装。

  1. 最小可运行示例
use wasm_bindgen::prelude::*;
use web_sys::{ErrorEvent, Window};

#[wasm_bindgen(start)]
pub fn init() -> Result<(), JsValue> {
    let window = web_sys::window().unwrap();
    let closure = Closure::wrap(Box::new(move |e: ErrorEvent| {
        let msg = e.message();
        let file = e.filename();
        let line = e.lineno();
        let col = e.colno();
        // 发送到 Rust 日志系统
        web_sys::console::error_1(&format!(
            "JS异常: {} at {}:{}:{}", msg, file, line, col
        ).into());
        // 阻止继续冒泡,避免控制台重复打印
        e.prevent_default();
    }) as Box<dyn FnMut(ErrorEvent)>);
    window.set_onerror(Some(closure.as_ref().unchecked_ref()));
    closure.forget(); // 全局长生命周期,不 drop
    Ok(())
}

编译后,任何 <script>wasm-bindgen 生成的胶水代码抛错都会被 Rust 闭包捕获。

  1. 原理拆解
  • set_onerror 只能捕获同步错误;对于 Promise 未处理拒绝,需要再注册 window.addEventListener("unhandledrejection", …)
  • 闭包内部使用 web_sys::console::error_1 只是示例,生产环境应通过 js_sys::Reflect::set 把错误对象序列化到 Rust 结构体,再送入日志通道(如 tokio::sync::mpsctracing)。
  • closure.forget() 把所有权交给 JS GC,Rust 侧不再管理,因此必须保证闭包内部无 panic;否则应使用 std::panic::catch_unwind 做二次防护。
  1. 工程化封装
#[wasm_bindgen]
pub struct JsErrorCapture {
    tx: std::sync::mpsc::Sender<JsValue>,
}

#[wasm_bindgen]
impl JsErrorCapture {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        let (tx, rx) = std::sync::mpsc::channel();
        let window = web_sys::window().unwrap();
        // 同步错误
        let tx1 = tx.clone();
        let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
            let _ = tx1.send(e.to_js_value());
        }) as Box<dyn FnMut(ErrorEvent)>);
        window.set_onerror(Some(onerror.as_ref().unchecked_ref()));
        onerror.forget();
        // 异步拒绝
        let tx2 = tx.clone();
        let onunhandled = Closure::wrap(Box::new(move |e: web_sys::PromiseRejectionEvent| {
            let _ = tx2.send(e.reason());
        }) as Box<dyn FnMut(web_sys::PromiseRejectionEvent)>);
        window.add_event_listener_with_callback(
            "unhandledrejection",
            onunhandled.as_ref().unchecked_ref(),
        ).unwrap();
        onunhandled.forget();
        // 启动 Rust 侧日志任务
        wasm_bindgen_futures::spawn_local(async move {
            while let Ok(err) = rx.recv() {
                if let Ok(err) = err.dyn_into::<js_sys::Error>() {
                    let stack = err.stack().as_string().unwrap_or_default();
                    // 这里可以把 stack 发给 Sentry、阿里云 SLS 或自研日志平台
                    log::error!("JS异常堆栈\n{}", stack);
                }
            }
        });
        JsErrorCapture { tx }
    }
}

该封装满足:

  • 零成本抽象:release 模式下闭包直接生成 JS 函数指针,无额外内存分配;
  • 内存安全:所有 JS 值通过 JsValue 强类型穿越 FFI,Rust 侧不手动 free;
  • 并发无数据竞争:通道使用 std::sync::mpsc,与 JS 单线程事件循环天然匹配。

拓展思考

  1. SourceMap 回译:生产环境代码经过压缩,行列号失真。可在 Rust 侧引入 source-map-mappings crate,在编译期把 .map 文件嵌入二进制,异常到达后即时解析,还原原始 TS/Rust 源码位置,方便国内云原生监控平台(如阿里云 ARMS、腾讯云 TSW)直接展示。
  2. 异步错误链路追踪:结合 wasm-bindgen-futuresfuture_to_promise,在 Rust async 函数内部使用 tracing::span! 记录上下文,一旦 Promise 被拒绝,可把完整的 Rust async 栈与 JS 栈拼接,实现“跨语言链路追踪”。
  3. std::panic::catch_unwind 联动:在 Rust 侧故意触发 panic 时,用 catch_unwind 捕获后封装成 js_sys::Error,再抛回 JS,形成“双向异常”通道,确保无论哪一侧先出错,都能统一由 Rust 日志系统收口,满足金融级 Rust wasm 微服务对“可观测性”的合规要求。