Babel:把单个 JS/TS 文件从源码改写为目标 JS
核验日期:2026-07-07。示例版本来自
examples/build-tools/package.json:@babel/core8.0.1、@babel/cli8.0.1、@babel/preset-env8.0.2、@babel/preset-typescript8.0.1。
需要掌握的问题
| 主题 | 读者必须回答的问题 | 原理与边界 | 高频错误 | 代码或观察证据 | 来源 |
|---|---|---|---|---|---|
| 定位与固定成本 | Babel 在构建链路里负责哪一段,为什么不是打包器? | Babel 读取单个 JS/TS/JSX 文件,经过 parser、AST、traverse、generator 输出 JS 和 Source Map;它不构建浏览器 bundle、不处理 CSS 图、不做类型证明 | 期望 Babel 输出 index.html、CSS 或 async chunk | pnpm --filter @roadmap/build-tools-slice build:babel 只生成 .js 与 .map | Babel 8 release |
| AST 流水线 | parse → AST → traverse/transform → generator 每步的输入输出是什么? | @babel/parser 把源码解析成 Babel AST,visitor 在遍历中替换节点,@babel/generator 从 AST 打印代码和 map | parser 语法插件只“识别”语法,却误以为会转换语义 | 自定义 visitor 改名示例、generator Source Map 选项 | @babel/parser、@babel/traverse、@babel/generator |
| visitor、preset、plugin 顺序 | 多个插件和 preset 同时存在时谁先改 AST? | plugin 是 visitor 集合;preset 是 plugin 组合。顺序改变会改变节点形态和后续 visitor 能否命中 | 把 preset 写成“声明式无序集合”,导致插件看不到原始语法 | 插件顺序最小例子和 BABEL_SHOW_CONFIG_FOR | Babel configuration |
| 配置发现 | babel.config.*、.babelrc、package.json 在 monorepo 里何时生效? | project-wide config 从 root 生效;file-relative config 随被编译文件向上查找,并受 package 边界影响;BABEL_SHOW_CONFIG_FOR 打印最终配置 | 子包 .babelrc 未命中、CLI 从错误 cwd 启动、同一文件叠加两套配置 | BABEL_SHOW_CONFIG_FOR=... pnpm ... | Babel config files |
preset-env + Browserslist | target 改变什么,什么仍要交给打包器或运行时? | preset-env 根据目标环境映射启用 transforms,也可在 Babel 7 注入 core-js polyfill;Babel 8 把 polyfill 注入迁到单独插件 | 认为 target 会自动补 Promise、自动打包模块、自动做类型检查 | .browserslistrc、modules: false 输出对照 | @babel/preset-env |
| 四条边界 | 语法转换、helper、polyfill、类型检查有什么区别? | 语法转换改 AST;helper 抽出复用逻辑;polyfill 提供运行时 API;类型检查由 tsc 或框架工具负责 | @babel/preset-typescript 删除类型后仍有运行时类型错误 | TS 类型删除、helper/polyfill 代码片段 | Babel 8 migration |
| Source Map | 源位置如何跨 Babel 输出保留? | generator 根据节点位置、输入源码和上游 map 生成 version: 3 map;链路中任一阶段丢 map 都会影响调试 | 生产泄露源码路径、map 指向 .js 而非 .ts、上游 map 未传入 | 示例产物断言 .map.sources 包含 .ts | @babel/generator |
| 缓存与 monorepo | 为什么缓存命中会错,怎么让配置变化参与失效? | Babel 自身转换结果取决于源码、Babel 版本、插件/preset、Browserslist、env、caller 与配置 root;monorepo 要固定 config root | CI 复用旧缓存、子包 Browserslist 改了但输出未变 | 显式 --config-file、打印 config、缓存键清单 | Babel configuration |
| 迁移与重大变化 | Babel 7.29 到 Babel 8 哪些变化会影响工程? | Babel 8 ESM-only,要求 Node `^22.18.0 | >=24.11.0`,默认 target 改为 Browserslist defaults,polyfill 注入迁移,TypeScript preset 选项变化 | Node 20/旧 CommonJS 集成直接报错、默认不再输出 ES5/CJS | |
| 插件供应链与严重问题 | 为什么“编译时插件”属于代码执行信任边界? | Babel 配置和插件在开发机/CI 上以 Node 权限运行;历史 CVE-2023-45133 说明分析不可信代码时 path.evaluate() 曾可触发任意代码执行 | 在服务端对用户上传源码运行自定义 Babel 分析、安装无审查插件 | CVE 复盘、锁版本、沙箱、最小权限 CI | CVE-2023-45133 |
内容边界说明:
| 分类 | 本文落点 |
|---|---|
| 编程语言与类型系统 | 只解释 JS/TS 语法如何被解析、转换和删除类型;不教学语言语法本身,也不把删除类型当类型检查 |
| Browser APIs 与持久化能力 | 覆盖 ESM 输出、动态导入语法保留、polyfill 与 Source Map;不讨论 IndexedDB、Storage 和权限模型 |
| UI 框架与元框架 | 只在 JSX/TS 插件边界处说明入口;组件状态、渲染和水合不属于 Babel 职责 |
| 构建工具与运行时 | 完整覆盖输入、解析、转换、配置、缓存、插件、产物、调试和迁移 |
| 后端、数据与 Agent 技术 | 只覆盖构建配置/插件以开发者权限执行的供应链边界;请求生命周期、数据库一致性和 Agent 评测不适用 |
Babel 先付固定成本,再换跨环境输出
Babel 负责把一份 JavaScript 或 TypeScript 源文件转换成另一份 JavaScript,并不包办整个应用构建。它的固定成本来自三处:
- 每个文件都要 parse 成 AST,再走 visitor,再由 generator 打印。
- 插件和 preset 的顺序会改变 AST 形态,配置不是纯注释。
- 输出是否适合浏览器,还取决于后续打包器、运行时 API 和部署策略。
因此 Babel 适合放在这些位置:
| 场景 | Babel 负责 | 不负责 |
|---|---|---|
| 应用源码进入 webpack/Rollup/Vite | JSX、TypeScript、语法降级、少量语义转换 | 模块图、CSS、chunk、HTML、dev server |
| npm 包源码发布 | 输出较低语法等级、保留 ESM/CJS 策略的一部分 | 判断消费者环境、生成 package exports |
| codemod 或静态分析 | 读 AST、改 AST、打印回源码 | 运行代码、证明类型正确 |
| 浏览器兼容补齐 | 语法转换、按配置插入 polyfill 入口或引用 | 保证所有 Web API 存在、处理加载顺序 |
严重问题的最小定位方式:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
产物没有 index.html 或 CSS | Babel 不是 bundler | find examples/build-tools/dist/babel -maxdepth 2 -type f | 后面接 webpack/Vite,或使用原生 ESM 发布策略 |
浏览器仍报 Promise is not defined | 语法 target 不等于运行时 API polyfill | 搜索产物是否有 core-js/modules/es.promise | 明确引入 polyfill 策略,不把 target 当 API 保证 |
| CI 和本地输出不同 | Browserslist、env 或 config root 不一致 | BABEL_SHOW_CONFIG_FOR=src/main.ts ... | 固定 --config-file、统一 Browserslist 和 Node 版本 |
从源码到输出:parser、AST、traverse、generator
Babel 的核心流程是单文件流水线。输入是字符串,输出仍是字符串;中间状态是 AST。
一个最小插件足以说明这条链:
// rename-plugin.mjs
export default function renamePlugin() {
return {
name: 'rename-demo',
visitor: {
Identifier(path) {
if (path.node.name === 'oldName') path.node.name = 'newName';
},
},
};
}如果输入是:
const oldName = 1;
console.log(oldName);预期结果是标识符都变成 newName,但导入解析、CSS、HTML 都不会出现,因为插件只看当前 AST。
这几个边界要记牢:
| 概念 | 输入 | 输出 | 边界 |
|---|---|---|---|
parser | 源码字符串和 parser options | Babel AST | parser plugin 只允许语法被识别,不负责把语法降级 |
traverse | AST 和 visitor | 被访问或替换后的 AST | visitor 必须处理作用域、节点类型和遍历时机 |
generator | AST、格式选项、输入源码 | 代码、map、raw mappings | 空格和换行不保证原样保留;Source Map 需要显式开启 |
错误完整流程:
| 症状 | 根因 | 观察命令 | 局部修正 |
|---|---|---|---|
SyntaxError: Unexpected token | parser 没启用对应语法,或源码超出 Babel 当前支持 | 用 @babel/parser 单独 parse 该文件,缩小到最小语法 | 加对应 parser/plugin/preset,或先用上游工具处理 |
| visitor 不触发 | 前一个 plugin 已经把节点改成另一种类型 | 在 visitor 里打印 path.type,或交换 plugin 顺序 | 把依赖原始语法的 plugin 放到更早的位置 |
| 输出格式完全变了 | generator 不是格式化器,AST 不保存所有排版细节 | 比较 AST 而不是比较空格 | 如果是 codemod,开启 retainLines 或后接 formatter |
visitor、plugin、preset 顺序决定看到哪棵 AST
plugin 是一组 visitor。preset 是一组 plugin 的组合。Babel 配置看起来像 JSON,但执行结果依赖顺序。官方 Plugin Ordering 给出的规则是:top-level plugins 先于 presets 执行;plugins 按数组从前到后执行;presets 反向执行,也就是数组最后一个 preset 先运行。
// babel.config.mjs
export default {
plugins: [
'./plugins/inspect-before-transform.mjs',
'./plugins/replace-feature-flag.mjs',
],
presets: [
['@babel/preset-env', { targets: { chrome: '109' }, modules: false }],
'@babel/preset-typescript',
],
};工程上用三个规则减少误判:
- 需要看“原始语法”的 plugin 放前面,例如收集 import、JSX 或 TS 节点。
- 不要以为把
@babel/preset-typescript写在presets数组里就会早于 top-level plugin;top-level plugin 一定先跑。 - 需要看“已删除 TypeScript 类型”之后的 AST 时,把自定义 plugin 放进一个单独 preset,并通过 preset 反向顺序控制,或拆成第二次 Babel pass。
常见难点完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
| 自定义 plugin 在 TypeScript 类型节点上报错 | top-level plugin 先于 preset 执行;plugin 扫描 Identifier 时假设所有节点都是运行时 JS,没跳过 TS 类型节点 | 给 visitor 打印 path.type,或在最小输入里只保留一行 type | 在 visitor 中跳过 TSType* 或 path.isTypeScript() 命中的路径;如果必须在 strip 后运行,把 plugin 放入单独 preset/第二次 Babel pass |
| 转换后 ESM 变 CommonJS,Tree Shaking 变差 | preset-env 的 modules 被设为 commonjs 或由 caller 误判 | 搜索产物 require(、exports. | 给 bundler 链路保留 modules: false,让打包器处理 ESM |
| 插件顺序改动后 helper 重复 | 多个阶段各自注入 helper,没有统一 @babel/runtime 策略 | 搜索产物里重复 helper 名 | 应用打包可交给 bundler 去重;库构建再评估 @babel/plugin-transform-runtime |
配置发现:先知道 Babel 实际加载了什么
Babel 配置有两类心智模型:
| 配置位置 | 适用场景 | monorepo 风险 |
|---|---|---|
babel.config.json/js/cjs/mjs/cts | project-wide config,适合根配置覆盖多个包 | 从子目录执行 CLI 时 root 不一致会导致未命中 |
.babelrc.* 或 package.json#babel | file-relative config,适合包内局部配置 | package 边界、忽略规则和根配置叠加容易误判 |
CLI --config-file | 示例、CI、临时迁移 | 路径错会完全换一套配置 |
BABEL_SHOW_CONFIG_FOR | 调试最终配置 | 只观察一个目标文件,不代表全部文件 |
共享示例刻意使用显式配置:
pnpm --filter @roadmap/build-tools-slice build:babel等价脚本来自 examples/build-tools/package.json:
babel src --out-dir dist/babel --extensions .ts --config-file ./configs/babel.config.mjs --source-maps --delete-dir-on-startexamples/build-tools/configs/babel.config.mjs 的核心配置是:
export default {
presets: [
['@babel/preset-env', { modules: false, targets: { chrome: '109' } }],
'@babel/preset-typescript',
],
sourceMaps: true,
};配置难点完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
| 子包里 Babel “什么都没转” | CLI cwd 和 config root 不一致 | BABEL_SHOW_CONFIG_FOR=examples/build-tools/src/main.ts pnpm --filter @roadmap/build-tools-slice build:babel | 用显式 --config-file 或统一在仓库根运行 |
| 某个文件应用了两套 preset | project-wide config 与 file-relative config 叠加 | BABEL_SHOW_CONFIG_FOR=<file> 看 config 链 | 删除重复 .babelrc,或用 babelrc: false/overrides 收敛 |
| CI 输出与本地不同 | envName、NODE_ENV、Browserslist env 不同 | 在 CI 打印 BABEL_ENV、NODE_ENV 和最终配置 | 把环境写进脚本,而不是依赖机器默认值 |
preset-env + Browserslist:兼容组合不是打包策略
preset-env 的问题是:目标环境缺哪些语法支持?它根据目标环境映射启用 transform plugin。Browserslist 的问题是:你的用户环境组合是什么?它们一起决定“哪些语法要降级”。
{
"browserslist": [
"> 0.25%",
"not dead"
]
}如果直接在 Babel 配置里写 targets:
export default {
targets: 'defaults',
presets: [
['@babel/preset-env', { modules: false }],
],
};边界是:
| 能力 | preset-env 会做 | 不会做 |
|---|---|---|
| 语法转换 | 可选链、class fields、private fields 等按 target 降级 | 打包多个文件、生成 chunk |
| module syntax | 可按 modules 转成 CJS/AMD/SystemJS 或保留 ESM | 判断你的库应该发 ESM 还是 CJS |
| polyfill | Babel 7 可通过 useBuiltIns/corejs 注入;Babel 8 迁到 babel-plugin-polyfill-corejs3 | 自动补齐 DOM/Web API、网络权限或 CSS 特性 |
| bugfix | Babel 8 中 preset-env 会默认做 bugfix 类转换 | 证明业务代码没有语义 bug |
严重问题完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
老浏览器报 Map is not defined | 只做语法转换,没有 runtime polyfill | 搜索产物是否包含 core-js/modules/es.map | 应用入口统一引入 polyfill,或 Babel 8 使用 babel-plugin-polyfill-corejs3 |
| 打包器无法 tree shake | Babel 先把 ESM 转成 CommonJS | `rg "require\( | exports\." examples/build-tools/dist/babel` |
| 默认升级 Babel 8 后产物不再是 ES5 | Babel 8 默认 target 改为 Browserslist defaults,不再默认降到 ES5/CJS | 对比 Babel 7 和 8 输出里的 arrow/class | 明确写 targets,不要依赖默认值 |
语法转换、helper、polyfill、类型检查是四条线
这四件事经常被混成“Babel 已经处理了”。
| 线 | 示例 | 输出变化 | 负责工具 |
|---|---|---|---|
| 语法转换 | obj?.name 降级成条件访问 | 改写当前文件代码 | Babel transform plugin |
| helper | class/private field 展开需要复用函数 | 插入 helper 或从 runtime 引用 | Babel helper / @babel/runtime |
| polyfill | Promise、Map、Array.prototype.at | 注入或要求加载运行时模块 | core-js、babel-plugin-polyfill-corejs3、应用入口 |
| 类型检查 | const n: number = 'x' | Babel 只删除类型,错误不一定出现 | tsc --noEmit、框架 checker、IDE |
TypeScript preset 的边界示例:
type Tool = { name: string };
export function label(tool: Tool): string {
return tool.name.toUpperCase();
}Babel 输出预期结果是删除 type Tool 和参数类型,保留运行时代码:
export function label(tool) {
return tool.name.toUpperCase();
}它不会证明 tool 一定有 name。如果调用方传入 {},运行时仍可能失败。
Source Map:让错误回到 .ts,也可能泄露源码
Babel 的 Source Map 来自 generator。它需要三类信息:AST 节点位置、原始源码、上游 inputSourceMap。共享示例开启 sourceMaps: true,CLI 也传了 --source-maps。
Source Map 的工程取舍:
| 策略 | 适用场景 | 风险 |
|---|---|---|
| external map | 线上错误需要映射,Sentry/浏览器可加载 .map | 公开 .map 可能暴露源码和路径 |
| hidden map | 上传给错误平台,不让浏览器自动请求 | 发布流程要保证 map 和 JS hash 匹配 |
| inline map | 本地调试、fixture | 文件体积大,不适合普通生产 |
| no map | 极高保密或极简发布 | 事故定位成本上升 |
错误完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
| DevTools 只能定位到生成后的 JS | 没生成 .map 或 sourceMappingURL 丢失 | tail -n 3 dist/babel/main.js | 开启 sourceMaps,确认发布保留 .map |
| map 指向错误文件名 | sourceFilename 或多输入 generator 参数缺失 | 解析 .map.sources | 给 generator 传原始源码映射,或保留每文件一进一出 |
| 生产泄露工作区路径 | map sources 包含绝对路径 | `rg "$PWD | file:" dist/babel` |
缓存与 monorepo:缓存键必须包含“会改变输出”的东西
Babel 常被上层工具缓存,例如 babel-loader、Jest、Vite/Rollup 插件或 CI 缓存。缓存命中是否正确,取决于 key 是否覆盖真实输入。
最小缓存键应包含:
const babelCacheKey = [
process.version,
'@babel/core@8.0.1',
'@babel/preset-env@8.0.2',
'@babel/preset-typescript@8.0.1',
JSON.stringify(targets),
JSON.stringify(browserslist),
JSON.stringify(pluginAndPresetOptions),
process.env.BABEL_ENV ?? process.env.NODE_ENV ?? 'development',
].join('|');monorepo 中的懒办法是先不做自研缓存:让上层工具的官方缓存接手,并用 BABEL_SHOW_CONFIG_FOR 确认 config root。只有构建时间被 Babel 明确拖慢,再引入缓存键策略。
缓存难点完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
改 .browserslistrc 后产物不变 | 缓存键漏 Browserslist | 清缓存前后比较输出 | 把 Browserslist 文件纳入 cache dependency |
| 子包升级 preset 后仍用旧输出 | 缓存键只包含根 package 版本 | 打印实际 @babel/core 和 preset 解析路径 | key 加入 lockfile hash 或 resolved package version |
| watch 模式漏外部配置变化 | loader/plugin 未登记外部依赖 | 修改配置文件看是否触发 rebuild | 在上层 loader 中登记 config 和 Browserslist 依赖 |
共享示例:Babel 只转换 TS/JS 模块
运行命令:
pnpm --filter @roadmap/build-tools-slice build:babel输入:
examples/build-tools/src/
main.ts # 浏览器入口,包含 CSS 导入和动态 import
catalog.ts # TypeScript 类型、可删除标记、工具列表
detail.ts # 动态导入目标
side-effect.ts # 必须保留的副作用
styles.css # Babel 不处理预期结果:
examples/build-tools/dist/babel/
catalog.js
catalog.js.map
detail.js
detail.js.map
main.js
main.js.map
side-effect.js
side-effect.js.map不会出现:
index.html
assets/*.css
assets/*.js
stats.json观察命令:
find examples/build-tools/dist/babel -maxdepth 1 -type f | sort
rg "sourceMappingURL|export type|: Tool|import\\('./detail.ts'\\)" examples/build-tools/dist/babel预期输出要点:
| 观察 | 预期 |
|---|---|
| 文件列表 | 只有四个 .js 和四个 .js.map |
catalog.js | 保留 ESM export,删除 export type 和 : Tool |
main.js | CSS import 仍只是 JS 里的 import 文本,不会生成 CSS 产物 |
.map | version 为 3,sources 指回对应 .ts |
Babel 7.29 到 Babel 8:迁移不是只改版本号
Babel 官方把 8.0 定位为现代化 major。重大变化包括 ESM-only、Node 版本要求、默认 target、polyfill 注入和 TypeScript preset 选项。
| 变化 | Babel 7.29 常见状态 | Babel 8 状态 | 迁移动作 |
|---|---|---|---|
| 包格式 | 许多集成仍能 CommonJS require() | Babel 包以 native ESM 发布 | 配置改 .mjs 或 ESM;工具侧改动态 import() |
| Node 版本 | 较旧 Node 仍可能跑 Babel 7 | 要求 Node `^22.18.0 | |
| 默认 target | 未配置时更容易输出老语法兼容结果 | 默认使用 Browserslist defaults,不再默认 ES5/CJS | 显式写 targets,尤其是 legacy 浏览器 |
| polyfill | preset-env useBuiltIns/corejs、transform-runtime corejs 常见 | Babel 8 移除这些注入入口,推荐 babel-plugin-polyfill-corejs3 | 先在 Babel 7 中迁移 polyfill 插件,再升 8 |
| TypeScript preset | isTSX、allExtensions 组合常见 | Babel 8 移除 isTSX、allExtensions 和 allowDeclareFields 配置项;.tsx 场景通常可删除前两项,非标准扩展名场景迁到 ignoreExtensions | 按文件扩展名场景删除或迁移 isTSX/allExtensions;从配置删除 allowDeclareFields,跑 TS/JSX fixture |
loose/spec | preset-env 里常见 | preset-env 移除,改 top-level assumptions | 先在 Babel 7.13+ 引入 assumptions |
迁移严重问题完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
Error [ERR_REQUIRE_ESM] | Babel 8 ESM-only,被旧 CJS 工具 require | 搜索自研脚本 require('@babel/core') | 改成 ESM 配置或 await import('@babel/core') |
| Node 20 CI 失败 | Babel 8 不支持该 Node 线 | node -v | 升到 Node 24 LTS 或官方支持版本 |
| 升级后 IE/旧 WebView 白屏 | 默认 target 变现代 | 比较 Babel 输出是否保留 class/arrow | 明确 targets,不要用默认值承诺 legacy |
| polyfill 入口不再生效 | Babel 8 移除 useBuiltIns/corejs 注入选项 | Babel 报 option removed 或产物无 core-js | 使用 babel-plugin-polyfill-corejs3 并固定 core-js 版本 |
插件供应链与历史任意代码执行风险
Babel plugin 和 config 是构建时 JavaScript。安装插件等同于允许它在开发机和 CI 上读文件、读环境变量、发网络请求、改输出。
CVE-2023-45133 的教训不是“普通转译都会 RCE”,而是:如果你在服务端对不可信源码运行 Babel 分析,并调用危险的静态求值能力,编译器 API 也会进入安全边界。官方复盘说明当时 path.evaluate() 曾可被精心构造的输入触发任意代码执行,后续通过 purity 检查和限制继承方法调用修复。
最低安全要求:
| 风险 | 触发条件 | 最小防线 |
|---|---|---|
| 恶意 plugin | 安装未知 Babel plugin/preset | lockfile、来源审查、最小 CI token、升级审查 |
| 不可信源码分析 | 服务端接收用户源码并跑 Babel visitor | 沙箱进程、禁用危险 API、限制 CPU/内存/网络 |
| 环境变量泄露 | plugin 读取 process.env 并写入产物或日志 | CI 最小权限、构建密钥隔离、不在 Babel plugin 中处理秘密 |
| 缓存污染 | 共享缓存跨分支/跨环境复用 | key 包含 Node、Babel、config、Browserslist、env |
局部修正示例:不要在不可信输入上调用 path.evaluate(),除非你已经把执行环境隔离。
export default function safeVisitor() {
return {
visitor: {
Expression(path, state) {
if (state.opts.untrustedInput) return;
const result = path.evaluate();
if (result.confident) {
// 只在可信源码上使用静态求值结果。
}
},
},
};
}