一、包管理器整体流程
Module 是源码级模块,例如一个 .js、.vue、.css 文件;Chunk 是构建器根据入口、动态导入、共享依赖等规则,将若干 Module 组合出的代码块;Asset 是最终输出的 JS、CSS、图片、字体、SourceMap 等文件。一个典型构建器本质上都在做:
读取配置
→ 确定入口
→ 解析和定位模块
→ 对模块进行转换
→ 建立 Module Graph
→ Tree Shaking / 优化
→ 建立 Chunk Graph
→ Code Splitting
→ 生成运行时代码
→ 压缩、Hash、SourceMap
→ 输出 AssetsWebpack 与 Vite 的核心区别不是“一个打包、一个不打包”,而是:Webpack 在开发环境也以构建完整依赖图和 Bundle 为中心;Vite 默认开发环境把模块加载交给浏览器原生 ESM,服务器按请求转换源码,而生产环境仍然会做完整打包。Vite 的“快”主要来自开发阶段少做、按需做、缓存做,而不是生产环境不打包。
1. 1 Webpack工作流程
Webpack 简化后的完整流程:
配置阶段
→ Compiler 初始化
→ 创建 Compilation
→ 从 Entry 构建 Module Graph
→ Loader 转换和依赖分析
→ Module / Chunk 优化
→ Tree Shaking
→ SplitChunks
→ 代码生成
→ 生成 Webpack Runtime
→ Asset 优化
→ Hash 和 SourceMap
→ 输出文件从整体上看,Webpack首先读取配置,将入口、Loader、Plugin、模块解析、代码分割和输出规则转换成内部统一配置,然后创建负责整个构建生命周期的 Compiler。当一次构建开始时,Compiler创建 Compilation,Compilation从 Entry出发,通过 Resolver找到真实文件,通过 Loader转换文件内容,再由 Parser分析 import、require、import() 等依赖,由此递归构建完整的 Module Graph。模块图形成后,Webpack分析哪些导出被使用、哪些模块存在副作用、哪些模块可以删除或者合并,这一过程形成 Tree Shaking的基础;随后根据入口、动态导入和模块共享关系建立 Chunk Graph,并通过 SplitChunks提取公共模块。Chunk结构确定后,Webpack为 Module和 Chunk生成代码,同时生成负责模块缓存、模块执行、异步 Chunk加载和 HMR的 Webpack Runtime。最后对生成的 JavaScript、CSS和其他 Asset进行压缩,生成 SourceMap和 Content Hash,再把最终文件输出到磁盘或者开发服务器的内存文件系统。
这条流程是概念上的主线。真实执行时,一些步骤会交错发生,例如 Loader转换、依赖分析和 Module Graph构建是一个递归循环;SourceMap也不是最后才生成,而是从 Loader开始逐层传递,最后再合并为产物 SourceMap。
1. 配置、Compiler 与 Compilation
Webpack构建从配置开始。配置负责说明从哪里开始、模块怎样解析、不同类型文件怎样转换、构建过程中使用哪些插件、代码怎样分块以及最终输出到哪里。
import path from 'node:path'
export default {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(process.cwd(), 'dist'),
filename: 'assets/[name].[contenthash:8].js',
chunkFilename: 'assets/[name].[contenthash:8].js',
clean: true,
},
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
alias: {
'@': path.resolve(process.cwd(), 'src'),
},
},
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: 'babel-loader',
},
],
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
},
},
}Webpack CLI会合并命令行参数、配置文件和默认配置,然后进行标准化。例如单字符串 Entry会被转换成内部统一的入口描述,简写的 Loader和 Plugin配置也会被整理成统一结构。mode 不只是一个标记:production 会默认启用压缩、导出使用分析、模块合并等生产优化,development 则更重视构建速度、模块可读性和增量编译。
配置标准化以后,Webpack创建 Compiler。Compiler代表整个 Webpack实例,持有最终配置、输入输出文件系统、Resolver、缓存、文件监听器以及生命周期 Hook。所有 Plugin都会在这一阶段执行 apply(compiler),将自己的逻辑注册到不同 Hook:
class BuildLogPlugin {
apply(compiler) {
compiler.hooks.beforeRun.tap('BuildLogPlugin', () => {
console.log('构建开始')
})
compiler.hooks.done.tap('BuildLogPlugin', () => {
console.log('构建结束')
})
}
}Compiler本身不等于一次具体构建。真正执行 compiler.run() 或进入 Watch模式时,Compiler会创建 Compilation。Compilation代表当前这一轮构建,保存本次构建中的 Module、Dependency、Module Graph、Chunk、Chunk Graph、Runtime Module、Asset、错误、警告和 Hash。
普通生产构建通常是:
一个 Compiler
→ 一个 CompilationWatch模式则通常是:
一个 Compiler
→ 初次 Compilation
→ 文件变化后的 Compilation
→ 下一次文件变化后的 CompilationCompiler复用缓存、Resolver和文件系统等长期资源,每次重新构建则由新的 Compilation保存本轮状态。
本阶段总结:配置阶段定义构建规则,Compiler把配置变成可运行的构建系统,Compilation承载一次具体构建。完成这一阶段后,Webpack拥有了处理业务模块所需的环境,但还没有真正建立应用的依赖图。
2. Entry、Loader 与 Module Graph
Compilation开始工作后,会从 Entry创建第一个入口依赖。例如:
entry: './src/index.js'表示从 index.js 开始构建。多入口:
entry: {
home: './src/home.js',
admin: './src/admin.js',
}表示 Webpack需要从两个入口分别构建依赖关系,后续再分析两个入口是否存在可共享模块。
Webpack内部需要区分 Dependency和 Module。Dependency表示一个模块引用,例如 index.js 中的 import App from './App.js';Module表示实际被解析和构建的模块;Module Graph则记录 Module之间的连接关系。
对于一个模块请求,Webpack首先通过 Resolver定位真实资源。Resolver会综合考虑相对路径、绝对路径、resolve.alias、resolve.extensions、package.json 的 exports、main、module、条件导出和 node_modules查找规则。例如:
import App from '@/App'结合:
resolve: {
alias: {
'@': path.resolve(process.cwd(), 'src'),
},
extensions: ['.ts', '.tsx', '.js'],
}最终可能解析为:
/project/src/App.tsx找到真实资源后,Webpack根据 module.rules 选择 Loader。假设导入:
import './style.scss'并配置:
{
test: /\.scss$/,
use: [
'style-loader',
'css-loader',
'sass-loader',
],
}Normal Loader从右向左执行:
原始 SCSS
→ sass-loader:转换成 CSS
→ css-loader:解析 @import、url() 和 CSS Modules
→ style-loader:生成将 CSS 插入页面的 JavaScriptLoader负责“转换模块内容”,但它通常不负责完成整个依赖图分析。Loader转换完成以后,Webpack Parser会解析输出代码的 AST,识别其中的依赖:
import { add } from './math.js'
const config = require('./config.js')
button.onclick = () => {
import('./Admin.js')
}Webpack会分别识别:
./math.js:静态 ESM Dependency
./config.js:CommonJS Dependency
./Admin.js:异步 Import Dependency然后对新发现的每个 Dependency继续执行 Resolver、Loader和 Parser,形成递归循环:
处理当前 Module
→ Resolve 依赖
→ Loader 转换
→ Parser 分析
→ 创建 Dependency
→ 创建后续 Module
→ 继续递归因此,“构建 Module Graph”和“Loader转换、依赖分析”并不是完全前后分开的步骤,而是相互交替进行。每处理完一个模块,Webpack就可能发现新的依赖,再继续构建新的模块。
最终形成的 Module Graph不只记录“哪个文件导入哪个文件”,还记录导入者、被导入者、同步依赖、异步依赖、使用了哪些导出、模块是否具有副作用以及依赖属于哪个 Runtime。
例如:
index.js
├── App.js
│ ├── Header.js
│ └── Content.js
├── request.js
└── import('./Admin.js')这里 App.js、Header.js、Content.js 和 request.js 是同步依赖链,Admin.js 是异步依赖入口,这些信息会在后续决定 Chunk如何划分。
本阶段总结:Webpack从 Entry出发,Resolver负责找到真实模块,Loader负责转换模块,Parser负责分析依赖,Compilation根据新依赖继续递归构建。最终形成的 Module Graph回答了“应用由哪些模块组成、模块之间如何依赖、依赖是同步还是异步”。
3. Module/Chunk 优化、Tree Shaking 与 SplitChunks
Module Graph构建完成后,Compilation进入 seal和全局优化阶段。这一阶段先分析 Module,再根据模块关系生成和优化 Chunk。
Tree Shaking的第一步是分析模块提供了哪些导出,以及这些导出是否被使用。例如:
// math.js
export function add(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}
// index.js
import { add } from './math.js'
console.log(add(1, 2))Webpack可以确定:
add:被使用
multiply:未被使用但“导出未使用”并不一定意味着整个模块或者整条语句可以直接删除。如果模块存在顶层副作用:
console.log('注册全局插件')
export function multiply(a, b) {
return a * b
}即使 multiply 未使用,顶层 console.log 仍然需要执行。因此 Webpack还需要结合副作用分析判断模块是否可以被跳过。
库和应用可以通过 package.json 声明:
{
"sideEffects": [
"**/*.css",
"./src/polyfills.js",
"./src/register-global.js"
]
}它表示 CSS、Polyfill和全局注册文件存在副作用,不能因为导出未使用就删除。错误地配置:
{
"sideEffects": false
}可能导致 CSS、Polyfill或全局注册逻辑在生产环境被错误移除。
Webpack生产优化中与 Tree Shaking相关的主要能力包括:
providedExports:分析模块提供了什么导出
usedExports:分析哪些导出被使用
sideEffects:判断模块能否整体跳过
concatenateModules:Scope Hoisting,合并模块作用域
minimize:由 Terser 等压缩器执行最终 DCE因此 Tree Shaking不是单一开关,而是一套协作过程:Webpack先通过 Module Graph标记使用情况和副作用,代码生成阶段避免生成不需要的导出,最后由压缩器删除未使用代码。ESM由于 import/export 可以静态分析,更适合 Tree Shaking;CommonJS的 require() 和 module.exports 可能动态执行,因此通常更难精确分析。Webpack Tree Shaking 文档
完成 Module层面的分析后,Webpack根据入口、动态导入和共享关系建立 Chunk Graph。需要明确区分:
Module:源码和模块语义单位
Chunk:若干 Module 组成的构建分组
Asset:Chunk 最终生成的 JS、CSS 等文件例如:
import App from './App.js'
button.onclick = () => {
import('./Admin.js')
}可能形成:
main Chunk
├── index.js
└── App.js
admin Async Chunk
└── Admin.js如果 main 和 admin 都依赖 React,Webpack可以通过 SplitChunksPlugin把 React提取成公共 Chunk。Webpack 5中 SplitChunksPlugin由 optimization.splitChunks配置:
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
minSize: 20 * 1024,
minChunks: 1,
cacheGroups: {
framework: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'framework',
priority: 30,
reuseExistingChunk: true,
},
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
reuseExistingChunk: true,
},
},
},
}chunks: 'all' 表示同时考虑同步和异步 Chunk;minSize 控制模块达到一定大小才值得拆分;minChunks 控制模块至少被多少个 Chunk共享;cacheGroups 用于定义模块匹配和分组策略;priority 在多个分组同时匹配时决定优先级;reuseExistingChunk 尽量复用已经存在的 Chunk。
SplitChunks需要解决两个相反问题。如果完全不提取公共模块,相同依赖可能进入多个 Chunk,造成重复下载;如果把全部 node_modules 放入一个巨大 Vendor Chunk,用户可能下载当前页面根本不需要的代码,而且任意依赖升级都会让整个 Vendor缓存失效。因此合理策略通常是:稳定且体积较大的框架依赖可以单独拆分,真正被多个入口共享的代码可以提取,低频功能则通过动态 Import延迟加载,而不是机械地把所有依赖合成一个文件。
Module Graph到 Chunk Graph的转换可以概括为:
Module Graph
→ 识别静态入口
→ 识别动态 import() 异步入口
→ 识别多入口共享模块
→ Tree Shaking 和模块合并
→ SplitChunks 提取共享模块
→ 形成 Chunk Graph本阶段总结:Tree Shaking决定哪些模块和导出不需要进入最终产物,SplitChunks决定需要保留的 Module应该如何组成 Chunk。前者优化总代码量,后者优化加载时机、公共依赖复用和长期缓存。
4. 代码生成与 Webpack Runtime
Module和 Chunk结构确定后,Webpack开始生成目标代码。代码生成需要把 ESM、CommonJS、动态 Import、资源引用等不同模块语义转换成统一、可执行的形式。
概念上,模块可能被转换成模块工厂:
const __webpack_modules__ = {
'./src/math.js': (
module,
exports,
__webpack_require__,
) => {
// math.js 转换后的代码
},
}实际生产输出可能因为 Scope Hoisting而不再给每个模块单独包装函数,但“模块需要转换成最终 Chunk中的可执行代码”这一点不变。
动态导入:
import('./Admin.js')概念上会转换成:
__webpack_require__
.e('admin')
.then(() => {
return __webpack_require__('./src/Admin.js')
})其中 __webpack_require__.e('admin') 负责确保 Admin Chunk已经加载,之后再执行其中的目标 Module。
为了运行这些模块,Webpack必须生成 Runtime。Runtime主要负责:
保存 Module Factory;
缓存已经执行的 Module;
实现
__webpack_require__;定义 ESM导出 Getter;
处理 ESM和 CommonJS互操作;
计算异步 Chunk URL;
动态创建
<script>加载 Chunk;管理 Chunk加载状态;
确定 Public Path;
支持 HMR;
支持 Module Federation等扩展功能。
最简化的模块执行器如下:
const moduleCache = {}
function __webpack_require__(moduleId) {
if (moduleCache[moduleId]) {
return moduleCache[moduleId].exports
}
const module = {
exports: {},
}
moduleCache[moduleId] = module
__webpack_modules__[moduleId](
module,
module.exports,
__webpack_require__,
)
return module.exports
}模块缓存保证同一个 Module通常只执行一次。异步 Chunk下载完成后,会向 Runtime注册新的 Module Factory,Runtime再执行目标 Module。
配置:
optimization: {
runtimeChunk: 'single',
}可以把 Runtime提取成独立文件:
runtime.abc123.js
main.def456.js
framework.ghi789.js
admin.jkl012.js这样应用代码变化时,Runtime和稳定框架 Chunk不一定发生变化,有利于缓存。
本阶段总结:代码生成负责把 Module和 Dependency转换成最终可执行代码,Webpack Runtime负责在浏览器中加载、缓存、连接和执行这些模块及异步 Chunk。代码生成解决“代码长什么样”,Runtime解决“代码怎样运行”。
5. Asset 优化、Hash、SourceMap 与输出
完成代码生成以后,Webpack会把 Chunk渲染成 Asset。Asset不仅包括 JavaScript,还可能包括:
JavaScript Chunk
CSS
HTML
图片
字体
SourceMap
Manifest
License 文件
Plugin 生成的其他资源Asset进入 processAssets 等处理阶段。JavaScript压缩器会删除空白、注释、不可达代码和未使用变量,执行常量折叠、表达式合并和变量名缩短;CSS插件负责 CSS提取、压缩和 URL重写;HTML插件可以生成 HTML并注入入口、Runtime、CSS和 Preload;Manifest插件则可以生成资源映射。
这里要再次区分 Tree Shaking和压缩:Tree Shaking基于 Module Graph判断哪些代码没有被使用,Minifier则在最终 JavaScript层面真正删除和压缩这些代码。
Webpack随后为最终资源计算 Hash。生产环境通常使用:
output: {
filename: 'assets/[name].[contenthash:8].js',
chunkFilename: 'assets/[name].[contenthash:8].js',
}contenthash 尽量由当前 Asset最终内容决定:
内容没有变化
→ Content Hash 不变
→ 文件名不变
→ 浏览器和 CDN 继续使用缓存
内容发生变化
→ Content Hash 改变
→ 文件名改变
→ 浏览器请求新文件SourceMap建立最终代码到原始源码的位置映射。它实际上贯穿整个转换过程:
TypeScript / JSX
→ Loader 转换和 SourceMap
→ Webpack 模块转换和 SourceMap
→ Chunk 合并和 SourceMap
→ Minifier 压缩和 SourceMap
→ 最终 SourceMap如果中间某个 Loader修改代码却没有返回正确 SourceMap,最终浏览器断点和线上错误堆栈就可能映射错误。
生产环境可以配置:
devtool: 'hidden-source-map'生成外部 SourceMap,但不在生产代码中写入公开引用,适合上传到错误监控平台。
最后,Webpack通过 outputFileSystem 输出文件。生产环境通常写入磁盘:
dist/
├── index.html
├── assets/runtime.31ab57cd.js
├── assets/main.81fc290a.js
├── assets/framework.1c2d3e4f.js
├── assets/admin.5a6b7c8d.js
└── sourcemaps/webpack-dev-server通常把开发 Asset写入内存文件系统,然后直接通过 HTTP返回给浏览器。Watch模式下输出完成后 Compiler不会销毁,而是继续等待文件变化,再创建新的 Compilation进行增量构建。
本阶段总结:Asset优化把生成代码处理成适合部署的最终资源,SourceMap解决调试映射,Content Hash解决缓存失效,emit则把 Compilation中的 Asset真正写入磁盘或内存文件系统。至此,一次 Webpack Compilation完成。
1.2 Vite工作流程
开发环境
Vite 开发环境简化后的完整流程
读取配置和 Mode
→ 创建 Dev Server、Plugin Container、Module Graph 和 HMR Server
→ 扫描并预构建第三方依赖
→ 浏览器请求 index.html
→ 注入 Vite Client
→ 浏览器请求 ESM 入口
→ Vite 按需执行 Resolve、Load、Transform
→ Import Analysis 和模块 URL 重写
→ 浏览器递归加载并执行 ESM
→ Vite 维护 Module Graph 和转换缓存
→ 文件变化后执行 HMRVite开发环境不会在启动时先把全部业务源码打成开发 Bundle,而是先创建开发服务器、Plugin容器、文件监听器、Module Graph和 HMR连接,并对第三方依赖进行预构建。浏览器请求 index.html 后,Vite注入 HMR Client;浏览器发现 <script type="module"> 后请求入口模块,Vite才对该模块执行 Resolve、Load、Transform和 Import Analysis。浏览器解析返回的 ESM后继续请求后续依赖,于是“浏览器发现并请求依赖”和“Vite按需转换模块”不断交替进行。Vite在服务端维护 Module Graph和转换缓存,文件变化后根据依赖关系找到 HMR接受边界,只重新加载受影响模块。
1. Dev Server 与依赖预构建
执行:
viteVite首先读取 vite.config.ts、Mode和环境变量,确定当前 Command是 serve,然后创建 HTTP Server、文件监听器、WebSocket Server、Plugin Container、Module Graph、Transform Cache和 Dependency Optimizer。
Plugin Container让开发环境可以按照模块请求执行:
resolveId
→ load
→ transform这套 Hook与生产构建的 Plugin接口相近,但开发环境不是一次处理全部业务模块,而是浏览器请求哪个模块,就处理哪个模块。
在浏览器加载业务代码之前,Vite还需要解决 npm依赖问题。浏览器原生 ESM不能直接识别:
import React from 'react'因为 'react' 是裸模块说明符,不是浏览器 URL。依赖还可能以 CommonJS或 UMD形式发布,也可能包含数百个细碎 ESM文件。
因此 Vite执行依赖预构建,主要解决两个问题:
CommonJS / UMD
→ 转换成浏览器可消费的 ESM
大量细碎 ESM 文件
→ 合并为较少的依赖模块Vite 8默认使用 Rolldown进行预构建,结果通常缓存到:
node_modules/.vite/deps源码中的:
import React from 'react'会被重写为类似:
import React from '/node_modules/.vite/deps/react.js?v=xxxx'预构建依赖通常使用长期强缓存,版本 Query则在 Lockfile、依赖或相关配置变化时更新。业务源码不会被这一过程生产打包,预构建结果也不是最终生产产物。Vite 依赖预构建文档
本阶段总结:Vite启动阶段创建按需转换源码所需的开发基础设施,并把格式复杂、文件数量较多的第三方依赖预构建成稳定、可缓存、适合浏览器加载的 ESM。
2. 浏览器 ESM 加载机制
浏览器访问:
http://localhost:5173/Vite读取 index.html,运行 HTML转换 Hook,并注入:
<script type="module" src="/@vite/client"></script>/@vite/client 负责建立 WebSocket、接收 HMR消息、更新 CSS、显示错误 Overlay以及在无法热更新时刷新页面。
假设 HTML中包含:
<script type="module" src="/src/main.ts"></script>浏览器随后请求 /src/main.ts。模块脚本通常具有 defer语义,在严格模式下运行,并遵守 CORS规则。
浏览器获取模块后,会经历:
Fetch
→ Parse
→ 解析静态 Import
→ 递归获取依赖
→ Instantiate / Link
→ Evaluate假设:
import { createApp } from './app.ts'
import './style.css'
createApp()浏览器解析到两个 Import后,会继续请求 app.ts 和 style.css 对应的模块。依赖获取和链接完成后,再按照 ESM依赖顺序执行模块。
ESM导入是 Live Binding:
// counter.js
export let count = 0
export function increase() {
count++
}
// main.js
import { count, increase } from './counter.js'
increase()
console.log(count) // 1count 不是导入时的简单值拷贝,而是指向导出模块绑定。同一个规范化 URL对应的模块通常只实例化和执行一次;URL中的 Query不同则可以产生新的模块实例,这也是 Vite HMR使用时间戳 Query加载新模块的基础。
动态导入:
const module = await import('./Admin.ts')只有在代码执行到这里时才发起请求。开发环境中它是浏览器原生动态 ESM加载;生产环境中则通常成为异步 Chunk边界。
所谓“Vite使用原生 ESM”并不意味着浏览器直接读取本地源码,也不意味着源码完全不经过编译。浏览器负责模块请求、链接和执行,Vite负责 TypeScript/JSX转换、路径解析、裸导入重写、CSS处理、资源处理和 HMR。
本阶段总结:浏览器原生 ESM决定模块如何请求、链接和执行,Vite则把项目源码转换成浏览器能够理解的 ESM响应。两者配合取代了开发环境启动前必须生成完整 Bundle的模式。
3. 按需转换与 Module Graph
浏览器请求:
/src/main.tsVite收到请求后执行:
Resolve
→ Load
→ Transform
→ Import AnalysisResolve负责 Alias、相对路径、包路径和虚拟模块;Load负责读取文件或者由 Plugin返回虚拟模块;Transform负责 TypeScript、JSX、Vue SFC、React Refresh、用户 Plugin和 import.meta.env等转换。Vite 8默认使用 Oxc进行 TypeScript/JSX转换,但只负责转译,不默认执行完整项目类型检查。
随后 Import Analysis分析模块中的静态和动态 Import。例如原始代码:
import React from 'react'
import App from './App.tsx'
import './style.css'
import logo from './logo.svg'返回给浏览器的结果近似:
import React from '/node_modules/.vite/deps/react.js?v=xxxx'
import App from '/src/App.tsx'
import '/src/style.css'
import logo from '/src/logo.svg?import'Vite会把裸导入改成预构建依赖 URL,把源码依赖改成浏览器可请求 URL,并记录当前模块的 Importer、Imported Modules、文件位置、转换结果和 HMR信息。
浏览器得到 main.ts 的转换结果后,又会请求 App.tsx、style.css 和 logo.svg。每个新请求都会再次经过同一套转换流程:
浏览器发现 Import
→ 请求对应模块
→ Vite 按需转换
→ 返回 ESM
→ 浏览器继续发现下一级 Import因此 Vite开发环境的 Module Graph通常随着实际请求逐步建立。尚未访问的动态路由可能没有被请求,也没有完成开发转换。
CSS和静态资源同样被包装成模块语义。开发环境中的 CSS响应会包含把样式插入页面的逻辑,以便后续局部更新;图片导入通常返回开发服务器 URL;?raw、?url、?worker 等 Query则让同一个资源采用不同加载方式。
本阶段总结:Vite以浏览器模块请求为最小处理单位,每个模块按需经过 Resolve、Load、Transform和 Import Analysis,并被记录进服务端 Module Graph。开发环境只处理当前页面真正请求的业务模块。
4. 缓存与 HMR
Vite的开发性能不仅来自按需转换,也来自分层缓存。
预构建依赖保存在:
node_modules/.vite并在浏览器中使用强缓存。业务源码转换结果保存在服务端 Transform Cache中,文件没有变化时可以直接复用转换结果或快速返回 304。浏览器自身也会缓存已经实例化的 ESM模块。
当文件变化时,Vite根据文件路径找到 Module Graph中的对应节点,使其转换缓存失效,并运行 Plugin的 handleHotUpdate。随后沿 Importer向上查找能够接受更新的 HMR边界。
如果模块或上层模块包含:
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
console.log('模块已更新', newModule)
})
}Vite通过 WebSocket通知 @vite/client,浏览器使用新时间戳重新加载:
/src/App.tsx?t=1780000000000URL变化意味着浏览器创建新的 ESM模块实例,HMR Client再调用接受回调。Vue SFC HMR和 React Fast Refresh建立在这一基础之上,负责组件替换和条件性的状态保留。
如果更新沿 Importer传播到 HTML入口仍然找不到安全边界,Vite会执行整页刷新。模块中创建的定时器、事件监听和全局副作用也应在 dispose 中清理,否则多次 HMR后可能产生重复行为。
本阶段总结:缓存减少重复转换和重复下载,Module Graph决定文件变化影响哪些模块,HMR则通过 WebSocket和带时间戳的新 ESM URL只更新受影响模块;无法找到安全边界时才退化为整页刷新。
生产环境
Vite 生产环境简化后的完整流程
读取生产配置
→ 以 index.html 或配置入口启动构建
→ Rolldown 构建完整 Module Graph
→ Plugin 转换
→ Tree Shaking
→ Code Splitting 和共享 Chunk 提取
→ CSS 与静态资源处理
→ 代码生成和 modulepreload 优化
→ JavaScript / CSS 压缩
→ SourceMap 和 Content Hash
→ 输出 distVite生产环境不再等待浏览器逐个请求源码,而是由 Rolldown从 index.html 或配置入口主动遍历完整依赖图。所有模块经过 Resolve、Load和 Transform后,Rolldown基于完整 Module Graph执行 Tree Shaking,再根据静态入口、动态 Import和共享依赖生成 Chunk。随后 Vite提取并分割 CSS,处理图片、字体等静态资源,生成浏览器 ESM代码和 Module Preload信息,使用 Oxc和 Lightning CSS完成压缩,最后生成 SourceMap、Content Hash并写入 dist。
1. 生产配置与完整 Module Graph
执行:
vite buildVite确定当前 Command为 build,默认 Mode为 production,读取生产环境变量和 apply: 'build' 的 Plugin,再创建 Rolldown构建配置。
普通 SPA默认以 index.html 为入口。Vite分析:
<script type="module" src="/src/main.ts"></script>将 main.ts 加入构建图,并通过 Rolldown递归执行:
Resolve
→ Load
→ Transform
→ 分析静态和动态 Import
→ 继续处理依赖与开发环境的区别是,生产环境由 Bundler主动遍历完整依赖图,而不是等待浏览器请求。动态导入:
const Admin = () => import('./pages/Admin.tsx')会被记录为异步入口,主入口和 Admin共同依赖的模块则会进入共享关系分析。
生产和开发环境都可以使用 Vite Plugin的 resolveId、load 和 transform,但调用方式不同:开发环境按模块请求执行,生产环境在完整构建图上执行;生产环境还会运行 renderChunk、generateBundle、writeBundle 等生成阶段 Hook。
本阶段总结:生产环境由 Rolldown从 HTML或配置入口主动建立完整 Module Graph。完整图包含同步依赖、动态入口和共享关系,是后续 Tree Shaking、Code Splitting和资源预加载的基础。
2. Tree Shaking、Code Splitting 与资源处理
完整 Module Graph形成后,Rolldown分析哪些模块和导出真正从入口可达,结合副作用信息删除未使用代码。其基本原理与 Webpack Tree Shaking相同:ESM静态 import/export 提供可分析的导出关系,副作用信息决定模块能否整体跳过,压缩器执行最终的死代码删除。
动态 import() 通常形成异步 Chunk:
const AdminPage = () => import('./pages/AdminPage.tsx')多个入口或异步模块共享的代码,则可能进入公共 Chunk。Vite 8可以通过 Rolldown配置人工分组:
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: 'framework',
test: /node_modules[\\/](react|react-dom)[\\/]/,
priority: 20,
},
{
name: 'vendor',
test: /node_modules/,
priority: 10,
minSize: 20 * 1024,
},
],
},
},
},
},
})旧版 Vite常见的 build.rollupOptions.output.manualChunks 在 Vite 8中主要作为兼容配置存在,新的底层配置是 build.rolldownOptions.output.codeSplitting。
生产环境中的 CSS通常会从 JavaScript中提取。入口 CSS可能生成:
assets/index.abc123.css异步 Admin模块专属的 CSS则可能生成:
assets/Admin.def456.css加载 Admin JavaScript时再加载对应 CSS。
静态资源也会进入构建图。较小资源可以转换成 Data URL,较大资源输出成带 Hash文件:
assets/logo.82ab31ef.pngJavaScript和 CSS中的资源引用会被重写为最终 URL。public目录中的资源一般直接复制,不参与模块图和 Content Hash,适用于必须保持固定名称的文件。
本阶段总结:Tree Shaking决定哪些代码不进入最终产物,Code Splitting决定保留代码的加载时机和缓存边界,CSS及静态资源处理则把非 JavaScript依赖转换成可部署 Asset。
3. 代码生成、压缩、Hash 与输出
Chunk结构确定后,Rolldown为每个 Chunk生成浏览器可执行的 ESM代码。动态 Import会指向最终异步 Chunk:
import('./Admin.51ab93cd.js')Vite根据完整 Chunk Graph生成 modulepreload:
<link
rel="modulepreload" href="/assets/framework.12ab34cd.js"
>这让浏览器能够提前并行加载入口的重要依赖,降低逐层解析 Import产生的网络瀑布。对于异步 Chunk,Vite也会计算它依赖的共享 Chunk,并在动态加载时进行相应预加载优化。
Vite 8默认使用 Oxc压缩 JavaScript,使用 Lightning CSS压缩 CSS。生产配置可以是:
export default defineConfig({
build: {
target: 'baseline-widely-available',
minify: 'oxc',
cssMinify: 'lightningcss',
sourcemap: 'hidden',
},
})build.target 决定输出代码兼容的浏览器范围。目标越旧,通常需要更多语法降级和辅助代码;目标越现代,可以保留更多原生语法。但语法转换不等于自动补齐所有浏览器 API Polyfill。
SourceMap需要组合:
原始 TS / TSX
→ Oxc 转换
→ Vite Plugin 转换
→ Rolldown 合并
→ Minifier 压缩
→ 最终 SourceMapContent Hash则根据最终文件内容生成:
assets/index.a1b2c3d4.js
assets/index.e5f6g7h8.css
assets/framework.i9j0k1l2.js
assets/Admin.m3n4o5p6.js内容没有变化的文件应尽量保持文件名不变,从而继续命中浏览器和 CDN缓存。Vite最后执行 generateBundle、writeBundle 等 Plugin Hook,并把结果写入:
dist/
├── index.html
└── assets/
├── index.a1b2c3d4.js
├── index.e5f6g7h8.css
├── framework.i9j0k1l2.js
└── Admin.m3n4o5p6.jsvite preview 只负责本地预览已经生成的 dist,不是正式生产服务器,也不负责生产级缓存、压缩、鉴权、CSP和高可用。
本阶段总结:代码生成把 Chunk Graph变成浏览器可执行的生产 ESM,Module Preload降低网络瀑布,压缩减少传输与解析体积,SourceMap提供调试映射,Content Hash提供缓存失效,最终所有资源写入 dist。
二、HMR热更新
HMR,Hot Module Replacement,解决的不是“文件变化后刷新页面”,而是“页面已经运行起来后,怎样只替换发生变化的模块,并让应用在不重新加载整个文档的情况下继续运行”。因此,一套完整的 HMR 必须同时处理服务端文件监听、模块依赖图失效、更新范围计算、客户端更新传输、旧模块副作用清理、新模块安装以及失败回退。Webpack 和 Vite 都遵循这个基本思想,但由于 Webpack 开发环境运行的是打包后的模块系统,而 Vite 开发环境主要依靠浏览器原生 ESM,两者生成更新、传输更新和安装新模块的方式有明显区别。
Webpack热更新
Webpack HMR 完整流程:
文件变化
→ Webpack 增量编译
→ 生成 Manifest 和 Hot-update Chunk
→ WebSocket 通知浏览器新 Hash
→ 浏览器请求 Manifest 和更新 Chunk
→ Runtime 沿 Parents 查找 Accept Boundary
→ 执行 Dispose
→ 删除旧模块缓存
→ 替换模块工厂
→ 重新执行模块并调用 Accept
→ 失败则整页刷新Webpack HMR 建立在增量编译和 Webpack Runtime 之上。文件变化后,Webpack使相关模块失效并重新编译,通过比较新旧 Compilation 生成 Hot-update Manifest 和 Hot-update Chunk;浏览器 Runtime 收到新编译通知后,请求 Manifest 和更新 Chunk,沿模块 Parents 查找 accept 边界,先执行旧模块的 dispose 清理副作用,再替换模块工厂、清除模块缓存并重新执行模块,最后调用 accept 回调。更新传播到入口仍找不到接收边界时,就退化为整页刷新。
1. 首次编译时注入 HMR Runtime
Webpack Dev Server 启动后,Webpack 从 Entry 出发建立 ModuleGraph,并生成开发 Chunk。开启 HMR 后,Webpack 会在产物中额外注入 HMR Runtime。
HMR Runtime 会在浏览器中维护:
当前编译 Hash;
模块工厂;
已执行模块的缓存;
模块的 Parents 和 Children;
accept、dispose等回调;Hot-update Manifest 和 Chunk 的加载能力。
因此,Webpack HMR 的运行基础不是浏览器原生模块系统,而是 Webpack 自己生成的模块系统。
2. 文件变化后执行增量编译
开发者修改文件后,文件监听器会通知 Webpack 当前构建已经失效。Webpack 根据 ModuleGraph 和构建缓存确定受影响模块,并重新执行这些模块对应的 Loader、依赖解析和代码生成。
例如:
App.js
└── UserPage.js
└── Button.js当 Button.js 修改后,Webpack 会重新构建 Button.js。如果它的导出结构或依赖关系发生变化,还可能进一步影响导入它的模块和相关 Chunk。
这一步仍然属于一次 Compilation,但属于增量 Compilation:没有变化且缓存有效的模块可以直接复用。
3. 生成 Hot-update Manifest 和 Hot-update Chunk
增量编译完成后,Webpack 会比较新旧两次 Compilation,确定哪些模块和 Chunk 发生了变化。随后生成两类更新文件,Manifest 负责回答“哪里变了”,Hot-update Chunk 负责提供“新的代码是什么”。
Hot-update Manifest 用于描述:
新的编译 Hash
哪些 Chunk 发生了变化
哪些 Chunk 或模块被删除Hot-update Chunk 则包含:
更新后的模块工厂
新增模块
被删除模块的标记
可能变化的 Runtime 代码为了正确替换模块,Webpack 必须尽量维持前后两次编译中 Module ID 和 Chunk ID 的稳定关系。
4. 浏览器检查并下载更新
Webpack Dev Server 通过 WebSocket 告诉浏览器:“服务端已经完成了一次新编译,并产生了新的 Hash。”
浏览器中的 HMR Client 收到消息后,会触发 Runtime 的更新检查流程:
请求 Hot-update Manifest
→ 找出当前页面已加载且发生变化的 Chunk
→ 下载对应的 Hot-update Chunk
→ 暂存新模块工厂
→ 等待所有更新资源准备完成WebSocket 通常只负责传递编译状态和新 Hash,真正的更新代码仍然通过 HTTP 请求 Hot-update Chunk 获取。
5. 沿 Parent 关系寻找 HMR Boundary
更新资源下载完成后,Webpack 不能立即替换模块,因为它必须先确定更新是否能够被应用安全接收。
假设:
App → UserPage → Button当 Button 变化时,可能出现三种情况。
如果 Button 调用了:
module.hot.accept()那么它是 Self-Accept Boundary,可以接收自身更新。
如果 UserPage 调用了:
module.hot.accept('./Button', callback)那么 UserPage 可以接收 Button 的更新,更新传播到 UserPage 停止。
如果 Button、UserPage 和 App 都没有接收更新,变化会一直传播到入口模块。入口也无法接收时,这次 HMR 不能安全应用,需要整页刷新。
如果某个模块显式调用 decline 拒绝更新,或者模块具有无法恢复的副作用,更新也可能失败。
6. Dispose 旧模块并安装新模块
找到合法 HMR Boundary 后,Webpack Runtime 开始正式应用更新。首先执行旧模块的 dispose 回调,用于清理旧模块产生的副作用:
module.hot.dispose((data) => {
clearInterval(timer)
window.removeEventListener('resize', handler)
data.previousState = state
})需要清理的内容通常包括:
定时器;
DOM 事件;
WebSocket;
手动创建的 DOM;
全局注册;
订阅关系。
dispose 参数中的 data 可以把部分数据传递给新模块,新模块可以通过 module.hot.data 读取。完成清理后,Webpack Runtime 会从模块缓存中删除旧模块,用 Hot-update Chunk 中的新模块工厂替换旧模块工厂,然后重新执行 Self-Accept 模块或调用父模块注册的 Accept 回调。
更新成功后,Runtime 修改当前编译 Hash,重新回到等待状态:
idle
→ check
→ prepare
→ ready
→ dispose
→ apply
→ idle如果新模块执行报错、某条依赖传播链没有接收者,或者更新过程中状态已经无法恢复,Runtime 会进入 Abort 或 Fail,Webpack Dev Server 通常执行整页刷新。
VIte热更新
Vite HMR 完整流程:
文件变化
→ Vite 找到对应 ModuleGraph 节点
→ 插件通过 handleHotUpdate 确定影响范围
→ 失效模块转换缓存
→ 沿 Importers 查找 Accept Boundary
→ WebSocket 发送更新描述
→ 浏览器动态导入带时间戳的新 URL
→ Vite 重新转换并返回新模块
→ 客户端执行 Dispose
→ 调用 Accept 或框架 HMR Runtime
→ 无安全边界则整页刷新Vite HMR 建立在原生 ESM 和服务端 ModuleGraph 之上。文件变化后,Vite 找到对应模块节点,通过 handleHotUpdate 确定影响范围,使转换缓存失效,并沿 Importer 关系寻找 import.meta.hot.accept 边界;找到边界后通过 WebSocket 发送更新描述,客户端再动态导入带时间戳的新模块 URL,由于 URL 变化,浏览器会创建新的模块记录,最后由 Accept 回调或 Vue HMR、React Fast Refresh 把新实现应用到当前页面。Vite不会自动替换旧 ESM 模块的所有绑定,找不到安全边界时同样需要整页刷新。
1. 建立服务端 ModuleGraph 和客户端 HMR Context
Vite Dev Server 启动后,会创建文件监听器、WebSocket 服务、模块转换缓存和 ModuleGraph。
浏览器从 <script type="module"> 入口开始请求模块,Vite 在返回模块时分析它的 Import 关系,并逐步记录:
当前模块导入了哪些模块
当前模块被哪些 Importer 导入
模块对应的真实文件
模块转换结果
模块是否调用了 import.meta.hot.accept与此同时,Vite 会在 HTML 中注入 HMR Client。每个使用 import.meta.hot 的模块,都可以在客户端注册 Accept、Dispose、Prune 等回调。
2. 文件变化后确定受影响模块
文件监听器发现文件变化后,Vite 根据真实文件路径查找对应的 ModuleGraph 节点。一个文件可能对应多个模块节点。Vite 会调用插件的 handleHotUpdate。React 框架插件可以分析具体变化内容,缩小受影响范围。Vite 会使这些模块的转换缓存和 ModuleGraph 状态失效,确保浏览器下一次请求时获得重新转换后的代码。
3. 沿 Importer 关系寻找 HMR Boundary
Vite 接下来沿 ModuleGraph 的 Importer 方向向上传播更新。
假设:
App.vue → UserPage.vue → Button.vue如果 Button.vue 可以接收自身更新,那么传播在 Button.vue 停止。如果 UserPage.vue 声明:
if (import.meta.hot) {
import.meta.hot.accept('./Button.vue', (newButtonModule) => {
// 使用新的 Button 模块
})
}那么 UserPage.vue 是 Button.vue 的依赖接收边界。
如果当前模块不能接收更新,Vite 就继续向它的 Importer 传播。只要某一条传播路径最终到达入口或 HTML,仍然没有找到接收边界,Vite 就不能保证局部更新后的应用状态正确,需要发送 Full Reload。所以,Vite HMR 的核心同样不是文件监听,而是在 ModuleGraph 中计算:
哪个模块失效
哪些 Importer 受影响
更新应该传播到哪里停止
是否存在无法接收的传播路径4. 通过 WebSocket 发送更新描述
找到 HMR Boundary 后,Vite 会通过 WebSocket 向浏览器发送更新消息。消息通常会描述:
哪个模块发生变化
哪个模块负责接收更新
模块更新时间戳
更新是 JavaScript 还是 CSSVite 的 WebSocket 通常不直接发送完整的新模块代码,它主要告诉浏览器“应该重新加载哪个模块”。如果无法找到合法边界,服务端发送的不是模块更新消息,而是 Full Reload 消息。
5. 浏览器请求带时间戳的新 ESM 模块
Vite HMR Client 收到 JavaScript 更新后,会通过动态 import() 请求新的模块 URL,例如:
/src/Button.vue?t=1784023200000浏览器的 Module Map 会缓存已经加载过的 ESM 模块。如果继续请求原来的 /src/Button.vue,浏览器可能直接复用旧模块记录,不会重新执行。
增加时间戳后,URL 发生变化,浏览器会把它视为一个新的 ESM 模块,重新完成:
HTTP 获取
→ Parse
→ Link
→ EvaluateVite Dev Server 收到带时间戳的新请求后,会重新运行相关 Plugin Transform,并返回最新代码。这就是 Vite HMR 利用原生 ESM 更新模块的核心:不是修改旧 Module Record,而是通过新 URL 创建新的 Module Record。
6. 执行 Dispose 和 Accept 回调
新模块加载完成后,Vite HMR Client 会先处理旧模块的 Dispose,再调用对应 HMR Boundary 的 Accept 回调,并把新的模块命名空间传递给接收者。
例如:
if (import.meta.hot) {
import.meta.hot.dispose((data) => {
clearInterval(timer)
data.count = count
})
import.meta.hot.accept((newModule) => {
console.log('新模块:', newModule)
})
}旧模块可以通过 import.meta.hot.data 把数据传递给新模块,但普通模块局部变量不会自动保留。
这里必须强调:
Vite 不会自动把旧模块的所有 Import Binding 改成指向新模块。
旧 URL 和带时间戳的新 URL 是两个不同的 ESM 模块记录。Accept Boundary 或框架 HMR Runtime 必须主动使用新的模块命名空间更新现有应用。
Vue HMR 会根据组件 ID 替换组件定义或渲染函数,React Fast Refresh 会比较组件签名并决定是否保留 Hook 状态。框架判断不兼容时,会重新挂载局部组件,而不是强行保留错误状态。
对于已经不再被任何模块引用的模块,Vite 还会调用 Prune 回调,让模块清理长期副作用。CSS 更新则通常直接替换对应的 <style> 内容或样式链接,不需要重新执行完整 JavaScript 模块树。
三、Loader 与 Plugin 机制
Loader 解决的是“Webpack 如何理解这个文件”,Plugin 解决的是“Webpack 在整个构建过程中还需要做什么”。Loader 是模块转换管道,Plugin 是编译生命周期扩展机制。两者并不是谁更强,而是作用层级不同;某些复杂功能会同时提供 Loader 和 Plugin,让 Loader 收集模块信息,再由 Plugin 汇总和生成全局产物。
用一个真实的 React + TypeScript + Sass 项目配置说明为例:
// webpack.config.js
const path = require('node:path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
const ReactRefreshWebpackPlugin = require(
'@pmmmwh/react-refresh-webpack-plugin',
)
module.exports = (_, argv) => {
const isProduction = argv.mode === 'production'
return {
mode: isProduction ? 'production' : 'development',
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: isProduction
? 'assets/js/[name].[contenthash:8].js'
: 'assets/js/[name].js',
chunkFilename: isProduction
? 'assets/js/[name].[contenthash:8].chunk.js'
: 'assets/js/[name].chunk.js',
publicPath: '/',
clean: true,
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js'],
},
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
sourceMaps: true,
presets: [
[
'@babel/preset-env',
{
modules: false,
},
],
[
'@babel/preset-react',
{
runtime: 'automatic',
development: !isProduction,
},
],
'@babel/preset-typescript',
],
plugins: [
!isProduction && 'react-refresh/babel',
].filter(Boolean),
},
},
},
{
test: /\.s[ac]ss$/i,
use: [
isProduction
? MiniCssExtractPlugin.loader
: 'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 2,
sourceMap: !isProduction,
modules: {
auto: /\.module\.s[ac]ss$/i,
localIdentName: isProduction
? '[hash:base64:8]'
: '[path][name]__[local]',
},
},
},
{
loader: 'postcss-loader',
options: {
sourceMap: !isProduction,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: !isProduction,
},
},
],
},
{
test: /\.(png|jpe?g|gif|webp|svg)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 4 * 1024,
},
},
generator: {
filename: 'assets/images/[name].[contenthash:8][ext]',
},
},
{
test: /\.(woff2?|eot|ttf|otf)$/i,
type: 'asset/resource',
generator: {
filename: 'assets/fonts/[name].[contenthash:8][ext]',
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new ForkTsCheckerWebpackPlugin(),
new webpack.DefinePlugin({
__APP_VERSION__: JSON.stringify(
process.env.npm_package_version ?? 'development',
),
}),
!isProduction && new ReactRefreshWebpackPlugin(),
isProduction &&
new MiniCssExtractPlugin({
filename: 'assets/css/[name].[contenthash:8].css',
chunkFilename: 'assets/css/[name].[contenthash:8].chunk.css',
}),
].filter(Boolean),
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
},
minimizer: [
'...',
new CssMinimizerPlugin(),
],
},
devtool: isProduction
? 'hidden-source-map'
: 'eval-source-map',
devServer: {
hot: true,
historyApiFallback: true,
port: 3000,
},
}
}Webpack的Loader
Webpack的Loader是把不同类型的文件转换成模块。Webpack 从入口递归分析依赖时,会遇到 .tsx、.scss、图片等不同类型的资源。Loader 的作用,就是在这些文件进入 Webpack 模块依赖图之前,对文件内容进行转换,使其变成 Webpack 能继续解析和处理的模块。
Webpack Loader Runner 会先从左向右调用每个 Loader 的 Pitch 方法。Pitch 发生在资源文件读取和内容转换之前,主要用于准备数据、检查后续请求或者控制 Loader 链。如果所有 Pitch 都没有返回结果,Webpack 会读取资源文件,再从右向左调用 Normal Loader,完成真正的内容转换;如果某个 Pitch 提前返回结果,就会终止继续向右执行和读取资源,并把返回结果交给该 Loader 左侧的 Normal Loader 继续处理。
正常流程:
a.pitch → b.pitch → c.pitch → 读取文件
↓
a.normal ← b.normal ← c.normal
b.pitch 短路:
a.pitch → b.pitch 返回结果
↓
a.normal
↓
输出Webpack 解析到 App.tsx 时,匹配到 babel-loader。babel-loader 调用 Babel,将 TypeScript 类型语法删除,并把 JSX 转换为 JavaScript。
App.tsx
↓ babel-loader
删除 TypeScript 类型
转换 JSX
应用目标浏览器语法降级
↓
JavaScript 模块
↓
Webpack 分析其中的 import/export需要注意,Babel 的 TypeScript Preset 主要负责“删除类型语法”,不会完成完整的 TypeScript 类型检查。实际项目通常再通过 ForkTsCheckerWebpackPlugin,或者单独运行 tsc --noEmit 完成类型检查。
当 Webpack 解析到:
import styles from './App.module.scss'多个 Loader 会按照配置从右向左执行:
App.module.scss
↓ sass-loader
将 Sass 编译成 CSS
↓ postcss-loader
执行 Autoprefixer 等 PostCSS 转换
↓ css-loader
解析 @import、url(),生成 CSS Modules 类名映射
↓ style-loader
生成把 CSS 注入 <style> 标签的运行时代码最终 App.module.scss 不再只是一个样式文件,而是成为了 Webpack 模块图中的一个模块。styles.container 则来自 css-loader 生成的 CSS Modules 导出对象。
生产环境一般不使用 style-loader,而是换成:
MiniCssExtractPlugin.loader由它和 MiniCssExtractPlugin 配合,把 CSS 提取成单独的 .css 文件。
Webpack的plugin
Loader 只能处理某个模块的内容,但很多工程能力并不属于“单个文件转换”。
例如:
构建开始时清理输出目录;
生成并注入 HTML;
定义编译时环境变量;
执行 TypeScript 类型检查;
提取和压缩 CSS;
修改 Chunk;
生成资源清单;
上传 SourceMap;
输出构建分析报告。
这些能力需要知道整个构建的状态或者访问最终构建产物,因此由 Plugin 实现。
Webpack Plugin 工作在编译级别。它通过 apply(compiler) 接入 Webpack,并通过 Tapable Hook 介入初始化、模块构建、Chunk 优化、资源生成和构建结束等生命周期。凡是需要访问全局编译状态、修改依赖图、操作 Chunk 或最终 Assets 的功能,通常都应该由 Plugin 实现。
在 React 项目中,常见配置如下:
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const ForkTsCheckerWebpackPlugin =
require('fork-ts-checker-webpack-plugin')
const ReactRefreshWebpackPlugin =
require('@pmmmwh/react-refresh-webpack-plugin')
module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html'
}),
new webpack.DefinePlugin({
__API_URL__: JSON.stringify(process.env.API_URL)
}),
new ForkTsCheckerWebpackPlugin(),
new ReactRefreshWebpackPlugin(),
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css'
})
]
}Webpack 启动时会创建长期存在的 Compiler,并对每个 Plugin 调用:
plugin.apply(compiler)Plugin 在 apply 中订阅 Webpack 暴露的生命周期 Hook:
class BuildInfoPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap(
'BuildInfoPlugin',
compilation => {
compilation.hooks.processAssets.tap(
{
name: 'BuildInfoPlugin',
stage:
compiler.webpack.Compilation
.PROCESS_ASSETS_STAGE_ADDITIONAL
},
assets => {
compilation.emitAsset(
'build-info.json',
new compiler.webpack.sources.RawSource(
JSON.stringify({
time: Date.now()
})
)
)
}
)
}
)
}
}这个 Plugin 没有转换某一个 .tsx 或 .scss 文件,而是在产物生成阶段向整个构建结果中添加了 build-info.json。
常见 Plugin 的职责可以放到完整构建过程中理解:
Webpack 启动
↓
DefinePlugin 注入编译时常量
↓
构建模块依赖图
↓
ForkTsCheckerWebpackPlugin 并行进行类型检查
ReactRefreshWebpackPlugin 注入开发环境刷新能力
↓
生成 Chunk 和 Assets
↓
MiniCssExtractPlugin 汇总 Loader 收集的 CSS
↓
HtmlWebpackPlugin 生成 HTML 并注入最终资源地址
↓
输出构建产物这里还体现了 Loader 与 Plugin 的协作。例如生产环境提取 CSS 时:
MiniCssExtractPlugin.loader
负责在模块处理阶段收集当前 CSS
↓
MiniCssExtractPlugin
负责在产物阶段汇总 CSS 并生成独立文件前者是 Loader,因为它参与单个 CSS 模块的处理;后者是 Plugin,因为它需要汇总整个 Compilation 中的 CSS 并生成最终资源。
项目中常见的plugin:
1. HtmlWebpackPlugin
new HtmlWebpackPlugin({
template: './public/index.html',
})Webpack 生产文件通常带有 Content Hash:
runtime.a81d201c.js
main.f671e23a.js
main.7ac5123e.css开发者不应该手动把这些文件名写进 HTML。HtmlWebpackPlugin 会读取 Compilation 中的入口和资产信息,生成最终 HTML,并自动注入当前构建真正产生的 JS、CSS。
它操作的是整个构建结果,而不是某一个源码模块,所以属于 Plugin。
2. ForkTsCheckerWebpackPlugin
new ForkTsCheckerWebpackPlugin()它负责在独立进程中执行完整 TypeScript 类型检查。
babel-loader 可以逐文件删除类型并快速输出 JavaScript,但完整类型检查需要了解整个 TypeScript Program、跨文件类型关系和 tsconfig.json。这类全项目工作更适合 Plugin,而不是让每个 Loader 重复执行。
3. ReactRefreshWebpackPlugin
!isProduction && new ReactRefreshWebpackPlugin()React Fast Refresh 需要两部分协作。
第一部分是 babel-loader 中的:
plugins: ['react-refresh/babel']它在转换 React 组件时注入组件注册信息和 Refresh Signature。
第二部分是:
new ReactRefreshWebpackPlugin()它负责把 React Refresh Runtime、Webpack HMR 和错误覆盖层等能力连接起来。
完整关系是:
react-refresh/babel
→ 在单个 React 模块中注入 Refresh 标记
ReactRefreshWebpackPlugin
→ 在整个 Webpack Runtime 中接入 Fast Refresh二者都只应在开发环境启用。React Refresh Webpack Plugin
4. MiniCssExtractPlugin
new MiniCssExtractPlugin({
filename: 'assets/css/[name].[contenthash:8].css',
})它从整个 ChunkGraph 中收集 CSS,并输出独立的 CSS 文件。它必须和 MiniCssExtractPlugin.loader 配合:
Loader 负责模块阶段
Plugin 负责资产生成阶段5. DefinePlugin
new webpack.DefinePlugin({
__APP_VERSION__: JSON.stringify('1.0.0'),
})DefinePlugin 在编译阶段执行标识符替换。例如:
console.log(__APP_VERSION__)会被替换为:
console.log('1.0.0')它不是在浏览器运行时创建一个全局变量,而是在构建阶段替换源码表达式。配合生产环境常量,压缩器还能进一步删除不可达分支。
6. CssMinimizerPlugin
optimization: {
minimizer: [
'...',
new CssMinimizerPlugin(),
],
}它在资产优化阶段压缩最终 CSS。'...' 表示保留 Webpack 生产模式默认的 JavaScript Minimizer,再额外添加 CSS Minimizer。
它处理的是最终 CSS 资产,不是单个 Sass 模块,因此属于 Plugin。
Vite的Plugin
Vite 没有独立的 Loader 机制,模块解析、内容加载、代码转换和构建生命周期扩展都由 Plugin 完成。开发环境中,Plugin 随浏览器的 ESM 请求按需执行 resolveId → load → transform;生产环境中,同一套 Hook 参与完整依赖图构建,并通过 generateBundle 等 Hook 操作最终产物。Vite 还增加了 configureServer、transformIndexHtml、handleHotUpdate 等开发服务器专属能力。
这是因为 Vite 开发环境并不是先把整个项目打包后再交给浏览器,而是以浏览器原生 ESM 请求为驱动。浏览器请求哪个模块,Vite 才解析、加载并转换哪个模块。因此,Vite Plugin 不仅要能转换代码,还要能够参与模块解析、开发服务器、HTML 转换和 HMR。
一个 Vite Plugin 可以同时提供:
export default function myPlugin() {
return {
name: 'my-plugin',
resolveId(source, importer) {
// 决定 import 对应哪个模块
},
load(id) {
// 提供模块内容,可以生成虚拟模块
},
transform(code, id) {
// 转换模块内容
},
transformIndexHtml(html) {
// 转换 index.html
},
handleHotUpdate(context) {
// 控制模块热更新范围
},
generateBundle(options, bundle) {
// 生产构建阶段检查或修改产物
}
}
}浏览器请求一个模块时,Vite 的模块处理过程可以概括为:
浏览器请求 /src/App.tsx
↓
resolveId:把导入地址解析为模块 ID
↓
load:读取文件或返回虚拟模块内容
↓
transform:转换 TypeScript、JSX 等代码
↓
分析 import 并重写为浏览器可访问的 ESM URL
↓
通过 HTTP 返回给浏览器开发环境中,resolveId、load、transform 会针对浏览器实际请求的模块按需执行,而不是在启动时处理整个项目。
如果文件发生变化,Vite 还可以调用 handleHotUpdate,让插件决定哪些模块失效、更新需要传播到哪里。然后 Vite 通过 WebSocket 通知浏览器,浏览器再通过新的 ESM URL 获取更新后的模块。
生产构建时,Vite 会从入口建立完整依赖图。同一个 Plugin 的 resolveId、load、transform 仍然可以参与模块处理,但此时它们工作在完整构建过程中。
等模块图、Chunk 和资源生成后,还会执行 generateBundle、writeBundle 等输出阶段 Hook:
入口
↓
resolveId / load / transform
↓
建立完整模块依赖图
↓
Tree Shaking 和 Code Splitting
↓
生成 Chunk 与 Assets
↓
generateBundle
↓
writeBundle所以 Vite Plugin 用一套 API 同时服务两种模式:
开发环境:按浏览器 ESM 请求转换单个模块
生产环境:参与完整模块图构建和产物生成Tree Shaking的原理
Tree Shaking 不是简单地“删除没有被调用的函数”,而是:构建工具从入口建立模块依赖图,利用 ESM 静态结构分析每个模块提供了哪些导出、哪些导出被使用,再结合副作用判断,删除不会影响程序运行的模块、导出和语句。
例如:
// math.js
export function add(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}
console.log('math loaded')// main.js
import { add } from './math.js'
console.log(add(1, 2))这里 multiply 没被使用,可以删除;但是 console.log('math loaded') 是模块顶层副作用,即使 multiply 没用,也不能把整个 math.js 直接删除。
因此 Tree Shaking 永远围绕两个问题展开:
这段代码是否被使用?
+
删除它是否会改变程序行为?只有同时满足“未使用”和“无副作用”,代码才能安全删除。
Webpack 的 Tree Shaking 原理
Webpack 从入口构建 ModuleGraph,分析 ESM 模块提供和使用的导出,把使用关系沿 import 和 re-export 向下传播;随后结合 sideEffects 判断未使用模块能否整体排除,再通过代码生成和压缩器删除未使用且无副作用的导出与语句。usedExports 负责导出使用分析,sideEffects 负责模块级删除判断,Minifier 负责最终语句级 DCE。
Webpack 的 Tree Shaking 发生在生产构建过程中。它不是由某一个阶段一次完成,而是由“Webpack 模块图分析 + 副作用判断 + 代码生成 + 压缩器 DCE”共同完成。
1. Webpack 先从入口建立模块依赖图
假设入口代码是:
import { add } from './math.js'
console.log(add(1, 2))Webpack 从 entry 开始解析源码,并记录:
main.js 导入了 math.js
math.js 提供 add 和 multiply
main.js 只使用了 add由于 ESM 的 import/export 在代码执行前就可以确定,Webpack 可以静态记录模块提供的导出和被使用的导出。
math.js
├── add:被 main.js 使用
└── multiply:没有被使用Webpack 中通常用 providedExports 表示模块提供的导出,用 usedExports 表示真正被其他模块使用的导出。
如果 Babel 在 Webpack 分析前把 ESM 转成了 CommonJS:
const math = require('./math')或者:
module.exports = {
add,
multiply
}静态导出关系会变得更难分析。因此 Webpack 项目通常需要保留 ESM:
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
modules: false
}]
]
}这里 modules: false 表示 Babel 不把 ESM 转成 CommonJS,而是交给 Webpack 分析。
2. Webpack 沿依赖图传播导出使用关系
导出使用关系不只分析一层,还会沿模块图继续传播。
例如:
// components/index.js
export { Button } from './Button.js'
export { Modal } from './Modal.js'// main.js
import { Button } from './components/index.js'Webpack 会沿重导出关系分析出:
main.js 使用 Button
↓
components/index.js 的 Button 重导出被使用
↓
Button.js 需要保留
↓
Modal.js 的导出没有被使用因此 Barrel 文件并不一定导致所有重导出模块最终进入产物。真正影响结果的是模块格式是否可以静态分析,以及这些模块是否存在副作用。
3. Webpack 使用 sideEffects 判断整个模块能不能删除
usedExports 解决的是:一个保留下来的模块中,哪些导出被使用了?
sideEffects 解决的是:如果一个模块的导出完全没被使用,能不能连这个模块及其依赖一起删除?
两者不是一回事。
例如:
// polyfill.js
Array.prototype.first = function () {
return this[0]
}import './polyfill.js'polyfill.js 没有导出,但导入它会修改全局原型,因此它具有副作用,不能因为“没有导出被使用”就删除。
组件库可以在 package.json 中声明:
{
"sideEffects": [
"**/*.css",
"**/*.scss",
"./src/polyfill.js"
]
}这相当于告诉构建工具:
普通 JS 模块:如果导出完全没使用,可以整模块删除
CSS、SCSS、polyfill.js:即使没有导出,也必须保留其执行效果如果错误配置:
{
"sideEffects": false
}但组件内部存在:
import './Button.css'那么生产构建中 CSS 可能被当成无副作用模块删除,最终出现“组件存在,但样式丢失”的问题。
所以 sideEffects: false 不是“开启 Tree Shaking”的固定写法,而是开发者对构建工具作出的副作用承诺。声明错误会直接改变程序行为。
4. Webpack 标记,压缩器完成最终删除
Webpack 得到导出使用情况后,会在代码生成阶段不再生成不需要的导出关系,并尽可能删除无副作用的模块。但语句级别的最终删除通常还需要压缩器配合。
例如:
function add(a, b) {
return a + b
}
function multiply(a, b) {
return a * b
}
console.log(add(1, 2))Webpack 已经知道 multiply 对外没有被使用,压缩器会继续判断它没有副作用,最终从生产代码中删除。
但是下面的代码不一定能被自动删除:
const component = createComponent()即使 component 没被使用,压缩器也无法确定 createComponent() 内部是否修改了全局状态。可以在确认它是纯函数调用后添加:
const component = /*#__PURE__*/ createComponent()这表示调用本身没有副作用;当返回值没有被使用时,可以删除整个调用。
/*#__PURE__*/ 是语句级提示,package.json 中的 sideEffects 是模块级提示,两者作用范围不同。
Webpack 生产配置可以概括为:
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
sideEffects: true,
minimize: true
}
}这些优化在生产模式通常已经默认启用,不必重复配置。Webpack 官方也明确区分了 usedExports 和模块级 sideEffects。
Vite 的 Tree Shaking 原理
Vite 开发环境以浏览器 ESM 请求为中心,主要进行按需模块转换,不会对业务源码执行完整的应用级 Tree Shaking;生产构建时,Vite 8 使用 Rolldown 从入口建立完整模块图,通过 ESM 引用关系追踪入口真正需要的模块、导出和语句,再结合静态副作用分析、sideEffects 和 /*#__PURE__*/ 等信息删除无用代码,最后与 Chunk 生成和压缩共同产出优化后的资源。
1. Vite 开发环境基本不对业务源码做完整 Tree Shaking
Vite 开发环境基于浏览器原生 ESM,浏览器请求哪个模块,Vite 就按需转换并返回哪个模块。
仍然使用前面的代码:
// main.js
import { add } from './math.js'浏览器会继续请求:
/src/math.jsVite 会把整个 math.js 转换后返回。即使浏览器只导入了 add,返回的模块中通常仍然包含 multiply。
浏览器请求 main.js
↓
发现 import './math.js'
↓
继续请求 math.js
↓
Vite 转换并返回 math.js
↓
浏览器完成 ESM 链接和执行原因是开发环境的目标是快速启动、按需转换、准确 SourceMap 和高效 HMR。如果启动时就遍历完整模块图并执行生产级 Tree Shaking,反而会失去 Vite 开发环境的性能优势。
因此需要区分:
Vite 开发环境的“按需加载模块”不等于 Tree Shaking。
按需加载表示没有被当前页面依赖的模块不会被请求;Tree Shaking 表示一个已经进入构建的模块内部,未使用的导出和语句可以从最终产物中删除。这是两个不同层次的优化。
依赖预构建过程中可能包含针对第三方依赖的图分析和优化,但它的主要目标是依赖格式兼容、减少模块请求和提高开发性能,不能等同于应用生产构建的完整 Tree Shaking。
2. Vite 生产环境由 Rolldown 完成 Tree Shaking
当前 Vite 8 的生产构建使用 Rolldown。执行 vite build 后,Vite 不再按浏览器请求返回单个源码模块,而是从 HTML 和配置入口建立完整模块依赖图。Vite 官方生产构建文档
整体过程可以概括为:
HTML/应用入口
↓
解析静态 import/export
↓
建立完整模块依赖图
↓
标记入口真正需要的导出和语句
↓
分析模块及语句副作用
↓
删除未使用且无副作用的模块、导出和语句
↓
Code Splitting、Chunk 生成
↓
压缩并输出生产资源假设:
// math.js
export function add(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}// main.js
import { add } from './math.js'
console.log(add(1, 2))Rolldown 会从入口需要执行的语句开始,追踪到 add,再追踪 add 所依赖的变量和表达式。multiply 没有被任何入口可达语句引用,并且删除它不会影响程序行为,因此不会进入最终 Chunk。
Rolldown 删除代码同样遵循两个条件:
值没有被使用
+
代码没有副作用它会分析顶层代码、函数调用、属性访问等操作是否可能产生副作用;无法确定时会选择保守保留。Vite 8 通过 Rolldown 与 Oxc 的语义分析提升这类 Tree Shaking 能力。Vite 8 官方说明 Rolldown DCE 文档
3. Vite 同样需要正确处理模块副作用
Vite 生产构建使用的 Rolldown同样读取包中的 sideEffects 信息。
例如组件库:
components/
├── index.js
├── Button.js
├── Button.css
├── Modal.js
└── Modal.css// index.js
export { Button } from './Button.js'
export { Modal } from './Modal.js'// Button.js
import './Button.css'
export function Button() {
// ...
}业务项目只使用:
import { Button } from 'component-library'正确配置:
{
"sideEffects": [
"**/*.css"
]
}生产构建结果是:
Button.js:Button 被使用,保留
Button.css:属于副作用资源,保留
Modal.js:导出未使用,可以删除
Modal.css:Modal.js 没有进入模块图结果,因此删除这说明 Tree Shaking 不是单纯按照文件后缀删除代码,而是在模块依赖关系和副作用规则的共同约束下,决定哪些模块及语句进入最终 Chunk。
Vite 一般不需要手动配置 Tree Shaking;Rolldown 默认启用。需要高级定制时可以使用:
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rolldownOptions: {
treeshake: true
}
}
})但不建议为了追求更小体积就随意把所有模块设成无副作用,因为这可能删除 CSS、Polyfill、组件注册和全局初始化代码。
四、构建优化方法
一套更符合实际项目的优化顺序是:
第一步:测量
记录启动、首次编译、增量编译、生产构建和产物体积
第二步:定位
确认耗时在 Loader、Plugin、模块解析、类型检查、压缩还是 SourceMap
第三步:优化构建过程
替换高成本转换器、缩小处理范围、拆分类型检查、开启缓存
第四步:优化构建产物
Tree Shaking、Code Splitting、Minify、资源优化和长期缓存
第五步:验证
比较构建时间、产物大小、页面加载、HMR 和兼容性是否发生回归构建优化不能看到某个配置就直接添加,而应该先区分瓶颈发生在哪个阶段:
模块解析慢
→ Loader/Plugin 转换慢
→ 模块数量过多
→ 类型检查慢
→ Chunk 优化慢
→ 压缩慢
→ SourceMap 生成慢
→ 文件写入或上传慢只有先定位阶段,优化才会有效。评价指标至少包括开发服务器启动时间、首次编译时间、单文件更新耗时、生产构建时间、构建内存峰值、首屏资源体积和压缩后体积。
先测量关注:
初始 JS/CSS 大小;
Brotli/Gzip 后大小;
主线程解析和执行时间;
重复依赖;
Chunk 请求瀑布;
缓存命中率;
LCP、INP、CLS;
低端设备表现。
Webpack 可通过 Stats 和 Bundle Analyzer 分析;Vite 8 可使用 Rolldown 构建分析能力。
工程中的构建优化至少包含两条主线:
构建过程优化:启动、首次编译、增量编译、生产构建要更快
构建结果优化:产物体积、加载速度、执行性能、缓存命中率要更好
Webpack 构建速度优化
Webpack 构建速度主要取决于四件事:需要处理多少模块、每个模块转换多慢、相同工作是否能复用,以及 Plugin 在生命周期中做了多少额外工作。
Webpack 构建速度优化应优先减少单模块转换成本,例如用 SWC 替换 Babel;然后缩小 Loader 处理范围、拆分类型检查、开启文件系统缓存、区分开发与生产任务、减少模块解析和检查高成本 Plugin。并行处理不是默认答案,只有任务足够昂贵时,Worker 带来的收益才可能超过通信成本。
1. 用 SWC 替换 Babel 等高成本转换器
babel-loader 本身只是 Babel 与 Webpack 之间的桥梁,真正耗时的是 Babel 对每个文件进行 JavaScript AST 解析、多个 Plugin 转换和代码生成。
在大型 React + TypeScript 项目中,如果 Babel 只用于:
TypeScript 语法删除
JSX 转换
新语法降级
React Fast Refresh那么可以考虑使用 Rust 编写的 SWC 替换 Babel。
原配置可能是:
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
include: path.resolve(__dirname, 'src'),
use: 'babel-loader'
}
]
}替换为:
const path = require('node:path')
const isDevelopment =
process.env.NODE_ENV !== 'production'
module.exports = {
module: {
rules: [
{
test: /\.[jt]sx?$/,
include: path.resolve(__dirname, 'src'),
use: {
loader: 'swc-loader',
options: {
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
decorators: true
},
transform: {
react: {
runtime: 'automatic',
development: isDevelopment,
refresh: isDevelopment
}
},
target: 'es2020'
},
sourceMaps: isDevelopment
}
}
}
]
}
}SWC 同样完成:
TS/TSX 解析
→ 删除 TypeScript 类型
→ JSX 转换
→ JavaScript 语法降级
→ 输出 JavaScript 和 SourceMap但 SWC 不会执行完整的 TypeScript 类型检查,因此通常需要把类型检查拆出去:
const ForkTsCheckerWebpackPlugin =
require('fork-ts-checker-webpack-plugin')
module.exports = {
plugins: [
new ForkTsCheckerWebpackPlugin()
]
}形成两条并行任务:
swc-loader:只负责快速生成可运行代码
ForkTsCheckerWebpackPlugin:负责完整类型检查CI 中还可以单独执行:
tsc --noEmit这样既保留类型安全,又不会让每个模块都在 Loader 阶段等待类型检查。
不过 swc-loader 不能无条件替代 Babel。如果项目依赖自定义 Babel Plugin、Babel Macro、特殊装饰器语义、React Compiler 或某些非标准转换,就需要先验证 SWC 是否有等价能力。替换后也要比较浏览器兼容目标、Polyfill 策略、SourceMap 和 HMR,而不能只比较构建耗时。SWC Loader 官方文档
知识点总结:
babel-loader → swc-loader的核心不是换了一个 Loader 名字,而是把每个模块的 AST 转换工作从 JavaScript 工具链换成了原生工具链。它通常可以显著降低模块转换耗时,但类型检查必须独立处理,Babel 专属插件也需要逐项验证。
2. 缩小 Loader 的处理范围
Loader 会对每个匹配模块执行一次。下面的配置会让 Babel 或 SWC 检查大量不需要处理的文件:
{
test: /\.[jt]sx?$/,
use: 'swc-loader'
}应使用 include 明确限定业务源码:
{
test: /\.[jt]sx?$/,
include: path.resolve(__dirname, 'src'),
use: 'swc-loader'
}对于不同类型的文件,可以使用 oneOf,使文件命中一条规则后不再继续匹配后面的规则:
module: {
rules: [
{
oneOf: [
{
test: /\.[jt]sx?$/,
include: path.resolve(__dirname, 'src'),
use: 'swc-loader'
},
{
test: /\.module\.scss$/,
use: [
'style-loader',
'css-loader',
'sass-loader'
]
},
{
type: 'asset'
}
]
}
]
}同时要避免把 ESLint、完整类型检查等工作放进每个模块的关键转换链。Lint 和类型检查通常更适合并行执行,或者放入独立的 CI 任务中。
3. 开启 Webpack 文件系统缓存
Webpack 可以缓存解析和转换后的模块,使第二次启动或生产构建直接复用未变化的结果:
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
}
}文件系统缓存需要把影响构建结果的配置和依赖纳入失效条件。如果修改了 Webpack 配置、Babel/SWC 配置或者自定义 Loader,却没有使缓存失效,就可能复用旧结果。
Webpack 开发模式默认主要使用内存缓存,但内存缓存会随进程退出而消失;filesystem 缓存可以跨进程复用,对大型项目的第二次启动和 CI 增量构建更有价值。Webpack Cache
4. 分离开发配置和生产配置
开发构建追求快速反馈,生产构建追求产物质量,两者不应该执行完全相同的任务。
开发环境可以使用:
module.exports = {
mode: 'development',
devtool: 'eval-cheap-module-source-map',
optimization: {
minimize: false
},
output: {
pathinfo: false
}
}开发环境通常不需要:
代码压缩
CSS 压缩
Content Hash
完整生产 SourceMap
复杂 Chunk 优化
压缩文件生成和上传生产环境再启用 Tree Shaking、Chunk 优化、Minify、Content Hash 和生产 SourceMap。否则每次修改一个组件,都可能重复执行只对部署有价值的昂贵任务。
5. 优化模块解析和模块数量
当项目包含数万个模块时,模块路径解析本身也会产生大量文件系统访问。应减少不必要的后缀猜测:
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js']
}如果项目不存在软链接依赖,还可以根据实际情况评估:
resolve: {
symlinks: false
}同时应避免大量 Barrel 导入:
// 不推荐:可能把大量重导出模块纳入解析过程
import { formatDate } from '@/utils'
// 更明确
import { formatDate } from '@/utils/formatDate'即使最终 Tree Shaking 可以删除未使用代码,Webpack 在构建阶段仍可能需要解析 Barrel 文件及其重导出关系。因此“最终没有进入产物”不代表“构建时完全没有成本”。
6. 检查高成本 Plugin
Plugin 可以介入整个编译生命周期,因此一个低效 Plugin 可能影响所有模块或所有产物。
常见问题包括:
在 compilation Hook 中重复遍历全部模块
在 processAssets 中多次复制大文件
每次构建都重新上传 SourceMap
在启动 Hook 中执行同步文件扫描
生成过于庞大的 Stats 数据
开发环境仍运行压缩和分析 Plugin应该根据环境条件加载 Plugin:
plugins: [
isDevelopment && new ReactRefreshWebpackPlugin(),
!isDevelopment &&
new MiniCssExtractPlugin(),
shouldAnalyze &&
new BundleAnalyzerPlugin()
].filter(Boolean)thread-loader 也不能盲目添加。Worker 启动、模块序列化和进程通信本身有成本,只适合转换非常昂贵、文件数量足够多的 Loader。对于已经使用原生实现且速度很快的 swc-loader,额外增加 Worker Pool 反而可能变慢。
二、Vite 构建速度优化
Vite 构建速度优化的重点是控制 Plugin 转换成本和模块请求数量。应通过 Profile 定位高成本 Hook,尽早过滤无关文件,避免大型 Barrel 文件和无意义的预处理,只对高频模块进行 warmup,并尽量使用 Vite 8 已提供的 Rolldown/Oxc 原生工具链。
Vite 没有 Webpack Loader,因此不能直接照搬“替换 Loader”的优化思路。Vite 的性能瓶颈通常来自 Plugin 转换、模块请求瀑布、Barrel 文件、路径解析以及 CSS 等额外预处理。
当前 Vite 8 使用 Rolldown 和 Oxc,React 项目中的 @vitejs/plugin-react v6 也已经使用 Oxc 处理 React Refresh,默认不再依赖 Babel。因此,如果项目没有必须使用的 Babel Plugin,应避免重新把 Babel 加回每个模块的转换流程。Vite 8 官方说明
1. 定位耗时 Plugin 和模块转换
可以检查每个模块的 Plugin 转换时间:
vite --debug plugin-transform也可以采集完整 CPU Profile:
vite --profile如果某个 Plugin 的 transform 对所有文件执行完整 AST 解析,应先使用文件类型或者内容特征快速过滤:
function customPlugin() {
return {
name: 'custom-plugin',
transform(code, id) {
if (!id.endsWith('.custom.ts')) {
return null
}
if (!code.includes('specialMarker')) {
return null
}
return expensiveTransform(code, id)
}
}
}config、configResolved 和 buildStart 会影响服务器启动时间;resolveId、load 和 transform 则会影响模块响应速度。重型同步任务不应无条件放在这些高频 Hook 中。
2. 减少模块请求和解析瀑布
Vite 开发环境按浏览器请求转换模块,因此大型 Barrel 文件会使浏览器间接请求大量模块:
import { Button } from '@/components'如果 components/index.ts 重导出了几百个组件,Vite 可能需要解析和转换大量文件,才能确定导出关系及副作用。
更明确的导入方式是:
import { Button } from '@/components/Button'对经常访问且转换较慢的模块,可以配置 warmup:
export default defineConfig({
server: {
warmup: {
clientFiles: [
'./src/main.tsx',
'./src/components/App.tsx',
'./src/utils/large-utils.ts'
]
}
}
})Warmup 只适合高频模块。如果把大量低频页面全部预热,会把按需转换重新变成启动时全量转换。Vite 性能指南
3. 减少不必要的转换工具
如果普通 CSS 已经能够满足需求,就不必让所有样式经过 Sass;如果 SVG 只作为图片显示,就不必全部转换成 React 组件;如果某个 Plugin 只服务少量文件,就不应让它检查整个项目。
对于 React 项目,Vite 8 默认优先使用:
import react from '@vitejs/plugin-react'只有项目明确依赖 SWC Plugin 或 SWC 专属配置时,才需要考虑:
import react from '@vitejs/plugin-react-swc'这与 Webpack 不同:Webpack 中 babel-loader → swc-loader 可能是显著优化;而在当前 Vite 8 中,官方 React Plugin 已经使用 Oxc,不能再机械地认为“换成 SWC 一定更快”。
面试问答题
Q1:请完整说明 Webpack 从配置到输出 Bundle 的工作流程。
Webpack 首先读取并规范化配置,创建长期存在的 Compiler 对象,然后调用各 Plugin 的 apply 方法,让 Plugin 通过 Tapable 注册生命周期 Hook。每次构建创建一个新的 Compilation。
Compilation 从 Entry 出发,通过 Resolver 将模块请求解析为真实资源,再依据 module.rules 调用 Loader 转换内容。Webpack 解析转换结果,提取 import、require()、import()、CSS URL 等依赖并递归处理,最终形成 ModuleGraph。
模块图完成后进入 Seal 和优化阶段:分析 provided/used exports、模块副作用,执行 Tree Shaking、模块合并、SplitChunks、Chunk ID 和 Module ID 分配,建立 ChunkGraph。之后生成 Webpack Runtime 和各 Chunk 代码,执行 JS/CSS 压缩、Content Hash、SourceMap 等处理,最后通过 processAssets/emit 输出资源。Watch 模式下文件变化会使相关模块失效并产生新的增量 Compilation。
Q2:Vite 为什么启动快?它是不是完全不打包?
Vite 快主要是因为开发模式不需要在服务器启动前构建完整应用 Bundle。它先启动开发服务器,浏览器从 HTML 中发现 ESM 入口,再按原生 ESM 依赖关系请求模块。Vite 只对实际请求的源码执行 resolveId/load/transform,转换结果还能缓存,因此启动成本不会简单等同于整个项目规模。
第三方依赖会提前预构建:一方面把 CommonJS/UMD 转成 ESM,另一方面把内部包含大量小模块的依赖合并,减少浏览器请求。文件变化时,Vite 只失效相关 ModuleGraph 节点并通过 HMR 更新边界,不需要重新生成整个开发 Bundle。
但“Vite 不打包”是错误说法。它通常只在开发模式下按需服务源码;生产环境仍然需要建立完整依赖图,执行 Tree Shaking、Code Splitting、压缩和 Hash。当前 Vite 8 的生产构建使用 Rolldown。
Q3:Vite 与 Webpack 最本质的区别是什么?
两者生产构建的目标非常相似,都会解析依赖图并输出优化 Chunk;最本质的区别集中在开发模式。
Webpack 是 Bundle/Compiler 驱动:构建工具先拥有完整或较完整的模块图,生成由 Webpack Runtime 执行的 Bundle;文件变化后进行增量编译,HMR 通过更新 Manifest 和更新 Chunk 替换模块。
Vite 是 Dev Server/Browser 驱动:浏览器使用原生 ESM 请求模块,Vite 按请求转换源码并维护 ModuleGraph;文件变化后通过 WebSocket 通知,浏览器重新请求带时间戳的模块 URL。Webpack 的扩展体系明确区分 Loader 和 Plugin;Vite 则主要通过统一 Plugin Hook 同时支持开发服务器和生产构建。
Q4:Vite 为什么要做依赖预构建?它和生产构建有什么区别?
依赖预构建解决两个开发环境问题。第一,浏览器原生 ESM 无法直接执行 CommonJS 和 UMD,Vite 需要将其转换成可被浏览器导入的 ESM。第二,有些 ESM 依赖内部包含数百个小文件,直接交给浏览器会产生大量请求和加载瀑布,所以 Vite会将它们预先合并。
Vite 扫描源码中的裸模块导入,以这些依赖为入口进行预构建,将结果缓存到 node_modules/.vite,并根据 Lockfile、配置和环境等信息决定是否失效。浏览器端则通过强缓存和版本查询参数复用结果。
它只是一种开发环境兼容与性能优化,不负责最终生产 Chunk 策略,也不能等同于 Tree Shaking。生产构建会重新从应用入口建立完整依赖图,进行全局裁剪、切块、压缩和产物生成。
Q 5:请说明 HMR 的完整工作原理,以及 Vite 和 Webpack 的区别。
HMR 包含文件监听、模块失效、寻找接收边界、传输更新、运行时替换和失败回退六个环节。文件变化后,构建工具首先确定受影响模块,并沿 Importer 或 Parent 关系寻找声明了 Accept 的 HMR Boundary。如果找到边界,就 Dispose 旧模块、加载新实现并执行 Accept 回调;如果传播到入口仍没有边界,只能整页刷新。
Webpack 会增量编译并产生 Hot-update manifest 和 Hot-update Chunk。浏览器 Runtime 下载这些文件,利用前后稳定的 Module/Chunk ID 确定更新内容,卸载模块缓存并安装新模块。
Vite 通常不会生成 Hot-update Chunk。它失效 ModuleGraph 节点,通过 WebSocket 发送模块更新信息,浏览器再以带时间戳的新 URL 动态导入更新后的 ESM 模块。查询参数产生新的浏览器模块记录,从而绕过旧模块缓存。
HMR 本身只避免页面刷新,不天然理解 React/Vue 状态。组件状态保留依赖 React Fast Refresh、Vue SFC HMR 等框架运行时;模块顶层副作用仍应在 Dispose 阶段清理。
Q 6:Webpack Loader 和 Plugin 有什么区别?Loader 的执行顺序是什么?
Loader 是模块级转换器,主要回答“这种资源怎样变成 Webpack 可分析的模块”,例如将 TypeScript 转换为 JavaScript、将 Sass 转成 CSS。它按 module.rules 匹配资源,可以同步或异步返回内容、SourceMap 和 Meta。
Plugin 是编译生命周期扩展,主要回答“怎样介入整个构建过程”,例如生成 HTML、修改 Chunk、压缩资产、上传 SourceMap、生成 Manifest。Plugin 通过 apply(compiler) 和 Tapable Hook 操作 Compiler 或 Compilation。
若配置为 ['a-loader', 'b-loader', 'c-loader'],Pitch 阶段从左到右执行 a → b → c,读取资源后 Normal 阶段从右到左执行 c → b → a。如果某个 Pitch 返回内容,会短路剩余流程并立即向左执行 Normal Loader。Loader 应传递 SourceMap、声明额外依赖并保持输出确定性,以支持 Watch 和缓存。
Q7:Vite Plugin 在开发和生产阶段是怎样工作的?
Vite Plugin 扩展了 Rolldown 风格的 Plugin 接口。resolveId 负责解析模块,load 负责提供内容或虚拟模块,transform 负责代码转换。这些 Hook 在开发和生产环境中都可以工作,因此一份插件通常可以复用。
区别在于调用模型:开发阶段没有完整 Bundle,浏览器每请求一个模块,Vite 的 Plugin Container 才对它调用 resolveId/load/transform;生产阶段由 Rolldown 遍历完整依赖图并调用相同 Hook,随后还会执行 generateBundle/writeBundle 等输出阶段 Hook。
configureServer、transformIndexHtml、handleHotUpdate 是 Vite 专属能力。输出阶段 Hook 在开发环境通常不会执行,依赖完整 AST 或完整 ChunkGraph 的插件也不能假设开发环境拥有生产构建上下文。
Q8:请说明 Webpack 和 Vite 的 Tree Shaking 原理,以及两者有什么区别。
Tree Shaking 本质是基于静态模块结构的无用代码消除。构建工具从入口建立模块依赖图,分析每个模块提供和使用的导出,再结合副作用信息,删除入口不可达、导出未使用并且移除后不会改变程序行为的模块和语句。ESM 的 import/export 可以在不执行代码的情况下确定依赖关系,因此比动态 require() 和 module.exports 更适合可靠的 Tree Shaking。
Webpack 在生产构建中先通过 ModuleGraph 分析模块之间的 import、export 和 re-export 关系,利用 usedExports 标记实际使用的导出,再通过 sideEffects 判断未使用其导出的模块能否整体跳过。Webpack 在代码生成阶段减少无用导出,最后由压缩器继续删除未使用的函数、变量和纯函数调用。因此 usedExports、sideEffects 和 Minifier 分别对应导出分析、模块级副作用判断和语句级 DCE,三者共同构成完整的 Tree Shaking。
Vite 开发环境基于浏览器原生 ESM,采用按请求转换模块的方式,通常不会对业务源码做生产级 Tree Shaking;即使只导入模块中的一个函数,浏览器通常仍会获取该模块的完整转换结果。生产环境执行 vite build 时,Vite 8 使用 Rolldown 从入口建立完整依赖图,通过 ESM 引用关系和语句级副作用分析确定需要保留的代码,再结合 sideEffects、Pure Annotation 等信息删除未使用模块和语句,最后完成分包、压缩和资源输出。
所以两者的核心原则相同,都是删除“未使用且无副作用”的代码;主要区别在于具体构建架构和实现方式。Webpack 由自身的模块图、导出信息和压缩器协同完成,而 Vite 开发环境基本不做完整 Tree Shaking,生产环境则主要交给 Rolldown完成。
Q9:在你的 Webpack 项目中,你是怎么做构建优化的?
我们项目使用 React、TypeScript、Sass 和 Webpack 5。随着模块数量增长,开发首次编译、增量编译和生产构建都越来越慢。我们没有一开始就直接替换 Loader,而是先建立构建性能基线,因为如果不测量,就无法判断瓶颈究竟在代码转译、类型检查、模块解析、Plugin、压缩还是 SourceMap,也无法证明优化是否真正有效。
测量时我们固定了 Node 版本、依赖锁文件、机器和构建参数,并且把冷构建和热构建分开统计。冷构建会删除 Webpack 文件系统缓存,用于评估完整构建能力;热构建保留缓存,用于评估日常开发和 CI 增量构建能力。每种情况连续执行多次取中位数,避免文件缓存和机器负载造成偶然误差。
我们重点记录了五类数据:开发环境首次编译时间、单文件修改后的增量编译时间、无缓存和有缓存的生产构建时间、构建期间 CPU 与内存峰值,以及首屏 JS/CSS、最大异步 Chunk 和压缩后资源体积。通过 Webpack Profile 和 Stats 数据查看模块数量、模块构建时间、Chunk 组成和依赖关系;通过 CPU Profile 分析 Loader、类型检查、Minifier 和 Plugin 的耗时;通过 Bundle Analyzer 检查重复依赖、巨大 Chunk 和错误进入首屏的模块。
测量结果显示,主要瓶颈集中在三个地方。第一,babel-loader 处理了大量 TSX 文件,JavaScript 和 TypeScript 转换占据了构建阶段的大部分 CPU 时间;第二,类型检查与代码转译串行执行;第三,开发环境仍然运行了 CSS 提取、完整 SourceMap、Bundle 分析等只对生产构建有价值的任务。另外,Stats 中还发现几个大型 Barrel 文件扩大了模块解析范围。
确认瓶颈之后,我们先优化最耗时的模块转换阶段。由于项目中的 Babel 主要用于删除 TypeScript 类型、转换 JSX 和进行浏览器语法降级,没有依赖大量 Babel 专属 Plugin,所以将 babel-loader 替换为基于 Rust 的 swc-loader。同时使用 include 将转换范围限制在 src,排除 node_modules,并通过 oneOf 避免同一个文件重复匹配多条 Loader 规则。
SWC 只负责代码转译,不进行完整的 TypeScript 类型检查,因此我们把类型检查交给 ForkTsCheckerWebpackPlugin 独立执行,并在 CI 中运行 tsc --noEmit。这样代码转译和类型检查可以并行进行,不会让每个模块都在 Loader 阶段等待类型检查。迁移后还专门验证了装饰器语义、浏览器兼容目标、Polyfill、React Fast Refresh 和 SourceMap,确保构建提速没有改变运行结果。
完成转换链优化后,我们开启了 Webpack 文件系统缓存,让未变化模块复用之前的解析和转换结果。开发环境和生产环境也进行了配置拆分:开发环境使用 eval-cheap-module-source-map,关闭代码压缩、CSS 提取、Content Hash、Bundle 分析和 SourceMap 上传;生产环境才执行 Tree Shaking、SplitChunks、CSS 提取、代码压缩和生产 SourceMap。这样日常开发不会重复执行只对部署有价值的昂贵任务。
模块图方面,我们根据 Stats 数据处理了几个导出大量模块的 Barrel 文件,把高频代码改成直接路径导入;缩小 resolve.extensions 范围;清理重复版本依赖;检查自定义 Plugin,删除开发环境中重复遍历所有 Module 和 Asset 的逻辑。我们也评估过 thread-loader,但 SWC 本身已经很快,额外的 Worker 启动和进程通信没有产生正向收益,因此没有保留。这个取舍是基于 A/B 测试,而不是看到“并行”就默认会更快。
构建速度稳定后,我们继续优化生产产物。首先保留 ESM,并正确配置 sideEffects,单独声明 CSS、Polyfill 等副作用文件,保证 Tree Shaking安全生效。然后按照路由和重型功能使用动态 import(),把编辑器、图表和地图拆成异步 Chunk。SplitChunks 只提取稳定且真正共享的依赖,没有把所有 node_modules 强制放进一个巨大的 Vendor Chunk。同时使用独立 Runtime、确定性的 Module ID 和 Chunk ID提高跨版本缓存稳定性。
部署层面,JS、CSS和图片使用 contenthash 并设置长期强缓存,HTML 使用 no-cache;CDN开启 Brotli/Gzip。生产 SourceMap 使用 hidden-source-map 上传错误监控平台,不部署到公共静态目录。发布系统保留一段时间的旧版本 Chunk,避免旧页面在动态加载资源时出现404。
每完成一项改造,我们都会使用同一套基线重新测量,而不是等所有配置改完后只看一次结果,这样可以知道每项优化的实际收益。例如,在我们的数据中:
替换 SWC 并拆分类型检查:
首次编译降低约 [40%]
开启文件系统缓存:
热构建降低约 [60%]
拆分开发和生产配置:
增量编译降低约 [30%]
优化模块图和 Plugin:
进一步降低模块解析与启动耗时
Tree Shaking 和路由分包:
首屏压缩后 JS 降低约 [35%]最终,开发首次编译从 [60 秒] 降到 [20 秒],增量编译从 [4 秒] 降到 [1 秒以内],无缓存生产构建从 [180 秒] 降到 [70 秒],首屏压缩后 JavaScript 从 [1.2 MB] 降到 [700 KB]。这些数字需要替换成项目中的真实数据。
最后,我们把生产构建时间、首屏压缩后资源体积、最大异步 Chunk 和重复依赖数量加入 CI 性能预算。当指标超过阈值时产生告警,防止后续业务迭代重新引入性能回退。
因此,我在这个项目中的构建优化不是简单地“把 Babel 换成 SWC”,而是形成了完整闭环:
建立可复现的性能基线
→ 定位最主要的构建瓶颈
→ 按收益逐项改造
→ 每项改造后重新测量
→ 检查兼容性和产物质量
→ 通过 CI 性能预算防止回退

评论
还没有评论
来发表第一条评论吧!