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.logwindow 赋值也没了。

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

可以转译成旧语法。

PromisefetchArray.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. 参考资料