跳到主要内容

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 从入口文件开始,递归解析所有依赖模块。

webpack.config.ts
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,以及如何命名。

webpack.config.ts
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、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 模块,它接收源文件内容作为输入,返回转换后的内容。

自定义 Loader 示例
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 函数式组合):

webpack.config.ts
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 中的 @importurl()
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
webpack.config.ts
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 库,它提供了一套发布-订阅模式的钩子系统。

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 示例

my-webpack-plugin.ts
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 对比

对比维度LoaderPlugin
作用转换特定类型的文件扩展 Webpack 功能,介入构建全流程
本质导出为函数的模块包含 apply 方法的类
触发时机在模块加载时触发通过 Tapable 钩子在构建任意阶段触发
配置位置module.rulesplugins 数组中
输入输出接收文件内容,返回转换后的内容接收 Compiler/Compilation 对象
执行顺序从右到左、从下到上按注册顺序,受钩子类型影响
典型场景编译 TS/SCSS、处理图片生成 HTML、提取 CSS、压缩代码

模块解析(Module Resolution)

Webpack 使用 enhanced-resolve 库来解析模块路径。resolve 配置决定了 Webpack 如何找到模块。

webpack.config.ts
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 后,可以用更短的路径引用模块,避免写长串的相对路径:

// 不使用 alias
import { Button } from '../../../components/Button';

// 使用 alias
import { Button } from '@components/Button';

HMR 热模块替换原理

HMR(Hot Module Replacement) 是 Webpack 最强大的开发体验特性之一,它能够在应用运行时替换、添加或删除模块,无需完整刷新页面

HMR 通信流程

HMR 详细步骤

  1. 文件监听:Webpack Dev Server 使用 chokidar 监听文件系统变化
  2. 增量编译:Webpack 只对变化的模块重新编译,生成新的 hash 和两个关键文件:
    • [hash].hot-update.json:更新清单(manifest),记录哪些 Chunk 需要更新
    • [chunkId].[hash].hot-update.js:更新后的模块代码
  3. WebSocket 通知:Dev Server 通过 WebSocket 将新 hash 推送给浏览器
  4. 请求更新:浏览器端的 HMR Runtime 通过 JSONP/fetch 请求更新文件
  5. 模块替换:HMR Runtime 用新模块替换旧模块,执行 module.hot.accept() 回调

HMR 代码示例

src/index.ts
import { render } from './render';

render();

// HMR 接口:当 render.ts 变化时,重新执行 render()
if (module.hot) {
module.hot.accept('./render', () => {
console.log('render 模块已更新');
render();
});
}
HMR 的局限性
  • HMR 仅用于开发环境,生产环境不需要
  • 如果没有配置 module.hot.accept(),HMR 会回退为完整页面刷新
  • React/Vue 等框架通常通过专用 Loader(如 react-refreshvue-loader)自动处理 HMR

基础配置示例

以下是一个完整的 Webpack 生产环境配置示例:

webpack.config.ts
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 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

常见面试问题

Q1: Webpack 的构建流程是怎样的?

答案

Webpack 的构建流程分为三大阶段:

1. 初始化阶段

  • 合并 CLI 参数、配置文件(webpack.config.ts)和默认配置
  • 创建 Compiler 对象(全局唯一),代表完整的 Webpack 环境
  • 遍历 plugins 数组,调用每个 Plugin 的 apply(compiler) 方法注册钩子
  • 注入 Webpack 内置插件(EntryPluginChunkPlugin 等)

2. 构建阶段(Make)

  • entry 配置的入口文件出发
  • 调用匹配的 Loader 链对模块进行转译(如 TS -> JS)
  • 使用 acorn 将转译后的代码解析为 AST(抽象语法树)
  • 遍历 AST,找出 importrequire 等依赖声明
  • 对每个依赖递归执行上述流程,最终构建出完整的模块依赖图(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 用于扩展功能

维度LoaderPlugin
职责将非 JS 文件转换为 Webpack 能处理的模块在构建流程的特定时机执行更广泛的任务
本质一个导出为函数的模块一个包含 apply 方法的类
作用范围仅作用于匹配的文件可以影响整个构建流程
配置module.rulesplugins
执行时机模块加载阶段贯穿整个构建生命周期

Loader 代码示例

markdown-loader.ts
// Loader 是一个函数,接收源代码,返回转换结果
import { marked } from 'marked';

export default function markdownLoader(source: string): string {
const html = marked(source);
// 返回 JS 模块代码
return `export default ${JSON.stringify(html)}`;
}

Plugin 代码示例

build-info-plugin.ts
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. 拉取更新内容

HMR Runtime 工作流程(伪代码)
// 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 回调,更新会冒泡到父模块
  • 如果冒泡到入口模块仍然没有被处理,则回退为完整页面刷新
CSS 的 HMR 为什么不需要手动 accept?

style-loaderMiniCssExtractPlugin 内部已经实现了 module.hot.accept() 逻辑。当 CSS 文件变化时,它会自动替换 <style> 标签或更新 <link> 标签的 href,无需开发者手动处理。

Q4: Webpack 的 HMR(热模块替换)原理是什么?

答案

HMR(Hot Module Replacement)能够在不刷新整个页面的情况下,对运行中的应用进行模块级别的替换、添加或删除,同时保留应用状态(如表单输入、滚动位置等)。

完整通信流程分为 5 个步骤

1. 文件监听

Webpack Dev Server 内部使用 chokidar 监听文件系统的变化。当源代码文件被修改并保存时,触发 Webpack 的增量编译。

2. 增量编译

Webpack 不会重新编译所有模块,而是只对变化的模块及其受影响的依赖进行重新编译。编译完成后生成两个关键文件:

HMR 产物
// 更新清单(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 值:

WebSocket 消息
// 服务端推送
{ type: 'hash', data: 'abc123def456' }
{ type: 'ok' } // 编译成功

4. 浏览器端拉取更新

浏览器端注入的 HMR Runtime 收到 WebSocket 消息后,主动向服务端发起 HTTP 请求,拉取更新清单和更新后的模块代码:

HMR Runtime 工作流程(伪代码)
// 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. 模块替换与冒泡机制

框架的 HMR 集成

在实际开发中,React 和 Vue 框架通过各自的 HMR 方案自动处理模块替换,开发者不需要手动写 module.hot.accept()

  • React:使用 react-refresh + @pmmmwh/react-refresh-webpack-plugin,能在保留组件状态的前提下热替换组件
  • Vuevue-loader 内置了 HMR 支持,组件的 <template><script><style> 各部分变更时分别处理

Q5: Webpack 的 Loader 和 Plugin 有什么区别?

答案

这是 Webpack 最经典的面试题之一。两者的核心区别可以用一句话概括:Loader 负责"翻译"文件,Plugin 负责"扩展"构建流程

本质区别

维度LoaderPlugin
定义导出为函数的 JS 模块包含 apply(compiler) 方法的类
职责将非 JS 文件转换为 Webpack 能处理的模块在构建生命周期的任意阶段执行扩展任务
输入输出接收文件源内容 string,返回转换后的 string接收 Compiler / Compilation 对象
作用范围仅对匹配 test 的文件生效影响整个构建流程
配置位置module.rulesplugins 数组
执行时机模块加载阶段(Make 阶段)通过 Tapable 钩子在任意阶段触发
执行顺序从右到左、从下到上(compose 顺序)按注册顺序,受钩子类型约束

Loader 示例——将 Markdown 转为 HTML 模块

markdown-loader.ts
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 示例——构建完成后输出文件清单

file-list-plugin.ts
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)

配置示例

应用 A(Remote)—— 暴露模块给其他应用
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' },
},
});
应用 C(Host)—— 消费远程模块
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' },
},
});
应用 C 中使用远程组件
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;

核心机制

  1. shared 依赖共享:多个应用可以共享同一份 reactreact-dom 等公共依赖,避免重复加载。singleton: true 确保全局只有一个实例
  2. 运行时加载:Host 应用在运行时通过网络请求加载 Remote 应用的 remoteEntry.js,再按需加载具体模块
  3. 版本协商:共享依赖支持版本范围配置,运行时会自动选择兼容版本
与微前端的关系

Module Federation 是实现微前端架构的一种底层技术方案。相比 qiankun 等框架级方案,Module Federation 更加轻量,且能实现模块级别的共享(而非整个应用级别的隔离加载)。

更多关于 Module Federation 的详细内容,请参考模块联邦专题文档。

相关链接