本节目标:让调试重新变得直观。读完后你能解释 source map 的每个字段在做什么,能配置出「断点落在
.ts上、栈信息显示源码行号」的开发环境,并且知道生产环境里 source map 该不该上传、传到哪里。
2.3 调试与 source map
前两节我们把目录、模块、配置都类型化了。类型能挡住的错误都被挡住了,剩下的错误只会在运行时出现——而且运行的是编译产物。dist/index.js 里第 12 行,对应源码里的哪一行?没有 source map 时,答案只能是「自己数」。
本节就是要消除这层错位,让调试器、日志栈、断点全部回到你亲手写的 .ts 文件上。
2.3.1 错位是怎么产生的
先看现象。写一段会抛错的服务端代码:
// src/index.ts
interface Order {
id: string;
amount: number;
}
function total(orders: Order[]): number {
return orders.reduce((sum, o) => sum + o.amount, 0);
}
const orders: Order[] = [{ id: "a1", amount: 100 }];
// 故意传一个不该传的值
console.log(total(orders as unknown as Order[]));
throw new Error("模拟启动失败");
用 tsc 编译后运行 node dist/index.js,栈信息是这样的:
Error: 模拟启动失败
at Object.<anonymous> (/app/dist/index.js:15:7)
at Module._compile (node:internal/modules/cjs/loader:1254:14)
dist/index.js:15:7——你要跑到 dist 目录里打开那份编译产物,对照着找出对应源码。如果构建还经过了压缩(minify),产物可能只有一行,行号直接变成 1:8423,对照工作就彻底不可行了。
source map 解决的就是这个映射问题:它是产物位置到源码位置的对照表,调试器读取它,就能把 dist/index.js:15:7 翻译回 src/index.ts:12:9。
2.3.2 source map 里有什么
开启 sourceMap: true 后,tsc 会在每个 .js 旁边生成一个 .js.map 文件,并在 .js 末尾追加一行引用:
//# sourceMappingURL=index.js.map
.js.map 是一份 JSON:
{
"version": 3,
"file": "index.js",
"sourceRoot": "",
"sources": ["../src/index.ts"],
"sourcesContent": null,
"names": [],
"mappings": "AAAA,MAAM,KAAK,GAAG..."
}
逐个字段看:
| 字段 | 含义 | 需要注意的点 |
|---|---|---|
version | source map 规范版本 | 固定为 3,所有工具都按 v3 实现 |
sources | 源码文件路径列表 | 相对于 map 文件本身,所以带 ../ |
sourcesContent | 源码原文(可选) | 为 null 时调试器需要能自己找到源文件 |
names | 原始标识符名称 | 压缩后用于还原变量名 |
mappings | 核心映射数据 | Base64 VLQ 编码的位置序列 |
mappings 值得单独说一句。它是用 Base64 VLQ(Variable Length Quantity)编码的紧凑字符串,按行、按列记录「产物的这个位置对应源码的哪个位置」。它之所以这么设计,是因为逐条记录位置会产生几十倍于代码本身的体积;VLQ 用增量编码把相邻位置压成几个字符。你不需要会手算 VLQ,但要理解它的两个性质:它是增量的(所以片段顺序不能乱)、它只记录位置不记录语义(所以类型信息不可能从中还原)。
sourcesContent 是一个实用开关。把它打开(inlineSources: true),源码原文会被嵌进 map 文件里,调试器不必再去磁盘上找源文件。这在「产物被部署到别处、源码不在同一台机器」时非常关键,代价是 map 文件体积变大。
2.3.3 tsconfig 里的相关选项
与调试直接相关的选项有四个:
{
"compilerOptions": {
"sourceMap": true,
"inlineSources": true,
"declaration": true,
"declarationMap": true
}
}
| 选项 | 产物 | 用途 |
|---|---|---|
sourceMap | .js.map | 调试 .js 时映射回 .ts |
inlineSources | 嵌入 sourcesContent | 产物与源码分离部署时仍能映射 |
declaration | .d.ts | 供其他包引用类型 |
declarationMap | .d.ts.map | 跳转到定义时落到 .ts 而非 .d.ts |
declarationMap 最容易被忽略,但体验差异很大。monorepo 里 apps/api 引用 packages/core 的 formatMoney,在 VS Code 里按住 Ctrl 点击跳转时:没有 declarationMap 会跳到 packages/core/dist/index.d.ts(一份只有签名的文件);有 declarationMap 则直接跳到 packages/core/src/index.ts 的真实实现。这个选项几乎零成本,库包一律建议开启。
还有两个相关但不同用途的选项:
inlineSourceMap: true:把 map 内容以 base64 内联进.js,不生成独立.js.map。适合单文件分发场景,代价是产物变大且无法单独控制。noEmitOnError:与调试无关,但它决定了有类型错误时是否仍产出文件——调试时如果产物「是旧的」,先怀疑这里。
sourceMap 与 inlineSourceMap 不要同时开,同时开启时 TypeScript 会报 error TS5053: Option 'sourceMap' cannot be specified with option 'inlineSourceMap'。
2.3.4 Node 调试的三种姿势
姿势一:--inspect 加 Chrome DevTools
最通用的方式,不依赖任何编辑器:
node --inspect dist/index.js
输出:
Debugger listening on ws://127.0.0.1:9222/1a2b3c4d-...
For help, see: https://nodejs.org/en/docs/inspector
然后在 Chrome 打开 chrome://inspect,点击目标进入 DevTools,Sources 面板里就能看到 src/index.ts(Node 会自动读取 source map),可以打断点、单步、查看作用域。想在进程启动前就断住(调试启动逻辑),用:
node --inspect-brk dist/index.js
--inspect-brk 会在第一行暂停,--inspect 则直接跑下去。
姿势二:VS Code launch.json
日常开发最顺手。在 .vscode/launch.json 里配置:
{
"version": "0.2.0",
"configurations": [
{
"name": "调试当前文件 (tsx)",
"type": "node",
"request": "launch",
"runtimeExecutable": "tsx",
"runtimeArgs": ["--inspect-brk"],
"program": "${file}",
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "调试构建产物",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/dist/index.js",
"preLaunchTask": "npm: build",
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"skipFiles": ["<node_internals>/**"]
}
]
}
第二个配置里的 outFiles 是关键:它告诉调试器「产物在哪」,调试器据此找到对应的 .js.map。如果断点是「空心圆」(未绑定断点),九成是因为 outFiles 没配对,或者 sourceMap 没开。
skipFiles 里的 <node_internals>/** 能让你在单步时不被 Node 内部代码打断,体验提升明显。
姿势三:tsx / ts-node 直接调试
前两节一直在用的 tsx 也可以直接挂调试器:
tsx --inspect-brk src/index.ts
它的原理是运行时用 esbuild 即时转译,转译产物自带 source map,所以调试器看到的仍是 .ts。这条路的好处是不需要预先构建,改完立刻能调;代价是启动稍慢(要转译),且与生产产物的行为可能有细微差别(比如 esbuild 不做类型检查)。
选择建议:日常开发用 tsx,复现生产问题用构建产物加 outFiles。两者都要能跑通,因为「开发能调、生产不能调」正是最需要调试的场景。
2.3.5 让生产日志的栈也指向源码
调试器只是场景之一。服务端更常见的是看日志里的错误栈——那里没有调试器读 source map,Node 打印的是产物的位置。Node 提供了开关:
node --enable-source-maps dist/index.js
开启后,栈信息会被重写为源码位置:
Error: 模拟启动失败
at Object.<anonymous> (/app/src/index.ts:12:7)
注意它同时也会读取 sourcesContent,因此能在栈里带出源码片段。另一个方案是 source-map-support 包:
npm i source-map-support
// 必须在其他 import 之前
import "source-map-support/register";
两者的区别:--enable-source-maps 是 Node 原生、零依赖、无需改代码;source-map-support 兼容老版本 Node,并且可以通过 API 手动 install({ environment: "node" }) 控制时机。新项目直接用原生开关。
2.3.6 生产环境该不该带 source map
这是个必须做决策的问题,两个选项各有代价:
| 策略 | 优点 | 风险 |
|---|---|---|
产物带 .map 并部署 | 线上栈直接可读,排查最快 | 源码对外可见,可能泄漏业务逻辑与内网信息 |
产物不带 .map | 无泄漏风险 | 线上错误栈全是产物行号 |
| 生成但不上传(上传到错误监控平台) | 两全 | 需要配置上传流程 |
推荐第三种:构建时照常生成 .map,通过 CI 上传到错误监控平台(Sentry 等),但不部署到静态资源服务器。上传后可以删除产物里的 .map,或确保服务器不响应 .map 请求。
判断依据是这份源码对攻击者有多大价值。前端产物本身可被下载反编译,source map 只是让这件事更省事,泄漏成本相对低;后端若把数据库连接逻辑、内部接口路径、鉴权绕过条件暴露出来,成本就高得多。所以常见做法是:前端可以带,后端默认不带。
无论选哪种,都要确认构建配置没有把 .map 意外打进产物目录并随镜像一起发布。检查方法是构建完 ls dist/*.map 数一下,再确认部署脚本没有 COPY dist ./dist 这种全量拷贝。
2.3.7 常见坑与真实报错
坑一:断点是空心圆,不绑定
调试器提示 Breakpoint set but not yet bound。原因通常是三选一:sourceMap 没开、outFiles 没配、或者断点打在的类型检查通过但被编译器擦除的代码上。最后一种很隐蔽——比如在 interface 声明行或纯类型注解行打断点,那里根本没有对应产物。
坑二:error TS5053: Option 'sourceMap' cannot be specified with option 'inlineSourceMap'
两个选项互斥,删掉其中一个。
坑三:断点落在了错误的行上
产物行号与源码行号有偏移。常见原因是构建链路里有一步没传 source map:比如 tsc 生成了 map,随后 tsc-alias 或某个后处理脚本改写了产物却没有重新生成 map。解决办法是让每个改写步骤都接上 source map 链(tsc-alias 默认会处理)。
坑四:栈里显示 webpack:// 或 file:///app/dist/... 而非源码
前者说明中间经过打包器,需要检查打包器的 devtool 配置(Vite 对应 build.sourcemap);后者说明 --enable-source-maps 没开,或者 .map 文件没跟着产物一起部署。
坑五:sources 路径指向了本机绝对路径
如果构建时源码路径是绝对路径,map 里会记录 /Users/xxx/project/src/index.ts。这既泄漏了目录结构,也会让别人拿到 map 后无法映射。解决:构建在容器里用固定的工作目录(如 /app),或让打包器输出相对路径。
坑六:修改源码后断点位置错乱
.js 与 .js.map 不同步——只更新了其中一个。用 tsc --watch 或构建脚本的 clean 选项保证两者一起重写。tsup 的 clean: true 就是干这个的。
2.3.8 把它接进开发流程
最后把本节内容固化成可执行的配置。package.json 里的一组脚本:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"debug": "tsx --inspect-brk src/index.ts",
"build": "tsup",
"start": "node --enable-source-maps --env-file=.env dist/index.js",
"start:debug": "node --inspect-brk --enable-source-maps dist/index.js"
}
}
对应的 tsup.config.ts 要打开 source map:
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node20",
sourcemap: true,
clean: true,
dts: true,
});
这里有两条链需要验证:
- 开发链:
npm run debug能断在.ts上。 - 生产链:
npm run build && npm run start后,故意抛一个错,栈里显示的是src/index.ts的行号。
两条链都验证通过,本节的目标才算达成。这两条链也是第 18 章 CI/CD 流水线里「构建产物可观测」的前置条件——发布后验证与回滚,第一步就是能读懂错误栈。想继续深入可以延伸阅读 Vite source map 深入 与 Node.js 性能调优指南 。
小结
本节围绕「产物行号对不上源码」这一个痛点展开。我们先是确认了错位的成因——编译与压缩都会打乱位置;随后拆解了 source map 的结构,明确 sources 相对 map 文件、mappings 是 VLQ 编码的位置序列、sourcesContent 决定源码是否内嵌。在 tsconfig 层面,sourceMap 与 inlineSources 服务于调试,declarationMap 服务于跳转定义,两者都建议开启,且 sourceMap 与 inlineSourceMap 互斥。
调试路径上,我们给出三种姿势:--inspect 加 Chrome DevTools 最通用,VS Code 的 outFiles 配置最顺手,tsx --inspect-brk 免构建最快;线上日志则用 --enable-source-maps 把栈重写回源码。生产环境的决策原则是「生成但不上传」,或上传到错误监控平台后从产物中移除。
到这里第 2 章结束:目录与模块有了稳定结构,配置有了类型与启动校验,运行时的错误也有了可读的栈。但错误本身怎么表达、怎么在类型层面就强制处理掉,还没有答案——现在的做法仍是抛 Error,调用方无法从类型上知道它会抛。下一节 3.1 Result/Either 与类型化错误
会把「错误」变成返回值的一部分,让遗漏处理在编译期就被抓住。
阅读导航:上一节:2.2 环境变量与配置的类型化 · 下一节:3.1 Result/Either 与类型化错误 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。