Webpack Loader 与 Plugin 开发
问题
如何编写自定义的 Webpack Loader 和 Plugin?它们的工作原理和核心机制是什么?
答案
Loader 和 Plugin 是 Webpack 最强大的两个扩展机制。Loader 负责将各种类型的文件转换为 Webpack 可以处理的有效模块,Plugin 则可以介入 Webpack 构建流程的任何阶段,执行更广泛的任务。理解它们的底层原理,不仅是高频面试考点,更是日常定制构建流程的必备技能。
Loader 开发
Loader 的本质
Loader 本质上是一个函数,接收源文件内容(字符串或 Buffer)作为参数,返回转换后的结果。它遵循单一职责原则:每个 Loader 只负责一种转换。
// Loader 就是一个导出的函数
// this 指向 Webpack 提供的 Loader Context
function myLoader(this: any, source: string): string {
// source 是文件原始内容
// 返回转换后的内容
return source.replace('foo', 'bar');
}
export default myLoader;
- Loader 是一个纯函数(或近似纯函数),输入源代码,输出转换后的代码
- Loader 不能用箭头函数声明,因为需要通过
this访问 Loader Context - 多个 Loader 按链式调用,前一个 Loader 的输出是下一个的输入
同步 Loader vs 异步 Loader
Loader 分为同步和异步两种,根据转换操作是否涉及异步任务来选择。
// 方式一:直接 return(最简单)
function syncLoader(this: any, source: string): string {
const result = source.replace(/console\.log\(.*?\);?/g, '');
return result;
}
// 方式二:使用 this.callback(可以传递额外信息)
function syncLoaderWithCallback(this: any, source: string): void {
const result = source.replace(/console\.log\(.*?\);?/g, '');
// this.callback(err, content, sourceMap?, meta?)
this.callback(null, result, undefined, undefined);
}
export default syncLoader;
import type { LoaderDefinitionFunction } from 'webpack';
const asyncLoader: LoaderDefinitionFunction = function (source) {
const callback = this.async(); // 告诉 Webpack 这是异步 Loader
// 模拟异步操作(如读取文件、网络请求、编译等)
setTimeout(() => {
const result = source.replace('__TIMESTAMP__', String(Date.now()));
callback(null, result); // 异步完成后调用 callback
}, 100);
};
export default asyncLoader;
调用 this.async() 后,Webpack 会等待 callback 被调用才继续处理。如果 Loader 涉及文件 I/O 或网络请求,必须使用异步模式,否则会阻塞整个构建流程。
Loader Context
this 指向的 Loader Context 提供了丰富的 API,以下是最常用的属性和方法:
| 属性/方法 | 类型 | 说明 |
|---|---|---|
this.callback | Function | 同步/异步模式下返回多个结果 |
this.async | Function | 将 Loader 标记为异步,返回 callback |
this.query | string | object | Loader 的 options 配置 |
this.resourcePath | string | 当前处理文件的绝对路径 |
this.rootContext | string | 项目根目录路径 |
this.emitFile | Function | 输出一个文件到构建目录 |
this.addDependency | Function | 添加文件依赖(文件变化时重新编译) |
this.cacheable | Function | 设置是否可缓存(默认 true) |
this.sourceMap | boolean | 是否需要生成 Source Map |
this.getOptions | Function | 获取经过 schema 校验的 options(Webpack 5) |
function contextDemo(this: any, source: string): string {
// 获取 options
const options = this.getOptions();
// 获取当前文件路径
console.log('Processing:', this.resourcePath);
// 添加文件依赖(当该文件变化时,触发重新编译)
this.addDependency('/path/to/config.json');
// 输出额外文件
this.emitFile('manifest.json', JSON.stringify({ version: '1.0' }));
// 标记为可缓存(输入不变则跳过转换)
this.cacheable(true);
return source;
}
Loader 执行顺序:Pitch 阶段与 Normal 阶段
Loader 的执行并非简单的从右到左,而是分为 Pitch 阶段(从左到右)和 Normal 阶段(从右到左)两个阶段。这是面试中的高频考点。
Pitch Loader 的熔断机制:如果某个 Loader 的 pitch 方法有返回值,则会跳过后续 Loader,直接进入前一个 Loader 的 normal 阶段。
function myLoader(source: string): string {
// Normal 阶段执行
return source + '\n// processed by myLoader';
}
// Pitch 方法:在 Normal 阶段之前执行
myLoader.pitch = function (
remainingRequest: string, // 剩余的 Loader 请求路径
precedingRequest: string, // 已经执行过 pitch 的 Loader 路径
data: Record<string, any> // 可在 pitch 和 normal 之间传递数据
): string | void {
// 如果返回值,则触发熔断
// 不返回值,则继续执行后续 Loader 的 pitch
data.startTime = Date.now(); // pitch 与 normal 之间共享数据
};
export default myLoader;
style-loader 就是利用 pitch 熔断机制实现的。它在 pitch 阶段返回一段 JS 代码,这段代码会用 require 引入 css-loader 的处理结果,然后将 CSS 注入到 DOM 中。这样 style-loader 就不需要在 normal 阶段处理 CSS 字符串了。
手写 markdown-loader
将 Markdown 文件转换为 HTML 字符串,供前端组件使用。
import { marked } from 'marked';
import type { LoaderDefinitionFunction } from 'webpack';
interface MarkdownLoaderOptions {
/** 是否启用 GitHub 风格 Markdown */
gfm?: boolean;
/** 是否对输出进行 HTML 转义 */
sanitize?: boolean;
/** 自定义 marked 配置 */
markedOptions?: marked.MarkedOptions;
}
const markdownLoader: LoaderDefinitionFunction = function (source) {
// 标记为可缓存
this.cacheable(true);
// 获取配置项
const options = this.getOptions() as MarkdownLoaderOptions;
// 配置 marked
marked.setOptions({
gfm: options.gfm !== false,
...options.markedOptions,
});
// 将 Markdown 转换为 HTML
const html = marked.parse(source as string);
// 返回一个 JS 模块,导出 HTML 字符串
// 注意:Loader 链最终必须返回 JS 代码
return `export default ${JSON.stringify(html)};`;
};
export default markdownLoader;
在 Webpack 中使用:
import path from 'path';
import type { Configuration } from 'webpack';
const config: Configuration = {
module: {
rules: [
{
test: /\.md$/,
use: [
{
loader: path.resolve(__dirname, 'loaders/markdown-loader.ts'),
options: {
gfm: true,
},
},
],
},
],
},
};
export default config;
手写 banner-loader
给每个文件顶部添加注释头(如版权信息、作者信息等),这在企业项目中非常常见。
import { validate } from 'schema-utils';
import type { LoaderDefinitionFunction } from 'webpack';
import type { Schema } from 'schema-utils/declarations/validate';
// 使用 schema-utils 定义 options 的校验规则
const schema: Schema = {
type: 'object',
properties: {
author: { type: 'string' },
license: { type: 'string' },
timestamp: { type: 'boolean' },
},
required: ['author'],
additionalProperties: false,
};
interface BannerLoaderOptions {
author: string;
license?: string;
timestamp?: boolean;
}
const bannerLoader: LoaderDefinitionFunction = function (source) {
// 获取并校验 options
const options = this.getOptions() as BannerLoaderOptions;
validate(schema, options, { name: 'BannerLoader', baseDataPath: 'options' });
// 构建 banner 注释
const lines: string[] = [
`/**`,
` * @author ${options.author}`,
];
if (options.license) {
lines.push(` * @license ${options.license}`);
}
if (options.timestamp) {
lines.push(` * @date ${new Date().toISOString()}`);
}
lines.push(` * @file ${this.resourcePath}`);
lines.push(` */`);
const banner = lines.join('\n');
// 在源代码顶部添加 banner
return `${banner}\n${source}`;
};
export default bannerLoader;
{
module: {
rules: [
{
test: /\.(ts|js)$/,
use: [
{
loader: path.resolve(__dirname, 'loaders/banner-loader.ts'),
options: {
author: 'My Team',
license: 'MIT',
timestamp: true,
},
},
],
},
],
},
}
loader-utils 和 schema-utils
这两个是 Webpack 官方提供的 Loader 开发辅助库:
| 工具库 | 用途 | 说明 |
|---|---|---|
loader-utils | 获取 Loader 参数、生成 hash、插值 | Webpack 5 中部分功能已内置(this.getOptions) |
schema-utils | 校验 Loader/Plugin 的 options | 基于 JSON Schema,提供清晰的错误提示 |
import { getOptions, interpolateName, getHashDigest } from 'loader-utils';
function myLoader(this: any, source: string): string {
// Webpack 4 中获取 options(Webpack 5 推荐用 this.getOptions)
const options = getOptions(this);
// 生成带 hash 的文件名(常用于 file-loader、url-loader)
const filename = interpolateName(this, '[name].[contenthash:8].[ext]', {
content: source,
});
// 生成内容 hash
const hash = getHashDigest(Buffer.from(source), 'md5', 'hex', 8);
return source;
}
在 Webpack 5 中,loader-utils 的 getOptions 已被 this.getOptions() 替代。新项目建议直接使用 Webpack 5 内置 API,loader-utils 主要在维护旧项目时使用。
Plugin 开发
Plugin 的本质
Plugin 是一个带有 apply 方法的对象或类。Webpack 在启动时会调用插件的 apply 方法,并传入 compiler 对象,插件通过在 compiler/compilation 的各种钩子上注册回调来介入构建流程。
import type { Compiler } from 'webpack';
class MyPlugin {
apply(compiler: Compiler): void {
// 在 compiler 的钩子上注册回调
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('构建完成!');
});
}
}
export default MyPlugin;
Tapable 钩子系统
Webpack 的整个事件机制基于 Tapable 库。Tapable 提供了多种钩子类型,理解它们是编写 Plugin 的基础。
| 钩子类型 | 执行方式 | 说明 |
|---|---|---|
SyncHook | 同步串行 | 按注册顺序依次执行,不关心返回值 |
SyncBailHook | 同步串行熔断 | 某个回调返回非 undefined 时停止执行 |
SyncWaterfallHook | 同步串行流水线 | 前一个回调的返回值传给下一个 |
SyncLoopHook | 同步循环 | 某个回调返回非 undefined 时从头重新执行 |
AsyncSeriesHook | 异步串行 | 异步回调按顺序依次执行 |
AsyncSeriesBailHook | 异步串行熔断 | 异步版本的 BailHook |
AsyncSeriesWaterfallHook | 异步串行流水线 | 异步版本的 WaterfallHook |
AsyncParallelHook | 异步并行 | 所有异步回调同时执行 |
AsyncParallelBailHook | 异步并行熔断 | 第一个返回值的回调决定结果 |
import {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
AsyncSeriesHook,
AsyncParallelHook,
} from 'tapable';
// 定义钩子
const hooks = {
// SyncHook:最基本的同步钩子
init: new SyncHook<[string]>(['name']),
// SyncBailHook:返回非 undefined 时停止
shouldProcess: new SyncBailHook<[string], boolean>(['filename']),
// SyncWaterfallHook:返回值传递给下一个
transform: new SyncWaterfallHook<[string]>(['code']),
// AsyncSeriesHook:异步串行
build: new AsyncSeriesHook<[string[]]>(['files']),
// AsyncParallelHook:异步并行
optimize: new AsyncParallelHook<[string[]]>(['assets']),
};
// 注册回调的三种方式
// 1. tap - 同步回调
hooks.init.tap('PluginA', (name) => {
console.log(`Init: ${name}`);
});
// 2. tapAsync - 异步回调(callback 风格)
hooks.build.tapAsync('PluginB', (files, callback) => {
setTimeout(() => {
console.log(`Built ${files.length} files`);
callback();
}, 100);
});
// 3. tapPromise - 异步回调(Promise 风格)
hooks.optimize.tapPromise('PluginC', async (assets) => {
await new Promise((resolve) => setTimeout(resolve, 50));
console.log(`Optimized ${assets.length} assets`);
});
Compiler vs Compilation
这是面试必考的核心概念:
| 对比项 | Compiler | Compilation |
|---|---|---|
| 生命周期 | 整个 Webpack 进程只有一个 | 每次构建(包括 watch 触发)都会新建 |
| 代表什么 | Webpack 的编译器实例,包含完整配置 | 一次具体的编译过程,包含模块和资源 |
| 主要属性 | options、hooks、inputFileSystem | modules、chunks、assets、hooks |
| 核心职责 | 启动编译、管理生命周期、创建 Compilation | 模块构建、依赖分析、代码生成、资源输出 |
| 变化频率 | 不变(配置确定后就固定) | 每次编译都不同(文件变化后内容不同) |
| 类比 | 工厂(不变的基础设施) | 一次生产过程(原料和产品每次不同) |
常用钩子
Compiler 钩子
| 钩子 | 类型 | 触发时机 | 典型用途 |
|---|---|---|---|
entryOption | SyncBailHook | 处理 entry 配置后 | 修改入口配置 |
afterPlugins | SyncHook | 内部插件应用完成后 | 注册额外插件 |
compile | SyncHook | 开始编译前 | 准备工作 |
compilation | SyncHook | 创建 Compilation 后 | 在 compilation 上注册钩子 |
make | AsyncParallelHook | 从入口开始构建模块 | 添加额外入口 |
afterCompile | AsyncSeriesHook | 编译完成后 | 分析编译结果 |
emit | AsyncSeriesHook | 输出文件到目录前 | 修改/添加输出文件 |
afterEmit | AsyncSeriesHook | 输出文件完成后 | 文件后处理 |
done | AsyncSeriesHook | 构建完全完成 | 统计、通知、清理 |
failed | SyncHook | 构建失败 | 错误上报 |
Compilation 钩子
| 钩子 | 类型 | 触发时机 | 典型用途 |
|---|---|---|---|
buildModule | SyncHook | 开始构建模块前 | 模块构建日志 |
succeedModule | SyncHook | 模块构建成功后 | 模块分析 |
seal | SyncHook | 停止接收新模块 | 最后的模块修改 |
optimize | SyncHook | 优化阶段开始 | 自定义优化逻辑 |
optimizeChunks | SyncBailHook | 优化 chunk | 自定义分包策略 |
processAssets | AsyncSeriesHook | 处理资源(Webpack 5) | 修改最终输出资源 |
afterProcessAssets | SyncHook | 资源处理完成后 | 资源验证 |
面试和实际开发中最常用的钩子:
compiler.hooks.emit— 在文件输出前修改资源compiler.hooks.done— 构建完成后执行统计或通知compiler.hooks.compilation— 获取 compilation 对象,再在其钩子上注册compilation.hooks.processAssets(Webpack 5)— 处理最终输出的资源
手写 FileListPlugin
在构建完成后,输出一个包含所有输出文件信息的清单文件。
import type { Compiler, Compilation } from 'webpack';
interface FileListPluginOptions {
/** 输出的文件名 */
filename?: string;
/** 是否包含文件大小 */
includeSize?: boolean;
}
class FileListPlugin {
private options: Required<FileListPluginOptions>;
constructor(options: FileListPluginOptions = {}) {
this.options = {
filename: options.filename ?? 'file-list.md',
includeSize: options.includeSize ?? true,
};
}
apply(compiler: Compiler): void {
const pluginName = FileListPlugin.name;
compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
const assets = compilation.assets;
const fileList = this.generateFileList(assets);
// 将文件清单添加到输出资源中
compilation.assets[this.options.filename] = {
source: () => fileList,
size: () => fileList.length,
} as any;
callback();
});
}
private generateFileList(assets: Compilation['assets']): string {
const lines: string[] = [
'# 构建文件清单',
'',
`> 生成时间:${new Date().toLocaleString('zh-CN')}`,
'',
];
if (this.options.includeSize) {
lines.push('| 文件名 | 大小 |');
lines.push('|--------|------|');
let totalSize = 0;
for (const [filename, asset] of Object.entries(assets)) {
const size = asset.size();
totalSize += size;
lines.push(`| ${filename} | ${this.formatSize(size)} |`);
}
lines.push('');
lines.push(`**总计**:${Object.keys(assets).length} 个文件,${this.formatSize(totalSize)}`);
} else {
for (const filename of Object.keys(assets)) {
lines.push(`- ${filename}`);
}
}
return lines.join('\n');
}
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
}
export default FileListPlugin;
手写 CleanExpiredPlugin
在每次构建前,清理 dist 目录中超过指定天数的旧文件。这在需要保留历史版本但清理过期缓存的场景下非常实用。
import fs from 'fs';
import path from 'path';
import type { Compiler } from 'webpack';
interface CleanExpiredPluginOptions {
/** 过期天数,默认 7 天 */
maxAge?: number;
/** 要清理的目录,默认为 output.path */
directory?: string;
/** 排除的文件/目录模式 */
exclude?: RegExp[];
/** 是否只打印(不实际删除) */
dryRun?: boolean;
}
class CleanExpiredPlugin {
private options: Required<CleanExpiredPluginOptions>;
constructor(options: CleanExpiredPluginOptions = {}) {
this.options = {
maxAge: options.maxAge ?? 7,
directory: options.directory ?? '',
exclude: options.exclude ?? [/\.gitkeep$/],
dryRun: options.dryRun ?? false,
};
}
apply(compiler: Compiler): void {
const pluginName = CleanExpiredPlugin.name;
// 在 emit 前执行清理
compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
const outputPath = this.options.directory || compiler.options.output?.path || 'dist';
const maxAgeMs = this.options.maxAge * 24 * 60 * 60 * 1000;
const now = Date.now();
try {
if (!fs.existsSync(outputPath)) {
callback();
return;
}
const removedFiles = this.cleanDirectory(outputPath, now, maxAgeMs);
if (removedFiles.length > 0) {
const action = this.options.dryRun ? '将被清理' : '已清理';
console.log(
`[CleanExpiredPlugin] ${action} ${removedFiles.length} 个过期文件:`
);
removedFiles.forEach((file) => console.log(` - ${file}`));
}
callback();
} catch (err) {
callback(err as Error);
}
});
}
private cleanDirectory(dir: string, now: number, maxAgeMs: number): string[] {
const removedFiles: string[] = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// 检查排除规则
if (this.options.exclude.some((pattern) => pattern.test(entry.name))) {
continue;
}
if (entry.isDirectory()) {
// 递归清理子目录
removedFiles.push(...this.cleanDirectory(fullPath, now, maxAgeMs));
} else if (entry.isFile()) {
const stat = fs.statSync(fullPath);
const fileAge = now - stat.mtimeMs;
if (fileAge > maxAgeMs) {
if (!this.options.dryRun) {
fs.unlinkSync(fullPath);
}
removedFiles.push(fullPath);
}
}
}
return removedFiles;
}
}
export default CleanExpiredPlugin;
使用方式:
import FileListPlugin from './plugins/FileListPlugin';
import CleanExpiredPlugin from './plugins/CleanExpiredPlugin';
import type { Configuration } from 'webpack';
const config: Configuration = {
// ...其他配置
plugins: [
new FileListPlugin({
filename: 'build-manifest.md',
includeSize: true,
}),
new CleanExpiredPlugin({
maxAge: 7,
exclude: [/\.gitkeep$/, /index\.html$/],
dryRun: process.env.NODE_ENV !== 'production',
}),
],
};
export default config;
常见 Loader 原理解析
babel-loader
babel-loader 是最常用的 Loader,负责将 ES6+/TypeScript/JSX 代码转译为向下兼容的 JavaScript。
import * as babel from '@babel/core';
import type { LoaderDefinitionFunction } from 'webpack';
const babelLoader: LoaderDefinitionFunction = function (source) {
const callback = this.async();
const options = this.getOptions();
// 调用 @babel/core 进行转译
babel.transform(
source as string,
{
filename: this.resourcePath,
sourceMap: this.sourceMap,
...options,
},
(err, result) => {
if (err) {
callback(err);
return;
}
// 返回转译后的代码和 Source Map
callback(null, result?.code ?? '', result?.map ?? undefined);
}
);
};
export default babelLoader;
生产项目中务必开启 cacheDirectory: true,babel-loader 会将转译结果缓存到 node_modules/.cache/babel-loader,在文件未变化时直接读取缓存,可大幅提升构建速度。
css-loader
css-loader 负责解析 CSS 文件中的 @import 和 url() 引用,将 CSS 转换为 JavaScript 模块。
import postcss from 'postcss';
import type { LoaderDefinitionFunction } from 'webpack';
const cssLoader: LoaderDefinitionFunction = function (source) {
const callback = this.async();
// 使用 PostCSS 解析 CSS
const processor = postcss([
// 解析 @import,将导入的 CSS 合并
resolveImportsPlugin(),
// 解析 url(),将引用的资源转为 require
resolveUrlPlugin(),
// CSS Modules 处理(如果启用)
cssModulesPlugin(),
]);
processor
.process(source as string, { from: this.resourcePath })
.then((result) => {
// 输出一个 JS 模块
// 将 CSS 内容作为字符串导出,附带依赖信息
const output = `
var cssExports = ${JSON.stringify(result.css)};
module.exports = cssExports;
`;
callback(null, output);
})
.catch((err) => callback(err));
};
style-loader
style-loader 将 CSS 字符串注入到 DOM 的 <style> 标签中。它巧妙地利用了 Pitch 机制。
import type { LoaderDefinitionFunction } from 'webpack';
const styleLoader: LoaderDefinitionFunction = function () {
// Normal 阶段不执行
};
// 关键:在 Pitch 阶段返回代码,触发熔断
styleLoader.pitch = function (remainingRequest: string): string {
// 返回一段 JS 代码,这段代码会:
// 1. 用 require 内联调用后续 Loader(css-loader)处理 CSS
// 2. 将得到的 CSS 字符串注入到 <style> 标签
return `
var content = require(${JSON.stringify(
'!!' + remainingRequest // !! 表示禁用所有前置 Loader
)});
var style = document.createElement('style');
style.textContent = typeof content === 'string' ? content : content.toString();
document.head.appendChild(style);
// 支持 HMR
if (module.hot) {
module.hot.accept(${JSON.stringify('!!' + remainingRequest)}, function() {
var newContent = require(${JSON.stringify('!!' + remainingRequest)});
style.textContent = typeof newContent === 'string'
? newContent
: newContent.toString();
});
}
`;
};
export default styleLoader;
如果 style-loader 在 Normal 阶段执行,它接收到的是 css-loader 输出的 JS 模块代码字符串,无法直接操作 DOM。通过 Pitch 熔断,style-loader 可以自己生成一段 JS 代码,在运行时 require css-loader 的结果并注入 DOM。这是一种非常精妙的设计。
常见 Plugin 原理解析
HtmlWebpackPlugin
自动生成 HTML 文件,并将打包后的 JS/CSS 资源自动注入到 HTML 中。
import type { Compiler, Compilation } from 'webpack';
class SimpleHtmlPlugin {
private template: string;
constructor(options: { template?: string } = {}) {
this.template = options.template ?? '<html><head></head><body></body></html>';
}
apply(compiler: Compiler): void {
compiler.hooks.emit.tapAsync('SimpleHtmlPlugin', (compilation, callback) => {
// 1. 收集所有 JS 和 CSS 资源
const jsFiles: string[] = [];
const cssFiles: string[] = [];
for (const [filename] of Object.entries(compilation.assets)) {
if (filename.endsWith('.js')) jsFiles.push(filename);
if (filename.endsWith('.css')) cssFiles.push(filename);
}
// 2. 生成 script 和 link 标签
const scripts = jsFiles
.map((f) => `<script defer src="${f}"></script>`)
.join('\n ');
const links = cssFiles
.map((f) => `<link rel="stylesheet" href="${f}">`)
.join('\n ');
// 3. 注入到 HTML 模板
let html = this.template;
html = html.replace('</head>', ` ${links}\n </head>`);
html = html.replace('</body>', ` ${scripts}\n </body>`);
// 4. 添加到输出资源
compilation.assets['index.html'] = {
source: () => html,
size: () => html.length,
} as any;
callback();
});
}
}
MiniCssExtractPlugin
将 CSS 从 JS Bundle 中提取为独立的 CSS 文件,用于生产环境替代 style-loader。
import type { Compiler, Compilation } from 'webpack';
class SimpleCssExtractPlugin {
apply(compiler: Compiler): void {
// 1. 注册一个自定义的 Loader(替代 style-loader)
compiler.hooks.compilation.tap('SimpleCssExtractPlugin', (compilation) => {
// 2. 在模块构建完成后收集 CSS
compilation.hooks.processAssets.tap(
{
name: 'SimpleCssExtractPlugin',
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_DERIVED,
},
(assets) => {
// 遍历所有模块,收集 CSS 内容
const cssMap = new Map<string, string[]>();
for (const chunk of compilation.chunks) {
const cssContents: string[] = [];
for (const module of compilation.chunkGraph.getChunkModulesIterable(chunk)) {
// 从标记了 CSS 类型的模块中提取内容
if ((module as any).__cssContent) {
cssContents.push((module as any).__cssContent);
}
}
if (cssContents.length > 0) {
cssMap.set(chunk.name ?? chunk.id?.toString() ?? 'unknown', cssContents);
}
}
// 3. 为每个 chunk 生成独立的 CSS 文件
for (const [chunkName, contents] of cssMap) {
const cssFilename = `${chunkName}.css`;
const cssContent = contents.join('\n');
compilation.emitAsset(
cssFilename,
new compiler.webpack.sources.RawSource(cssContent)
);
}
}
);
});
}
}
- 开发环境用
style-loader:CSS 注入到<style>标签,支持 HMR,修改即时生效 - 生产环境用
MiniCssExtractPlugin:CSS 提取为独立文件,支持并行加载、缓存、压缩
两者不能同时使用,需要根据环境切换。
常见面试问题
Q1: 如何编写一个自定义 Webpack Loader?
答案:
编写一个自定义 Loader 需要遵循以下步骤:
第一步:创建 Loader 函数
import { validate } from 'schema-utils';
import type { LoaderDefinitionFunction } from 'webpack';
import type { Schema } from 'schema-utils/declarations/validate';
// 1. 定义 options 的 JSON Schema
const schema: Schema = {
type: 'object',
properties: {
methods: {
type: 'array',
items: { type: 'string' },
description: '要移除的 console 方法列表',
},
},
additionalProperties: false,
};
interface RemoveConsoleOptions {
methods?: string[];
}
// 2. 编写 Loader 函数(不能用箭头函数!)
const removeConsoleLoader: LoaderDefinitionFunction = function (source) {
// 3. 获取并校验 options
const options = this.getOptions() as RemoveConsoleOptions;
validate(schema, options, { name: 'RemoveConsoleLoader' });
// 4. 标记为可缓存
this.cacheable(true);
const methods = options.methods ?? ['log', 'debug', 'info'];
// 5. 执行转换
let result = source as string;
for (const method of methods) {
const regex = new RegExp(`console\\.${method}\\s*\\([^)]*\\);?`, 'g');
result = result.replace(regex, '');
}
// 6. 返回结果
return result;
};
export default removeConsoleLoader;
第二步:在 Webpack 配置中使用
import path from 'path';
import type { Configuration } from 'webpack';
const config: Configuration = {
module: {
rules: [
{
test: /\.ts$/,
use: [
'ts-loader',
{
loader: path.resolve(__dirname, 'loaders/remove-console-loader.ts'),
options: {
methods: ['log', 'debug'],
},
},
],
},
],
},
// 也可以通过 resolveLoader 简化路径
resolveLoader: {
modules: ['node_modules', path.resolve(__dirname, 'loaders')],
},
};
第三步:编写测试
import compiler from './compiler'; // 使用 webpack 测试辅助
describe('remove-console-loader', () => {
it('应该移除 console.log 语句', async () => {
const input = `
const a = 1;
console.log('debug info');
console.error('error');
export default a;
`;
const stats = await compiler('test-entry.ts', {
methods: ['log'],
});
const output = stats.toJson({ source: true }).modules?.[0]?.source;
expect(output).not.toContain('console.log');
expect(output).toContain('console.error'); // error 不应被移除
});
});
- 单一职责:每个 Loader 只做一件事
- 链式调用:通过管道组合多个 Loader
- 无状态:不要在 Loader 中保存状态
- 使用
this.getOptions()+schema-utils校验参数 - 合理使用缓存(
this.cacheable(true))
Q2: 如何编写一个自定义 Webpack Plugin?
答案:
编写 Plugin 需要理解 Tapable 钩子系统,并选择合适的钩子注册回调。
完整示例:构建耗时统计插件
import type { Compiler, Stats } from 'webpack';
interface BuildTimePluginOptions {
/** 是否输出详细的各阶段耗时 */
verbose?: boolean;
/** 耗时超过此阈值(ms)则警告 */
warnThreshold?: number;
/** 自定义输出回调 */
onComplete?: (data: BuildTimeData) => void;
}
interface BuildTimeData {
totalTime: number;
phases: Record<string, number>;
timestamp: string;
}
class BuildTimePlugin {
private options: Required<BuildTimePluginOptions>;
private startTime: number = 0;
private phases: Map<string, number> = new Map();
constructor(options: BuildTimePluginOptions = {}) {
this.options = {
verbose: options.verbose ?? false,
warnThreshold: options.warnThreshold ?? 10000,
onComplete: options.onComplete ?? (() => {}),
};
}
apply(compiler: Compiler): void {
const pluginName = BuildTimePlugin.name;
// 编译开始
compiler.hooks.compile.tap(pluginName, () => {
this.startTime = Date.now();
this.phases.clear();
this.phases.set('compile_start', this.startTime);
});
// 模块构建完成
compiler.hooks.afterCompile.tap(pluginName, () => {
this.phases.set('after_compile', Date.now());
});
// 资源输出
compiler.hooks.emit.tapAsync(pluginName, (_compilation, callback) => {
this.phases.set('emit', Date.now());
callback();
});
// 构建完成
compiler.hooks.done.tap(pluginName, (stats: Stats) => {
const endTime = Date.now();
const totalTime = endTime - this.startTime;
// 计算各阶段耗时
const phaseTimings: Record<string, number> = {};
const entries = Array.from(this.phases.entries());
for (let i = 1; i < entries.length; i++) {
const phaseName = entries[i][0];
phaseTimings[phaseName] = entries[i][1] - entries[i - 1][1];
}
// 输出统计
console.log(`\n⏱ 构建总耗时:${totalTime}ms`);
if (this.options.verbose) {
console.log('各阶段耗时:');
for (const [phase, time] of Object.entries(phaseTimings)) {
console.log(` ${phase}: ${time}ms`);
}
}
if (totalTime > this.options.warnThreshold) {
console.warn(
`⚠️ 构建耗时 ${totalTime}ms 超过阈值 ${this.options.warnThreshold}ms`
);
}
// 触发回调
this.options.onComplete({
totalTime,
phases: phaseTimings,
timestamp: new Date().toISOString(),
});
});
}
}
export default BuildTimePlugin;
使用方式:
import BuildTimePlugin from './plugins/BuildTimePlugin';
export default {
plugins: [
new BuildTimePlugin({
verbose: true,
warnThreshold: 5000,
onComplete: (data) => {
// 可以将数据上报到监控系统
console.log('Build data:', JSON.stringify(data));
},
}),
],
};
Plugin 开发核心步骤总结:
- 创建一个类,定义
apply(compiler: Compiler)方法 - 在
apply中通过compiler.hooks.xxx.tap/tapAsync/tapPromise注册钩子 - 如果需要操作模块/资源,通过
compiler.hooks.compilation获取compilation对象 - 在回调中执行自定义逻辑,注意异步钩子需要调用
callback()或返回Promise
Q3: Compiler 和 Compilation 的区别是什么?
答案:
这是 Webpack 插件体系中两个最核心的对象,区分它们是理解 Plugin 开发的关键。
Compiler(编译器):
- 代表 Webpack 的完整配置环境,在 Webpack 启动时创建,贯穿整个生命周期
- 整个 Webpack 进程中只有一个 Compiler 实例
- 包含 Webpack 的所有配置信息(
options)、文件系统(inputFileSystem、outputFileSystem)、插件等 - 提供 Webpack 生命周期的顶级钩子(
compile、emit、done等)
Compilation(编译过程):
- 代表一次具体的编译过程,每次文件变化触发重新编译时都会创建新的 Compilation
- 包含当前编译的所有信息:模块(
modules)、依赖图、chunk(chunks)、输出资源(assets) - 在 watch 模式下,一个 Compiler 会创建多个 Compilation
import type { Compiler, Compilation } from 'webpack';
class DemoPlugin {
apply(compiler: Compiler): void {
// Compiler 级别:整个构建生命周期只触发一次(非 watch 模式)
compiler.hooks.done.tap('DemoPlugin', () => {
console.log('Compiler: 整个构建完成');
});
// Compilation 级别:每次编译都会触发
compiler.hooks.compilation.tap('DemoPlugin', (compilation: Compilation) => {
console.log('Compilation: 新的一次编译开始');
// 在 Compilation 上注册钩子,操作模块和资源
compilation.hooks.processAssets.tap(
{ name: 'DemoPlugin', stage: 0 },
(assets) => {
// 可以读取/修改所有输出资源
console.log('当前输出文件:', Object.keys(assets));
}
);
});
}
}
核心区别对比:
| 维度 | Compiler | Compilation |
|---|---|---|
| 创建时机 | Webpack 启动时创建一次 | 每次编译(包括 watch 触发)都新建 |
| 实例数量 | 全局唯一 | 可能有多个(watch 模式) |
| 包含信息 | 全局配置、文件系统、插件列表 | 模块、chunk、依赖图、输出资源 |
| 访问方式 | plugin.apply(compiler) | compiler.hooks.compilation.tap(cb) |
| 常用操作 | 注册生命周期钩子、读取配置 | 修改模块、操作资源、自定义分包 |
| 类比 | 工厂(基础设施不变) | 一次生产过程(原料/产品每次不同) |
不要在 Compiler 钩子中缓存 Compilation 的引用。由于每次重新编译都会创建新的 Compilation,使用旧的引用会导致内存泄漏和数据错误。
Q4: 如何编写一个自定义 Webpack Loader?Loader 的执行顺序是怎样的?
答案:
Webpack Loader 的本质是一个导出的函数,接收源文件内容作为参数,返回转换后的内容。编写一个自定义 Loader 需要理解以下核心要点:
Loader 基本结构:
import type { LoaderDefinitionFunction } from 'webpack';
// Loader 必须是普通函数(不能是箭头函数),因为需要通过 this 访问 Loader Context
const replaceEnvLoader: LoaderDefinitionFunction = function (source) {
// 1. 获取 options(Webpack 5 推荐使用 this.getOptions)
const options = this.getOptions() as { env: Record<string, string> };
// 2. 标记为可缓存(输入不变时跳过转换)
this.cacheable(true);
// 3. 执行转换:将代码中的 __ENV_XXX__ 替换为实际环境变量
let result = source as string;
for (const [key, value] of Object.entries(options.env)) {
result = result.replace(
new RegExp(`__ENV_${key}__`, 'g'),
JSON.stringify(value)
);
}
// 4. 返回转换后的代码
return result;
};
export default replaceEnvLoader;
异步 Loader(涉及 I/O 操作时使用):
import type { LoaderDefinitionFunction } from 'webpack';
const asyncLoader: LoaderDefinitionFunction = function (source) {
const callback = this.async(); // 告诉 Webpack 这是异步 Loader
// 模拟异步操作
fetchRemoteConfig().then((config) => {
const result = (source as string).replace('__CONFIG__', JSON.stringify(config));
callback(null, result); // 异步完成后调用 callback(err, content, sourceMap?, meta?)
}).catch((err) => {
callback(err as Error);
});
};
async function fetchRemoteConfig(): Promise<Record<string, unknown>> {
// 实际场景:从配置中心拉取配置
return { apiUrl: 'https://api.example.com', version: '1.0' };
}
export default asyncLoader;
Loader 的执行顺序——两个阶段:
Loader 的执行分为 Pitch 阶段和 Normal 阶段,这是面试高频考点。
假设配置了三个 Loader:[a-loader, b-loader, c-loader]
执行规则总结:
| 阶段 | 顺序 | 说明 |
|---|---|---|
| Pitch 阶段 | 从左到右(从上到下) | 依次执行每个 Loader 的 pitch 方法(如果有) |
| Normal 阶段 | 从右到左(从下到上) | 依次执行每个 Loader 的默认导出函数 |
Pitch 的熔断机制:如果某个 Loader 的 pitch 方法返回了非 undefined 的值,后续 Loader 的 pitch 和 normal 都会被跳过,直接进入前一个 Loader 的 normal 阶段。
function bLoader(source: string): string {
return source; // Normal 阶段
}
bLoader.pitch = function (remainingRequest: string): string | void {
// 如果返回值 → 熔断:跳过 c-loader,直接进入 a-loader 的 normal
if (someCondition) {
return `module.exports = require(${JSON.stringify('!!' + remainingRequest)});`;
}
// 不返回值 → 继续执行后续 Loader 的 pitch
};
export default bLoader;
style-loader 正是利用 pitch 熔断机制实现的。它在 pitch 阶段返回一段 JS 代码(这段代码会 require css-loader 的处理结果并注入 DOM),从而跳过自己的 normal 阶段。
Loader 开发核心工具:
| 工具 | 用途 | 说明 |
|---|---|---|
this.getOptions() | 获取 Loader 配置项 | Webpack 5 内置,替代 loader-utils 的 getOptions |
this.async() | 标记为异步 Loader | 返回 callback 函数 |
this.cacheable(true) | 标记为可缓存 | 输入不变时跳过转换 |
this.emitFile() | 输出额外文件 | 如 Source Map、manifest |
this.addDependency() | 添加文件依赖 | 文件变化时触发重新编译 |
schema-utils | 校验 options | 提供清晰的参数校验错误提示 |
Q5: 如何编写一个自定义 Webpack Plugin?Tapable 钩子系统是什么?
答案:
Webpack Plugin 是一个带有 apply 方法的类或对象。Webpack 启动时会调用每个插件的 apply(compiler) 方法,插件通过在 compiler 和 compilation 的钩子上注册回调来介入构建流程的各个阶段。
Plugin 基本结构:
import type { Compiler, Compilation } from 'webpack';
interface AnalyzerOptions {
/** 输出文件名 */
reportFilename?: string;
/** 是否在控制台打印 */
logToConsole?: boolean;
}
class BundleAnalyzerPlugin {
private options: Required<AnalyzerOptions>;
constructor(options: AnalyzerOptions = {}) {
this.options = {
reportFilename: options.reportFilename ?? 'bundle-report.json',
logToConsole: options.logToConsole ?? true,
};
}
// 核心:apply 方法接收 compiler 对象
apply(compiler: Compiler): void {
const pluginName = BundleAnalyzerPlugin.name;
// 在 emit 钩子(输出文件前)注册回调
compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
const report = this.analyzeAssets(compilation);
if (this.options.logToConsole) {
console.log('\n--- Bundle Analysis ---');
for (const item of report) {
console.log(` ${item.name}: ${this.formatSize(item.size)}`);
}
console.log('--- End ---\n');
}
// 将分析报告添加到输出资源
const reportJson = JSON.stringify(report, null, 2);
compilation.assets[this.options.reportFilename] = {
source: () => reportJson,
size: () => reportJson.length,
} as any;
callback();
});
}
private analyzeAssets(compilation: Compilation): Array<{ name: string; size: number }> {
return Object.entries(compilation.assets).map(([name, asset]) => ({
name,
size: asset.size(),
})).sort((a, b) => b.size - a.size);
}
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
}
export default BundleAnalyzerPlugin;
Tapable 钩子系统:
Webpack 的整个事件机制基于 Tapable 库。compiler 和 compilation 对象上的所有钩子都是 Tapable 钩子实例。理解 Tapable 的钩子类型是编写 Plugin 的关键。
常见钩子类型:
| 钩子类型 | 执行方式 | 特点 | 使用场景 |
|---|---|---|---|
SyncHook | 同步串行 | 不关心返回值,依次执行 | 通知型操作(日志、统计) |
SyncBailHook | 同步串行熔断 | 某个回调返回非 undefined 时停止 | 条件判断(是否跳过某操作) |
SyncWaterfallHook | 同步串行流水线 | 前一个回调的返回值传给下一个 | 数据管道(逐步修改数据) |
AsyncSeriesHook | 异步串行 | 异步回调按顺序依次执行 | 异步 I/O 操作 |
AsyncParallelHook | 异步并行 | 所有异步回调同时执行 | 并行优化任务 |
三种注册回调的方式:
class MyPlugin {
apply(compiler: Compiler): void {
// 1. tap — 同步注册
compiler.hooks.compile.tap('MyPlugin', (params) => {
console.log('编译开始(同步)');
});
// 2. tapAsync — 异步注册(callback 风格)
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
setTimeout(() => {
console.log('emit 完成(异步 callback)');
callback(); // 必须调用 callback
}, 100);
});
// 3. tapPromise — 异步注册(Promise 风格)
compiler.hooks.done.tapPromise('MyPlugin', async (stats) => {
await sendNotification('构建完成');
console.log('done 完成(异步 Promise)');
});
}
}
常用的 Compiler 和 Compilation 钩子:
| 钩子 | 所属对象 | 类型 | 触发时机 | 典型用途 |
|---|---|---|---|---|
compile | Compiler | SyncHook | 编译开始前 | 准备工作 |
compilation | Compiler | SyncHook | 创建 Compilation 后 | 在 compilation 上注册钩子 |
emit | Compiler | AsyncSeriesHook | 输出文件前 | 修改/添加输出文件 |
done | Compiler | AsyncSeriesHook | 构建完成 | 统计、通知 |
processAssets | Compilation | AsyncSeriesHook | 处理资源 | 修改最终产物(Webpack 5) |
optimizeChunks | Compilation | SyncBailHook | 优化 chunk | 自定义分包策略 |
Plugin 开发核心步骤总结:
- 创建一个类,定义
apply(compiler: Compiler)方法 - 在
apply中选择合适的钩子,通过tap/tapAsync/tapPromise注册回调 - 操作模块/资源时,通过
compiler.hooks.compilation获取compilation对象 - 异步钩子中必须调用
callback()或返回Promise,否则构建会挂起 - 使用
compilation.assets读取/修改输出资源,使用compilation.emitAsset()添加新资源
Webpack 5 引入了 compilation.hooks.processAssets 钩子,替代了之前在 emit 中操作资源的模式。它提供了多个 stage(阶段),让不同的 Plugin 可以按优先级处理资源:
compilation.hooks.processAssets.tap(
{
name: 'MyPlugin',
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, // 汇总阶段
},
(assets) => {
// 在这个阶段处理资源
}
);