简介:这份资源面向希望在 Visual Studio Code 中高效调试 TypeScript 的开发者,尤其适合刚接触 TS 调试配置、需要打通断点与源码映射环节的初中级前端或 Node.js 工程师。内容围绕 tsconfig.json 编译选项、.vscode/launch.json 调试配置、断点设置与源码映射等核心环节展开,并延伸至 Debugger for Chrome 的前端调试与 watch、Call Stack、Scope 等进阶技巧。资源包共 10 个文件,以 5 个 json 配置、2 个 ts 源码为主,另含 readme 说明、LICENSE 与 .gitignore,整体约 5KB,体积轻量却覆盖了从项目初始化到调试运行的完整骨架。目前已有 1979 人学习下载,读者可据此快速搭建可运行的 TS 调试环境,理解 sourceMap 与 outFiles 的关联逻辑,掌握断点、单步执行与变量监视的排错思路,并可直接复用其中的配置模板,减少自行摸索调试链路的时间成本。
1. 在 VS Code 里调试 TypeScript:为什么你的断点总是灰的
很多人第一次在 VS Code 里调试 TypeScript,遇到的不是报错,而是断点变成灰色空心圈,鼠标悬上去提示 "Breakpoint set but not yet bound"。代码明明在跑,日志也打得出来,断点就是不生效。这个场景几乎每个从 JavaScript 转到 TypeScript 的开发者都踩过,包括我在内。
问题的根源在于:Node.js 原生只认 JavaScript,TypeScript 需要先编译成 JS 才能执行。而 VS Code 的调试器默认把断点绑在源文件的行号上,如果运行时加载的 JS 和编辑器里的 TS 行号对不上,断点自然绑不住。解决这件事的核心就是source map——它记录了编译后 JS 与原始 TS 之间的行列映射关系。
这篇内容面向的是已经在用 VS Code 写 TypeScript、但调试体验还停留在console.log阶段的开发者。我会把 launch.json 的配置逻辑、tsconfig 的关键参数、ts-node 与编译后调试两条路线的取舍,以及断点绑不上的排查顺序讲清楚。读完你至少能做到:在 TS 源文件里打断点、单步、看作用域变量,而不是对着编译产物发呆。
2. 先搞懂 VS Code 调试 TypeScript 的底层链路
2.1 调试器、Node 运行时和 source map 三者的关系
VS Code 本身不是调试器,它只是一个前端界面。真正干活的是Debug Adapter(调试适配器),Node 场景下用的是js-debug,它负责和 Node 的 Inspector 协议通信。你在编辑器里点的每一个断点、每一次单步,都是通过这层适配器转发给 Node 进程的。
Node 进程执行的是编译后的 JS 文件。当它命中断点暂停时,返回给调试适配器的是 JS 文件的行列位置。VS Code 拿到这个位置后,会去读对应的.js.map文件,把 JS 位置反查回 TS 位置,然后高亮你源文件里对应的那一行。整条链路是这样的:
TS 源文件 → tsc/ts-node 编译 → JS + .js.map → Node 执行 → Inspector 协议 → js-debug → VS Code 显示任何一个环节断了,断点就会变灰。最常见的断点环节是:编译时没生成 source map,或者生成了但outFiles没配对,导致 VS Code 找不到 map 文件。
2.2 tsconfig.json 里必须打开的四个开关
在动手写 launch.json 之前,先确认tsconfig.json里这几个参数是对的。我见过太多人调试配置写了一堆,结果sourceMap是 false,白折腾半天。
{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "outDir": "./dist", "rootDir": "./src", "sourceMap": true, "inlineSources": true, "esModuleInterop": true, "strict": true }, "include": ["src/**/*.ts"] }逐个说清楚:
sourceMap: true:生成.js.map文件,这是断点能绑定的前提,没有它后面全白搭。inlineSources: true:把 TS 源码内容内联进 map 文件。这样即使发布时没带.ts源文件,调试器也能还原出源码。本地开发建议开。outDir和rootDir:明确编译输出目录和源码根目录。这两个不设,编译产物可能散落在源码旁边,outFiles就很难写准。module: commonjs:Node 环境下调试,CommonJS 兼容性最好。用 ESM 也能调,但 launch.json 配置会更绕,新手先别碰。
提示:改完 tsconfig 记得删掉旧的
dist目录重新编译,残留的旧 map 文件会干扰调试,这种玄学问题排查起来很费时间。
2.3 两条路线:ts-node 直跑 vs 编译后调试
调试 TypeScript 有两条主流路线,选哪条取决于你的项目阶段。
路线一:ts-node 直跑。不生成中间 JS 文件,ts-node 在内存里完成编译,配合--inspect直接启动调试。优点是快、干净,改完代码直接重启就行。缺点是启动稍慢(每次都要编译),且和某些依赖原生模块的场景兼容性一般。
路线二:tsc 编译后调试。先tsc生成dist目录,再对dist里的 JS 启动调试,靠 source map 映射回 TS。优点是贴近生产环境,编译产物你能亲眼看到。缺点是每次改代码要重新编译,需要配合 watch 模式。
我的习惯是:日常开发用 ts-node,联调和排查编译相关问题时切到编译后调试。下面两章分别把两条路线的配置写透。
3. 用 ts-node 在 VS Code 里跑通第一个断点
3.1 安装依赖与最小可调试项目结构
先建一个最小项目,把依赖装好。这里用到的包都是调试链路里的标准件,不引入额外框架。
mkdir ts-debug-demo && cd ts-debug-demo npm init -y npm install --save-dev typescript ts-node @types/node npx tsc --init装完后目录结构应该是这样:
ts-debug-demo/ ├── node_modules/ ├── package.json ├── tsconfig.json └── src/ └── index.tssrc/index.ts写一段带函数调用和循环的代码,方便验证断点、单步和变量查看:
interface User { id: number; name: string; score: number; } function calcTotal(users: User[]): number { let total = 0; for (const user of users) { total += user.score; // 在这行打断点 } return total; } const users: User[] = [ { id: 1, name: "A", score: 80 }, { id: 2, name: "B", score: 92 }, { id: 3, name: "C", score: 75 }, ]; const result = calcTotal(users); console.log("total:", result);这段代码故意用了接口、数组遍历和累加,调试时你能在「变量」面板里看到user对象展开后的字段,也能观察total每轮循环的变化。比console.log('hello')更能验证调试器是否真的在工作。
3.2 launch.json 的 ts-node 配置逐字段拆解
在项目根目录建.vscode/launch.json,写入下面这份配置:
{ "version": "0.2.0", "configurations": [ { "name": "Debug TS with ts-node", "type": "node", "request": "launch", "runtimeArgs": ["-r", "ts-node/register"], "args": ["${workspaceFolder}/src/index.ts"], "cwd": "${workspaceFolder}", "protocol": "inspector", "sourceMaps": true, "skipFiles": ["<node_internals>/**"], "resolveSourceMapLocations": [ "${workspaceFolder}/**", "!**/node_modules/**" ] } ] }每个字段的作用和踩坑点:
type: "node":走 Node 调试适配器。VS Code 新版本内置了 js-debug,不需要额外装扩展。runtimeArgs: ["-r", "ts-node/register"]:在 Node 启动前预加载 ts-node 的注册钩子,这样require一个.ts文件时会被自动编译。这是 ts-node 调试的核心。args:传给脚本的参数,这里指向入口 TS 文件。注意路径要用${workspaceFolder}拼,写相对路径在不同工作区打开方式下会翻车。sourceMaps: true:告诉调试器启用 source map 映射。虽然默认值通常是 true,但显式写出来避免被其他配置覆盖。skipFiles:把 Node 内部模块排除在单步范围外。不加这个,你按 F11 会一头扎进node:internal里出不来。resolveSourceMapLocations:限制 source map 的查找范围,排除node_modules。不加的话,调试器可能去解析依赖包里的 map,拖慢启动甚至报错。
配好后,在total += user.score;那行左侧点一下打红点,按 F5 启动。如果断点变实心红点并停住,说明链路通了。
3.3 断点类型与调试面板的实战用法
断点不只是红点一种。在行号左侧右键,能看到几个实用选项:
| 断点类型 | 适用场景 | 设置方式 |
|---|---|---|
| 普通断点 | 常规暂停 | 左键点行号左侧 |
| 条件断点 | 只在满足条件时停 | 右键 → 条件断点,填表达式 |
| 命中计数断点 | 第 N 次命中才停 | 右键 → 命中计数 |
| 日志断点 | 不暂停,只打日志 | 右键 → 日志消息,用{}插值 |
条件断点在循环里特别有用。比如上面那段代码,你只想在user.id === 2时停下来,就右键断点选「条件断点」,输入user.id === 2。这样循环前两轮不会打断你,直接停在目标数据上。
日志断点是我用得最多的。它不暂停程序,只在调试控制台输出一条消息,相当于一个不用改代码、不用重新编译的console.log。消息里用{user.name}这种花括号语法插入变量值。排查那些「加日志要重启、重启又复现不了」的问题时,这个功能能省很多时间。
调试面板左侧的「变量」区可以展开对象,「监视」区可以手动输入表达式实时求值,「调用堆栈」区能看清函数调用链。这几个面板配合断点用,基本能替代大部分console.log。
4. 编译后调试:tsc + source map 的完整配置
4.1 先编译再调试的目录约定
ts-node 适合快速迭代,但当项目变大、或者你要验证「编译产物到底长什么样」时,就得走编译后调试这条路。先把 tsconfig 里的输出目录约定好,前面 2.2 节已经设了outDir: "./dist"和rootDir: "./src",编译后结构是:
ts-debug-demo/ ├── dist/ │ ├── index.js │ └── index.js.map ├── src/ │ └── index.ts └── tsconfig.json执行一次编译:
npx tsc编译完打开dist/index.js,你会看到文件末尾有一行//# sourceMappingURL=index.js.map,这就是调试器找到映射关系的入口。如果这行缺失,断点必然绑不上。
4.2 launch.json 的 outFiles 与 preLaunchTask 配置
编译后调试的 launch.json 和 ts-node 那份差别不小:
{ "version": "0.2.0", "configurations": [ { "name": "Debug TS (compiled)", "type": "node", "request": "launch", "program": "${workspaceFolder}/dist/index.js", "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "tsc: build", "sourceMaps": true, "skipFiles": ["<node_internals>/**"] } ] }关键字段解释:
program:指向编译后的 JS 入口,不是 TS 文件。这是和 ts-node 路线最大的区别。outFiles:告诉调试器去哪里找.js.map文件。这个字段写错是断点变灰的头号原因。用 glob 模式dist/**/*.js覆盖所有产物,别只写单个文件。preLaunchTask:启动调试前自动执行的任务,这里绑定一个编译任务,保证每次调试前代码是最新的。省去手动tsc的步骤。
preLaunchTask引用的任务要在.vscode/tasks.json里定义:
{ "version": "2.0.0", "tasks": [ { "label": "tsc: build", "type": "shell", "command": "npx tsc", "group": "build", "problemMatcher": ["$tsc"] } ] }problemMatcher: ["$tsc"]会把编译错误解析到 VS Code 的「问题」面板,编译失败时调试不会启动,避免你对着旧产物调试半天。
4.3 验证 source map 是否生效的三个检查点
配完之后如果断点还是灰的,按这个顺序查:
检查点一:map 文件是否存在且内容合理。打开dist/index.js.map,它应该是一段 JSON,里面有version、sources、mappings字段。sources里应该指向../src/index.ts。如果sources是空的或者指向奇怪路径,说明rootDir配错了。
检查点二:outFiles 的 glob 能否匹配到文件。在 VS Code 里按 Ctrl+Shift+P,运行「Debug: Diagnose Breakpoint Problems」,它会列出调试器实际加载了哪些脚本、哪些断点没绑定。这个诊断命令是排查断点问题的后悔药,比瞎猜快得多。
检查点三:调试控制台有没有报 source map 解析错误。启动调试后看「调试控制台」,如果出现Could not read source map之类的警告,基本就是路径问题。常见的是outFiles写成了绝对路径但实际是相对路径,或者dist目录被.gitignore影响导致调试器忽略。
注意:如果你用了
webpack、esbuild这类打包工具,source map 的生成方式不同,outFiles要指向打包输出目录,且要确认打包配置里devtool开了source-map而不是eval。eval类型的 map 在调试器里表现很怪,断点位置会偏移。
5. 调试 TypeScript 时最容易翻车的五个坑
5.1 断点灰色:从现象到根因的排查顺序
现象:断点是灰色空心圈,程序正常运行但不停。
原因:按概率排序,一是sourceMap没开或没重新编译;二是outFiles路径不对,调试器找不到 map;三是运行时加载的 JS 和编辑器里的 TS 不是同一份(比如改了代码没重新编译);四是工作区打开了子目录,${workspaceFolder}解析出的路径和实际不符。
解决:先跑「Debug: Diagnose Breakpoint Problems」看诊断输出,它会明确告诉你断点为什么没绑定。然后确认dist目录是新的,最后检查outFiles的 glob 能不能匹配到实际文件。这三步能解决九成以上的灰断点。
5.2 ts-node 启动报 Unknown file extension ".ts"
现象:按 F5 后调试控制台报TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"。
原因:Node 版本较新时默认按 ESM 处理,而 ts-node 的register钩子走的是 CommonJS 路径,两者冲突。或者package.json里设了"type": "module"。
解决:确认tsconfig.json里module是commonjs,package.json里没有"type": "module"。如果项目必须用 ESM,改用ts-node/esm加载器,并把 launch.json 的runtimeArgs换成["--loader", "ts-node/esm"]。但 ESM 调试的坑更多,非必要不折腾。
5.3 单步调试跳进 node_modules 出不来
现象:按 F11 单步进入函数,结果跳进了依赖包的源码或者 Node 内部模块,堆栈越来越深。
原因:skipFiles没配,或者配了但没覆盖到实际路径。
解决:在 launch.json 里加"skipFiles": ["<node_internals>/**", "**/node_modules/**"]。<node_internals>是 Node 内部模块的占位符,**/node_modules/**覆盖所有依赖。配好后 F11 只会进入你自己的代码。如果只想跳过部分依赖,可以精确写包名路径。
5.4 条件断点表达式不生效或报错
现象:设了条件断点,但要么每次都停,要么直接报表达式错误。
原因:条件表达式在当前作用域里访问了不存在的变量,或者用了调试器不支持的语法。
解决:条件断点里的表达式是在当前暂停作用域求值的,只能访问那一刻可见的变量。比如在循环外设的条件引用了循环内的变量,就会报错。另外,条件断点对性能有影响,循环次数很大时每次迭代都要求值,会明显拖慢程序。数据量大时改用命中计数断点更合适。
5.5 修改代码后断点位置偏移
现象:改了 TS 代码,重新调试时断点停在了错误的行,或者停在了空行上。
原因:编译产物没更新,source map 还是旧的,映射关系对不上。
解决:走编译后调试路线时,务必配preLaunchTask自动编译,或者开tsc --watch让编译跟着改。手动编译容易忘,忘了就是这种玄学偏移。ts-node 路线一般没这个问题,因为每次启动都重新编译,但如果你用了--transpile-only缓存,也可能出现旧映射,清掉缓存目录即可。
6. 把调试配置沉淀成可复用的工作区模板
调试配置调通一次之后,别让它散落在各个项目里。我的习惯是在用户级 snippets 或者一个模板仓库里存一份 baseline,新项目直接拷.vscode目录,改两个路径就能用。下面这份是我常用的双配置模板,同时保留 ts-node 快速调试和编译后调试两条路线,用「配置名称」区分,F5 时下拉选择即可。
{ "version": "0.2.0", "configurations": [ { "name": "TS: ts-node (fast)", "type": "node", "request": "launch", "runtimeArgs": ["-r", "ts-node/register"], "args": ["${workspaceFolder}/src/index.ts"], "cwd": "${workspaceFolder}", "sourceMaps": true, "skipFiles": ["<node_internals>/**", "**/node_modules/**"] }, { "name": "TS: compiled (prod-like)", "type": "node", "request": "launch", "program": "${workspaceFolder}/dist/index.js", "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "tsc: build", "sourceMaps": true, "skipFiles": ["<node_internals>/**", "**/node_modules/**"] } ] }这份模板里有两个细节值得说。第一,两条配置的skipFiles都写全了,避免单步时误入依赖。第二,编译后调试那条绑了preLaunchTask,保证每次启动前都是最新产物。ts-node 那条不绑任务,因为它自己会编译。
再进一步,可以把常用的条件断点表达式、日志断点消息也整理成一份速查。比如日志断点里user.id={user.id}, score={user.score}这种写法,比临时加console.log再删掉高效得多。调试器用熟了之后,你会发现很多原本要靠打日志排查的问题,直接在断点上就能看清数据流。
最后说个我自己的教训:早期我总觉得调试配置是「一次性」的,每个项目重新配一遍,结果每次都在outFiles和sourceMap上重复踩坑。后来把这份模板固定下来,新项目五分钟就能进入可调试状态,省下的时间远比维护模板多。调试这件事,配置一次到位,后面就是纯收益。希望帮到你。
本文还有配套的精品资源,点击获取