SWC 凭什么比 Babel 快 ?项目优化迁移踩坑记录

零、从一次 Ctrl+S 说起

下午三点,你改完一个按钮的圆角,按下 Ctrl+S。

手指离开键盘,端起水杯喝了一口,抬眼看一下屏幕------右下角的编译进度条还在转。再等两秒,页面才慢慢刷新出来。

一天里你按了大概 200 次 Ctrl+S,每次等 3 秒,就是 10 分钟纯等待。如果项目再大一点、CI 再跑一次全量构建,这个数字会变成 20 分钟、40 分钟。

于是你做了一件几乎所有前端都做过的事:把 babel-loader 换成 swc-loader。

然后你发现:dev 编译从 3 秒变成 0.4 秒,生产构建从 90 秒变成 20 秒。快得有点不讲道理。

但紧接着又冒出三个问题:

  1. 它凭什么这么快? 只是"Rust 比 JS 快"这么简单吗?
  2. 为什么有的人换了只快 2 倍,有的人快 6 倍?
  3. 换完之后哪些坑会等着我?(类型检查、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 可编程平台"。

内核只干三件事:

  1. @babel/parser:源码 → AST
  2. @babel/traverse:遍历 AST、修改节点、追踪作用域
  3. @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 到底慢在哪?真正的大头是这四个:

  1. AST 节点是普通 JS 对象。 每个节点都是 { type, start, end, loc, leadingComments, ... },一个中大型项目的节点数量在百万级,内存分配 + GC 压力非常可观。
  2. Path / Scope 机制很重。 每处理一个节点都要包一层 NodePath,还要追踪作用域(判断某个标识符是不是绑定、要不要 rename),这部分是纯 CPU。
  3. 部分插件内部还会做嵌套遍历。 外层一次,插件内部再 path.traverse() 几次,实际遍历次数被放大。
  4. 全部跑在 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 再换 成本可控但需要流程保障

我建议的落地顺序(也是我自己的落地顺序):

  1. 先上 @swc/jest ------ 收益快、风险低,先给团队建立信心;
  2. 再把 prod 构建 换成 SWC,跑一次产物 diff(体积、polyfill、sourcemap);
  3. 最后换 dev(这里才需要处理 React Refresh,也最容易背"热更新不生效"的锅);
  4. 全部稳定后,再把 Terser 换成 swcMinify。

十二、换完编译器之后,还有这 10 件事

编译器只是一环。想让构建真正快,按收益排序:

  1. cache: { type: 'filesystem' } + experiments.cacheUnaffected ------ 收益最大、成本最低;
  2. 缩小 include 范围 :用绝对路径 include: path.resolve(__dirname, 'src'),别用正则全盘扫;
  3. 精简 resolve.extensions ,并按命中频率排序(.tsx 放前面);
  4. 少用深层正则 alias ,resolve 每次都要跑;
  5. 类型检查异步化 :ForkTsCheckerWebpackPlugin({ async: true });
  6. devtool 降级 :dev 用 eval-cheap-module-source-map,别用 source-map;
  7. 生产压缩换 swcMinify / esbuildMinify;
  8. 调整 splitChunks,别让一个 vendor chunk 反复失效导致整包重编;
  9. 清理不再需要的 loader/plugin(很多项目 30% 的时间花在没人用的老插件上);
  10. 终极方案: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`);
})();

三个提醒,让测出来的数字别自欺欺人:

  1. 对比要公平 :两边都别带 preset-env;带了的话 targets 也要一致;
  2. 注意预热:第一次调用包含模块加载、配置解析,必须丢掉;
  3. 单文件数字 ≠ 项目数字 :串行循环测的是"单线程能力",真实 webpack 里 SWC 还能吃到并发红利,所以项目整体收益通常比单文件比值更夸张------这也是我建议你直接在 CI 上对比一次完整构建的原因。

十五、结语

回头看,Babel 和 SWC 的差别,本质不是"谁写得好",而是两个时代对工具的期待不同:

  • Babel 的时代,前端工具的第一诉求是**"能不能覆盖足够多的语法"**,所以它长成了一个插件平台;
  • SWC 的时代,项目规模上了一个量级,第一诉求变成了**"能不能在可接受的时间里编译完上千个模块"**,所以它长成了一台吞吐机器。

你感受到的"特别快",不是某个环节快了 20 倍,而是三件事叠加:

单节点成本降一个数量级 + 一次遍历做多种转换 + webpack 终于能用满所有 CPU 核心。

最后一句话送给正在做迁移的你:

先 profiling,再换工具;换完记得做产物 diff;类型检查永远要单独保住。


如果这篇对你有帮助,欢迎点赞收藏。评论区可以聊聊你们项目的编译耗时和迁移踩过的坑,我看看能不能补充进这篇。

相关推荐
鶴哥只手遮天29 分钟前
《Redis 8.10.3 深度剖析》系列 ·第 17 章. 一个迟到十六年的类型
redis·后端·开源
再吃一根胡萝卜30 分钟前
不改一行官方源码:用一个插件把 DeepSeek Harness 换成「平安one」
前端
涛涛ing44 分钟前
2026年,你选的不再是框架,是渲染模型
前端
知守观1 小时前
同一个团队两套分包:DDD 四层落地后,domain 层最大的类还是 5885 行
java·后端·领域驱动设计
AiMan1 小时前
Perplexity还是不可导出左侧所有对话,只能单条内容,方法不对吗
前端
AiMan1 小时前
千问生成的内容能否下载吗?
前端
Anymous1 小时前
我手写的 Redis 锁,输给了 10 秒的 TTL
后端·架构
北城笑笑1 小时前
Vue 109 ,从零搭建 Vue3 + Electron 工程:生态、安全桥接与打包选型( Vue + Electron )
前端·javascript·vue.js·安全·electron·vue
JarvanMo1 小时前
Flutter 3.50 & Dart 3.14发布,一起看看有哪些变化!
前端