Webpack迁到Vite第七节(二):全家桶移除 --- Babel 全家桶深度分析
本篇定位:Babel 在 Webpack 时期扛着语法转译、API polyfill、helper 去重三座大山。迁移到 Vite 后 esbuild 接手了语法转译,但 polyfill 和 helper 的问题在新架构中怎么解决?本篇逐包拆解四个 Babel 依赖移除的原因,以及 Vite 对应的替代链路。
思维导图
Babel 全家桶移除
├── @babel/core --- 核心编译器
│ └── esbuild 替代:目标浏览器原生 ES6+,无需语法降级
├── @babel/preset-env --- 按 browserslist 转译
│ └── 替代:esbuild target: modules(不降级)+ plugin-legacy(ES5 兜底)
├── @babel/plugin-transform-runtime --- helper 去重
│ └── 替代:chunk 级编译天然共享;regeneratorRuntime 残留但不修
└── babel-plugin-import --- antd 按需加载
└── 替代:统一入口导入 + Rollup tree-shaking(生产)+ Vite 自动依赖预构建(开发)
1.2 Babel 相关(4 个)------ 逐包深度分析
本册分析 Babel 全家桶 4 个包;整个第七节系列合计移除 16 个包(七(一) 构建核心 4 + 本册 4 + 七(三) Loader/Plugin 6 + 七(三) 其他 3 个中移除 2 个(core-js 保留升级),另有后续 lodash 替换)。
Babel 相关(4 个)
@babel/core --- Babel 核心
原用途:Babel 的核心编译器,负责将 ES6+ 代码解析为 AST(抽象语法树),然后应用插件转换,最后生成目标代码。
Babel 的处理流程:
输入源码(ES6+)
│
▼
① 解析(Parser): 源码 → AST
│ @babel/parser 将字符串解析为 AST 树
│
▼
② 转换(Transform): AST → AST
│ 遍历 AST 节点,应用 preset/plugin 的转换规则
│ @babel/preset-env → 转换箭头函数、const/let 等
│ @babel/plugin-transform-runtime → 复用辅助函数
│
▼
③ 生成(Generator): AST → 输出代码
│ @babel/generator 将 AST 转回字符串
│
▼
输出代码(ES5)
为什么不再需要 :Babel 在这里做的核心工作是 ES6+ 语法 → ES5 语法转译 (箭头函数→function、const/let→var、class→构造函数等),以兼容 browserslist 中可能出现的旧浏览器(> 1% 覆盖率可能包含 IE11 等不支持 ES6 的浏览器)。
Vite 时期,vite.config.js 未显式设置 build.target。Vite 7 的默认值为 'baseline-widely-available'(约 Chrome 107+/Firefox 104+/Safari 16+);且本项目因使用 @vitejs/plugin-legacy(未传 modernTargets),target 被覆盖为插件的默认现代浏览器集合 ['es2020', 'edge79', 'firefox67', 'chrome64', 'safari12']。无论哪一层,这些目标浏览器原生支持 ES6+ 语法 ------const、箭头函数、class、async/await 等都不需要转译。因此,esbuild 不需要做"同样的工作",它根本就不需要做 ES6→ES5 的语法降级。
javascript
// vite.config.js --- 没有显式设置 build.target
// Vite 7 默认 'baseline-widely-available',本项目经 plugin-legacy 覆盖为现代浏览器集合
// esbuild 不转译任何 ES6+ 语法,因为目标浏览器原生支持
这不是"esbuild 比 Babel 更快地完成了转译",而是"转译这个工作量本身被消除了"------目标浏览器升级了,不再需要照顾 ES5 兼容。ES5 兼容场景(如签署人使用旧 Android 手机)由 @vitejs/plugin-legacy 处理------让 Babel 重回构建链路生成传统产物(已实施)(详见 第七节(五):ES5 降级方案专题)。
@babel/preset-env --- 按目标环境转译
原用途 :根据目标浏览器列表(browserslist),自动确定需要转译哪些 ES6+ 语法。例如,如果目标浏览器支持箭头函数,@babel/preset-env 就不会转换箭头函数;如果不支持,就转换为普通函数。
@babel/preset-env 的工作逻辑:
javascript
// babel.config.js
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage', // 按需引入 polyfill
corejs: 3, // 使用 core-js 3
}],
// 注意:没有 targets 选项,目标浏览器由 package.json 中的 browserslist 统一管理
]
json
// package.json
{
"browserslist": [
"> 1%", // 全球使用率超过 1% 的浏览器
"last 2 versions", // 每个浏览器最近 2 个版本
"not ie <= 10" // 排除 IE10 及以下
]
}
为什么 targets 不在 babel.config.js 中?
browserslist 是前端工具链的共享配置 ------不只 Babel 在用,autoprefixer(CSS 前缀)、postcss-preset-env(CSS 特性降级)、eslint-plugin-compat(兼容性检查)等工具都会读取同一份 browserslist。把它放在 package.json 的顶层,所有工具自动共享,无需在每个工具配置中重复声明。
@babel/preset-env 在不传 targets 时,默认从以下位置按优先级查找 browserslist:
package.json的browserslist字段- 项目根目录的
.browserslistrc文件 BROWSERSLIST环境变量
browserslist 各条目含义:
| 条目 | 含义 |
|---|---|
> 1% |
全球市场份额超过 1% 的浏览器版本 |
last 2 versions |
每个浏览器的最新 2 个主版本(如 Chrome 120、Chrome 119) |
not ie <= 10 |
排除 IE10 及以下 |
它做了三件事:
- 查
browserslist确定目标浏览器范围 - 查
@babel/compat-data确定哪些语法需要转换 - 只转换目标浏览器不支持的语法
为什么不再需要 :@babel/preset-env 根据 browserslist 确定需要降级哪些语法(如箭头函数→function、const→var)。esbuild 也有 target 选项可以控制语法级别,但在本项目配置中,esbuild 的 target 为现代浏览器集合(chrome64+ 等),不做任何 ES6→ES5 转译 ------因为目标浏览器已原生支持 ES6+ 语法。@babel/preset-env 的语法降级工作不是被 esbuild 替代了,而是因为目标浏览器升级而不再需要了 。ES5 降级的场景交由 @vitejs/plugin-legacy(内部使用 Babel)处理,而非 esbuild 的 target(已实施,见第七节(五))。
@babel/plugin-transform-runtime --- 辅助函数复用
原用途 :Babel 在转换代码时,会生成一些辅助函数(helper),比如 _classCallCheck、_extends、_defineProperty 等。默认情况下,每个文件都会内联这些函数,导致大量重复代码。
没有 @babel/plugin-transform-runtime 时:
javascript
// 文件A.js
function _classCallCheck(instance, Constructor) { ... } // 内联 helper
var A = function A() { _classCallCheck(this, A); }
// 文件B.js(同样的 helper 再内联一次)
function _classCallCheck(instance, Constructor) { ... } // 重复!
var B = function B() { _classCallCheck(this, B); }
有 @babel/plugin-transform-runtime 时:
javascript
// 从 @babel/runtime 统一引用,不重复内联
var _classCallCheck = require('@babel/runtime/helpers/classCallCheck');
var A = function A() { _classCallCheck(this, A); }
为什么不再需要 :esbuild 没有辅助函数的概念------它直接生成目标代码,不产生中间 helper。新增的 plugin-legacy 走"先打包后转译"路径(chunk 级编译),helper 在 chunk 内天然共享,不需要 @babel/runtime 外部包。
transform-runtime 实际处理了哪些 helper(Webpack 时期 dist 产物实测):
项目源码中的 Babel helper:
_extends: 43 次(class 继承/Object.assign 兜底)
_typeof: 0 次(源码无直接 typeof Symbol)
_slicedToArray: 0 次(源码解构均可在目标环境原生运行)
_asyncToGenerator: 0 次(async/await 在旧 browserslist 目标中需降级,但已由 regenerator 处理)
外部文件(pdf.worker.min.js)中的 helper: 2,624 次(预编译文件,未经项目 Babel 处理)
项目源码中只有
_extends产生了 43 次 helper,全部由transform-runtime转为@babel/runtime引用。其余 helper 类型(_typeof、_slicedToArray等)在项目源码中零出现------Vue 2 Options API 以对象字面量为主,不使用class、极少解构深层嵌套。
在 plugin-legacy 流程中,Babel 处理的是 Rollup 输出的 chunk(189 个),每个 chunk 内 helper 定义一次后共享------不需要 @babel/runtime 外部运行时。效果等价于 transform-runtime,但不引入额外依赖。
⚠️ 深入分析:plugin-legacy 下的 helper 与 regeneratorRuntime
上文提到"helper 在 chunk 内天然共享"------这对 _classCallCheck、_extends 等短 helper 成立。但 async/await 的转译走的是另一条路径:@babel/preset-env 使用 @babel/plugin-transform-regenerator 将 async/await 转为 generator + regeneratorRuntime,后者是一段约 1.9KB 的 generator 运行时(minified),会被内联到每一个需要它的 chunk 中。
实测数据 (npm run build 产物全文搜索):
| Helper 类型 | 出现文件数 | 来源 |
|---|---|---|
_classCallCheck |
1 | pdf-legacy(PDF.js 第三方) |
_createClass |
1 | pdf-legacy |
_asyncToGenerator |
1 | pdf-legacy |
regeneratorRuntime |
105 | 项目业务代码 |
关键发现:
- 项目自身代码(Vue 2 Options API)不产生
_classCallCheck/_asyncToGenerator等短 helper------这些 helper 全部来自第三方pdfjs-dist - 唯一大量出现的是
regeneratorRuntime------189 个 legacy chunk 中 105 个 使用了async/await
重复开销计算:
单文件 regeneratorRuntime 大小: 1.9 KB (minified)
受影响文件数: 105 / 189 个 legacy 文件
原始总重复: 105 × 1.9 KB = 198 KB
gzip 后重复 (30% 压缩率): ~59 KB
项目总构建产物 (dist/): 约 22 MB
重复占比 (gzip): < 0.3%
以核心 mixin envelopeMixin-legacy.js(约 73KB)为例,regeneratorRuntime 占 2.7%。
那为什么不加 @babel/plugin-transform-runtime 来解决?
@vitejs/plugin-legacy 的 Babel 配置在源码中硬编码:
javascript
// node_modules/@vitejs/plugin-legacy/dist/index.js
babelrc: false, // 不读 .babelrc
configFile: false, // 不读 babel.config.js
且插件公开的 Options 接口中没有预留 Babel plugin 注入点 ------无法通过 vite.config.js 添加 @babel/plugin-transform-runtime。要绕过这个限制,有以下三种方案,但各有硬伤:
| 方案 | 是什么 | 怎么做 | 硬伤 |
|---|---|---|---|
| patch-package | npm 生态工具:修改 node_modules/ 源码后生成 .patch 文件(Git diff),提交到仓库。团队 npm install 后通过 "postinstall": "patch-package" 自动应用补丁 |
① 改 @vitejs/plugin-legacy/dist/index.js ② npx patch-package @vitejs/plugin-legacy ③ 提交 patches/ 目录 |
依赖升级时 patch 大概率冲突,需手动重做;团队须理解 patch 的存在和原理 |
| wrapper plugin | 自定义 Vite 插件,包裹在 legacy() 外面,通过 transform 钩子拦截 legacy chunk,在官方插件处理完后再跑一次 Babel ,二次注入 @babel/plugin-transform-runtime |
① 在 vite.config.js 中写一个 { name, transform(code,id){...} } 插件 ② 放在 legacy() 之后 注册 ③ 对含 -legacy 标识的 chunk 调 Babel |
与 plugin-legacy 内部实现强耦合(chunk 命名、转换时机);二次 Babel 增加构建时间 |
| fork 插件 | 把 @vitejs/plugin-legacy 源码复制到项目中,修改后作为本地插件使用,脱离 npm 官方版本 |
① 拷贝源码到本地 ② 修改 Babel 配置 ③ import 本地路径 |
完全失去官方更新(安全补丁、bug 修复);需手动追踪上游变更并合并 |
无论哪种方案,都是在和插件设计对抗。
决策 :❌ 不加。优化收益 < 0.3%(且仅影响旧浏览器),实现成本高(需对抗插件架构),还需引入 @babel/runtime 生产依赖。保持 @vitejs/plugin-legacy 开箱配置即可。
babel-plugin-import --- antd 按需加载
原用途 :babel-plugin-import 自动把 import { Button } from 'ant-design-vue' 转换为按需导入路径:
javascript
// 开发者写的代码:
import { Button } from 'ant-design-vue'
// babel-plugin-import 转换后的代码:
import Button from 'ant-design-vue/lib/button' // 只导入 Button
import 'ant-design-vue/lib/button/style' // 只导入 Button 样式
但实际上,本项目从未启用它 。babel.config.js 中 babel-plugin-import 的配置被注释掉了:
javascript
// babel.config.js(实际代码)
// lazy load ant-design-vue
// if your use import on Demand, Use this code
// plugins.push(['import', {
// 'libraryName': 'ant-design-vue',
// 'libraryDirectory': 'es',
// 'style': true
// }])
module.exports = {
presets: [...],
plugins: ['@babel/plugin-transform-runtime'], // 注意:只有 transform-runtime,没有 import
}
项目采用的是手动子路径导入 ------开发者在 src/components/pc/antd.js 中手工书写了 111 个子路径(57 个组件 + 53 个样式 + 1 个 version 导入):
javascript
// src/components/pc/antd.js(实际代码)
import { default as Button } from 'ant-design-vue/es/button'
import 'ant-design-vue/es/button/style'
import { default as Modal } from 'ant-design-vue/es/modal'
import 'ant-design-vue/es/modal/style'
// ... 40+ 个组件
为什么用子路径导入?通过实验验证:
在 Webpack 生产构建环境下,用同一个 ant-design-vue 1.7.8 做了对照实验:
import { Button } from 'ant-design-vue' (主入口导入) |
import Button from 'ant-design-vue/es/button' (子路径导入) |
|
|---|---|---|
| 构建时遍历的模块数 | 466 | 34 |
| 最终 bundle 大小 | 566,067 bytes | 566,066 bytes |
结论:Webpack 的 tree-shaking 能正确处理 antd 的 JS 代码------两个 bundle 大小几乎相同(差 1 byte)。但主入口导入时 Webpack 需要遍历解析全部 466 个模块(包括未使用的 Affix、Anchor、Carousel 等),构建速度明显慢于子路径的 34 个模块。
子路径导入的真正价值不是减小产物体积(tree-shaking 已经做到了),而是:
-
按需加载样式 :每个组件的样式(
.less文件)标记在sideEffects中("es/**/style/*"),无法被 tree-shaking。使用主入口import { Button } from 'ant-design-vue'不会自动引入 Button 的样式------需要额外import 'ant-design-vue/es/button/style'。子路径导入 + 手动样式行让开发者显式控制每个组件的样式加载 -
减少构建时解析的模块数:34 vs 466,Webpack 只需解析实际使用的模块树,构建更快
-
babel-plugin-import 的作用 :自动化上述手动过程------开发者写
import { Button } from 'ant-design-vue',插件自动拆分为组件子路径 + 样式子路径,省去手动维护百余行导入的繁琐
为什么不再需要 :Vite 使用 Rollup 做生产构建,Rollup 的 tree-shaking 同样能正确处理 antd。更重要的是,迁移到 Vite 后采用了 import 'ant-design-vue/dist/antd.less' 全量样式导入(替代 53 个独立样式导入),配合 Vite 开发模式的自动依赖预构建,两端都得到了更好的方案:
Webpack 时期:babel-plugin-import(未启用)→ 手动子路径导入 → 组件按需 + 样式按需
Vite 时期: 统一入口导入 → Rollup tree-shaking + 全量样式文件
开发模式:Vite 自动扫描并预构建依赖 → 减少请求数
生产构建:Rollup tree-shaking 自动处理
遗留代码清理 :src/components/pc/antd.js 和 src/components/h5/vant/index.js 中的大量子路径导入(antd 111 条 + vant 54 条)是项目手写的,与 babel-plugin-import 无关。在 Vite 开发模式每个子路径是一次独立 HTTP 请求,改为统一入口导入后(import { Button } from 'ant-design-vue'),首屏请求数从 110+ 降到 ~4。详见第九节。
optimizeDeps 的角色澄清 :optimizeDeps.include 是 Vite 自动依赖扫描的兜底机制 ------本项目所有依赖(antd、vant、moment、lodash-es)均在 main.js 中直接 import,自动扫描已全部覆盖,该字段已从 vite.config.js 中删除(完整演进见第六节 §1.9)。开发与生产的模块处理分工:
开发模式:Vite 自动扫描入口 import → esbuild 预构建 → 合并碎片模块 → 减少 HTTP 请求
生产构建:Rollup tree-shaking → 静态分析 ESM import/export → 移除未使用代码 → 减小 bundle 体积
Loader/Plugin(6 个)
(内容见第七节(三))
附录:Babel vs esbuild 深入对比
为什么 esbuild 比 Babel 快
| 维度 | Babel | esbuild |
|---|---|---|
| 实现语言 | JavaScript | Go |
| 解析器 | @babel/parser(JS 实现的 AST 解析器) |
自研 Go 解析器 |
| 转换方式 | AST → 插件遍历 → 修改 AST → 生成代码 | AST → 直接生成目标代码 |
| 并行能力 | 单线程(需 worker 池手动并行) | 原生多线程(Go 的 goroutine) |
| 启动时间 | ~500ms(加载 JS 模块) | ~10ms(原生二进制进程) |
| 缓存策略 | 需手动配置 cacheDirectory |
内置缓存 |
| 插件生态 | 大量可定制插件 | 有限(设计上故意保持简单) |
速度对比数据(来自 esbuild 官方基准测试):
- Babel 转换 100KB 代码:~500ms
- esbuild 转换 100KB 代码:~50ms
- 速度差距:10x ~ 20x
为什么 Go 比 JS 快? 两个核心原因:
- 编译为机器码:Go 编译为原生二进制,直接在 CPU 上运行。Babel 是 JavaScript,需要在 Node.js 的 V8 引擎中解释执行(即使有 JIT 编译,仍有解释器开销)
- 多线程:Go 的 goroutine 允许在多个 CPU 核心上并行处理文件。JavaScript 的单线程模型需要手动创建 worker 线程池才能利用多核
esbuild 能覆盖 Babel 的哪些功能
| 功能 | Babel | esbuild(本项目) | 覆盖情况 |
|---|---|---|---|
| ES6+ 语法转译(箭头函数/const/class 等) | @babel/preset-env |
target 为现代浏览器集合 → 不做转译 |
不需要(目标浏览器原生支持) |
| 按需 polyfill | useBuiltIns: 'usage' |
不支持 | 不需要(目标浏览器原生支持 API) |
| JSX/TSX 转译 | 需要插件 | 内置支持 | ---(本项目无 JSX/TSX 源码) |
自定义插件(如 babel-plugin-import) |
丰富插件生态 | 不支持 | ❌ 用统一入口导入 + Rollup tree-shaking 替代 |
| 辅助函数复用 | @babel/plugin-transform-runtime |
无 helper 概念 | 不适用(不做转译,不产生 helper) |
esbuild 的 target 选项如何工作
javascript
// vite.config.js --- 本项目没有显式设置 build.target
// Vite 7 默认 'baseline-widely-available';本项目经 plugin-legacy 覆盖为 ['es2020','edge79','firefox67','chrome64','safari12']
// 含义:目标浏览器支持原生 ESM → 不转译任何 ES6+ 语法
实际 target 下的行为 :esbuild 不做任何语法降级 。const 保持 const,箭头函数保持箭头函数,class 保持 class,async/await 保持原样。因为目标浏览器(chrome64+/firefox67+/safari12+ 等)全部原生支持这些语法。
如果显式设置 target: 'es2015'(本项目未设置,仅作参考),esbuild 会进行以下转换:
target的含义是"输出代码需兼容的最低浏览器版本"。target: 'es2015'意味着目标浏览器支持 ES2015------因此 ES2015 引入的语法(const/let、class、箭头函数等)无需转换 ,但 ES2015 之后 引入的语法(async/await、??等)需要转换:
【转换】ES2015 之后引入的语法(目标浏览器不支持)
async/await(ES2017) → generator + regenerator 或 Promise 链
??(ES2020 空值合并) → 显式 null/undefined 检查
?.(ES2020 可选链) → 显式属性存在检查
【保留】ES2015 及之前已存在的语法(目标浏览器原生支持)
const/let(ES2015) → 保留(就是 ES6)
箭头函数(ES2015) → 保留
class(ES2015) → 保留
template literal(ES2015) → 保留
destructuring(ES2015) → 保留
【始终保留】ES5 及更早的语法不涉及"转换"
var、function、for、if 等
注意 :esbuild 只转换语法 ,不转换API 。Object.entries() 是 API,不是语法。如果目标浏览器不支持 Object.entries,esbuild 不会自动 polyfill。需要自己通过 core-js 引入。
ES3 / ES5 / ES2015 的区别
这三个级别经常被混淆,但它们之间有明确的语法边界:
ES3 (1999) ES5 (2009) ES6 / ES2015
│ │ │
▼ ▼ ▼
已有: var 新增: strict mode 新增: const / let
function JSON.parse/stringify 箭头函数 =>
for / while Array.forEach/map class
原始对象操作 Object.create 模板字符串 `${}`
getter / setter Promise
"use strict" Symbol / Map / Set
for...of / 解构
──────────────────────────────────────────────────────────────────────────────
典型浏览器: 典型浏览器: 典型浏览器:
IE6 ~ IE8 IE9 ~ IE11 Chrome 49+
Safari 9 Firefox 52+
ES5 ≠ ES2015 :ES2015(即 ES6)在 ES5 基础上新增了 const/let、箭头、class、模板字符串等语法,两者相差 6 年标准演进。IE 11 是 ES5 级别浏览器------它不支持任何 ES2015 语法。
对本项目的意义:Webpack 时期的 browserslist 包含 IE 11,Babel 必须以 ES5 为目标(const→var、箭头→function、class→构造函数)。Vite plugin-legacy 使用相同的 browserslist,降级目标同样是 ES5。两者都不是 ES2015、也不是 ES3------ES3 早已被 ES5 全面覆盖,不需要单独考虑。
esbuild 的 target 支持范围
实测验证:
es3 ❌ 不存在此选项
es5 ❌ ERROR: Transforming const to the configured target environment ("es5") is not supported yet
es2015 ✅
es2016 ✅
...
es2022 ✅
esnext ✅
esbuild 的 target 最低只到 es2015 ,不支持 es5 或更低。这意味着:
- 如果目标浏览器不支持 ES2015(如 IE11、旧 Android WebView),esbuild 无法 将
const/let/class/箭头函数等语法转为 ES5 - ES5 降级必须走
@vitejs/plugin-legacy(内部用 Babel),esbuild 在架构层面就放弃了 ES5 @vitejs/plugin-legacy不是"可选的优化",是 ES5 兼容的唯一可行路径
本项目不需要 polyfill 的推理
vite.config.js 中 build.target 未显式设置------Vite 7 默认为 'baseline-widely-available',本项目经 @vitejs/plugin-legacy 覆盖为现代浏览器集合(chrome64+/firefox67+/safari12+ 等)。这个范围包括:
- Chrome 64+(2018)
- Firefox 67+(2019)
- Safari 12+(2018)
- Edge 79+(2020)
这些浏览器已经实现了 ES2020 及以上的大部分 API。项目中使用的 ES6+ API(Promise、Object.assign、Array.includes、Map、Set 等)在这些浏览器中都原生支持,不需要 polyfill。
本节小结 :Babel 四个包全部移除,不是因为 esbuild"更快地做了同样的事",而是因为 转译工作量本身被消除了 ------目标浏览器(chrome64+ 等现代集合)已原生支持 ES6+。唯一遗留的是 plugin-legacy 下的
regeneratorRuntime跨 chunk 重复(105/189 个 chunk),但优化收益不足 0.3% 且实现路径被锁死,决策不加。