一、你正面临的真实痛点
线上时不时有用户反馈"点按钮没反应",但你打开错误监控后台,干干净净,一条报错都没有。
翻代码才发现,到处都是这种写法:
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 用来把新语法转成旧语法。但它的本质是一台代码加工机,工作流程分三步:
- 拆(Parser) :把代码字符串拆成一颗树,叫 AST(抽象语法树) 。
- 巡逻 + 改(Traverse + Types) :遍历器在树上走,每遇到一种节点就问"有人要处理这个吗?"。我们在
visitor里注册一个回调,说"CatchClause 我来处理",然后在回调里用工厂方法造出新节点,插到树上。 - 装(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 ← 挂全局上报函数