自动给所有 catch 块补上错误上报:从原理到落地

一、你正面临的真实痛点

线上时不时有用户反馈"点按钮没反应",但你打开错误监控后台,干干净净,一条报错都没有。

翻代码才发现,到处都是这种写法:

javascript 复制代码
async function loadData() {
  try {
    const res = await fetch('/api/data')
    return res.json()
  } catch (e) {
    // 打一行 log 就完了,错误没有继续抛出,也没有上报
    console.log('加载失败')
  }
}

错误被 catch 逮住后,既没有 throw,也没有上报,就这么无声无息地被吞掉了。这种"静默故障"是最致命的------你连错误堆栈都拿不到,根本无从查起。

你当然可以手动给每个 catch 加一句上报,但整个项目有成百上千个 catch,每次写新代码都得记得加,维护成本太高,不可持续。

目标很明确:在构建阶段,用工具自动扫描所有代码,找到每个 catch 块,在它的第一行塞进一条 __CATCH_REPORT__ 调用,让错误在"被吃掉"之前先被记录下来。同时,原来的控制流(是吞是抛)完全不受影响。


二、Babel:一台代码加工机

你肯定听说过 Babel 用来把新语法转成旧语法。但它的本质是一台代码加工机,工作流程分三步:

  1. 拆(Parser) :把代码字符串拆成一颗树,叫 AST(抽象语法树)
  2. 巡逻 + 改(Traverse + Types) :遍历器在树上走,每遇到一种节点就问"有人要处理这个吗?"。我们在 visitor 里注册一个回调,说"CatchClause 我来处理",然后在回调里用工厂方法造出新节点,插到树上。
  3. 装(Generator) :所有插件都改完了,把最终的树印回代码字符串。

我们要做的事:在第二步 里蹲守,当遍历器喊到"发现一个 CatchClause!"的时候,造一句上报代码,插进 catch 块的最前面。


三、具体怎么改:认识目标、造节点、塞进去

3.1 catch 在 AST 里长什么样?

javascript 复制代码
catch (e) {
  console.log('出错了')
}

在 Babel 眼里,它是一个 CatchClause 节点,结构如下:

css 复制代码
CatchClause
├── param: Identifier (name: "e")     ← catch 的参数
└── body: BlockStatement              ← 那对 {}
    └── body: [                       ← 块里的语句列表(数组)
          ExpressionStatement         ← console.log('出错了')
        ]

我们要做的就是往 node.body.body 这个数组的最前面,塞一个新节点。

3.2 用工厂方法造出要插入的节点

我们的目标是造出这样一条语句:

arduino 复制代码
__CATCH_REPORT__(e, "src/utils.js", "loadData");

但你不是在"写代码",你是在造节点对象,然后把这个对象挂到树上。所以需要一层一层从里到外组装。

第一步:造最小的零件

bash 复制代码
t.identifier('__CATCH_REPORT__')   // → 一个"名字"节点,代表 __CATCH_REPORT__
t.identifier('e')                  // → 一个"名字"节点,代表变量 e
t.stringLiteral('src/utils.js')    // → 一个"字符串"节点,代表 "src/utils.js"
t.stringLiteral('loadData')        // → 一个"字符串"节点,代表 "loadData"

这四个是最小的原子零件,不能再拆了。

第二步:把零件组装成一次函数调用

less 复制代码
t.callExpression(
  t.identifier('__CATCH_REPORT__'),        // ← 谁被调用
  [                                         // ← 参数列表
    t.identifier('e'),
    t.stringLiteral('src/utils.js'),
    t.stringLiteral('loadData')
  ]
)

这一步造出来的节点,对应的代码是 __CATCH_REPORT__(e, "src/utils.js", "loadData")------一个表达式 。但表达式不能直接放在 {} 块里,{ __CATCH_REPORT__(...) } 在语法上不完整,它需要变成一个语句

第三步:套上"语句壳"

less 复制代码
t.expressionStatement(
  t.callExpression(...)   // ← 把第二步的整个调用塞进来
)

套完之后,它才变成一条合法的语句:__CATCH_REPORT__(e, "src/utils.js", "loadData");------注意末尾的分号,这就是 expressionStatement 带来的。

最终结构长这样(从外到内):

css 复制代码
ExpressionStatement          ← 语句壳(第三步)
└── CallExpression           ← 函数调用(第二步)
    ├── callee: Identifier("__CATCH_REPORT__")
    └── arguments: [
          Identifier("e"),               ← 第一步
          StringLiteral("src/utils.js"), ← 第一步
          StringLiteral("loadData")      ← 第一步
        ]

这个节点就可以直接塞进 catch 块的语句数组里了。

3.3 塞进去

scss 复制代码
node.body.body.unshift(reportStmt)

一行搞定。原来的语句全部往后挪一位,控制流不受任何影响。

3.4 补充:为什么不能偷懒拼字符串?

你可能会想:既然最终就是加一行代码,为什么不找到 catch (e) { 这个字符串,在后面拼一行就完了?

因为在 Babel 的流水线上,不止你一个插件在工作。 你改完之后,后面可能还有 preset-env 转箭头函数、压缩插件缩短变量名、其它业务插件继续改代码。这些插件只认 AST 节点对象,不认识裸字符串。如果你塞了一行字符串进去,后面的插件就完全看不懂,整条流水线就断了。

所以所有插件必须用统一的"中间语言"------AST 节点对象------来传递代码。每个插件在同一棵树上增删改查,最后 Generator 再一次性印回字符串。


四、写一个 Vite 插件,让它在构建时自动跑起来

4.1 Vite 插件长什么样?

Vite 插件就是一个普通对象,必须有一个 name,以及若干钩子函数。我们只需要用到 transform 这个钩子------Vite 在打包每个文件时都会调用它,把源码字符串和文件路径传进来,你返回改完的代码就行。基本骨架是:

javascript 复制代码
export default function myPlugin() {
  return {
    name: 'my-plugin',
    transform(code, id) {
      // code: 这个文件的源码字符串
      // id:   这个文件的绝对路径,比如 /Users/me/project/src/utils.js
      // 返回 { code: 改完的代码, map: source map } 即可
    }
  }
}

我们要做的就是在 transform 里调用 Babel,把第三节讲的逻辑跑一遍。

4.2 完整代码

javascript 复制代码
// vite-plugin-enhance-catch.js
import { transformSync } from '@babel/core'
import * as t from '@babel/types'

export default function vitePluginEnhanceCatch() {
  return {
    name: 'vite-enhance-catch',

    transform(code, id) {
      if (!/.(js|ts|jsx|tsx)$/.test(id) || id.includes('node_modules')) return null

      const result = transformSync(code, {
        filename: id,
        babelrc: false,       // 不读项目已有的 babel 配置,避免干扰
        configFile: false,
        plugins: [{
          visitor: {
            CatchClause(path, state) {
              const node = path.node

              // 没有参数的 catch {} 拿不到错误对象,跳过
              if (!node.param) return

              // 去重:第一条已经是 __CATCH_REPORT__ 就不再插
              const first = node.body.body[0]
              if (
                t.isExpressionStatement(first) &&
                t.isCallExpression(first.expression) &&
                t.isIdentifier(first.expression.callee, { name: '__CATCH_REPORT__' })
              ) return

              // 造节点并插到 catch 块第一行
              const reportStmt = t.expressionStatement(
                t.callExpression(
                  t.identifier('__CATCH_REPORT__'),
                  [
                    t.identifier(node.param.name),
                    t.stringLiteral(state.filename)
                  ]
                )
              )
              node.body.body.unshift(reportStmt)
            }
          }
        }]
      })

      return { code: result.code, map: result.map }
    }
  }
}

核心就这些。文件过滤、去重、造节点、插入------四步,没有多余的东西。

4.3 注册

javascript 复制代码
// vite.config.js
import vitePluginEnhanceCatch from './vite-plugin-enhance-catch.js'

export default {
  plugins: [
    vitePluginEnhanceCatch()
  ]
}

4.4 效果

javascript 复制代码
// 改造前
try { ... }
catch (e) {
  console.log('出错了')
}

// 改造后
try { ... }
catch (e) {
  __CATCH_REPORT__(e, 'src/utils.js')
  console.log('出错了')
}

原来的 console.log 还在,控制流完全不变,只是错误在被吞掉之前先被记录了一笔。空 catch 块 catch(e) {} 同样会被注入------这正好是我们最想要的,空 catch 是最典型的静默吞错。

4.5 谁把改完的树写回代码?

不需要你手动写回去。transformSync 内部把三步全做了:

arduino 复制代码
你传入的源码字符串
       ↓
   ① Parser 拆成 AST
       ↓
   ② Traverse 跑你的 visitor(你在这里改了树)
       ↓
   ③ Generator 自动把改完的树印回代码字符串
       ↓
返回 { code: "改完的代码", map: source map }

你只管在 visitor 里改节点,改完之后 transformSync 自己会把整棵树重新生成代码,塞在 result.code 里还给你。

一句话:你改树,它印码,各管各的。


五、运行时:让浏览器认识 __CATCH_REPORT__

到这里,构建阶段的事已经做完了。但编译产物里多出来的 __CATCH_REPORT__(e, 'src/utils.js') 只是一句调用------浏览器执行到它时,如果找不到这个函数,就会直接报 ReferenceError,反而制造新故障。所以我们还需要在运行时把这个函数定义好。

在入口文件(比如 main.js)里挂一个全局函数:

php 复制代码
window.__CATCH_REPORT__ = function(err, file) {
  fetch('/api/log', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      errorMessage: err?.message || '',
      errorStack: err?.stack || '',
      fileName: file,
      pageUrl: location.href,
      timestamp: Date.now()
    })
  }).catch(() => {})  // 上报自身绝不能抛错
}

六、整条链路回顾

阶段 做了什么
问题 已有 catch 把错误吃了,线上静默故障无日志
思路 构建时自动给每个 catch 块第一行插入 __CATCH_REPORT__ 调用
为什么是 AST 流水线上多个插件接力,都只认节点对象;同时能精准去重
关键细节 去重(检查首条语句)、无参数 catch 跳过
落地 Vite 插件内部调 Babel,处理 .js/.ts/.jsx/.tsx,排除 node_modules
运行 入口挂 window.__CATCH_REPORT__,POST 到后端

文件清单:

arduino 复制代码
vite-plugin-enhance-catch.js   ← Vite 插件(内含 Babel visitor)
vite.config.js                 ← 注册插件
main.js                        ← 挂全局上报函数
相关推荐
朱涛的自习室1 小时前
Munk AI 桌面端「预告」
android·前端·人工智能
程序员包打听1 小时前
从 npx 到 moonx,moonbit 的野心与展望
前端·后端
laboratory agent开发1 小时前
工具调用失败后怎么办?重试分层与降级回路的三种路线
服务器·前端·网络
IMPYLH2 小时前
HTML 的 <dialog> 元素
前端·html
AI编程实验室2 小时前
用 npm + Three.js 做一颗西瓜:把夏天的清凉感放进浏览器
前端·后端·ai编程
渣波2 小时前
基于 Milvus 构建小说知识库 RAG,实现图书智能问答(天龙八部实战)
前端·后端
橘子星2 小时前
RAG 实战:3 步将整本《天龙八部》存入向量数据库(一)
javascript·人工智能
JavaGuide2 小时前
我最推荐的 4 个 AI 编程 Skills:grill-me、research、diagnosing-bugs、code-review
前端·后端·ai编程
Moment2 小时前
2026 了,前端转 AI 全栈我是这么学的 😍😍😍
前端·后端·面试