source map 模式选错,线上调试白屏,构建还慢了两倍——四种模式实测对比
线上报了一个 JS 错误,你打开监控平台一看,堆栈信息全是指向 chunk-3a2b1c.js:1:2345。这种压缩成一行的代码,神仙也定位不到问题。
你第一反应是:上 source map。
然后随便搜了个配置,往 webpack.config.js 或 vite.config.ts 里一贴,部署上线。确实能定位了,但发现构建时间直接从 40 秒变成了 90 秒,而且 source map 文件动不动 5MB、10MB,用户打开 F12 就能看到你完整的源码。
这就是 source map 模式选错的代价。我用一个真实的中型项目(约 120 个页面,300+ 组件),对四种常用模式做了完整实测,结果差异远比想象中大。
先说结论:生产环境调试首选 hidden-source-map 配合监控平台上传,开发环境用 eval-cheap-module-source-map,别碰默认值。
四种模式的真机数据
测试项目基于 Webpack 5.89.0,React 18 + TypeScript,源码总大小约 4.7MB(不含 node_modules)。构建环境:MacBook Pro M1 Pro,16GB RAM。每个模式跑 3 次取平均值,清除缓存后计时。
下面是实测结果:
| 模式 | 构建耗时 | 产物大小 | source map 大小 | 源码可见性 |
|---|---|---|---|---|
| 无 source map | 42s | 2.3MB | 0 | 不可见 |
eval |
44s | 2.3MB | 内联于 bundle | 可见(混淆后) |
eval-cheap-module-source-map |
47s | 2.4MB | 内联于 bundle | 可见(行级) |
source-map |
89s | 2.3MB | 5.8MB | 完全可见 |
hidden-source-map |
87s | 2.3MB | 5.8MB | 不可见(需手动关联) |
cheap-module-source-map |
63s | 2.3MB | 4.1MB | 可见(行级) |
nosources-source-map |
78s | 2.3MB | 3.2MB | 不可见(仅堆栈) |
eval 系列构建速度接近无 source map,因为它在每个模块代码末尾拼接 //# sourceURL 注释,不生成独立的 .map 文件。代价是只映射到编译后代码,不是原始源码,调试体验打折扣。
source-map 和 hidden-source-map 构建时间基本一致,慢了整整一倍多。原因在于它们都要执行完整的源码映射计算,生成独立的 .map 文件。唯一的区别是:hidden-source-map 不在 bundle 末尾添加 //# sourceMappingURL 注释,浏览器不会自动加载它。
线上调试,到底需要暴露什么
很多人对 source map 的恐惧来自"源码泄露"。但仔细想想,真正危险的不是 source map 本身,而是浏览器能自动加载它。
source-map 模式下,你部署了 main.js 和 main.js.map,任何人打开 Chrome DevTools 就能在 Sources 面板看到你完整的 React 组件、业务逻辑、API 请求封装,甚至连注释都在。2020 年就有人通过这种方式发现某电商平台的优惠券校验逻辑,直接薅了 200 多万羊毛。
hidden-source-map 解决了这个问题:map 文件照常生成,但 bundle 里没有指向它的注释。浏览器根本不知道有 map 文件存在,普通用户打开 F12 什么也看不到。而你的监控平台(Sentry、Fundebug、自研)可以在接收错误时,用上传的 map 文件反向解析堆栈。
具体做法:
// webpack.config.js
module.exports = {
devtool: 'hidden-source-map',
// ...
}
构建完成后,把 dist/**/*.map 上传到监控平台,或者存到只有内网能访问的 OSS 上。Sentry 的 Webpack 插件可以自动完成这一步:
npm install @sentry/webpack-plugin --save-dev
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin');
module.exports = {
devtool: 'hidden-source-map',
plugins: [
sentryWebpackPlugin({
org: 'your-org',
project: 'your-project',
authToken: process.env.SENTRY_AUTH_TOKEN,
release: {
name: '1.0.0',
},
}),
],
};
构建时插件会自动把 .map 文件上传到 Sentry,然后删除本地的 .map 文件,确保不会部署到生产服务器。
如果你的监控平台不支持自动上传,也可以用脚本手动处理:
# !/bin/bash
# 构建后执行
scp dist/**/*.map user@internal-server:/var/sourcemaps/release-1.0.0/
rm dist/**/*.map
这样做的好处是:生产服务器上根本没有 map 文件,不存在泄露风险;同时错误堆栈能精确到源码行号。
为什么 source-map 拖慢构建两倍
生成 source map 慢,主要慢在两个阶段。
第一阶段是 AST 遍历与映射关系生成。Webpack 在处理每个模块时,需要记录源码的每一个 token 经过 loader 转换、压缩、合并后,对应到最终产物中的哪个位置。这个映射关系用 VLQ 编码存储,计算量随代码量线性增长。4.7MB 的源码,经过 babel、terser 之后,映射关系的数据结构本身就有几十 MB。
第二阶段是 文件写入。source-map 生成的 .map 文件包含了 sourcesContent 字段,也就是所有源文件的完整内容。这就是为什么 4.7MB 源码能生成 5.8MB 的 map 文件——它等于源码 + 映射数据。写入这个大文件本身就是 I/O 瓶颈。
cheap-module-source-map 快一些(63s vs 89s),因为它只做行级映射,不做列级映射。也就是说,它能告诉你错误在哪一行,但不知道是哪一列。对于绝大多数调试场景,知道行号就够了。而且它忽略 loader 之间的中间代码映射,直接从源码映射到最终产物,省掉了中间步骤的计算。
nosources-source-map 生成的 map 文件只有 3.2MB,因为它不包含 sourcesContent。这意味着拿到 map 文件也看不到源码内容,只能看到堆栈的原始位置信息。但缺点是你必须有原始源码才能完整还原,对监控平台的集成要求更高。
开发环境怎么选
开发环境的选择逻辑完全不同。生产环境你关心的是"安全 + 能定位错误",开发环境你关心的是"构建快 + 调试准"。
Vite 默认的 esbuild 在开发模式下根本不用 source map,它用 esbuild 的增量编译 + 浏览器原生 ESM import,DevTools 直接能看到 .ts、.tsx 源文件。所以 Vite 用户不需要纠结这个配置。
Webpack 用户则不然。默认的 eval 模式构建最快,但调试时看到的是编译后的代码,打断点经常错位。我推荐 eval-cheap-module-source-map,它在 eval 的基础上加了行级映射,断点基本准确,构建只慢了 5 秒左右,完全可以接受。
// webpack.dev.js
module.exports = {
mode: 'development',
devtool: 'eval-cheap-module-source-map',
}
不要用 eval-source-map,它做列级映射,在大型项目中会让重新编译明显变慢——每次保存文件等 2-3 秒才能看到热更新,这个体验很差。
场景速查
一句话版本,直接对照着选:
- 生产环境 + 有监控平台:
hidden-source-map,map 文件上传后删除本地文件 - 生产环境 + 无监控平台 + 可接受源码暴露:
cheap-module-source-map,行级映射够用,构建快 30% - 生产环境 + 无监控平台 + 不想暴露源码:
nosources-source-map,只能看到堆栈,看不到源码 - 开发环境 Webpack:
eval-cheap-module-source-map - 开发环境 Vite:不用配置,默认就好
我的项目最终用了 hidden-source-map + Sentry 自动上传,构建时间从 89s 优化到 87s,几乎没有空间可以压榨了。真正让构建变慢的不是 source map 模式,而是你是否真的需要它。不需要就别开,需要就接受这个成本,然后从别的地方找补——比如升级到 swc 或 esbuild 做压缩,比纠结 source map 模式有效得多。
常见问题
问:为什么我用了 hidden-source-map,Sentry 还是解析不出源码位置?
大概率是 release 版本没对上。Sentry 通过 release 字段匹配错误事件和 source map,你的构建脚本必须保证每次发布都生成唯一的 release 名称(如 Git commit hash),并且在 Sentry 上传 map 文件和 SDK 初始化时使用同一个 release 值。检查 Sentry.init({ release: 'xxx' }) 和上传时的 release 参数是否一致。
问:cheap-module-source-map 和 cheap-source-map 有什么区别?
带 module 的会经过 loader 的 source map 合并,最终映射回原始源码。不带 module 的只映射到 webpack 处理后的模块代码,不经过 loader 映射。如果你用了 babel、ts-loader 等,一定要选带 module 的,否则调试时看到的是 babel 转换后的 JS,不是你的 TS 源码。
问:Vite 生产环境用 hidden-source-map 会慢多少?
Vite 生产构建基于 Rollup,hidden-source-map 模式下我实测一个 300+ 组件的 Vue 项目,构建时间从 31s 增加到 54s,约慢 74%。比 Webpack 的 110% 增幅好一些,但依然明显。如果不想等,可以考虑 hidden: true 只在上传前临时生成,平时关闭。
问:能不能不生成 source map,靠其他方式定位线上错误?
可以,但代价更大。一种做法是关闭代码压缩混淆,线上直接跑格式化后的代码,这样堆栈天然可读。但 bundle 体积会膨胀 3-5 倍,首屏加载明显变慢。另一种做法是在构建时注入行列号的函数封装,但这本质上是自己实现了一套简易 source map。除非你的场景极其特殊(如对接的监控平台完全不支持 source map),否则还是用标准方案省事。