Skip to content

从 ES Module 到 Vite、Rollup、Rolldown:现代前端构建体系深度讲解 ​

写给已经会写 React / Vue / TypeScript,但对 Vite、Rollup、Rolldown、ESM、tree shaking、HMR、依赖预构建这些词还没有形成完整知识地图的前端同学。

目录 ​

  1. 先建立一张总地图
  2. 为什么前端需要模块化
  3. ES Module:现代 JavaScript 的官方模块系统
  4. CommonJS:Node 时代的模块系统
  5. 浏览器已经支持 ESM,为什么还要打包
  6. Bundler 到底做了什么
  7. Rollup:ESM-first 的经典打包器
  8. esbuild:极快的 Go 构建工具
  9. Vite:不是单纯的打包器,而是现代前端构建平台
  10. Rolldown:Rust 时代的 Rollup-like Bundler
  11. Oxc、SWC、Babel、Terser 这些工具分别是什么
  12. 高频核心概念详解
  13. 从开发到生产:一段代码如何被 Vite 处理
  14. 常见问题与面试高频问法
  15. 实践建议:真实项目里如何判断和排查
  16. 一页速记
  17. 参考资料

1. 先建立一张总地图 ​

很多人第一次听到这些词时会混乱,是因为它们处在不同层级:

txt
语言标准层
  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

最短理解:

txt
ESM 是模块标准
Rollup / Rolldown / Webpack / esbuild 是打包器
Vite 是构建工具和开发服务器
Oxc / Babel / SWC / esbuild 是代码转换工具
浏览器和 Node 是最终运行环境

再具体一点:

txt
你写的源码
  TypeScript / JSX / CSS / 图片 / SVG

先经过转换
  TS -> JS
  JSX -> JS
  新语法 -> 目标浏览器可运行语法

再经过模块处理
  解析 import/export
  构建依赖图
  tree shaking
  code splitting
  合并 chunk

最后输出产物
  HTML
  JS
  CSS
  assets
  sourcemap

这篇文章的主线就是:

txt
模块标准为什么出现
  -> 浏览器如何加载模块
  -> 打包器为什么仍然必要
  -> Vite 为什么快
  -> Rollup 为什么常用于生产构建
  -> Rolldown 为什么出现
  -> 面试和真实项目里应该怎么说、怎么看、怎么排查

2. 为什么前端需要模块化 ​

早期前端页面可能只有几个脚本:

html
<script src="./jquery.js"></script>
<script src="./utils.js"></script>
<script src="./page.js"></script>

这种方式的问题很快会暴露:

  • 所有变量容易挂到全局作用域,命名冲突。
  • 文件依赖顺序靠人工维护。
  • 一个文件改动可能影响全局。
  • 无法清晰表达“这个文件依赖谁、暴露什么能力”。
  • 项目一大,可维护性迅速下降。

于是前端开始需要模块系统。

模块系统本质上解决三个问题:

txt
封装:一个文件内部的变量默认不污染外部
依赖:明确表达当前模块依赖哪些模块
导出:明确表达当前模块提供哪些能力

现代前端里,一个模块通常就是一个文件:

ts
// price.ts
export function formatPrice(value: number) {
  return `$${value.toFixed(2)}`;
}
ts
// product-card.ts
import { formatPrice } from "./price";

export function renderProductCard(price: number) {
  return formatPrice(price);
}

这就是模块化。


3. ES Module:现代 JavaScript 的官方模块系统 ​

ES Module 通常简称 ESM,是 JavaScript 官方标准模块系统。

它的典型语法是:

ts
export function add(a: number, b: number) {
  return a + b;
}
ts
import { add } from "./math";

console.log(add(1, 2));

3.1 ESM 的核心特征 ​

ESM 最重要的特征不是语法好看,而是它的结构是静态的。

所谓静态,是指:

ts
import { add } from "./math";

这句代码必须出现在模块顶层,不能随便写在 if 里:

ts
// 语法错误
if (condition) {
  import { add } from "./math";
}

也不能把模块路径写成任意运行时表达式:

ts
// 静态 import 不允许这样写
import { add } from getModulePath();

正因为它是静态结构,构建工具才能在“不运行代码”的情况下知道:

  • 当前文件依赖了哪些文件。
  • 当前文件导出了哪些能力。
  • 哪些导出被使用了。
  • 哪些导出没有被使用,可以删除。

这就是 ESM 对 tree shaking 非常友好的根本原因。

3.2 ESM 的导入是 live binding ​

ESM 的导入不是“复制一份值”,而是对原模块导出绑定的实时引用。

ts
// counter.ts
export let count = 0;

export function increase() {
  count += 1;
}
ts
// app.ts
import { count, increase } from "./counter";

console.log(count); // 0
increase();
console.log(count); // 1

count 会跟随原模块里的值变化。这叫 live binding。

3.3 ESM 默认是严格模式 ​

ESM 模块默认运行在 strict mode 下。

这意味着一些历史上宽松的写法会直接报错,例如:

js
// 在非严格模式下可能意外创建全局变量
foo = 1;

在 ESM 里,这类代码会报错,因为 foo 没有声明。

3.4 ESM 的顶层作用域不是全局作用域 ​

在普通脚本里,顶层变量可能污染全局:

html
<script>
  var name = "app";
</script>

但在模块里:

html
<script type="module">
  const name = "app";
</script>

name 是模块内部变量,不会自动挂到 window 上。

3.5 浏览器中的 ESM ​

浏览器可以通过下面方式加载 ESM:

html
<script type="module" src="/src/main.js"></script>

模块内部可以继续 import:

js
import { createApp } from "./app.js";

注意,浏览器原生 ESM 对路径比较严格。

下面这种相对路径是可以的:

js
import { createApp } from "./app.js";

但下面这种裸模块路径,浏览器默认并不知道怎么解析:

js
import React from "react";

"react" 这种没有 ./、../、/、https:// 开头的路径,叫 bare import,也就是裸导入。

浏览器原生不认识 bare import,除非你使用 import map,或者由 Vite / Webpack / Rollup 这类工具帮你转换。

3.6 动态 import ​

ESM 还有动态导入:

ts
const module = await import("./heavy-chart");
module.renderChart();

动态 import 是运行时加载,常用于:

  • 路由懒加载。
  • 大组件延迟加载。
  • 按条件加载某个模块。
  • 触发 code splitting。

在 React 里常见写法:

tsx
const SettingsPage = lazy(() => import("./pages/settings-page"));

动态 import 和静态 import 的区别:

txt
静态 import
  编译阶段就能确定依赖
  必须写在顶层
  适合常规依赖

动态 import()
  运行时才触发加载
  可以写在函数、事件、条件里
  常用于懒加载和拆包

3.7 import map 是什么 ​

import map 是浏览器提供的一种映射机制,用来告诉浏览器:

txt
当你看到 import "react" 时,应该去哪个 URL 加载它

示例:

html
<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@19"
  }
}
</script>

然后你就可以:

js
import React from "react";

不过真实工程里,Vite 等构建工具更常见。import map 在特定场景有用,例如微前端、无构建原型、运行时模块映射等。


4. CommonJS:Node 时代的模块系统 ​

ESM 之前,Node.js 主要使用 CommonJS,简称 CJS。

典型写法:

js
const fs = require("fs");

function add(a, b) {
  return a + b;
}

module.exports = {
  add,
};

使用:

js
const { add } = require("./math");

4.1 CommonJS 的特点 ​

CommonJS 更偏运行时:

js
if (condition) {
  const mod = require("./a");
}

这种写法在 CJS 里是允许的。

甚至可以:

js
const moduleName = getModuleName();
const mod = require(moduleName);

这给运行时灵活性带来好处,但也让静态分析更困难。

4.2 ESM 和 CommonJS 的关键区别 ​

对比项ESMCommonJS
语法import/exportrequire/module.exports
标准归属JavaScript 标准Node.js 传统模块系统
加载方式静态优先,也支持动态 import()运行时 require()
静态分析友好较困难
Tree shaking友好较困难
浏览器原生支持支持不支持
导入绑定live binding更接近值对象读取

4.3 为什么很多 npm 包有 CJS / ESM 两份产物 ​

因为历史兼容。

很多老 Node 项目仍然使用:

js
const lodash = require("lodash");

现代前端项目则更希望使用:

ts
import { debounce } from "lodash-es";

所以一个库可能同时发布:

txt
dist/index.cjs
dist/index.mjs
dist/index.d.ts

并在 package.json 中声明:

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"
    }
  }
}

这里几个字段容易混:

txt
main
  传统 Node 入口,常指向 CJS

module
  早期 bundler 约定字段,常指向 ESM

exports
  现代 Node 推荐字段,可以精确控制不同环境和不同导入方式的入口

type
  控制 .js 文件默认按 ESM 还是 CJS 解析

5. 浏览器已经支持 ESM,为什么还要打包 ​

这是理解 Vite 的关键。

浏览器确实已经支持 ESM:

html
<script type="module" src="/src/main.js"></script>

但真实项目并不只是浏览器能不能运行 import 的问题。

真实项目通常包含:

txt
TypeScript
JSX / TSX
CSS Modules
PostCSS
TailwindCSS
Sass / Less
图片
字体
SVG
Web Workers
WASM
环境变量
npm 依赖
浏览器兼容目标
生产缓存策略

浏览器原生 ESM 不能直接处理所有这些工程需求。

5.1 浏览器不认识 TypeScript ​

你写:

ts
function greet(name: string) {
  return `Hello ${name}`;
}

浏览器不能直接运行 : string 这种类型标注。必须先转换成 JavaScript:

js
function greet(name) {
  return `Hello ${name}`;
}

5.2 浏览器不认识 JSX ​

你写:

tsx
export function App() {
  return <div>Hello</div>;
}

浏览器不能直接运行 JSX,需要转换成 JS。

例如 React 17+ 的自动运行时可能转换为:

js
import { jsx as _jsx } from "react/jsx-runtime";

export function App() {
  return _jsx("div", { children: "Hello" });
}

5.3 浏览器不认识 bare import ​

你写:

ts
import React from "react";

浏览器不知道 "react" 对应哪个文件。

构建工具会把它解析到:

txt
node_modules/react/index.js

或者开发时转换成类似:

js
import React from "/node_modules/.vite/deps/react.js?v=hash";

5.4 生产环境需要减少请求和优化缓存 ​

如果一个页面有几千个模块,原生 ESM 可能导致大量请求。

开发环境可以接受,因为本地网络极快,而且按需加载能提升启动速度。

生产环境不适合完全散模块,因为:

  • 网络延迟真实存在。
  • HTTP 请求有调度成本。
  • 需要压缩。
  • 需要长期缓存文件名 hash。
  • 需要提取公共依赖。
  • 需要删除没用代码。
  • 需要按路由拆包。

所以生产仍然需要 bundling。

5.5 结论 ​

txt
浏览器支持 ESM
  解决的是“模块语法能不能运行”

构建工具
  解决的是“真实工程如何高效、兼容、可维护地发布”

6. Bundler 到底做了什么 ​

Bundler,中文常叫打包器。

它的输入是源码,输出是浏览器或 Node 更适合运行的产物。

6.1 最小工作流程 ​

txt
入口文件
  -> 解析 import/export
  -> 找到依赖模块
  -> 递归构建依赖图
  -> 转换每个模块
  -> 合并或拆分模块
  -> 输出文件

例如:

ts
// main.ts
import { render } from "./render";
import "./style.css";

render();

Bundler 会知道:

txt
main.ts
  depends on render.ts
  depends on style.css

再继续分析:

txt
render.ts
  depends on react
  depends on ./button.tsx

最终得到一个模块依赖图:

txt
main.ts
 ├─ render.ts
 │   ├─ react
 │   └─ button.tsx
 └─ style.css

6.2 Bundler 常见能力 ​

能力含义
resolve把 import 路径解析成真实文件路径
load读取模块内容
transform转换模块内容,例如 TSX -> JS
bundle合并和组织模块
tree shaking删除没有用到的代码
code splitting按入口或动态 import 拆分 chunk
minify压缩代码
asset handling处理图片、字体、CSS、WASM 等
sourcemap生成源码映射,方便调试

6.3 依赖图是什么 ​

依赖图就是每个模块之间的引用关系。

txt
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,抽象语法树。

代码:

ts
const count = add(1, 2);

会被解析成一棵树,类似:

txt
VariableDeclaration
  name: count
  init: CallExpression
    callee: add
    arguments:
      NumericLiteral 1
      NumericLiteral 2

构建工具不会只靠字符串搜索来处理代码,而是通过 AST 理解代码结构。

例如它能知道:

ts
import { Button } from "./button";

这里是一个导入声明,而不是普通字符串。

6.5 Chunk 是什么 ​

Chunk 是打包后的一块输出代码。

源码里可能有 300 个模块,但输出后可能是:

txt
index-abc123.js
vendor-react-def456.js
settings-page-789abc.js

每个输出文件就是一个 chunk。

常见 chunk 类型:

txt
entry chunk
  入口 chunk,例如首页入口

vendor chunk
  第三方依赖 chunk,例如 react、react-dom

async chunk
  动态 import 生成的懒加载 chunk

shared chunk
  多个入口共享出来的公共 chunk

6.6 Sourcemap 是什么 ​

生产代码通常会被压缩:

js
function n(n,r){return n+r}

这很难调试。

Sourcemap 是一个映射文件,用来告诉浏览器:

txt
压缩后文件第 1 行第 20 列
对应源码 src/math.ts 第 2 行第 10 列

开发时 sourcemap 方便调试,生产环境是否开启要根据安全和排查需求决定。


7. Rollup:ESM-first 的经典打包器 ​

Rollup 是一个 JavaScript 模块打包器。

它的核心气质是:

txt
ESM-first
tree shaking 强
适合 library bundling
输出格式灵活
插件生态成熟

7.1 Rollup 为什么常用于库打包 ​

假设你写一个工具库:

ts
export function formatDate() {}
export function formatPrice() {}
export function parseQuery() {}

用户可能只用:

ts
import { formatPrice } from "my-utils";

如果打包器可以很好地删除未使用导出,用户最终包体就更小。

Rollup 从设计上非常重视 ESM 和 tree shaking,因此长期以来非常适合库构建。

7.2 Rollup 能输出哪些格式 ​

常见格式:

txt
esm
  ES Module,给现代 bundler 或浏览器使用

cjs
  CommonJS,给 Node require 使用

umd
  Universal Module Definition,同时兼容浏览器全局变量、AMD、CommonJS

iife
  Immediately Invoked Function Expression,立即执行函数,适合直接 script 标签加载

例如一个库可能输出:

txt
dist/index.mjs
dist/index.cjs
dist/index.umd.js

7.3 Rollup 插件机制 ​

Rollup 插件会参与构建生命周期。

常见 hook:

txt
resolveId
  解析模块路径

load
  加载模块内容

transform
  转换模块内容

buildStart
  构建开始

generateBundle
  生成产物阶段

一个极简 Rollup 插件大概长这样:

ts
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 写的构建工具。

它的关键词是:

txt
非常快
支持 TS / JSX 转换
支持 bundling
支持 minify
API 简洁

8.1 esbuild 在 Vite 里传统上做什么 ​

在 Vite 2 到 Vite 7 的架构里,esbuild 常用于:

  • 依赖预构建。
  • TypeScript / JSX 快速转换。
  • 某些压缩场景。

典型分工:

txt
开发阶段
  esbuild 负责快速转换和依赖预构建

生产阶段
  Rollup 负责最终打包

8.2 为什么 esbuild 快 ​

常见原因:

  • Go 语言实现,原生性能好。
  • 并行能力强。
  • 设计上减少不必要的抽象层。
  • 不做类型检查,只做语法转换。

注意:esbuild 转换 TypeScript 时不会做类型检查。

例如:

ts
const age: number = "18";

TypeScript 编译器会报类型错误,但 esbuild 只会擦掉类型:

js
const age = "18";

所以真实项目里通常需要:

bash
tsc --noEmit

来做类型检查。

8.3 esbuild 和 Rollup 的关系 ​

二者不是同一种定位。

txt
esbuild
  快速转换、预构建、压缩、也可以 bundle

Rollup
  更成熟的生产打包能力、插件生态、输出控制、library bundling

Vite 过去把它们组合起来,是为了兼顾开发速度和生产构建质量。


9. Vite:不是单纯的打包器,而是现代前端构建平台 ​

Vite 的名字来自法语,意思是 fast。

但 Vite 不是“另一个 Webpack”那么简单。它更像一个现代前端开发平台:

txt
dev server
HMR
依赖预构建
源码按需转换
插件系统
环境变量处理
CSS 处理
静态资源处理
生产构建
SSR 支持
库模式

9.1 传统 bundler dev server 的问题 ​

在传统模式中,启动开发服务器可能需要:

txt
扫描整个项目
打包整个应用
生成内存 bundle
浏览器才能打开页面

项目越大,启动越慢。

修改一个文件后,也可能需要较大范围重新构建。

9.2 Vite 的开发模式 ​

Vite 的核心变化是:

txt
开发时不急着把整个应用打包成一个 bundle
而是利用浏览器原生 ESM
浏览器请求哪个模块,Vite 就转换哪个模块

流程大概是:

txt
浏览器请求 /src/main.tsx
  -> Vite 转换 main.tsx
  -> main.tsx import ./App.tsx
  -> 浏览器再请求 /src/App.tsx
  -> Vite 转换 App.tsx

这叫按需服务源码。

9.3 Vite 为什么需要依赖预构建 ​

开发时源码可以按需转换,但第三方依赖不适合完全原样交给浏览器。

原因有两个。

第一,有些依赖不是 ESM,而是 CommonJS 或 UMD。

浏览器不能直接运行:

js
module.exports = {}

第二,有些 ESM 依赖内部有大量小模块。

例如一个库可能内部 import 了上百个文件。如果浏览器开发时一个个请求,会非常慢。

所以 Vite 会把依赖预构建成更适合浏览器加载的形式。

过去 Vite 使用 esbuild 做依赖预构建。Vite 8 开始,Rolldown 成为统一 bundler 后,这条链路正在被统一。

9.4 HMR 是什么 ​

HMR 是 Hot Module Replacement,热模块替换。

它的目标是:

txt
改了一个模块
不刷新整个页面
只替换受影响的模块
尽量保留应用状态

例如你在 React 组件里改了一个按钮文案:

tsx
export function SaveButton() {
  return <button>Save</button>;
}

Vite 可以只更新这个模块,而不是让整个页面重新加载。

普通刷新会丢失页面状态:

txt
表单输入没了
弹窗关闭了
滚动位置重置了
调试状态丢了

HMR 尽量避免这些问题。

9.5 Vite 的生产构建 ​

生产环境不能简单照搬开发模式。

开发模式追求:

txt
启动快
更新快
调试方便

生产构建追求:

txt
包体小
请求少
缓存友好
执行性能好
兼容目标明确

因此生产构建要做:

  • tree shaking。
  • code splitting。
  • minify。
  • hash 文件名。
  • CSS 提取。
  • 资源内联或拷贝。
  • 生成 manifest。

9.6 Vite 版本分界:旧答案和新答案 ​

这点很重要,因为很多资料已经过期。

txt
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。

它的定位可以这样理解:

txt
Rollup-like API
Rollup-compatible plugin model
Rust performance
面向 Vite 的统一构建底座

10.1 为什么需要 Rolldown ​

Vite 过去的性能已经很好,但内部有两条构建链路:

txt
开发时
  native ESM + esbuild

生产时
  Rollup

这带来几个问题:

txt
行为一致性
  dev 和 build 可能有细微差异

插件复杂度
  同一个插件需要适配不同阶段的能力和限制

维护成本
  两套底层工具链需要桥接

性能上限
  大型生产构建仍然可能慢

Rolldown 的目标就是:

txt
用一个高性能、Rollup 兼容的 bundler
统一 Vite 内部构建链路

10.2 Rolldown 和 Rollup 的关系 ​

不要把 Rolldown 理解成“完全不同的新玩意”。

更准确的理解是:

txt
Rolldown 希望继承 Rollup 的 API 和生态心智
同时用 Rust 实现更高性能的打包核心

它关注:

  • 兼容 Rollup 配置。
  • 兼容 Rollup 插件模型。
  • 支持现代 ESM 构建。
  • 提高大型项目构建速度。
  • 与 Vite 深度集成。

10.3 Rolldown 和 esbuild 的关系 ​

esbuild 很快,但它不是 Rollup 兼容生态的完整替代品。

Rolldown 更像是:

txt
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 转译器。

典型用途:

txt
新语法 -> 旧语法
JSX -> JS
插件化语法转换
代码注入

例如:

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 压缩工具。

主要做:

  • 删除空白。
  • 缩短变量名。
  • 删除不可达代码。
  • 压缩表达式。

例如:

js
function add(firstValue, secondValue) {
  return firstValue + secondValue;
}

压缩成:

js
function add(n,d){return n+d}

过去很多构建工具用 Terser 做生产压缩。现在 esbuild、SWC、Oxc 等也都有自己的压缩能力。

11.5 TypeScript Compiler ​

tsc 是 TypeScript 官方编译器。

它有两个重要角色:

txt
类型检查
  检查类型错误

代码编译
  把 TypeScript 转成 JavaScript

但在 Vite 项目中,常见方式是:

txt
Vite / esbuild / Rolldown 负责 TS 转 JS
tsc --noEmit 负责类型检查

因为 esbuild 等工具转换更快,但不做完整类型检查。


12. 高频核心概念详解 ​

这一节集中解释面试和真实项目中最容易碰到的词。

12.1 Tree shaking ​

Tree shaking 是删除未使用代码。

示例:

ts
// utils.ts
export function used() {
  return "used";
}

export function unused() {
  return "unused";
}
ts
// app.ts
import { used } from "./utils";

console.log(used());

如果最终 bundle 里没有 unused,就是 tree shaking 生效了。

Tree shaking 依赖:

  • ESM 静态结构。
  • 正确的 sideEffects 标记。
  • Bundler 的静态分析能力。
  • 代码本身没有不可判断的动态副作用。

12.2 Side effect ​

Side effect 是副作用。

一个模块只导出函数,不在顶层做其他事,通常副作用较少:

ts
export function add(a: number, b: number) {
  return a + b;
}

但下面代码有顶层副作用:

ts
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 里可以声明:

json
{
  "sideEffects": false
}

意思是:

txt
这个包的模块没有顶层副作用
如果没有被使用,可以安全删除

也可以保留 CSS 等副作用文件:

json
{
  "sideEffects": [
    "*.css"
  ]
}

12.3 Code splitting ​

Code splitting 是代码拆分。

目的:

txt
不要让用户首屏加载所有代码
把暂时用不到的代码延后加载

示例:

tsx
const AdminPage = lazy(() => import("./pages/admin-page"));

构建后,admin-page 可能变成单独 chunk:

txt
admin-page-a1b2c3.js

用户访问管理页时才加载。

12.4 Lazy loading ​

Lazy loading 是懒加载。

它是一种策略:

txt
需要时再加载

常见对象:

  • 页面组件。
  • 图表库。
  • 富文本编辑器。
  • 地图 SDK。
  • 大图片。
  • 低频功能面板。

Code splitting 是实现 lazy loading 的重要手段。

12.5 Bundle size ​

Bundle size 是打包产物体积。

常见关注:

txt
原始体积
  dist 中实际 JS/CSS 文件大小

gzip 体积
  服务器 gzip 压缩后传输大小

brotli 体积
  br 压缩后传输大小,通常比 gzip 更小

解析执行成本
  文件下载后,浏览器还要 parse、compile、execute

注意,包体不是越小就一定越快。一个很小但执行很重的脚本也可能阻塞页面。

12.6 HMR ​

HMR 是热模块替换。

它解决的是开发体验。

普通改代码:

txt
保存文件
  -> 页面刷新
  -> 应用状态丢失

HMR:

txt
保存文件
  -> Vite 找到变更模块
  -> 推送更新
  -> 浏览器替换模块
  -> 尽量保留状态

在 React 中,Fast Refresh 是 HMR 体验的重要部分。

12.7 Dependency pre-bundling ​

依赖预构建是 Vite 传统架构的重要概念。

它主要解决:

txt
CommonJS / UMD 依赖转换成 ESM
把内部模块很多的依赖合并成较少请求
提升开发服务器性能

比如:

ts
import { debounce } from "lodash-es";

如果一个依赖内部有大量小文件,浏览器原生 ESM 逐个请求会慢。预构建会把它们提前整理好。

12.8 Bare import ​

裸导入是指:

ts
import React from "react";
import { debounce } from "lodash-es";

这种路径不是相对路径,也不是绝对 URL。

浏览器原生不知道 "react" 是什么,构建工具会根据 Node resolution 规则去 node_modules 中解析。

12.9 Node resolution ​

Node resolution 是 Node 查找模块的规则。

例如:

js
import React from "react";

工具会大概这样找:

txt
当前目录 node_modules/react
父目录 node_modules/react
继续向上找
读取 react/package.json
根据 exports / module / main 等字段选择入口

现代构建工具会模拟或扩展这套规则。

12.10 Alias ​

Alias 是路径别名。

例如 Vite 配置:

ts
resolve: {
  alias: {
    "@": fileURLToPath(new URL("./src", import.meta.url)),
  },
}

然后代码可以写:

ts
import { Button } from "@/components/button";

而不是:

ts
import { Button } from "../../../components/button";

注意:如果用 TypeScript,还需要在 tsconfig.json 中配置 paths,否则编辑器和类型系统可能不认识。

12.11 Transpile 和 Compile ​

这两个词常混用。

一般可以这样理解:

txt
compile
  编译,一个更宽泛的词,从一种语言或形式转换成另一种

transpile
  转译,通常指高级 JS/TS 到另一种 JS 版本

例如:

txt
TypeScript -> JavaScript
JSX -> JavaScript
ES2023 -> ES2018

都可以叫 transpile。

12.12 Polyfill ​

Polyfill 是运行时垫片。

例如旧浏览器不支持:

js
Array.prototype.at

你可以引入 polyfill,让旧环境也有这个 API。

转译和 polyfill 不一样:

txt
转译
  改写语法

polyfill
  补运行时 API

例如可选链:

js
user?.profile?.name

可以转译成旧语法。

但 Promise、fetch、Array.prototype.includes 这类 API,如果环境没有,就需要 polyfill。

12.13 Minify 和 Mangle ​

Minify 是压缩。

包括:

  • 删除空格。
  • 删除注释。
  • 简化表达式。
  • 删除不可达代码。

Mangle 是混淆或缩短名称:

js
const userProfile = getUserProfile();

变成:

js
const a=b();

Mangle 可以降低体积,但会降低可读性,所以需要 sourcemap 辅助排查。

12.14 Target ​

Target 是目标运行环境。

例如:

ts
build: {
  target: "es2020"
}

意思是输出代码可以使用 ES2020 支持的语法。

如果目标是旧浏览器,构建工具要转换更多语法,包体和构建成本可能上升。

12.15 SSR ​

SSR 是 Server-Side Rendering,服务端渲染。

普通 SPA:

txt
浏览器下载 JS
JS 执行
渲染页面

SSR:

txt
服务端先生成 HTML
浏览器更快看到内容
再下载 JS 激活交互

这个“激活交互”的过程叫 hydration。

Vite 本身支持 SSR 构建能力,很多框架如 Nuxt、SvelteKit、Astro 也建立在 Vite 生态上。

12.16 Hydration ​

Hydration 是水合。

服务端已经输出了 HTML:

html
<button>Like</button>

但这个按钮还没有绑定 React/Vue 的事件。

浏览器加载 JS 后,把事件、状态和组件逻辑接到已有 HTML 上,这就是 hydration。

12.17 ESM-only package ​

ESM-only package 是只发布 ESM 的包。

如果老项目还在用 CommonJS:

js
const pkg = require("some-esm-only-package");

可能报错。

解决方式可能是:

  • 改用 import。
  • 升级 Node 或构建工具。
  • 使用动态 import()。
  • 找兼容 CJS 的旧版本。

13. 从开发到生产:一段代码如何被 Vite 处理 ​

假设项目入口:

tsx
// 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 开发阶段 ​

运行:

bash
npm run dev

大致流程:

txt
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 更新

开发阶段的核心目标:

txt
启动快
改动反馈快
错误提示清晰
源码调试方便

13.2 生产阶段 ​

运行:

bash
npm run build

大致流程:

txt
1. Vite 读取配置
2. 找到入口
3. 构建完整依赖图
4. 转换 TS / JSX / CSS / 静态资源
5. tree shaking
6. code splitting
7. 压缩代码
8. 生成 hash 文件名
9. 输出 dist

产物可能是:

txt
dist/
  index.html
  assets/
    index-D8cV4x9a.js
    index-B6a2s91q.css
    vendor-react-A3f0e1d2.js
    logo-C9x1a2b3.svg

生产阶段的核心目标:

txt
加载快
缓存稳定
包体合理
运行性能好
可部署

13.3 开发和生产为什么可能表现不一致 ​

因为两者目标不同,链路也不同。

常见差异:

txt
开发不压缩,生产压缩
开发按需转换,生产全量构建依赖图
开发 sourcemap 更完整
生产会 tree shaking
生产会拆包
生产可能替换环境变量
开发和生产曾经使用不同底层工具

所以一个问题只在生产出现,并不罕见。

排查时要明确:

txt
是 dev 问题
还是 build 问题
还是 preview / 部署环境问题

14. 常见问题与面试高频问法 ​

14.1 Vite 为什么快 ​

可以分两层回答。

开发阶段:

txt
Vite 利用浏览器原生 ESM,开发时不需要先全量打包整个项目。
源码按需转换,浏览器请求哪个模块才处理哪个模块。
第三方依赖不常变,会提前预构建。
HMR 基于模块边界更新,改动反馈范围更小。

生产阶段:

txt
生产仍然要打包,因为需要 tree shaking、code splitting、压缩、缓存优化和减少请求。

如果结合最新架构:

txt
Vite 8 开始默认使用 Rolldown 作为统一 bundler,进一步提升构建性能并减少历史上 dev/build 底层工具差异。

14.2 Vite 和 Rollup 是什么关系 ​

简洁答法:

txt
Vite 是上层构建工具,Rollup 是底层打包器。
传统 Vite 生产构建使用 Rollup,Vite 插件系统也大量沿用 Rollup 插件模型。

补充 Vite 8 之后的情况:

txt
Vite 8 已转向 Rolldown。Rolldown 目标是兼容 Rollup API 和插件生态,同时用 Rust 提供更高性能。

14.3 Vite 和 Webpack 有什么区别 ​

可以这样说:

txt
Webpack 是经典 bundler,开发和生产都围绕 bundle 构建展开,生态完整,能力强。
Vite 开发时利用浏览器原生 ESM 按需服务源码,避免启动时全量打包,所以冷启动和 HMR 更快。
生产阶段二者都需要打包优化,只是底层实现和生态模型不同。

不要简单说“Vite 一定比 Webpack 好”。更准确:

txt
Vite 在现代 ESM 项目和开发体验上优势明显。
Webpack 在历史包袱、复杂遗留工程、特殊 loader/plugin 生态中仍然常见。

14.4 Rollup 和 Webpack 有什么区别 ​

常见回答:

txt
Rollup 更 ESM-first,tree shaking 能力强,输出格式干净,常用于库打包。
Webpack 更偏应用构建,loader/plugin 生态庞大,处理复杂资源和历史场景能力强。

但现在边界已经没有那么绝对:

  • Rollup 也能打应用。
  • Webpack 也能打库。
  • Vite 应用生产构建长期使用 Rollup。
  • 新工具链正在重塑边界。

面试里说“历史定位和优势场景不同”比说“谁替代谁”更稳。

14.5 ESM 为什么更利于 tree shaking ​

因为 ESM 是静态结构。

ts
import { add } from "./math";

构建工具能提前知道:

  • 从哪个模块导入。
  • 导入了哪个名字。
  • 哪个导出被使用。

CommonJS 可以运行时动态 require:

js
const mod = require(getModuleName());

这让静态分析更困难。

14.6 为什么 Vite 开发时不打包,生产时还要打包 ​

开发时:

txt
本地网络快
更需要启动快和 HMR 快
原生 ESM 按需加载更合适

生产时:

txt
真实网络有延迟
用户设备性能不一
需要减少请求
需要压缩
需要长期缓存
需要删除无用代码
需要按路由拆包

所以开发和生产策略不同。

14.7 什么是依赖预构建 ​

依赖预构建主要是 Vite 为开发环境做的优化。

它做两件事:

txt
把 CommonJS / UMD 依赖转换为 ESM
把内部模块很多的依赖合并成较少模块,减少浏览器请求数量

14.8 为什么有时候改 node_modules 后 Vite 没反应 ​

因为依赖通常被预构建和缓存了。

可尝试:

bash
rm -rf node_modules/.vite
npm run dev -- --force

Windows PowerShell:

powershell
Remove-Item -Recurse -Force node_modules/.vite
npm run dev -- --force

具体命令要看包管理器和项目脚本。

14.9 为什么生产 build 报错,dev 正常 ​

常见原因:

txt
生产构建会做完整依赖图分析
生产会 tree shaking 和 minify
生产会替换环境变量
生产目标浏览器不同
生产使用不同插件阶段
某些代码只在 build 时执行
某些依赖有错误的 package.json exports

排查步骤:

txt
先 npm run build 复现
再 npm run preview 验证产物
检查报错模块和插件
确认是否是 SSR / browser 环境差异
查看是否有动态 require、Node 内置模块、环境变量缺失

14.10 为什么浏览器不能直接 import "react" ​

因为 "react" 是 bare import。

浏览器不知道它对应:

txt
node_modules/react/index.js

构建工具会根据包解析规则把它变成可加载 URL。

也可以用 import map 告诉浏览器映射关系,但工程项目一般交给构建工具处理。

14.11 Vite 插件和 Rollup 插件有什么关系 ​

Vite 插件接口基于 Rollup 插件接口扩展而来。

很多 Rollup hook 在 Vite 中也能使用:

txt
resolveId
load
transform
buildStart
generateBundle

Vite 还增加了一些特有 hook:

txt
config
configResolved
configureServer
transformIndexHtml
handleHotUpdate

也就是说:

txt
Rollup 插件心智是理解 Vite 插件的基础
Vite 插件又增加了 dev server 和 HMR 相关能力

14.12 什么是 virtual module ​

Virtual module 是虚拟模块。

它没有真实文件,但插件可以让你这样 import:

ts
import { routes } from "virtual:routes";

然后插件在 load 阶段返回模块内容:

ts
export const routes = [...]

常见用途:

  • 自动生成路由。
  • 注入配置。
  • 生成图标集合。
  • 生成 Markdown 索引。

14.13 什么是 module graph ​

Module graph 是模块图。

它记录:

txt
哪个模块 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 也可以跟着拆出去:

txt
settings-page.js
settings-page.css

用户不访问 settings 页面,就不加载它的 CSS。

14.16 什么是 manualChunks ​

manualChunks 是 Rollup / Vite 中手动控制拆包的配置。

例如:

ts
build: {
  rollupOptions: {
    output: {
      manualChunks: {
        react: ["react", "react-dom"],
      },
    },
  },
}

它能把 React 拆成单独 chunk。

但不要为了“看起来高级”过度配置。错误拆包可能导致:

  • 请求变多。
  • 缓存收益不明显。
  • 首屏反而变慢。
  • chunk 之间依赖复杂。

14.17 什么是 library mode ​

Vite library mode 是用 Vite 构建库。

例如你写组件库:

txt
src/index.ts
  export Button
  export Dialog

希望输出:

txt
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:

ts
rollupOptions: {
  external: ["react", "react-dom"],
}

这样使用者项目自己提供 React,避免重复打包和版本冲突。

14.19 什么是 monorepo 下的构建问题 ​

Monorepo 中多个包互相引用:

txt
packages/ui
packages/utils
apps/web

常见问题:

  • 源码包没有提前编译。
  • TS path alias 和 bundler alias 不一致。
  • package.json exports 配错。
  • 同一依赖重复安装多个版本。
  • React 被打包两份导致 hooks 报错。
  • Vite 是否需要把 workspace 包纳入 optimizeDeps。

排查重点:

txt
模块最终解析到了哪里
是不是同一份 React
workspace 包发布入口是否正确
dev 和 build 是否走同一入口

14.20 为什么会出现 duplicated React ​

React hooks 有一个经典错误:

txt
Invalid hook call

原因之一是项目里存在两份 React。

例如:

txt
app/node_modules/react
packages/ui/node_modules/react

解决方向:

  • 组件库把 React 放到 peerDependencies。
  • bundler external React。
  • monorepo 确保依赖提升和版本一致。
  • 检查 alias 是否把 React 指向同一个路径。

15. 实践建议:真实项目里如何判断和排查 ​

15.1 先判断问题发生在哪个阶段 ​

遇到构建问题,不要一上来乱改配置。

先分层:

txt
安装阶段
  npm install / pnpm install 报错

类型阶段
  tsc --noEmit 报错

开发阶段
  npm run dev 报错

生产构建阶段
  npm run build 报错

产物预览阶段
  npm run preview 报错

部署环境阶段
  本地正常,线上异常

不同阶段对应完全不同的排查方向。

15.2 看报错时重点看什么 ​

优先看:

txt
第一个报错
报错文件路径
报错插件名
报错发生在 transform / resolve / load / generate 哪个阶段
是源码还是 node_modules
是 dev 还是 build

例如:

txt
[vite:resolve] Failed to resolve import "@/components/button"

这通常是 alias / tsconfig paths / 文件路径问题。

txt
[commonjs] Dynamic require of "fs" is not supported

这通常是某个依赖把 Node 环境代码带到了浏览器构建。

15.3 不要盲目加依赖或复制配置 ​

构建问题常见坏习惯:

txt
随便装一个插件
复制一段 optimizeDeps
复制一段 manualChunks
把错误依赖强行 external
加一堆 alias 掩盖问题

更稳的做法:

txt
先确认模块解析路径
再确认包的 ESM/CJS 入口
再确认 dev/build 差异
最后决定配置修复点

15.4 如何看最终 bundle ​

可以使用:

  • rollup-plugin-visualizer
  • vite-bundle-visualizer
  • Chrome DevTools Coverage
  • Network 面板
  • Source Map Explorer

重点看:

txt
哪个依赖最大
是否打入了重复依赖
是否把低频功能放进首屏
是否有 locale 全量引入
是否有图表、编辑器、地图 SDK 提前加载

15.5 如何优化包体 ​

常见策略:

txt
按路由动态 import
低频组件懒加载
避免整包引入 lodash / icon 库
检查 sideEffects
使用 ESM 版本依赖
大库按需加载
图片压缩与合适格式
删除无用 polyfill
合理设置 browserslist / build target

错误策略:

txt
盲目 manualChunks
为了分包而分包
把所有 node_modules 拆成 vendor
不看运行时瀑布图只看 dist 文件大小

15.6 如何回答“你了解 Vite 源码吗” ​

不熟源码也可以从结构回答:

txt
Vite dev server 的核心是基于原生 ESM 的按需模块服务。
请求进来后,Vite 经过插件容器执行 resolve、load、transform 等流程,把源码转换成浏览器可执行模块。
它维护 module graph 来记录模块依赖关系,并通过 WebSocket 推送 HMR 更新。
生产构建则通过底层 bundler 构建完整依赖图,做 tree shaking、code splitting 和资源优化。

这个回答体现了理解,不是背 API。


16. 一页速记 ​

16.1 核心关系 ​

txt
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 为什么快:

txt
开发时利用浏览器原生 ESM,源码按需转换,不需要启动前全量打包;
依赖提前预构建;
HMR 基于模块边界更新,所以反馈快。
生产仍然会打包优化。

ESM 为什么利于 tree shaking:

txt
ESM import/export 是静态结构,构建工具能在编译阶段分析依赖图和使用关系。

Vite 和 Rollup 关系:

txt
Vite 是上层工具,Rollup 是传统生产打包器和插件模型基础。
Vite 8 通过 Rolldown 统一底层 bundler。

Rolldown 是什么:

txt
Rolldown 是 Rust 写的 Rollup-like bundler,目标兼容 Rollup API 和插件生态,同时提升性能,并成为 Vite 的统一打包底座。

为什么生产还要打包:

txt
为了减少请求、压缩代码、tree shaking、code splitting、缓存优化和兼容目标环境。

16.3 排查口诀 ​

txt
先分阶段:install / typecheck / dev / build / preview / deploy
再看插件:resolve / load / transform / generate
再看模块:源码 / node_modules / workspace package
再看格式:ESM / CJS / UMD
再看环境:browser / node / SSR
最后改配置

17. 参考资料 ​