source map 模式选错,线上调试白屏,构建还慢了两倍——四种模式实测对比

线上报了一个 JS 错误,你打开监控平台一看,堆栈信息全是指向 chunk-3a2b1c.js:1:2345。这种压缩成一行的代码,神仙也定位不到问题。

你第一反应是:上 source map。

然后随便搜了个配置,往 webpack.config.jsvite.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-maphidden-source-map 构建时间基本一致,慢了整整一倍多。原因在于它们都要执行完整的源码映射计算,生成独立的 .map 文件。唯一的区别是:hidden-source-map 不在 bundle 末尾添加 //# sourceMappingURL 注释,浏览器不会自动加载它。


线上调试,到底需要暴露什么

很多人对 source map 的恐惧来自"源码泄露"。但仔细想想,真正危险的不是 source map 本身,而是浏览器能自动加载它。

source-map 模式下,你部署了 main.jsmain.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,只能看到堆栈,看不到源码
  • 开发环境 Webpackeval-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-mapcheap-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),否则还是用标准方案省事。