Webpack 核心原理
问题
什么是 Webpack?它的核心原理和构建流程是怎样的?
答案
Webpack 是一个现代 JavaScript 应用程序的静态模块打包工具(static module bundler)。它会从一个或多个入口点出发,递归地构建一个依赖图(dependency graph),然后将项目中所需的每一个模块组合成一个或多个 Bundle(输出文件)。
为什么需要打包工具?
在现代前端开发中,项目往往由成百上千个模块组成,直接在浏览器中加载这些模块会面临诸多问题:
// 1. 模块化:浏览器对 ESM 的支持有限(尤其是旧浏览器)
import { debounce } from './utils';
import React from 'react';
// 2. 资源类型:浏览器无法直接处理 .ts、.scss、.vue 等文件
import styles from './app.module.scss';
import logo from './logo.svg';
// 3. 性能问题:大量零散文件会导致 HTTP 请求过多
// 4. 兼容性:需要将 ES6+/TypeScript 转译为 ES5
// 5. 优化:代码压缩、Tree Shaking、代码分割等
Webpack 的核心价值在于:将开发时的模块化代码,转换为浏览器可高效运行的生产代码,同时提供开发体验增强(HMR、Source Map 等)。
核心概念
Webpack 有 6 个核心概念,理解它们之间的关系是掌握 Webpack 的关键:
Entry(入口)
入口是 Webpack 构建依赖图的起点。Webpack 从入口文件开始,递归解析所有依赖模块。
import type { Configuration } from 'webpack';
// 单入口
const singleEntry: Configuration = {
entry: './src/index.ts',
};
// 多入口
const multiEntry: Configuration = {
entry: {
app: './src/app.ts',
admin: './src/admin.ts',
},
};
// 动态入口
const dynamicEntry: Configuration = {
entry: () => ({
app: './src/app.ts',
vendor: ['react', 'react-dom'],
}),
};
Output(输出)
告诉 Webpack 在哪里输出它所创建的 Bundle,以及如何命名。
import path from 'path';
import type { Configuration } from 'webpack';
const config: Configuration = {
output: {
path: path.resolve(__dirname, 'dist'),
// [name] 是入口名,[contenthash:8] 用于缓存
filename: '[name].[contenthash:8].js',
// 非入口 chunk 的文件名(如动态导入)
chunkFilename: '[name].[contenthash:8].chunk.js',
// 清理输出目录
clean: true,
},
};
Module(模块)
在 Webpack 中,一切皆模块。不仅是 JS/TS 文件,CSS、图片、字体等都可以是模块。
Loader(加载器)
Webpack 本身只能理解 JavaScript 和 JSON 文件。Loader 让 Webpack 能够处理其他类型的文件,并将它们转换为有效的模块。
Plugin(插件)
Plugin 用于执行范围更广的任务,如打包优化、资源管理、注入环境变量等。Plugin 可以介入 Webpack 构建流程的每一个环节。
Chunk 与 Bundle
- Module:源代码中的每一个文件(JS、CSS、图片等)都是一个模块
- Chunk:Webpack 在打包过程中,将多个 Module 组合而成的中间产物。Chunk 有三种来源:entry 入口、动态
import()和splitChunks配置 - Bundle:Chunk 经过编译压缩等处理后的最终输出文件,通常一个 Chunk 对应一个 Bundle
构建流程
Webpack 的构建流程可以分为三个阶段:初始化、构建(Make)、生成(Seal)。
详细流程说明
1. 初始化阶段
// 1. 合并配置:shell 参数 + webpack.config.ts + 默认配置
const options = mergeOptions(shellArgs, configFile, defaultOptions);
// 2. 创建 Compiler —— Webpack 的核心引擎
const compiler = new Compiler(options);
// 3. 遍历配置中的 plugins,调用 apply 方法注册
options.plugins.forEach((plugin) => {
plugin.apply(compiler);
});
// 4. 触发 environment / afterEnvironment 钩子
compiler.hooks.environment.call();
// 5. 注册内置插件(EntryPlugin、ChunkPlugin 等)
new WebpackOptionsApply().process(options, compiler);
2. 构建阶段(Make)
// 1. 调用 compiler.run() 触发 compile 钩子
compiler.hooks.compile.call();
// 2. 创建 Compilation 对象 —— 代表一次编译过程
const compilation = new Compilation(compiler);
// 3. 从入口开始,递归构建模块
async function buildModule(module: Module): Promise<void> {
// a. 调用匹配的 Loader 链转译文件内容
const source = await runLoaders(module.resource, matchedLoaders);
// b. 使用 acorn 将源代码解析为 AST
const ast = parse(source);
// c. 遍历 AST,收集依赖(import/require)
const dependencies = analyzeDependencies(ast);
// d. 对每个依赖递归执行 buildModule
for (const dep of dependencies) {
await buildModule(dep);
}
}
3. 生成阶段(Seal)
// 1. 根据入口和依赖关系创建 Chunk
const chunks = createChunks(compilation.moduleGraph);
// 2. 对 Chunk 进行优化
optimizeChunks(chunks); // splitChunks 在这里执行
// 3. 为每个 Chunk 生成代码
for (const chunk of chunks) {
// a. 生成模块代码(包裹为 Webpack 模块格式)
const moduleCode = generateModuleCode(chunk.modules);
// b. 注入运行时代码(__webpack_require__ 等)
const runtimeCode = generateRuntime(chunk);
// c. 合并为最终 Bundle
const bundle = concat(runtimeCode, moduleCode);
// d. 写入文件系统
fs.writeFileSync(outputPath, bundle);
}
Loader 机制
Loader 的作用
Loader 本质上是一个导出为函数的 JavaScript 模块,它接收源文件内容作为输入,返回转换后的内容。
import type { LoaderContext } from 'webpack';
// Loader 就是一个函数
export default function myLoader(
this: LoaderContext<{ prefix: string }>,
source: string
): string {
// 获取 options
const options = this.getOptions();
// 转换源代码
const result = `/** ${options.prefix} */\n${source}`;
// 返回转换结果
return result;
}
执行顺序
Loader 的执行顺序是从右到左、从下到上(compose 函数式组合):
const config: Configuration = {
module: {
rules: [
{
test: /\.scss$/,
// 执行顺序:sass-loader → css-loader → style-loader
use: ['style-loader', 'css-loader', 'sass-loader'],
},
],
},
};
这是函数式编程中 compose 的概念。类似于 compose(f, g, h)(x) 等价于 f(g(h(x)))。先执行最内层(最右侧)的函数,结果作为下一个函数的输入。
常见 Loader
| Loader | 作用 |
|---|---|
babel-loader | 将 ES6+/JSX 转译为 ES5 |
ts-loader | 编译 TypeScript(也可用 babel-loader + @babel/preset-typescript) |
css-loader | 解析 CSS 中的 @import 和 url() |
style-loader | 将 CSS 注入到 DOM 的 <style> 标签 |
sass-loader | 将 SCSS/SASS 编译为 CSS |
postcss-loader | 使用 PostCSS 处理 CSS(自动加前缀等) |
file-loader | 将文件发送到输出目录并返回 URL(Webpack 5 已内置 Asset Modules) |
url-loader | 小文件转 Base64 Data URL(Webpack 5 已内置) |
thread-loader | 将耗时的 Loader 放到 worker 池中执行 |
cache-loader | 将 Loader 的编译结果缓存到磁盘(Webpack 5 已内置持久缓存) |
Loader 分类与执行阶段
Loader 可以通过 enforce 字段分为 4 类,执行顺序为:
pre loader → normal loader → inline loader → post loader
const config: Configuration = {
module: {
rules: [
{
test: /\.ts$/,
enforce: 'pre', // 前置 Loader,最先执行
use: ['eslint-loader'],
},
{
test: /\.ts$/,
// 默认 normal Loader
use: ['babel-loader'],
},
{
test: /\.ts$/,
enforce: 'post', // 后置 Loader,最后执行
use: ['custom-post-loader'],
},
],
},
};
Plugin 机制
Plugin 的作用
Plugin 可以介入 Webpack 构建流程的任意环节,执行范围更广的任务。Webpack 本身就是由大量内置 Plugin 构成的。
Tapable 钩子系统
Webpack 的 Plugin 机制基于 Tapable 库,它提供了一套发布-订阅模式的钩子系统。
import {
SyncHook, // 同步钩子
SyncBailHook, // 同步熔断钩子(返回非 undefined 则中断)
SyncWaterfallHook, // 同步瀑布流钩子(上一个返回值传给下一个)
AsyncSeriesHook, // 异步串行钩子
AsyncParallelHook, // 异步并行钩子
} from 'tapable';
// Compiler 上的核心钩子
class Compiler {
hooks = {
run: new AsyncSeriesHook(['compiler']),
compile: new SyncHook(['params']),
compilation: new SyncHook(['compilation', 'params']),
make: new AsyncParallelHook(['compilation']),
emit: new AsyncSeriesHook(['compilation']),
done: new AsyncSeriesHook(['stats']),
};
}
自定义 Plugin 示例
import type { Compiler, Compilation } from 'webpack';
class MyWebpackPlugin {
apply(compiler: Compiler): void {
// 注册 compilation 钩子(同步)
compiler.hooks.compilation.tap('MyPlugin', (compilation: Compilation) => {
console.log('新的编译开始');
});
// 注册 emit 钩子(异步)
compiler.hooks.emit.tapAsync(
'MyPlugin',
(compilation: Compilation, callback: () => void) => {
// 获取即将输出的资源
const assets = compilation.assets;
// 添加一个新文件到输出
compilation.assets['filelist.txt'] = {
source: () => Object.keys(assets).join('\n'),
size: () => Object.keys(assets).join('\n').length,
} as any;
callback();
}
);
// 注册 done 钩子(异步 Promise)
compiler.hooks.done.tapPromise('MyPlugin', async (stats) => {
console.log(`构建完成,耗时 ${stats.endTime! - stats.startTime!}ms`);
});
}
}
export default MyWebpackPlugin;
Compiler 与 Compilation
- Compiler:全局唯一,代表完整的 Webpack 配置环境。在 Webpack 启动时创建,包含了所有配置信息和钩子
- Compilation:每次编译(包括 watch 模式下文件变化触发的重编译)都会创建一个新的 Compilation 对象,包含当前编译的模块、Chunk、资源等信息
常见 Plugin
| Plugin | 作用 |
|---|---|
HtmlWebpackPlugin | 自动生成 HTML 文件并注入打包后的资源 |
MiniCssExtractPlugin | 将 CSS 提取为独立文件(替代 style-loader) |
DefinePlugin | 在编译时将代码中的变量替换为常量值 |
CopyWebpackPlugin | 将文件或文件夹复制到输出目录 |
TerserWebpackPlugin | 压缩 JavaScript 代码(Webpack 5 内置) |
CssMinimizerWebpackPlugin | 压缩 CSS 代码 |
BundleAnalyzerPlugin | 可视化分析打包结果 |
CompressionWebpackPlugin | 生成 gzip/brotli 压缩文件 |
ESLintWebpackPlugin | 在构建过程中运行 ESLint |
ForkTsCheckerWebpackPlugin | 在独立进程中进行 TypeScript 类型检查 |
Loader vs Plugin 对比
| 对比维度 | Loader | Plugin |
|---|---|---|
| 作用 | 转换特定类型的文件 | 扩展 Webpack 功能,介入构建全流程 |
| 本质 | 导出为函数的模块 | 包含 apply 方法的类 |
| 触发时机 | 在模块加载时触发 | 通过 Tapable 钩子在构建任意阶段触发 |
| 配置位置 | module.rules 中 | plugins 数组中 |
| 输入输出 | 接收文件内容,返回转换后的内容 | 接收 Compiler/Compilation 对象 |
| 执行顺序 | 从右到左、从下到上 | 按注册顺序,受钩子类型影响 |
| 典型场景 | 编译 TS/SCSS、处理图片 | 生成 HTML、提取 CSS、压缩代码 |
模块解析(Module Resolution)
Webpack 使用 enhanced-resolve 库来解析模块路径。resolve 配置决定了 Webpack 如何找到模块。
import path from 'path';
import type { Configuration } from 'webpack';
const config: Configuration = {
resolve: {
// 路径别名
alias: {
'@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
'@utils': path.resolve(__dirname, 'src/utils'),
},
// 自动补全文件扩展名(按顺序尝试)
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
// 模块搜索目录
modules: ['node_modules', path.resolve(__dirname, 'src')],
// package.json 中查找入口的字段
mainFields: ['browser', 'module', 'main'],
// 条件导出(Node.js exports 字段)
conditionNames: ['import', 'require', 'default'],
},
};
配置 alias 后,可以用更短的路径引用模块,避免写长串的相对路径:
// 不使用 alias
import { Button } from '../../../components/Button';
// 使用 alias
import { Button } from '@components/Button';
HMR 热模块替换原理
HMR(Hot Module Replacement) 是 Webpack 最强大的开发体验特性之一,它能够在应用运行时替换、添加或删除模块,无需完整刷新页面。
HMR 通信流程
HMR 详细步骤
- 文件监听:Webpack Dev Server 使用
chokidar监听文件系统变化 - 增量编译:Webpack 只对变化的模块重新编译,生成新的 hash 和两个关键文件:
[hash].hot-update.json:更新清单(manifest),记录哪些 Chunk 需要更新[chunkId].[hash].hot-update.js:更新后的模块代码
- WebSocket 通知:Dev Server 通过 WebSocket 将新 hash 推送给浏览器
- 请求更新:浏览器端的 HMR Runtime 通过 JSONP/fetch 请求更新文件
- 模块替换:HMR Runtime 用新模块替换旧模块,执行
module.hot.accept()回调
HMR 代码示例
import { render } from './render';
render();
// HMR 接口:当 render.ts 变化时,重新执行 render()
if (module.hot) {
module.hot.accept('./render', () => {
console.log('render 模块已更新');
render();
});
}
- HMR 仅用于开发环境,生产环境不需要
- 如果没有配置
module.hot.accept(),HMR 会回退为完整页面刷新 - React/Vue 等框架通常通过专用 Loader(如
react-refresh、vue-loader)自动处理 HMR
基础配置示例
以下是一个完整的 Webpack 生产环境配置示例:
import path from 'path';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import type { Configuration } from 'webpack';
const isDev = process.env.NODE_ENV === 'development';
const config: Configuration = {
mode: isDev ? 'development' : 'production',
// 入口
entry: {
app: './src/index.tsx',
},
// 输出
output: {
path: path.resolve(__dirname, 'dist'),
filename: isDev ? '[name].js' : '[name].[contenthash:8].js',
chunkFilename: isDev ? '[name].chunk.js' : '[name].[contenthash:8].chunk.js',
clean: true,
publicPath: '/',
},
// 模块解析
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
// Loader 配置
module: {
rules: [
// TypeScript / JavaScript
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { targets: 'defaults' }],
'@babel/preset-typescript',
['@babel/preset-react', { runtime: 'automatic' }],
],
cacheDirectory: true,
},
},
},
// CSS / SCSS
{
test: /\.s?css$/,
use: [
isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: { modules: { auto: true } },
},
'postcss-loader',
'sass-loader',
],
},
// 图片资源(Webpack 5 Asset Modules)
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024, // 8KB 以下转 Base64
},
},
generator: {
filename: 'images/[name].[hash:8][ext]',
},
},
// 字体文件
{
test: /\.(woff2?|eot|ttf|otf)$/i,
type: 'asset/resource',
generator: {
filename: 'fonts/[name].[hash:8][ext]',
},
},
],
},
// Plugin 配置
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
minify: !isDev,
}),
!isDev && new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
}),
new ForkTsCheckerWebpackPlugin(),
].filter(Boolean) as Configuration['plugins'],
// 优化配置
optimization: {
minimize: !isDev,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: { drop_console: true },
},
}),
new CssMinimizerPlugin(),
],
// 代码分割
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 10,
},
common: {
minChunks: 2,
name: 'common',
priority: 5,
},
},
},
// 将 Webpack 运行时代码单独提取
runtimeChunk: 'single',
},
// 开发工具
devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map',
// 开发服务器
devServer: {
port: 3000,
hot: true,
historyApiFallback: true,
open: true,
},
// 缓存配置(Webpack 5)
cache: {
type: 'filesystem',
},
};
export default config;
安装所需依赖:
- npm
- Yarn
- pnpm
- Bun
npm install webpack webpack-cli webpack-dev-server html-webpack-plugin mini-css-extract-plugin css-minimizer-webpack-plugin terser-webpack-plugin fork-ts-checker-webpack-plugin babel-loader @babel/core @babel/preset-env @babel/preset-typescript @babel/preset-react css-loader style-loader postcss-loader sass-loader sass --save-dev
yarn add webpack webpack-cli webpack-dev-server html-webpack-plugin mini-css-extract-plugin css-minimizer-webpack-plugin terser-webpack-plugin fork-ts-checker-webpack-plugin babel-loader @babel/core @babel/preset-env @babel/preset-typescript @babel/preset-react css-loader style-loader postcss-loader sass-loader sass --dev
pnpm add webpack webpack-cli webpack-dev-server html-webpack-plugin mini-css-extract-plugin css-minimizer-webpack-plugin terser-webpack-plugin fork-ts-checker-webpack-plugin babel-loader @babel/core @babel/preset-env @babel/preset-typescript @babel/preset-react css-loader style-loader postcss-loader sass-loader sass --save-dev
bun add webpack webpack-cli webpack-dev-server html-webpack-plugin mini-css-extract-plugin css-minimizer-webpack-plugin terser-webpack-plugin fork-ts-checker-webpack-plugin babel-loader @babel/core @babel/preset-env @babel/preset-typescript @babel/preset-react css-loader style-loader postcss-loader sass-loader sass --dev
常见面试问题
Q1: Webpack 的构建流程是怎样的?
答案:
Webpack 的构建流程分为三大阶段:
1. 初始化阶段
- 合并 CLI 参数、配置文件(
webpack.config.ts)和默认配置 - 创建 Compiler 对象(全局唯一),代表完整的 Webpack 环境
- 遍历
plugins数组,调用每个 Plugin 的apply(compiler)方法注册钩子 - 注入 Webpack 内置插件(
EntryPlugin、ChunkPlugin等)
2. 构建阶段(Make)
- 从
entry配置的入口文件出发 - 调用匹配的 Loader 链对模块进行转译(如 TS -> JS)
- 使用 acorn 将转译后的代码解析为 AST(抽象语法树)
- 遍历 AST,找出
import、require等依赖声明 - 对每个依赖递归执行上述流程,最终构建出完整的模块依赖图(ModuleGraph)
3. 生成阶段(Seal)
- 根据依赖图和配置将模块组织成 Chunk(Entry Chunk、Async Chunk)
- 执行优化操作:Tree Shaking、Scope Hoisting、代码分割(splitChunks)
- 为每个 Chunk 生成代码,注入 Webpack 运行时代码(
__webpack_require__等) - 将最终的 Bundle 文件写入磁盘(
output.path目录)
// 核心钩子触发顺序
compiler.hooks.beforeRun // 准备运行
compiler.hooks.run // 开始运行
compiler.hooks.compile // 创建 Compilation 前
compiler.hooks.compilation // 创建 Compilation 后
compiler.hooks.make // 开始构建(从入口出发)
compilation.hooks.seal // 构建完成,开始生成
compiler.hooks.emit // 输出文件前(最后修改资源的机会)
compiler.hooks.done // 构建完成
提到 Tapable 钩子系统 是整个流程的调度机制,Webpack 本身和所有 Plugin 都是通过订阅钩子来介入构建流程的。
Q2: Loader 和 Plugin 的区别是什么?
答案:
核心区别:Loader 用于转换文件,Plugin 用于扩展功能。
| 维度 | Loader | Plugin |
|---|---|---|
| 职责 | 将非 JS 文件转换为 Webpack 能处理的模块 | 在构建流程的特定时机执行更广泛的任务 |
| 本质 | 一个导出为函数的模块 | 一个包含 apply 方法的类 |
| 作用范围 | 仅作用于匹配的文件 | 可以影响整个构建流程 |
| 配置 | module.rules | plugins |
| 执行时机 | 模块加载阶段 | 贯穿整个构建生命周期 |
Loader 代码示例:
// Loader 是一个函数,接收源代码,返回转换结果
import { marked } from 'marked';
export default function markdownLoader(source: string): string {
const html = marked(source);
// 返回 JS 模块代码
return `export default ${JSON.stringify(html)}`;
}
Plugin 代码示例:
import type { Compiler } from 'webpack';
// Plugin 是一个类,通过 apply 方法注册钩子
class BuildInfoPlugin {
apply(compiler: Compiler): void {
compiler.hooks.done.tap('BuildInfoPlugin', (stats) => {
const { time, assets } = stats.toJson({ assets: true });
console.log(`构建耗时: ${time}ms`);
console.log(`输出文件: ${assets?.length} 个`);
});
}
}
export default BuildInfoPlugin;
Loader 是翻译官(把一种语言翻译成另一种),Plugin 是项目经理(在项目的各个阶段介入并执行特定任务)。
Q3: HMR 热模块替换的原理是什么?
答案:
HMR 能在不刷新页面的情况下替换、添加或删除模块,保留应用状态。其核心通信流程如下:
1. 建立通信通道
- 启动 Webpack Dev Server 时,会在浏览器端注入 HMR Runtime 代码
- 浏览器与 Dev Server 之间通过 WebSocket 建立长连接
2. 监听文件变化
- Dev Server 使用
chokidar监听文件系统 - 文件变化时,触发 Webpack 增量编译(只编译变化的模块)
3. 推送更新通知
- 编译完成后,通过 WebSocket 向浏览器推送消息:
{ type: 'hash', data: 'newHash123' } - 浏览器端 HMR Runtime 收到新 hash
4. 拉取更新内容
// 1. 收到 WebSocket 消息
websocket.onmessage = (event) => {
const { type, data } = JSON.parse(event.data);
if (type === 'hash') {
latestHash = data;
}
if (type === 'ok') {
hotCheck(); // 开始检查更新
}
};
async function hotCheck(): Promise<void> {
// 2. 请求更新清单 manifest
const manifest = await fetch(`/${latestHash}.hot-update.json`);
// manifest: { c: { main: true }, r: [], m: [] }
// 3. 根据 manifest 请求更新的 chunk
for (const chunkId of Object.keys(manifest.c)) {
const updateChunk = await loadScript(
`/${chunkId}.${latestHash}.hot-update.js`
);
}
// 4. 替换模块:用新模块替换模块缓存中的旧模块
// 5. 执行 module.hot.accept() 注册的回调
}
5. 模块替换
- HMR Runtime 在内部模块缓存(
__webpack_modules__)中用新模块替换旧模块 - 执行
module.hot.accept()中注册的回调函数 - 如果模块没有注册
accept回调,更新会冒泡到父模块 - 如果冒泡到入口模块仍然没有被处理,则回退为完整页面刷新
style-loader 和 MiniCssExtractPlugin 内部已经实现了 module.hot.accept() 逻辑。当 CSS 文件变化时,它会自动替换 <style> 标签或更新 <link> 标签的 href,无需开发者手动处理。
Q4: Webpack 的 HMR(热模块替换)原理是什么?
答案:
HMR(Hot Module Replacement)能够在不刷新整个页面的情况下,对运行中的应用进行模块级别的替换、添加或删除,同时保留应用状态(如表单输入、滚动位置等)。
完整通信流程分为 5 个步骤:
1. 文件监听
Webpack Dev Server 内部使用 chokidar 监听文件系统的变化。当源代码文件被修改并保存时,触发 Webpack 的增量编译。
2. 增量编译
Webpack 不会重新编译所有模块,而是只对变化的模块及其受影响的依赖进行重新编译。编译完成后生成两个关键文件:
// 更新清单(Manifest)—— 记录哪些 Chunk 需要更新
// 文件名格式:[hash].hot-update.json
const manifest = {
c: { main: true }, // 需要更新的 chunk
r: [], // 需要移除的 chunk
m: [], // 需要移除的模块
};
// 更新模块代码 —— 包含变更模块的新代码
// 文件名格式:[chunkId].[hash].hot-update.js
self["webpackHotUpdate"]("main", {
"./src/render.ts": (module, exports, __webpack_require__) => {
// 新的模块代码
}
});
3. WebSocket 通知
Webpack Dev Server 和浏览器之间在页面加载时就已建立了 WebSocket 长连接。编译完成后,服务端通过 WebSocket 向浏览器推送新的 hash 值:
// 服务端推送
{ type: 'hash', data: 'abc123def456' }
{ type: 'ok' } // 编译成功
4. 浏览器端拉取更新
浏览器端注入的 HMR Runtime 收到 WebSocket 消息后,主动向服务端发起 HTTP 请求,拉取更新清单和更新后的模块代码:
// 1. 收到新 hash → 请求 manifest
const manifest = await fetch(`/${newHash}.hot-update.json`);
// 2. 根据 manifest 请求变更的 chunk
for (const chunkId of Object.keys(manifest.c)) {
await loadScript(`/${chunkId}.${newHash}.hot-update.js`);
}
// 3. 用新模块代码替换内部模块缓存 __webpack_modules__
// 4. 执行 module.hot.accept() 中注册的回调
5. 模块替换与冒泡机制
在实际开发中,React 和 Vue 框架通过各自的 HMR 方案自动处理模块替换,开发者不需要手动写 module.hot.accept():
- React:使用
react-refresh+@pmmmwh/react-refresh-webpack-plugin,能在保留组件状态的前提下热替换组件 - Vue:
vue-loader内置了 HMR 支持,组件的<template>、<script>、<style>各部分变更时分别处理
Q5: Webpack 的 Loader 和 Plugin 有什么区别?
答案:
这是 Webpack 最经典的面试题之一。两者的核心区别可以用一句话概括:Loader 负责"翻译"文件,Plugin 负责"扩展"构建流程。
本质区别:
| 维度 | Loader | Plugin |
|---|---|---|
| 定义 | 导出为函数的 JS 模块 | 包含 apply(compiler) 方法的类 |
| 职责 | 将非 JS 文件转换为 Webpack 能处理的模块 | 在构建生命周期的任意阶段执行扩展任务 |
| 输入输出 | 接收文件源内容 string,返回转换后的 string | 接收 Compiler / Compilation 对象 |
| 作用范围 | 仅对匹配 test 的文件生效 | 影响整个构建流程 |
| 配置位置 | module.rules | plugins 数组 |
| 执行时机 | 模块加载阶段(Make 阶段) | 通过 Tapable 钩子在任意阶段触发 |
| 执行顺序 | 从右到左、从下到上(compose 顺序) | 按注册顺序,受钩子类型约束 |
Loader 示例——将 Markdown 转为 HTML 模块:
import { marked } from 'marked';
// Loader 就是一个函数:接收源代码字符串 → 返回 JS 模块代码字符串
export default function markdownLoader(source: string): string {
const html = marked.parse(source);
return `export default ${JSON.stringify(html)};`;
}
Plugin 示例——构建完成后输出文件清单:
import type { Compiler, Compilation } from 'webpack';
class FileListPlugin {
// Plugin 是一个类:通过 apply 方法订阅 Tapable 钩子
apply(compiler: Compiler): void {
compiler.hooks.emit.tapAsync(
'FileListPlugin',
(compilation: Compilation, callback: () => void) => {
const fileList = Object.keys(compilation.assets).join('\n');
// 在 emit 阶段注入一个新文件到输出目录
compilation.assets['filelist.txt'] = {
source: () => fileList,
size: () => fileList.length,
} as any;
callback();
}
);
}
}
export default FileListPlugin;
关系比喻:
- Loader 不能访问 Webpack 的编译上下文(Compiler/Compilation),它只能处理单个文件的内容转换
- Plugin 不能直接转换文件内容,但可以通过钩子在适当时机修改模块图、修改输出资源等
- 两者是互补关系而非替代关系,一个完整的 Webpack 配置通常同时需要多个 Loader 和 Plugin
Q6: Webpack 5 的 Module Federation 解决了什么问题?
答案:
Module Federation(模块联邦)是 Webpack 5 引入的一项革命性特性,它解决的核心问题是:如何在多个独立构建的应用之间,在运行时动态共享和加载模块,而不需要重新打包。
传统方案的痛点:
在大型前端项目中,通常会将系统拆分为多个独立的子应用(如微前端架构)。传统方案面临以下问题:
| 传统方案 | 痛点 |
|---|---|
| npm 包共享 | 版本更新需要所有消费方重新安装、重新构建、重新部署 |
| Git Submodule | 管理复杂,构建耦合 |
| iframe 嵌入 | 通信复杂,体验割裂,性能损耗 |
| 外部 Script(UMD) | 全局变量污染,版本管理困难,无法按需加载 |
Module Federation 的解决思路:
让多个独立构建的应用在运行时直接共享模块,任何应用都可以同时作为模块的提供者(Remote)和消费者(Host):
配置示例:
import { container } from 'webpack';
const { ModuleFederationPlugin } = container;
new ModuleFederationPlugin({
name: 'appA', // 应用名称
filename: 'remoteEntry.js', // 暴露的入口文件
exposes: {
'./Header': './src/components/Header', // 暴露 Header 组件
'./utils': './src/utils/index', // 暴露工具函数
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});
import { container } from 'webpack';
const { ModuleFederationPlugin } = container;
new ModuleFederationPlugin({
name: 'appC',
remotes: {
// 声明从哪里加载远程模块
appA: 'appA@http://localhost:3001/remoteEntry.js',
appB: 'appB@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});
import React, { lazy, Suspense } from 'react';
// 动态加载远程组件(运行时从 appA 的服务器拉取)
const RemoteHeader = lazy(() => import('appA/Header'));
const RemoteProductList = lazy(() => import('appB/ProductList'));
function App(): React.ReactElement {
return (
<div>
<Suspense fallback={<div>加载头部...</div>}>
<RemoteHeader />
</Suspense>
<Suspense fallback={<div>加载商品列表...</div>}>
<RemoteProductList />
</Suspense>
</div>
);
}
export default App;
核心机制:
shared依赖共享:多个应用可以共享同一份react、react-dom等公共依赖,避免重复加载。singleton: true确保全局只有一个实例- 运行时加载:Host 应用在运行时通过网络请求加载 Remote 应用的
remoteEntry.js,再按需加载具体模块 - 版本协商:共享依赖支持版本范围配置,运行时会自动选择兼容版本