《TypeScript编程实战》2.3 调试与 source map

本节解决「运行时报错的行号对不上源码」这一痛点。先讲清 source map 是什么、由哪些字段组成,再配置 tsconfig 里的 sourceMap、inlineSources 与 declarationMap;随后给出 Node 调试的三种姿势,讨论生产环境该不该带 source map,并列出断点错位、找不到源文件等常见坑与真实报错。读完你能让断点准确落在 .ts 文件上。

本节目标:让调试重新变得直观。读完后你能解释 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..."
}

逐个字段看:

字段含义需要注意的点
versionsource 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,
});

这里有两条链需要验证:

  1. 开发链:npm run debug 能断在 .ts 上。
  2. 生产链: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 与类型化错误 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes