从 ES Module 到 Vite、Rollup、Rolldown:现代前端构建体系深度讲解
写给已经会写 React / Vue / TypeScript,但对 Vite、Rollup、Rolldown、ESM、tree shaking、HMR、依赖预构建这些词还没有形成完整知识地图的前端同学。
目录
- 先建立一张总地图
- 为什么前端需要模块化
- ES Module:现代 JavaScript 的官方模块系统
- CommonJS:Node 时代的模块系统
- 浏览器已经支持 ESM,为什么还要打包
- Bundler 到底做了什么
- Rollup:ESM-first 的经典打包器
- esbuild:极快的 Go 构建工具
- Vite:不是单纯的打包器,而是现代前端构建平台
- Rolldown:Rust 时代的 Rollup-like Bundler
- Oxc、SWC、Babel、Terser 这些工具分别是什么
- 高频核心概念详解
- 从开发到生产:一段代码如何被 Vite 处理
- 常见问题与面试高频问法
- 实践建议:真实项目里如何判断和排查
- 一页速记
- 参考资料
1. 先建立一张总地图
很多人第一次听到这些词时会混乱,是因为它们处在不同层级:
语言标准层
ES Module / ESM
CommonJS / CJS
代码转换层
Babel
SWC
Oxc
esbuild
TypeScript compiler
模块打包层
Rollup
Rolldown
Webpack
Rspack
Parcel
esbuild bundler
上层构建工具层
Vite
Next.js
Nuxt
Astro
SvelteKit
运行环境层
Browser
Node.js
Deno
Bun
Edge Runtime最短理解:
ESM 是模块标准
Rollup / Rolldown / Webpack / esbuild 是打包器
Vite 是构建工具和开发服务器
Oxc / Babel / SWC / esbuild 是代码转换工具
浏览器和 Node 是最终运行环境再具体一点:
你写的源码
TypeScript / JSX / CSS / 图片 / SVG
先经过转换
TS -> JS
JSX -> JS
新语法 -> 目标浏览器可运行语法
再经过模块处理
解析 import/export
构建依赖图
tree shaking
code splitting
合并 chunk
最后输出产物
HTML
JS
CSS
assets
sourcemap这篇文章的主线就是:
模块标准为什么出现
-> 浏览器如何加载模块
-> 打包器为什么仍然必要
-> Vite 为什么快
-> Rollup 为什么常用于生产构建
-> Rolldown 为什么出现
-> 面试和真实项目里应该怎么说、怎么看、怎么排查2. 为什么前端需要模块化
早期前端页面可能只有几个脚本:
<script src="./jquery.js"></script>
<script src="./utils.js"></script>
<script src="./page.js"></script>这种方式的问题很快会暴露:
- 所有变量容易挂到全局作用域,命名冲突。
- 文件依赖顺序靠人工维护。
- 一个文件改动可能影响全局。
- 无法清晰表达“这个文件依赖谁、暴露什么能力”。
- 项目一大,可维护性迅速下降。
于是前端开始需要模块系统。
模块系统本质上解决三个问题:
封装:一个文件内部的变量默认不污染外部
依赖:明确表达当前模块依赖哪些模块
导出:明确表达当前模块提供哪些能力现代前端里,一个模块通常就是一个文件:
// price.ts
export function formatPrice(value: number) {
return `$${value.toFixed(2)}`;
}// product-card.ts
import { formatPrice } from "./price";
export function renderProductCard(price: number) {
return formatPrice(price);
}这就是模块化。
3. ES Module:现代 JavaScript 的官方模块系统
ES Module 通常简称 ESM,是 JavaScript 官方标准模块系统。
它的典型语法是:
export function add(a: number, b: number) {
return a + b;
}import { add } from "./math";
console.log(add(1, 2));3.1 ESM 的核心特征
ESM 最重要的特征不是语法好看,而是它的结构是静态的。
所谓静态,是指:
import { add } from "./math";这句代码必须出现在模块顶层,不能随便写在 if 里:
// 语法错误
if (condition) {
import { add } from "./math";
}也不能把模块路径写成任意运行时表达式:
// 静态 import 不允许这样写
import { add } from getModulePath();正因为它是静态结构,构建工具才能在“不运行代码”的情况下知道:
- 当前文件依赖了哪些文件。
- 当前文件导出了哪些能力。
- 哪些导出被使用了。
- 哪些导出没有被使用,可以删除。
这就是 ESM 对 tree shaking 非常友好的根本原因。
3.2 ESM 的导入是 live binding
ESM 的导入不是“复制一份值”,而是对原模块导出绑定的实时引用。
// counter.ts
export let count = 0;
export function increase() {
count += 1;
}// app.ts
import { count, increase } from "./counter";
console.log(count); // 0
increase();
console.log(count); // 1count 会跟随原模块里的值变化。这叫 live binding。
3.3 ESM 默认是严格模式
ESM 模块默认运行在 strict mode 下。
这意味着一些历史上宽松的写法会直接报错,例如:
// 在非严格模式下可能意外创建全局变量
foo = 1;在 ESM 里,这类代码会报错,因为 foo 没有声明。
3.4 ESM 的顶层作用域不是全局作用域
在普通脚本里,顶层变量可能污染全局:
<script>
var name = "app";
</script>但在模块里:
<script type="module">
const name = "app";
</script>name 是模块内部变量,不会自动挂到 window 上。
3.5 浏览器中的 ESM
浏览器可以通过下面方式加载 ESM:
<script type="module" src="/src/main.js"></script>模块内部可以继续 import:
import { createApp } from "./app.js";注意,浏览器原生 ESM 对路径比较严格。
下面这种相对路径是可以的:
import { createApp } from "./app.js";但下面这种裸模块路径,浏览器默认并不知道怎么解析:
import React from "react";"react" 这种没有 ./、../、/、https:// 开头的路径,叫 bare import,也就是裸导入。
浏览器原生不认识 bare import,除非你使用 import map,或者由 Vite / Webpack / Rollup 这类工具帮你转换。
3.6 动态 import
ESM 还有动态导入:
const module = await import("./heavy-chart");
module.renderChart();动态 import 是运行时加载,常用于:
- 路由懒加载。
- 大组件延迟加载。
- 按条件加载某个模块。
- 触发 code splitting。
在 React 里常见写法:
const SettingsPage = lazy(() => import("./pages/settings-page"));动态 import 和静态 import 的区别:
静态 import
编译阶段就能确定依赖
必须写在顶层
适合常规依赖
动态 import()
运行时才触发加载
可以写在函数、事件、条件里
常用于懒加载和拆包3.7 import map 是什么
import map 是浏览器提供的一种映射机制,用来告诉浏览器:
当你看到 import "react" 时,应该去哪个 URL 加载它示例:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19"
}
}
</script>然后你就可以:
import React from "react";不过真实工程里,Vite 等构建工具更常见。import map 在特定场景有用,例如微前端、无构建原型、运行时模块映射等。
4. CommonJS:Node 时代的模块系统
ESM 之前,Node.js 主要使用 CommonJS,简称 CJS。
典型写法:
const fs = require("fs");
function add(a, b) {
return a + b;
}
module.exports = {
add,
};使用:
const { add } = require("./math");4.1 CommonJS 的特点
CommonJS 更偏运行时:
if (condition) {
const mod = require("./a");
}这种写法在 CJS 里是允许的。
甚至可以:
const moduleName = getModuleName();
const mod = require(moduleName);这给运行时灵活性带来好处,但也让静态分析更困难。
4.2 ESM 和 CommonJS 的关键区别
| 对比项 | ESM | CommonJS |
|---|---|---|
| 语法 | import/export | require/module.exports |
| 标准归属 | JavaScript 标准 | Node.js 传统模块系统 |
| 加载方式 | 静态优先,也支持动态 import() | 运行时 require() |
| 静态分析 | 友好 | 较困难 |
| Tree shaking | 友好 | 较困难 |
| 浏览器原生支持 | 支持 | 不支持 |
| 导入绑定 | live binding | 更接近值对象读取 |
4.3 为什么很多 npm 包有 CJS / ESM 两份产物
因为历史兼容。
很多老 Node 项目仍然使用:
const lodash = require("lodash");现代前端项目则更希望使用:
import { debounce } from "lodash-es";所以一个库可能同时发布:
dist/index.cjs
dist/index.mjs
dist/index.d.ts并在 package.json 中声明:
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}这里几个字段容易混:
main
传统 Node 入口,常指向 CJS
module
早期 bundler 约定字段,常指向 ESM
exports
现代 Node 推荐字段,可以精确控制不同环境和不同导入方式的入口
type
控制 .js 文件默认按 ESM 还是 CJS 解析5. 浏览器已经支持 ESM,为什么还要打包
这是理解 Vite 的关键。
浏览器确实已经支持 ESM:
<script type="module" src="/src/main.js"></script>但真实项目并不只是浏览器能不能运行 import 的问题。
真实项目通常包含:
TypeScript
JSX / TSX
CSS Modules
PostCSS
TailwindCSS
Sass / Less
图片
字体
SVG
Web Workers
WASM
环境变量
npm 依赖
浏览器兼容目标
生产缓存策略浏览器原生 ESM 不能直接处理所有这些工程需求。
5.1 浏览器不认识 TypeScript
你写:
function greet(name: string) {
return `Hello ${name}`;
}浏览器不能直接运行 : string 这种类型标注。必须先转换成 JavaScript:
function greet(name) {
return `Hello ${name}`;
}5.2 浏览器不认识 JSX
你写:
export function App() {
return <div>Hello</div>;
}浏览器不能直接运行 JSX,需要转换成 JS。
例如 React 17+ 的自动运行时可能转换为:
import { jsx as _jsx } from "react/jsx-runtime";
export function App() {
return _jsx("div", { children: "Hello" });
}5.3 浏览器不认识 bare import
你写:
import React from "react";浏览器不知道 "react" 对应哪个文件。
构建工具会把它解析到:
node_modules/react/index.js或者开发时转换成类似:
import React from "/node_modules/.vite/deps/react.js?v=hash";5.4 生产环境需要减少请求和优化缓存
如果一个页面有几千个模块,原生 ESM 可能导致大量请求。
开发环境可以接受,因为本地网络极快,而且按需加载能提升启动速度。
生产环境不适合完全散模块,因为:
- 网络延迟真实存在。
- HTTP 请求有调度成本。
- 需要压缩。
- 需要长期缓存文件名 hash。
- 需要提取公共依赖。
- 需要删除没用代码。
- 需要按路由拆包。
所以生产仍然需要 bundling。
5.5 结论
浏览器支持 ESM
解决的是“模块语法能不能运行”
构建工具
解决的是“真实工程如何高效、兼容、可维护地发布”6. Bundler 到底做了什么
Bundler,中文常叫打包器。
它的输入是源码,输出是浏览器或 Node 更适合运行的产物。
6.1 最小工作流程
入口文件
-> 解析 import/export
-> 找到依赖模块
-> 递归构建依赖图
-> 转换每个模块
-> 合并或拆分模块
-> 输出文件例如:
// main.ts
import { render } from "./render";
import "./style.css";
render();Bundler 会知道:
main.ts
depends on render.ts
depends on style.css再继续分析:
render.ts
depends on react
depends on ./button.tsx最终得到一个模块依赖图:
main.ts
├─ render.ts
│ ├─ react
│ └─ button.tsx
└─ style.css6.2 Bundler 常见能力
| 能力 | 含义 |
|---|---|
| resolve | 把 import 路径解析成真实文件路径 |
| load | 读取模块内容 |
| transform | 转换模块内容,例如 TSX -> JS |
| bundle | 合并和组织模块 |
| tree shaking | 删除没有用到的代码 |
| code splitting | 按入口或动态 import 拆分 chunk |
| minify | 压缩代码 |
| asset handling | 处理图片、字体、CSS、WASM 等 |
| sourcemap | 生成源码映射,方便调试 |
6.3 依赖图是什么
依赖图就是每个模块之间的引用关系。
App.tsx -> Button.tsx -> icon.tsx
App.tsx -> user-store.ts
user-store.ts -> api-client.ts构建工具基于依赖图做很多事情:
- 判断哪些文件需要参与构建。
- 判断哪些导出可以删除。
- 判断哪些模块应该拆到同一个 chunk。
- 判断 HMR 时哪些模块需要更新。
- 判断循环依赖。
6.4 AST 是什么
AST 是 Abstract Syntax Tree,抽象语法树。
代码:
const count = add(1, 2);会被解析成一棵树,类似:
VariableDeclaration
name: count
init: CallExpression
callee: add
arguments:
NumericLiteral 1
NumericLiteral 2构建工具不会只靠字符串搜索来处理代码,而是通过 AST 理解代码结构。
例如它能知道:
import { Button } from "./button";这里是一个导入声明,而不是普通字符串。
6.5 Chunk 是什么
Chunk 是打包后的一块输出代码。
源码里可能有 300 个模块,但输出后可能是:
index-abc123.js
vendor-react-def456.js
settings-page-789abc.js每个输出文件就是一个 chunk。
常见 chunk 类型:
entry chunk
入口 chunk,例如首页入口
vendor chunk
第三方依赖 chunk,例如 react、react-dom
async chunk
动态 import 生成的懒加载 chunk
shared chunk
多个入口共享出来的公共 chunk6.6 Sourcemap 是什么
生产代码通常会被压缩:
function n(n,r){return n+r}这很难调试。
Sourcemap 是一个映射文件,用来告诉浏览器:
压缩后文件第 1 行第 20 列
对应源码 src/math.ts 第 2 行第 10 列开发时 sourcemap 方便调试,生产环境是否开启要根据安全和排查需求决定。
7. Rollup:ESM-first 的经典打包器
Rollup 是一个 JavaScript 模块打包器。
它的核心气质是:
ESM-first
tree shaking 强
适合 library bundling
输出格式灵活
插件生态成熟7.1 Rollup 为什么常用于库打包
假设你写一个工具库:
export function formatDate() {}
export function formatPrice() {}
export function parseQuery() {}用户可能只用:
import { formatPrice } from "my-utils";如果打包器可以很好地删除未使用导出,用户最终包体就更小。
Rollup 从设计上非常重视 ESM 和 tree shaking,因此长期以来非常适合库构建。
7.2 Rollup 能输出哪些格式
常见格式:
esm
ES Module,给现代 bundler 或浏览器使用
cjs
CommonJS,给 Node require 使用
umd
Universal Module Definition,同时兼容浏览器全局变量、AMD、CommonJS
iife
Immediately Invoked Function Expression,立即执行函数,适合直接 script 标签加载例如一个库可能输出:
dist/index.mjs
dist/index.cjs
dist/index.umd.js7.3 Rollup 插件机制
Rollup 插件会参与构建生命周期。
常见 hook:
resolveId
解析模块路径
load
加载模块内容
transform
转换模块内容
buildStart
构建开始
generateBundle
生成产物阶段一个极简 Rollup 插件大概长这样:
export default function virtualModulePlugin() {
const virtualModuleId = "virtual:config";
const resolvedVirtualModuleId = "\0" + virtualModuleId;
return {
name: "virtual-module",
resolveId(id: string) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
load(id: string) {
if (id === resolvedVirtualModuleId) {
return "export const appName = 'demo';";
}
},
};
}Vite 的插件系统大量继承 Rollup 的插件设计,这也是理解 Vite 插件时必须了解 Rollup 的原因。
7.4 Rollup 的局限
Rollup 很优秀,但在超大型应用、极致性能、复杂 dev server 场景下也有压力:
- JavaScript 实现的性能上限有限。
- 对 CommonJS、动态 require 等场景需要插件处理。
- 大型应用生产构建速度可能不如 Rust / Go 工具链。
- dev server 不是它的主战场。
所以 Vite 过去开发阶段大量依赖 esbuild;到 Vite 8,底层打包链路已经切换到 Rolldown 这种更统一的高性能 bundler。
8. esbuild:极快的 Go 构建工具
esbuild 是用 Go 写的构建工具。
它的关键词是:
非常快
支持 TS / JSX 转换
支持 bundling
支持 minify
API 简洁8.1 esbuild 在 Vite 里传统上做什么
在 Vite 2 到 Vite 7 的架构里,esbuild 常用于:
- 依赖预构建。
- TypeScript / JSX 快速转换。
- 某些压缩场景。
典型分工:
开发阶段
esbuild 负责快速转换和依赖预构建
生产阶段
Rollup 负责最终打包8.2 为什么 esbuild 快
常见原因:
- Go 语言实现,原生性能好。
- 并行能力强。
- 设计上减少不必要的抽象层。
- 不做类型检查,只做语法转换。
注意:esbuild 转换 TypeScript 时不会做类型检查。
例如:
const age: number = "18";TypeScript 编译器会报类型错误,但 esbuild 只会擦掉类型:
const age = "18";所以真实项目里通常需要:
tsc --noEmit来做类型检查。
8.3 esbuild 和 Rollup 的关系
二者不是同一种定位。
esbuild
快速转换、预构建、压缩、也可以 bundle
Rollup
更成熟的生产打包能力、插件生态、输出控制、library bundlingVite 过去把它们组合起来,是为了兼顾开发速度和生产构建质量。
9. Vite:不是单纯的打包器,而是现代前端构建平台
Vite 的名字来自法语,意思是 fast。
但 Vite 不是“另一个 Webpack”那么简单。它更像一个现代前端开发平台:
dev server
HMR
依赖预构建
源码按需转换
插件系统
环境变量处理
CSS 处理
静态资源处理
生产构建
SSR 支持
库模式9.1 传统 bundler dev server 的问题
在传统模式中,启动开发服务器可能需要:
扫描整个项目
打包整个应用
生成内存 bundle
浏览器才能打开页面项目越大,启动越慢。
修改一个文件后,也可能需要较大范围重新构建。
9.2 Vite 的开发模式
Vite 的核心变化是:
开发时不急着把整个应用打包成一个 bundle
而是利用浏览器原生 ESM
浏览器请求哪个模块,Vite 就转换哪个模块流程大概是:
浏览器请求 /src/main.tsx
-> Vite 转换 main.tsx
-> main.tsx import ./App.tsx
-> 浏览器再请求 /src/App.tsx
-> Vite 转换 App.tsx这叫按需服务源码。
9.3 Vite 为什么需要依赖预构建
开发时源码可以按需转换,但第三方依赖不适合完全原样交给浏览器。
原因有两个。
第一,有些依赖不是 ESM,而是 CommonJS 或 UMD。
浏览器不能直接运行:
module.exports = {}第二,有些 ESM 依赖内部有大量小模块。
例如一个库可能内部 import 了上百个文件。如果浏览器开发时一个个请求,会非常慢。
所以 Vite 会把依赖预构建成更适合浏览器加载的形式。
过去 Vite 使用 esbuild 做依赖预构建。Vite 8 开始,Rolldown 成为统一 bundler 后,这条链路正在被统一。
9.4 HMR 是什么
HMR 是 Hot Module Replacement,热模块替换。
它的目标是:
改了一个模块
不刷新整个页面
只替换受影响的模块
尽量保留应用状态例如你在 React 组件里改了一个按钮文案:
export function SaveButton() {
return <button>Save</button>;
}Vite 可以只更新这个模块,而不是让整个页面重新加载。
普通刷新会丢失页面状态:
表单输入没了
弹窗关闭了
滚动位置重置了
调试状态丢了HMR 尽量避免这些问题。
9.5 Vite 的生产构建
生产环境不能简单照搬开发模式。
开发模式追求:
启动快
更新快
调试方便生产构建追求:
包体小
请求少
缓存友好
执行性能好
兼容目标明确因此生产构建要做:
- tree shaking。
- code splitting。
- minify。
- hash 文件名。
- CSS 提取。
- 资源内联或拷贝。
- 生成 manifest。
9.6 Vite 版本分界:旧答案和新答案
这点很重要,因为很多资料已经过期。
Vite 2 到 Vite 7 的典型说法
dev 阶段依赖 native ESM + esbuild
build 阶段默认使用 Rollup
Vite 8 及之后的重要变化
Rolldown 成为默认统一 bundler
Vite 进入 Rust-powered bundling 的新阶段所以面试时要注意语境。
如果对方问的是 Vite 传统架构,可以说:
Vite 开发时利用浏览器原生 ESM 按需服务源码,并用 esbuild 做依赖预构建和快速转换;生产构建则使用 Rollup。
如果对方问的是 Vite 8 或 Rolldown:
Vite 8 已经切换到 Rolldown 作为统一 bundler,目标是减少过去 esbuild 和 Rollup 两条链路带来的差异,同时提高大型项目构建性能。
截至 2026-07-01,Vite 8 已稳定发布,并将 Rolldown 作为默认打包引擎。实际项目仍要看当前依赖的 Vite 主版本和配置。
10. Rolldown:Rust 时代的 Rollup-like Bundler
Rolldown 是一个 Rust 写的 JavaScript bundler。
它的定位可以这样理解:
Rollup-like API
Rollup-compatible plugin model
Rust performance
面向 Vite 的统一构建底座10.1 为什么需要 Rolldown
Vite 过去的性能已经很好,但内部有两条构建链路:
开发时
native ESM + esbuild
生产时
Rollup这带来几个问题:
行为一致性
dev 和 build 可能有细微差异
插件复杂度
同一个插件需要适配不同阶段的能力和限制
维护成本
两套底层工具链需要桥接
性能上限
大型生产构建仍然可能慢Rolldown 的目标就是:
用一个高性能、Rollup 兼容的 bundler
统一 Vite 内部构建链路10.2 Rolldown 和 Rollup 的关系
不要把 Rolldown 理解成“完全不同的新玩意”。
更准确的理解是:
Rolldown 希望继承 Rollup 的 API 和生态心智
同时用 Rust 实现更高性能的打包核心它关注:
- 兼容 Rollup 配置。
- 兼容 Rollup 插件模型。
- 支持现代 ESM 构建。
- 提高大型项目构建速度。
- 与 Vite 深度集成。
10.3 Rolldown 和 esbuild 的关系
esbuild 很快,但它不是 Rollup 兼容生态的完整替代品。
Rolldown 更像是:
Rollup 的生态兼容性
+
esbuild 级别工具链性能追求
+
Vite 内部统一构建需求这不是说 esbuild 没用了,而是 Vite 希望核心 bundling 能从多工具拼接,走向更一致的底层。
10.4 Rolldown 对普通开发者意味着什么
短期:
- 你仍然使用 Vite。
- 多数 Vite 配置不需要大改。
- Rollup 插件和 Vite 插件仍然是重要心智。
中长期:
- 构建会更快。
- dev 和 build 行为差异会减少。
- 插件兼容边界会更清晰。
- Vite 大型项目体验会更好。
11. Oxc、SWC、Babel、Terser 这些工具分别是什么
前端构建链路里还有很多名字,它们不是 bundler,但常和 bundler 一起出现。
11.1 Babel
Babel 是 JavaScript 转译器。
典型用途:
新语法 -> 旧语法
JSX -> JS
插件化语法转换
代码注入例如:
const value = user?.profile?.name;可以转换成兼容旧浏览器的写法。
Babel 的优势是生态成熟、插件非常多。
缺点是性能不如 Rust / Go 工具链。
11.2 SWC
SWC 是用 Rust 写的编译器工具链。
常见于:
- Next.js。
- 高性能 TS / JSX 转换。
- minify。
可以理解为 Babel 的高性能替代方案之一。
11.3 Oxc
Oxc 是一个 Rust 写的 JavaScript / TypeScript 工具链项目。
它包含很多底层能力:
- parser。
- resolver。
- transformer。
- linter。
- minifier。
Rolldown 和 Vite 新工具链与 Oxc 生态关系密切。
你可以把 Oxc 理解为现代 JS 工具链里的 Rust 基础设施集合。
11.4 Terser
Terser 是 JavaScript 压缩工具。
主要做:
- 删除空白。
- 缩短变量名。
- 删除不可达代码。
- 压缩表达式。
例如:
function add(firstValue, secondValue) {
return firstValue + secondValue;
}压缩成:
function add(n,d){return n+d}过去很多构建工具用 Terser 做生产压缩。现在 esbuild、SWC、Oxc 等也都有自己的压缩能力。
11.5 TypeScript Compiler
tsc 是 TypeScript 官方编译器。
它有两个重要角色:
类型检查
检查类型错误
代码编译
把 TypeScript 转成 JavaScript但在 Vite 项目中,常见方式是:
Vite / esbuild / Rolldown 负责 TS 转 JS
tsc --noEmit 负责类型检查因为 esbuild 等工具转换更快,但不做完整类型检查。
12. 高频核心概念详解
这一节集中解释面试和真实项目中最容易碰到的词。
12.1 Tree shaking
Tree shaking 是删除未使用代码。
示例:
// utils.ts
export function used() {
return "used";
}
export function unused() {
return "unused";
}// app.ts
import { used } from "./utils";
console.log(used());如果最终 bundle 里没有 unused,就是 tree shaking 生效了。
Tree shaking 依赖:
- ESM 静态结构。
- 正确的 sideEffects 标记。
- Bundler 的静态分析能力。
- 代码本身没有不可判断的动态副作用。
12.2 Side effect
Side effect 是副作用。
一个模块只导出函数,不在顶层做其他事,通常副作用较少:
export function add(a: number, b: number) {
return a + b;
}但下面代码有顶层副作用:
console.log("module loaded");
window.__APP_VERSION__ = "1.0.0";
export function add(a: number, b: number) {
return a + b;
}即使 add 没被使用,打包器也不一定能安全删除这个模块,因为删除后 console.log 和 window 赋值也没了。
package.json 里可以声明:
{
"sideEffects": false
}意思是:
这个包的模块没有顶层副作用
如果没有被使用,可以安全删除也可以保留 CSS 等副作用文件:
{
"sideEffects": [
"*.css"
]
}12.3 Code splitting
Code splitting 是代码拆分。
目的:
不要让用户首屏加载所有代码
把暂时用不到的代码延后加载示例:
const AdminPage = lazy(() => import("./pages/admin-page"));构建后,admin-page 可能变成单独 chunk:
admin-page-a1b2c3.js用户访问管理页时才加载。
12.4 Lazy loading
Lazy loading 是懒加载。
它是一种策略:
需要时再加载常见对象:
- 页面组件。
- 图表库。
- 富文本编辑器。
- 地图 SDK。
- 大图片。
- 低频功能面板。
Code splitting 是实现 lazy loading 的重要手段。
12.5 Bundle size
Bundle size 是打包产物体积。
常见关注:
原始体积
dist 中实际 JS/CSS 文件大小
gzip 体积
服务器 gzip 压缩后传输大小
brotli 体积
br 压缩后传输大小,通常比 gzip 更小
解析执行成本
文件下载后,浏览器还要 parse、compile、execute注意,包体不是越小就一定越快。一个很小但执行很重的脚本也可能阻塞页面。
12.6 HMR
HMR 是热模块替换。
它解决的是开发体验。
普通改代码:
保存文件
-> 页面刷新
-> 应用状态丢失HMR:
保存文件
-> Vite 找到变更模块
-> 推送更新
-> 浏览器替换模块
-> 尽量保留状态在 React 中,Fast Refresh 是 HMR 体验的重要部分。
12.7 Dependency pre-bundling
依赖预构建是 Vite 传统架构的重要概念。
它主要解决:
CommonJS / UMD 依赖转换成 ESM
把内部模块很多的依赖合并成较少请求
提升开发服务器性能比如:
import { debounce } from "lodash-es";如果一个依赖内部有大量小文件,浏览器原生 ESM 逐个请求会慢。预构建会把它们提前整理好。
12.8 Bare import
裸导入是指:
import React from "react";
import { debounce } from "lodash-es";这种路径不是相对路径,也不是绝对 URL。
浏览器原生不知道 "react" 是什么,构建工具会根据 Node resolution 规则去 node_modules 中解析。
12.9 Node resolution
Node resolution 是 Node 查找模块的规则。
例如:
import React from "react";工具会大概这样找:
当前目录 node_modules/react
父目录 node_modules/react
继续向上找
读取 react/package.json
根据 exports / module / main 等字段选择入口现代构建工具会模拟或扩展这套规则。
12.10 Alias
Alias 是路径别名。
例如 Vite 配置:
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
}然后代码可以写:
import { Button } from "@/components/button";而不是:
import { Button } from "../../../components/button";注意:如果用 TypeScript,还需要在 tsconfig.json 中配置 paths,否则编辑器和类型系统可能不认识。
12.11 Transpile 和 Compile
这两个词常混用。
一般可以这样理解:
compile
编译,一个更宽泛的词,从一种语言或形式转换成另一种
transpile
转译,通常指高级 JS/TS 到另一种 JS 版本例如:
TypeScript -> JavaScript
JSX -> JavaScript
ES2023 -> ES2018都可以叫 transpile。
12.12 Polyfill
Polyfill 是运行时垫片。
例如旧浏览器不支持:
Array.prototype.at你可以引入 polyfill,让旧环境也有这个 API。
转译和 polyfill 不一样:
转译
改写语法
polyfill
补运行时 API例如可选链:
user?.profile?.name可以转译成旧语法。
但 Promise、fetch、Array.prototype.includes 这类 API,如果环境没有,就需要 polyfill。
12.13 Minify 和 Mangle
Minify 是压缩。
包括:
- 删除空格。
- 删除注释。
- 简化表达式。
- 删除不可达代码。
Mangle 是混淆或缩短名称:
const userProfile = getUserProfile();变成:
const a=b();Mangle 可以降低体积,但会降低可读性,所以需要 sourcemap 辅助排查。
12.14 Target
Target 是目标运行环境。
例如:
build: {
target: "es2020"
}意思是输出代码可以使用 ES2020 支持的语法。
如果目标是旧浏览器,构建工具要转换更多语法,包体和构建成本可能上升。
12.15 SSR
SSR 是 Server-Side Rendering,服务端渲染。
普通 SPA:
浏览器下载 JS
JS 执行
渲染页面SSR:
服务端先生成 HTML
浏览器更快看到内容
再下载 JS 激活交互这个“激活交互”的过程叫 hydration。
Vite 本身支持 SSR 构建能力,很多框架如 Nuxt、SvelteKit、Astro 也建立在 Vite 生态上。
12.16 Hydration
Hydration 是水合。
服务端已经输出了 HTML:
<button>Like</button>但这个按钮还没有绑定 React/Vue 的事件。
浏览器加载 JS 后,把事件、状态和组件逻辑接到已有 HTML 上,这就是 hydration。
12.17 ESM-only package
ESM-only package 是只发布 ESM 的包。
如果老项目还在用 CommonJS:
const pkg = require("some-esm-only-package");可能报错。
解决方式可能是:
- 改用
import。 - 升级 Node 或构建工具。
- 使用动态
import()。 - 找兼容 CJS 的旧版本。
13. 从开发到生产:一段代码如何被 Vite 处理
假设项目入口:
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app";
import "./style.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);13.1 开发阶段
运行:
npm run dev大致流程:
1. Vite 启动开发服务器
2. 扫描依赖和入口
3. 对第三方依赖做预处理
4. 浏览器请求 index.html
5. index.html 引用 /src/main.tsx
6. 浏览器请求 main.tsx
7. Vite 转换 TSX 为 JS
8. 浏览器继续根据 import 请求 app.tsx、style.css
9. 修改文件后,Vite 通过 WebSocket 推送 HMR 更新开发阶段的核心目标:
启动快
改动反馈快
错误提示清晰
源码调试方便13.2 生产阶段
运行:
npm run build大致流程:
1. Vite 读取配置
2. 找到入口
3. 构建完整依赖图
4. 转换 TS / JSX / CSS / 静态资源
5. tree shaking
6. code splitting
7. 压缩代码
8. 生成 hash 文件名
9. 输出 dist产物可能是:
dist/
index.html
assets/
index-D8cV4x9a.js
index-B6a2s91q.css
vendor-react-A3f0e1d2.js
logo-C9x1a2b3.svg生产阶段的核心目标:
加载快
缓存稳定
包体合理
运行性能好
可部署13.3 开发和生产为什么可能表现不一致
因为两者目标不同,链路也不同。
常见差异:
开发不压缩,生产压缩
开发按需转换,生产全量构建依赖图
开发 sourcemap 更完整
生产会 tree shaking
生产会拆包
生产可能替换环境变量
开发和生产曾经使用不同底层工具所以一个问题只在生产出现,并不罕见。
排查时要明确:
是 dev 问题
还是 build 问题
还是 preview / 部署环境问题14. 常见问题与面试高频问法
14.1 Vite 为什么快
可以分两层回答。
开发阶段:
Vite 利用浏览器原生 ESM,开发时不需要先全量打包整个项目。
源码按需转换,浏览器请求哪个模块才处理哪个模块。
第三方依赖不常变,会提前预构建。
HMR 基于模块边界更新,改动反馈范围更小。生产阶段:
生产仍然要打包,因为需要 tree shaking、code splitting、压缩、缓存优化和减少请求。如果结合最新架构:
Vite 8 开始默认使用 Rolldown 作为统一 bundler,进一步提升构建性能并减少历史上 dev/build 底层工具差异。14.2 Vite 和 Rollup 是什么关系
简洁答法:
Vite 是上层构建工具,Rollup 是底层打包器。
传统 Vite 生产构建使用 Rollup,Vite 插件系统也大量沿用 Rollup 插件模型。补充 Vite 8 之后的情况:
Vite 8 已转向 Rolldown。Rolldown 目标是兼容 Rollup API 和插件生态,同时用 Rust 提供更高性能。14.3 Vite 和 Webpack 有什么区别
可以这样说:
Webpack 是经典 bundler,开发和生产都围绕 bundle 构建展开,生态完整,能力强。
Vite 开发时利用浏览器原生 ESM 按需服务源码,避免启动时全量打包,所以冷启动和 HMR 更快。
生产阶段二者都需要打包优化,只是底层实现和生态模型不同。不要简单说“Vite 一定比 Webpack 好”。更准确:
Vite 在现代 ESM 项目和开发体验上优势明显。
Webpack 在历史包袱、复杂遗留工程、特殊 loader/plugin 生态中仍然常见。14.4 Rollup 和 Webpack 有什么区别
常见回答:
Rollup 更 ESM-first,tree shaking 能力强,输出格式干净,常用于库打包。
Webpack 更偏应用构建,loader/plugin 生态庞大,处理复杂资源和历史场景能力强。但现在边界已经没有那么绝对:
- Rollup 也能打应用。
- Webpack 也能打库。
- Vite 应用生产构建长期使用 Rollup。
- 新工具链正在重塑边界。
面试里说“历史定位和优势场景不同”比说“谁替代谁”更稳。
14.5 ESM 为什么更利于 tree shaking
因为 ESM 是静态结构。
import { add } from "./math";构建工具能提前知道:
- 从哪个模块导入。
- 导入了哪个名字。
- 哪个导出被使用。
CommonJS 可以运行时动态 require:
const mod = require(getModuleName());这让静态分析更困难。
14.6 为什么 Vite 开发时不打包,生产时还要打包
开发时:
本地网络快
更需要启动快和 HMR 快
原生 ESM 按需加载更合适生产时:
真实网络有延迟
用户设备性能不一
需要减少请求
需要压缩
需要长期缓存
需要删除无用代码
需要按路由拆包所以开发和生产策略不同。
14.7 什么是依赖预构建
依赖预构建主要是 Vite 为开发环境做的优化。
它做两件事:
把 CommonJS / UMD 依赖转换为 ESM
把内部模块很多的依赖合并成较少模块,减少浏览器请求数量14.8 为什么有时候改 node_modules 后 Vite 没反应
因为依赖通常被预构建和缓存了。
可尝试:
rm -rf node_modules/.vite
npm run dev -- --forceWindows PowerShell:
Remove-Item -Recurse -Force node_modules/.vite
npm run dev -- --force具体命令要看包管理器和项目脚本。
14.9 为什么生产 build 报错,dev 正常
常见原因:
生产构建会做完整依赖图分析
生产会 tree shaking 和 minify
生产会替换环境变量
生产目标浏览器不同
生产使用不同插件阶段
某些代码只在 build 时执行
某些依赖有错误的 package.json exports排查步骤:
先 npm run build 复现
再 npm run preview 验证产物
检查报错模块和插件
确认是否是 SSR / browser 环境差异
查看是否有动态 require、Node 内置模块、环境变量缺失14.10 为什么浏览器不能直接 import "react"
因为 "react" 是 bare import。
浏览器不知道它对应:
node_modules/react/index.js构建工具会根据包解析规则把它变成可加载 URL。
也可以用 import map 告诉浏览器映射关系,但工程项目一般交给构建工具处理。
14.11 Vite 插件和 Rollup 插件有什么关系
Vite 插件接口基于 Rollup 插件接口扩展而来。
很多 Rollup hook 在 Vite 中也能使用:
resolveId
load
transform
buildStart
generateBundleVite 还增加了一些特有 hook:
config
configResolved
configureServer
transformIndexHtml
handleHotUpdate也就是说:
Rollup 插件心智是理解 Vite 插件的基础
Vite 插件又增加了 dev server 和 HMR 相关能力14.12 什么是 virtual module
Virtual module 是虚拟模块。
它没有真实文件,但插件可以让你这样 import:
import { routes } from "virtual:routes";然后插件在 load 阶段返回模块内容:
export const routes = [...]常见用途:
- 自动生成路由。
- 注入配置。
- 生成图标集合。
- 生成 Markdown 索引。
14.13 什么是 module graph
Module graph 是模块图。
它记录:
哪个模块 import 了哪个模块
哪个模块被哪些模块 import
模块的转换结果
HMR 边界Vite dev server 维护 module graph 来支持按需加载和 HMR。
14.14 什么是 HMR boundary
HMR boundary 是热更新边界。
如果一个模块能接受自己的更新,更新就可以停在这里。
如果不能,更新会向上冒泡到 import 它的模块。
如果一直找不到能处理更新的边界,可能触发整页刷新。
React Fast Refresh 会帮助 React 组件形成更好的热更新体验,但也有规则,例如组件导出形态过于复杂时可能退化。
14.15 什么是 CSS code splitting
CSS code splitting 是 CSS 拆分。
如果一个路由组件单独懒加载,它引用的 CSS 也可以跟着拆出去:
settings-page.js
settings-page.css用户不访问 settings 页面,就不加载它的 CSS。
14.16 什么是 manualChunks
manualChunks 是 Rollup / Vite 中手动控制拆包的配置。
例如:
build: {
rollupOptions: {
output: {
manualChunks: {
react: ["react", "react-dom"],
},
},
},
}它能把 React 拆成单独 chunk。
但不要为了“看起来高级”过度配置。错误拆包可能导致:
- 请求变多。
- 缓存收益不明显。
- 首屏反而变慢。
- chunk 之间依赖复杂。
14.17 什么是 library mode
Vite library mode 是用 Vite 构建库。
例如你写组件库:
src/index.ts
export Button
export Dialog希望输出:
dist/my-lib.mjs
dist/my-lib.cjs
dist/style.css就可以使用 library mode。
库构建要特别关注:
- 不要把 React 打进库里,通常设为 external。
- 输出 ESM 和 CJS。
- 生成类型声明。
- 正确配置 package.json exports。
- 保留 CSS 或提供样式入口。
14.18 什么是 external
External 是外部依赖,不打进当前 bundle。
例如组件库不应该内置一份 React:
rollupOptions: {
external: ["react", "react-dom"],
}这样使用者项目自己提供 React,避免重复打包和版本冲突。
14.19 什么是 monorepo 下的构建问题
Monorepo 中多个包互相引用:
packages/ui
packages/utils
apps/web常见问题:
- 源码包没有提前编译。
- TS path alias 和 bundler alias 不一致。
- package.json exports 配错。
- 同一依赖重复安装多个版本。
- React 被打包两份导致 hooks 报错。
- Vite 是否需要把 workspace 包纳入 optimizeDeps。
排查重点:
模块最终解析到了哪里
是不是同一份 React
workspace 包发布入口是否正确
dev 和 build 是否走同一入口14.20 为什么会出现 duplicated React
React hooks 有一个经典错误:
Invalid hook call原因之一是项目里存在两份 React。
例如:
app/node_modules/react
packages/ui/node_modules/react解决方向:
- 组件库把 React 放到 peerDependencies。
- bundler external React。
- monorepo 确保依赖提升和版本一致。
- 检查 alias 是否把 React 指向同一个路径。
15. 实践建议:真实项目里如何判断和排查
15.1 先判断问题发生在哪个阶段
遇到构建问题,不要一上来乱改配置。
先分层:
安装阶段
npm install / pnpm install 报错
类型阶段
tsc --noEmit 报错
开发阶段
npm run dev 报错
生产构建阶段
npm run build 报错
产物预览阶段
npm run preview 报错
部署环境阶段
本地正常,线上异常不同阶段对应完全不同的排查方向。
15.2 看报错时重点看什么
优先看:
第一个报错
报错文件路径
报错插件名
报错发生在 transform / resolve / load / generate 哪个阶段
是源码还是 node_modules
是 dev 还是 build例如:
[vite:resolve] Failed to resolve import "@/components/button"这通常是 alias / tsconfig paths / 文件路径问题。
[commonjs] Dynamic require of "fs" is not supported这通常是某个依赖把 Node 环境代码带到了浏览器构建。
15.3 不要盲目加依赖或复制配置
构建问题常见坏习惯:
随便装一个插件
复制一段 optimizeDeps
复制一段 manualChunks
把错误依赖强行 external
加一堆 alias 掩盖问题更稳的做法:
先确认模块解析路径
再确认包的 ESM/CJS 入口
再确认 dev/build 差异
最后决定配置修复点15.4 如何看最终 bundle
可以使用:
rollup-plugin-visualizervite-bundle-visualizer- Chrome DevTools Coverage
- Network 面板
- Source Map Explorer
重点看:
哪个依赖最大
是否打入了重复依赖
是否把低频功能放进首屏
是否有 locale 全量引入
是否有图表、编辑器、地图 SDK 提前加载15.5 如何优化包体
常见策略:
按路由动态 import
低频组件懒加载
避免整包引入 lodash / icon 库
检查 sideEffects
使用 ESM 版本依赖
大库按需加载
图片压缩与合适格式
删除无用 polyfill
合理设置 browserslist / build target错误策略:
盲目 manualChunks
为了分包而分包
把所有 node_modules 拆成 vendor
不看运行时瀑布图只看 dist 文件大小15.6 如何回答“你了解 Vite 源码吗”
不熟源码也可以从结构回答:
Vite dev server 的核心是基于原生 ESM 的按需模块服务。
请求进来后,Vite 经过插件容器执行 resolve、load、transform 等流程,把源码转换成浏览器可执行模块。
它维护 module graph 来记录模块依赖关系,并通过 WebSocket 推送 HMR 更新。
生产构建则通过底层 bundler 构建完整依赖图,做 tree shaking、code splitting 和资源优化。这个回答体现了理解,不是背 API。
16. 一页速记
16.1 核心关系
ESM
JavaScript 官方模块标准,import/export
CommonJS
Node 传统模块系统,require/module.exports
Rollup
ESM-first bundler,tree shaking 强,适合库构建
esbuild
Go 写的高速构建工具,常用于转译、预构建、压缩
Vite
上层构建工具,提供 dev server、HMR、插件系统、生产构建
Rolldown
Rust 写的 Rollup-like bundler,目标兼容 Rollup 生态并统一 Vite 底层
Oxc
Rust JS/TS 工具链基础设施,包含 parser、transformer、minifier 等能力16.2 面试短答
Vite 为什么快:
开发时利用浏览器原生 ESM,源码按需转换,不需要启动前全量打包;
依赖提前预构建;
HMR 基于模块边界更新,所以反馈快。
生产仍然会打包优化。ESM 为什么利于 tree shaking:
ESM import/export 是静态结构,构建工具能在编译阶段分析依赖图和使用关系。Vite 和 Rollup 关系:
Vite 是上层工具,Rollup 是传统生产打包器和插件模型基础。
Vite 8 通过 Rolldown 统一底层 bundler。Rolldown 是什么:
Rolldown 是 Rust 写的 Rollup-like bundler,目标兼容 Rollup API 和插件生态,同时提升性能,并成为 Vite 的统一打包底座。为什么生产还要打包:
为了减少请求、压缩代码、tree shaking、code splitting、缓存优化和兼容目标环境。16.3 排查口诀
先分阶段:install / typecheck / dev / build / preview / deploy
再看插件:resolve / load / transform / generate
再看模块:源码 / node_modules / workspace package
再看格式:ESM / CJS / UMD
再看环境:browser / node / SSR
最后改配置