自动给所有 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                        ← 挂全局上报函数
相关推荐
卸任3 小时前
Vibe coding工具使用心得
前端·ai编程
brien94 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance4 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M4 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写4 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李4 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易4 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding4 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
风骏时光牛马4 小时前
前沿视界
前端
kyriewen4 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员