零、从一次 Ctrl+S 说起
下午三点,你改完一个按钮的圆角,按下 Ctrl+S。
手指离开键盘,端起水杯喝了一口,抬眼看一下屏幕------右下角的编译进度条还在转。再等两秒,页面才慢慢刷新出来。
一天里你按了大概 200 次 Ctrl+S,每次等 3 秒,就是 10 分钟纯等待。如果项目再大一点、CI 再跑一次全量构建,这个数字会变成 20 分钟、40 分钟。
于是你做了一件几乎所有前端都做过的事:把 babel-loader 换成 swc-loader。
然后你发现:dev 编译从 3 秒变成 0.4 秒,生产构建从 90 秒变成 20 秒。快得有点不讲道理。
但紧接着又冒出三个问题:
- 它凭什么这么快? 只是"Rust 比 JS 快"这么简单吗?
- 为什么有的人换了只快 2 倍,有的人快 6 倍?
- 换完之后哪些坑会等着我?(类型检查、tree-shaking、热更新、装饰器)
这篇文章把这三件事一次讲透。你可以把它当成"Babel → SWC 迁移手册",也可以只当成一篇编译原理科普------两件事其实是同一件事。
一、结论先行(赶时间看这里)
Babel 是一个用 JavaScript 写的、插件驱动的"可编程转译框架";SWC 是一个用 Rust 写的、能力内置的"高性能编译器"。
Babel 优先"能改什么",SWC 优先"多快能改完"。
你感受到的"快",不是某一处快了 20 倍,而是三件事叠加:单节点成本降一个数量级 + 一次遍历做多种转换 + webpack 终于能用满所有 CPU 核心。
| 你关心的 | 一句话答案 |
|---|---|
| 为什么快 | Rust 原生执行 + 内置转换 + 异步原生调用让 webpack 真正并发 |
| 为什么大项目差距更大 | 成本随文件数线性涨,而并行度从 1 变成 N,是乘法关系 |
| 会不会变快 20 倍 | 纯转译步骤可能;整个 build 通常 2~6 倍,瓶颈会转移 |
| 能不能无脑换 | 不能,类型检查、tree-shaking、React Refresh、装饰器 是四个必查点 |
二、先分清三个角色,90% 的困惑都出在这
新手最容易犯的错,是把"编译器""loader""打包器"混成一个东西,然后配置看不懂、报错也看不懂。先分层:
bash
┌─────────────────────────────────────────────────────────┐
│ ① 打包器 Bundler webpack / rspack / vite / turbopack │
│ 管:模块图、依赖解析、代码分割、资源处理、最终产物 │
├─────────────────────────────────────────────────────────┤
│ ② Loader 适配层 babel-loader / swc-loader │
│ 管:把文件内容喂给编译器,再把结果交回打包器 │
├─────────────────────────────────────────────────────────┤
│ ③ 编译器内核 @babel/core / @swc/core │
│ 管:parse → transform → generate(真正干活的车间) │
└─────────────────────────────────────────────────────────┘
对照表:
| 角色 | Babel 系 | SWC 系 |
|---|---|---|
| 编译器内核 | @babel/core(JS 实现) |
@swc/core(Rust 实现,编译成 Node 原生模块) |
| webpack 适配器 | babel-loader |
swc-loader |
| 配置载体 | babel.config.js / .babelrc / loader options |
.swcrc / loader options |
| 命令行 | @babel/cli |
@swc/cli |
一个必须澄清的命名陷阱:
⚠️
core-js和上面两个xxx/core完全不是一类东西。@babel/core/@swc/core是编译器 ;core-js是polyfill 库 (补Promise、Array.prototype.at这些运行时 API)。编译器只是"按需帮你把 core-js 的 import 注进去",它自己不提供任何能力。
还有一个底层区分,建议直接刻进脑子:
| 谁负责 | 例子 | |
|---|---|---|
| 语法转换 | Babel / SWC | () => {}、class、<div />、type X = {} 这类写法要降级 |
| API polyfill | core-js | Promise、Map、Array.at 这类运行时能力要补齐 |
这两件事永远是分开配置的。换编译器,不改变 polyfill 策略;换 polyfill 策略,也不影响编译器。
三、Babel 是为什么被造出来的
3.1 时代痛点:新语法出来了,浏览器不认
2014 年前后,ES6(ES2015)一口气带来 let/const、箭头函数、class、模板字符串、解构、模块化、Generator......但浏览器支持度参差不齐,IE 还在服役。
当时的工程现实非常拧巴:
你想用新语法提升开发体验,但线上必须跑在旧环境里。
Babel(最初叫 6to5)就是来解决这件事的:把下一代 JavaScript 语法,编译成当前环境能跑的旧 JavaScript。
注意它的定位:源码到源码的编译器(source-to-source compiler),不是把 JS 编译成机器码。它只是"换个写法",语义不变。
3.2 设计哲学:内核极薄,能力全部外挂
Babel 做了一个非常关键的产品决策------不把自己做成一个功能固定的编译器,而是做成一个"AST 可编程平台"。
内核只干三件事:
@babel/parser:源码 → AST@babel/traverse:遍历 AST、修改节点、追踪作用域@babel/generator:AST → 代码 + sourcemap
所有具体的语法转换,全部由插件实现:
| 你以为是一个功能 | 实际上是 |
|---|---|
| 转 JSX | @babel/preset-react(本质是 JSX 转换插件的合集) |
| 转 TypeScript | @babel/preset-typescript(本质是"擦掉类型语法"的插件) |
| 兼容旧浏览器 | @babel/preset-env(本质是"读 browserslist,按需塞一堆降级插件"的调度器) |
这套设计带来的结果非常极端:
✅ 生态爆炸。 埋点注入、按需加载(babel-plugin-import)、国际化文案提取、代码审计、自研 DSL 编译......只要你能描述成"遍历 AST 然后改节点",Babel 都能干。 ✅ 新语法能抢跑。 TC39 提案还没定稿,插件作者先写出来,你就能用。
❌ 代价是:它从一开始就没把"速度"当成第一目标。
3.3 先纠正一个流传很广的错误说法
网上常见一句话:
"Babel 每个插件都要遍历一次 AST,插件越多遍历次数越多,所以慢。"
这个说法并不准确。 @babel/traverse 会把所有插件的 visitor 合并成一棵 visitor 树,一次遍历里按节点类型依次触发所有匹配的 visitor。所以"插件多 = 遍历多次"是错的。
那 Babel 到底慢在哪?真正的大头是这四个:
- AST 节点是普通 JS 对象。 每个节点都是
{ type, start, end, loc, leadingComments, ... },一个中大型项目的节点数量在百万级,内存分配 + GC 压力非常可观。 - Path / Scope 机制很重。 每处理一个节点都要包一层
NodePath,还要追踪作用域(判断某个标识符是不是绑定、要不要 rename),这部分是纯 CPU。 - 部分插件内部还会做嵌套遍历。 外层一次,插件内部再
path.traverse()几次,实际遍历次数被放大。 - 全部跑在 Node 主线程。 单线程 + 事件循环,没有真正的并行------这条最致命,下一节展开。
四、SWC 是为什么被造出来的
4.1 一句话背景
SWC 全称 Speedy Web Compiler,名字里就写着目标。作者本身是 Babel 重度用户,被大型项目里 Babel 的编译耗时折磨够了,于是用 Rust 重写了一套编译器。
官网首页的原话:
SWC is 20x faster than Babel on a single thread and 70x faster on four cores.
请留意这两个数字的差别:单线程 20 倍,四核 70 倍 。第二个数字才是重点------它说明 SWC 的优势不只是"代码写得快",而是原生多线程。
也要诚实提示:这是"纯转译器"维度的基准。真实项目里端到端构建的收益会小一些(社区普遍反馈"构建提速明显,但远到不了 20 倍"),原因见第六节。
4.2 设计哲学:把高频能力焊进内核
SWC 做了和 Babel 完全相反的选择:
- 常用转换内置 :TypeScript 剥离、JSX/TSX、ES 版本降级、装饰器、ESM↔CJS、压缩(minify) 全部在编译器内核里,不需要外部插件;
- 不追求"万物皆插件" :扩展能力有(WASM 插件体系、
jsc.experimental.plugins),但门槛高,生态远小于 Babel; - 一切以吞吐为目标:Rust + 紧凑内存布局 + 无 GC 停顿 + 多线程分片。
4.3 关键机制:为什么它真的能吃满 CPU
这是全文最值钱的一段,建议慢读。SWC 快,是三层叠加的结果,不是单点。
第一层:单次遍历,批量转换
css
Babel 的处理链(多个阶段)
源码 → parse → [TS 擦除阶段] → [JSX 阶段] → [语法降级阶段] → generate → 产物
SWC 的处理链(一次搞定)
源码 → parse → [一次遍历:擦除类型 + 转 JSX + 语法降级] → generate → 产物
类比:Babel 像是零件在几个工位之间来回搬(每搬一次都要重新定位、重新记录状态);SWC 是一次装夹,多轴同时加工。
第二层:原生执行,没有解释和 GC 的过路费
Rust 编译成机器码,没有 JS 的解释开销、没有 GC 停顿。AST 节点用紧凑结构 + 字符串驻留,单节点处理成本比 Babel 低一到两个数量级。
第三层(最关键):异步原生调用,让 webpack 真正并发
这一层几乎没人讲,但它才是"70 倍"在工程上成立的原因。
对比两个 loader 的返回方式:
js
// babel-loader(本质):同步返回字符串
module.exports = function (source) {
const result = babel.transformSync(source, options);
return result.code; // ← 同步,函数返回即完成
};
// swc-loader(本质):返回 Promise,计算在 Rust 线程里
module.exports = function (source) {
return swc.transform(source, options).then(r => r.code); // ← 异步
};
webpack 的模块构建阶段本来可以 并发处理多个模块,但遇到同步 loader 就退化成串行:一个模块没编译完,下一个模块不会开始。
于是:
| Babel(同步 loader) | SWC(异步 loader) | |
|---|---|---|
| 实际有效并行度 | ≈ 1 个核 | ≈ CPU 核数 |
| 8 核机器上的理论收益 | 1x | 接近 8x(受 IO/其他环节限制) |
所以这不是"Rust 快 20 倍",而是"Rust 快 20 倍 × 8 个核一起干活"。
顺手解释一个常见疑问:
"那我给 babel-loader 加
thread-loader,不也能并行?"
实践过的人都知道效果一般,原因很实在:
- worker 之间要序列化传输源码和结果,通信成本不低;
- 每个 worker 都要重新加载一遍 babel 配置、preset、插件;
- 内存占用暴涨,CI 机器上经常直接 OOM。

五、为什么"项目越大,差距越夸张"
不要靠感觉,给一个成本模型:
scss
编译总耗时 ≈ ( 文件数 × 单文件平均 AST 节点数 × 单节点处理成本 ) / 有效并行度 + 固定开销
四项里,只有"固定开销"不随项目规模变化。逐项看:
| 因子 | Babel | SWC | 大项目下的效果 |
|---|---|---|---|
| 单节点处理成本 | 高(JS 对象 + Path/Scope + GC) | 低(原生 + 紧凑结构) | 线性放大差距 |
| 有效并行度 | ≈ 1(同步 loader 阻塞) | ≈ CPU 核数 | 乘法放大差距 |
| 文件数 | 增长 | 增长 | 差值的绝对值线性增长 |
| 固定开销(进程启动、配置加载、模块图) | 要付 | 要付 | 被稀释,占比下降 |
所以你会看到非常典型的曲线:
- 30 个文件的小 demo:Babel 3 秒,SWC 1 秒 ------ 你根本不在乎;
- 1500 个
.tsx的中大型项目:Babel 90 秒,SWC 12 秒 ------ 这不是优化,这是换了个工作方式。
小项目感觉不出差距,不是差距不存在,而是分母太小。
还有两个容易被忽略的放大器:
放大器 A:HMR 的边际收益。 dev 阶段每次保存只重编译变更文件,所以单文件转译速度 直接决定你按下 Ctrl+S 之后要等多久。SWC 单文件是毫秒级,Babel 是几十到几百毫秒------手感差别就是"丝滑"和"卡一下"。
放大器 B:CI 的排队效应。 本地 8 核还能靠 thread-loader 硬撑;CI 机器通常 2~4 核、内存还紧,Babel 方案经常直接退化回单线程。这就是很多团队"本地还行,CI 一跑 8 分钟"的真实原因。
六、重要认知:transform 快了 ≠ build 快了
这是迁移后最容易被"期望落空"击中的地方,也是我这篇文章最想讲清楚的一点。
一次 webpack --mode=production 的时间大致由这些环节构成(比例因项目差异极大,下面是量级示意):
| 环节 | 大致占比 | 换 SWC 有收益吗 |
|---|---|---|
| transpile(Babel/SWC 干活) | 大项目 30%~50% | 收益最大 |
| webpack 自身解析模块、找依赖(内部 acorn) | 10%~20% | 不变 |
| Terser 压缩(生产) | 15%~30% | 可换 swcMinify |
| 模块图 / chunk 生成 / 代码生成 | 5%~15% | 不变 |
| CSS、图片、字体等资源处理 | 看项目 | 不变 |
其他插件(fork-ts-checker、copy-webpack-plugin...) |
看项目 | 不变 |
所以真实的收益链路是这样的:
erlang
第一步:Babel 90s → SWC 20s (transpile 阶段,4~5 倍)
第二步:+ swcMinify 20s → 14s (压缩阶段,再省 30%)
第三步:+ filesystem cache 热启动 4s (日常体感的关键)
第一步给你 4~6 倍,第二步给你 30%,第三步才决定你日常开发爽不爽。
如果你换完发现只快了 2 倍,别怀疑 SWC------大概率是瓶颈已经转移了。用 webpack --profile --json > stats.json 配合可视化工具看时间到底花在哪,别盲猜。
想再进一步,就得动 webpack 本身了:Rspack / Turbopack 这类 Rust 打包器,把"模块解析 + 依赖分析 + 代码生成"也一起换掉。那是另一个量级的话题。
七、同一段 TSX 的两种旅程(看清楚差异在哪)
光看原理容易虚,我们拿一个最小组件走两遍。
7.1 输入
tsx
// src/Card.tsx
type Props = { title: string };
export const Card = ({ title }: Props) => (
<div className="card">{title}</div>
);
7.2 Babel 的输出(preset-typescript + preset-react automatic runtime)
js
import { jsx as _jsx } from "react/jsx-runtime";
export const Card = ({ title }) => /*#__PURE__*/_jsx("div", {
className: "card",
children: title
});
7.3 SWC 的输出(syntax: typescript, tsx: true + runtime: automatic)
js
import { jsx as _jsx } from "react/jsx-runtime";
export const Card = ({ title })=>/*#__PURE__*/ _jsx("div", {
className: "card",
children: title
});
几乎一模一样。 这个对比本身就是迁移风险的最好答案:
两者输出的语义高度一致 ,差别在"谁算得快",不在"算得对不对"。 所以迁移的真实风险集中在边角语法 (装饰器、
const enum、特殊提案)和配置项,而不是日常业务代码。
7.4 唯一明显不同:helper 的来源
async/await 这类需要降级的语法,两边都会注入 helper 函数。差别在于helper 放哪:
js
// Babel + @babel/plugin-transform-runtime:从包中引入
import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator";
// SWC + externalHelpers: true:从 @swc/helpers 引入
import { _ as _async_to_generator } from "@swc/helpers/_/_async_to_generator";
这就是为什么 @swc/helpers 必须是生产依赖 (不是 devDependency),否则 CI 的 --prod 安装会让构建直接失败。这也是迁移里最常见的"本地好好的,CI 挂了"之一。
八、Webpack 配置对照(可直接复制)
8.1 原来的 Babel 版本
bash
pnpm add -D @babel/core babel-loader @babel/preset-env \
@babel/preset-react @babel/preset-typescript core-js
js
// webpack.config.js(节选)
module.exports = {
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true, // 缓解,不解决
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: 3,
targets: '> 0.5%, last 2 versions, not dead',
}],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
],
plugins: [
// 埋点、按需加载、i18n......一堆业务插件
],
},
},
},
],
},
};
8.2 换成 SWC
bash
pnpm add -D swc-loader @swc/core core-js
pnpm add @swc/helpers # 生产依赖!externalHelpers 需要
js
// webpack.config.js(节选)
const path = require('node:path');
module.exports = (env, argv) => {
const isDev = argv.mode === 'development';
return {
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'swc-loader',
options: {
// 推荐把配置放独立文件,dev / prod 分开维护
configFile: path.resolve(__dirname, isDev ? '.swcrc' : '.swcrc.prod'),
// 保持 false(异步):这是并行能力的来源
sync: false,
},
},
},
],
},
plugins: [
...(isDev ? [new (require('@pmmmwh/react-refresh-webpack-plugin'))()] : []),
],
cache: {
type: 'filesystem',
buildDependencies: { config: [__filename] },
},
};
};
8.3 dev 配置:.swcrc
json
{
"$schema": "https://swc.rs/schema.json",
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": false,
"dynamicImport": true
},
"transform": {
"react": {
"runtime": "automatic",
"development": true,
"refresh": true
}
},
"target": "es2020",
"externalHelpers": true,
"loose": false
},
"module": { "type": "es6" },
"sourceMaps": true,
"minify": false
}
8.4 prod 配置:.swcrc.prod
json
{
"$schema": "https://swc.rs/schema.json",
"jsc": {
"parser": { "syntax": "typescript", "tsx": true },
"transform": {
"react": { "runtime": "automatic", "development": false, "refresh": false }
},
"externalHelpers": true,
"loose": false
},
"env": {
"mode": "usage",
"coreJs": "3.37",
"targets": "> 0.5%, last 2 versions, not dead"
},
"module": { "type": "es6" },
"sourceMaps": true,
"minify": false
}
四个必须解释清楚的点:
| 配置 | 为什么这么写 |
|---|---|
jsc.target 与 env.targets 二选一 |
同时写会冲突。dev 不需要 polyfill(开发机是新浏览器),直接 jsc.target: "es2020" 最省时间;prod 才用 env.targets + mode:"usage" 按需注入 core-js |
module.type: "es6" 必须保住 ESM |
手滑写成 "commonjs",webpack 拿到的就是已转成 CJS 的代码,tree-shaking 当场失效,包体积会莫名其妙变大 |
externalHelpers: true |
helper 从 @swc/helpers import,而不是每个文件内联一份。所以 @swc/helpers 必须是生产依赖 |
minify: false |
loader 阶段不压缩,压缩交给 webpack 的 optimization.minimizer;否则 sourcemap 会错乱,构建反而更慢 |
8.5 生产压缩也一起换掉(顺手拿 30%)
js
const TerserPlugin = require('terser-webpack-plugin');
// webpack.config.js
optimization: {
minimizer: [
new TerserPlugin({
minify: TerserPlugin.swcMinify, // 复用已安装的 @swc/core
parallel: true,
}),
],
},
terser-webpack-plugin 自带 esbuildMinify 和 swcMinify 两个开关,装了 @swc/core 就能直接用,不需要额外引入压缩插件。
8.6 别忘了 webpack 5 自己的缓存
很多人换完 loader 觉得"没那么神",是因为没开持久化缓存------它比换编译器更能改善日常体验:
js
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: { config: [__filename] },
},
experiments: {
cacheUnaffected: true, // 模块级细粒度缓存,大项目收益明显
},
};
九、混合方案:当你有离不开的 Babel 插件
现实里很少有项目能做到"纯 SWC"。埋点注入、babel-plugin-import、自研 AST 改写这些,SWC 生态里往往没有等价物。这时用串联 loader:
js
use: [
{
loader: 'babel-loader',
options: {
// 只留那两三个无论如何都换不掉的插件
plugins: ['你的埋点插件'],
// 注意:这里不要再放 preset-env / preset-react,重活已经交给 swc
},
},
{ loader: 'swc-loader', options: { configFile: '.swcrc' } },
],
⚠️ loader 数组是「从后往前」执行 :swc-loader 先跑(负责 TS/JSX/降级),babel-loader 后跑(只做那点自定义改写)。写反了会直接报语法错误。
这个方案会损失一部分速度(大概是纯 SWC 耗时的 1.5~2 倍),但换来的是"能上线"。中大型公司迁移基本都走这条路,而不是一步到位。
十、迁移必踩的 10 个坑(每坑带正确写法)
坑 1:以为 SWC 会做类型检查------不会
Babel 和 SWC 都只是擦掉类型语法,不校验任何类型。类型检查必须单独拉出来:
js
// webpack.config.js
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
plugins: [
new ForkTsCheckerWebpackPlugin({
async: isDev, // dev 不阻塞编译
typescript: { mode: 'write-references' },
}),
],
或者在 CI 里单独跑 tsc --noEmit:
bash
pnpm tsc --noEmit
这是最高频事故:换完之后"类型错误没人报了",上线才发现。
坑 2:React Fast Refresh 只配了一半
.swcrc 里的 refresh: true 只负责生成组件注册代码 ;HMR runtime 的注入仍然靠 @pmmmwh/react-refresh-webpack-plugin。两个都要开,且只在 development 开:
js
plugins: [
...(isDev ? [new ReactRefreshWebpackPlugin()] : []),
],
(swc-loader 从 0.1.13 起就与这个插件兼容,不需要额外找 babel 版插件。)
坑 3:装饰器行为差异
老式(legacy)装饰器要显式打开:
json
{
"jsc": {
"parser": { "syntax": "typescript", "tsx": true, "decorators": true },
"transform": { "legacyDecorator": true, "decoratorMetadata": true }
}
}
decoratorMetadata: true 是 NestJS / TypeORM 这类依赖 design:type 元数据的框架必须的。装饰器这块 Babel 和 SWC 的边角行为不完全一致,有装饰器的大项目务必回归测试。
坑 4:tree-shaking 突然失效
90% 是 module.type 写成了 commonjs,或者 jsc.target 设得太低导致 ESM 被降级。检查产物里有没有这一行:
js
Object.defineProperty(exports, "__esModule", { value: true });
有,就说明 ESM 已经被转成 CJS 了,webpack 的 tree-shaking 直接失效。
坑 5:env.mode: "usage" 没装 core-js,或版本写错
json
"env": { "mode": "usage", "coreJs": "3.37" }
coreJs 要写实际安装的版本号,不是 3。装了 core-js@2 的话行为完全不对。
坑 6:自定义 Babel 插件直接失效
babel-plugin-import、自研埋点插件、babel-plugin-transform-remove-console 等,SWC 没有对应实现。要么用第九节的混合方案,要么找社区已有的 SWC 插件(如 @swc/plugin-styled-components)。
坑 7:忘了 @swc/helpers 是运行时依赖
externalHelpers: true 时它是生产依赖 ,放 devDependencies 会在 CI 的 --prod 安装里炸掉。
坑 8:.swcrc 和 loader options 冲突
优先级:loader options > .swcrc 。而 loader 里如果再写 configFile: false,.swcrc 会被完全忽略。配置"飘了"就先查这三者的优先级。
坑 9:browserslist 读到了不该读的地方
env.targets 会读取 browserslist 配置。如果项目里同时有 package.json 的 browserslist 和 .browserslistrc,结果可能和你以为的不一样------先打印确认:
bash
npx browserslist
坑 10:少数第三方包编译不通过
一些 npm 包依赖特定的 Babel 转换产物。给它们单独开一条 rule,仍然走 babel-loader,与主体代码分流处理,问题最小:
js
{
test: /\.(js|jsx)$/,
include: /node_modules\/(某个包)/,
use: { loader: 'babel-loader', options: { /* 最小配置 */ } },
},
十一、选型决策:到底该不该换
| 场景 | 推荐 | 理由 |
|---|---|---|
| 全新项目 + React/Vue/TS | 纯 SWC | 没有历史包袱,直接吃最大收益 |
| 中大型项目,有 1~2 个离不开的 Babel 插件 | SWC + 少量 Babel 串联 | 速度与兼容性的最优解 |
| 有大量自研 Babel 插件(埋点/DSL/国际化/审计) | 暂不迁移,或先只迁 prod | 插件重写成本远大于编译收益 |
| 已上了 Rspack | 不用装 swc-loader,用 builtin:swc-loader |
Rspack 内置 SWC |
| 只想让 Jest 变快 | @swc/jest |
收益明显、风险最低,建议第一个试点 |
| 瓶颈不在 transpile | 先 profiling,别换 | 换了也白换 |
| 对产物行为零容忍(金融/政企强审计场景) | 先做产物 diff 再换 | 成本可控但需要流程保障 |
我建议的落地顺序(也是我自己的落地顺序):
- 先上
@swc/jest------ 收益快、风险低,先给团队建立信心; - 再把 prod 构建 换成 SWC,跑一次产物 diff(体积、polyfill、sourcemap);
- 最后换 dev(这里才需要处理 React Refresh,也最容易背"热更新不生效"的锅);
- 全部稳定后,再把 Terser 换成
swcMinify。
十二、换完编译器之后,还有这 10 件事
编译器只是一环。想让构建真正快,按收益排序:
cache: { type: 'filesystem' }+experiments.cacheUnaffected------ 收益最大、成本最低;- 缩小
include范围 :用绝对路径include: path.resolve(__dirname, 'src'),别用正则全盘扫; - 精简
resolve.extensions,并按命中频率排序(.tsx放前面); - 少用深层正则 alias ,
resolve每次都要跑; - 类型检查异步化 :
ForkTsCheckerWebpackPlugin({ async: true }); devtool降级 :dev 用eval-cheap-module-source-map,别用source-map;- 生产压缩换
swcMinify/esbuildMinify; - 调整
splitChunks,别让一个 vendor chunk 反复失效导致整包重编; - 清理不再需要的 loader/plugin(很多项目 30% 的时间花在没人用的老插件上);
- 终极方案:Rspack 。打包器本体也换成 Rust,
builtin:swc-loader内置,配置兼容度已经很高。
十三、生态趋势:这不是工具替换,是代际更替
把时间线拉长看会更清楚:
yaml
2014 Babel JS 实现,插件平台,解决 ES6 兼容
2019 SWC Rust 实现,解决 Babel 慢
2020 esbuild Go 实现,解决依赖预构建与极限速度
2022 @swc/jest 测试场景原生化
2023 Rspack Rust 打包器,替换 webpack 本体
2023 Turbopack Rust,Next.js 官方
2024+ oxc / Rolldown Rust,面向 lint + 打包的一体化工具链
方向非常明确:前端工具链正在从"JS 写的 JS 工具",迁移到"原生语言写的、多线程的、一体化的工具"。
而 Babel 不会消失------它正在从"日常编译器"退化为**"定制化 AST 处理平台"**。这个生态位没人能替代,就像现在没人用 sed 做代码重构,但 sed 也没消失。
所以正确的认知不是"SWC 干掉 Babel",而是:
标准转译交给 SWC,特殊 AST 改写留给 Babel。
十四、附录:花 5 分钟自己测一遍
网上那些"快 20 倍"的数字,和你的项目没有关系。想看真实差距,跑这个脚本(拿你自己项目里的一个真实组件当样本最准):
js
// bench.cjs
// pnpm add -D @babel/core @babel/preset-typescript @babel/preset-react @swc/core
// node bench.cjs
const fs = require('node:fs');
const { performance } = require('node:perf_hooks');
const babel = require('@babel/core');
const swc = require('@swc/core');
const code = fs.readFileSync('./bench/fixture.tsx', 'utf8');
const N = Number(process.env.N || 300);
function buildBabel() {
return babel.transformSync(code, {
filename: 'fixture.tsx',
presets: [
'@babel/preset-typescript',
['@babel/preset-react', { runtime: 'automatic' }],
],
babelrc: false,
configFile: false,
}).code;
}
async function buildSwc() {
const r = await swc.transform(code, {
filename: 'fixture.tsx',
jsc: {
parser: { syntax: 'typescript', tsx: true },
transform: { react: { runtime: 'automatic' } },
target: 'es2020',
},
module: { type: 'es6' },
});
return r.code;
}
(async () => {
// 预热:第一次调用包含模块加载与配置解析,必须丢掉
buildBabel();
await buildSwc();
let t = performance.now();
for (let i = 0; i < N; i++) buildBabel();
const babelMs = performance.now() - t;
t = performance.now();
for (let i = 0; i < N; i++) await buildSwc();
const swcMs = performance.now() - t;
console.log(`样本数: ${N}`);
console.log(`Babel 总耗时: ${babelMs.toFixed(0)} ms (${(babelMs / N).toFixed(2)} ms/文件)`);
console.log(`SWC 总耗时: ${swcMs.toFixed(0)} ms (${(swcMs / N).toFixed(2)} ms/文件)`);
console.log(`单文件提速: ${(babelMs / swcMs).toFixed(1)}x`);
})();
三个提醒,让测出来的数字别自欺欺人:
- 对比要公平 :两边都别带
preset-env;带了的话targets也要一致; - 注意预热:第一次调用包含模块加载、配置解析,必须丢掉;
- 单文件数字 ≠ 项目数字 :串行循环测的是"单线程能力",真实 webpack 里 SWC 还能吃到并发红利,所以项目整体收益通常比单文件比值更夸张------这也是我建议你直接在 CI 上对比一次完整构建的原因。
十五、结语
回头看,Babel 和 SWC 的差别,本质不是"谁写得好",而是两个时代对工具的期待不同:
- Babel 的时代,前端工具的第一诉求是**"能不能覆盖足够多的语法"**,所以它长成了一个插件平台;
- SWC 的时代,项目规模上了一个量级,第一诉求变成了**"能不能在可接受的时间里编译完上千个模块"**,所以它长成了一台吞吐机器。
你感受到的"特别快",不是某个环节快了 20 倍,而是三件事叠加:
单节点成本降一个数量级 + 一次遍历做多种转换 + webpack 终于能用满所有 CPU 核心。
最后一句话送给正在做迁移的你:
先 profiling,再换工具;换完记得做产物 diff;类型检查永远要单独保住。
如果这篇对你有帮助,欢迎点赞收藏。评论区可以聊聊你们项目的编译耗时和迁移踩过的坑,我看看能不能补充进这篇。