跳到主要内容

Webpack Loader 与 Plugin 开发

问题

如何编写自定义的 Webpack Loader 和 Plugin?它们的工作原理和核心机制是什么?

答案

Loader 和 Plugin 是 Webpack 最强大的两个扩展机制。Loader 负责将各种类型的文件转换为 Webpack 可以处理的有效模块,Plugin 则可以介入 Webpack 构建流程的任何阶段,执行更广泛的任务。理解它们的底层原理,不仅是高频面试考点,更是日常定制构建流程的必备技能。

Loader 开发

Loader 的本质

Loader 本质上是一个函数,接收源文件内容(字符串或 Buffer)作为参数,返回转换后的结果。它遵循单一职责原则:每个 Loader 只负责一种转换。

最简 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 分为同步和异步两种,根据转换操作是否涉及异步任务来选择。

sync-loader.ts — 同步 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;
async-loader.ts — 异步 Loader
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.callbackFunction同步/异步模式下返回多个结果
this.asyncFunction将 Loader 标记为异步,返回 callback
this.querystring | objectLoader 的 options 配置
this.resourcePathstring当前处理文件的绝对路径
this.rootContextstring项目根目录路径
this.emitFileFunction输出一个文件到构建目录
this.addDependencyFunction添加文件依赖(文件变化时重新编译)
this.cacheableFunction设置是否可缓存(默认 true)
this.sourceMapboolean是否需要生成 Source Map
this.getOptionsFunction获取经过 schema 校验的 options(Webpack 5)
使用 Loader Context 示例
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 阶段。

pitch-loader.ts — Pitch Loader 示例
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 机制

style-loader 就是利用 pitch 熔断机制实现的。它在 pitch 阶段返回一段 JS 代码,这段代码会用 require 引入 css-loader 的处理结果,然后将 CSS 注入到 DOM 中。这样 style-loader 就不需要在 normal 阶段处理 CSS 字符串了。

手写 markdown-loader

将 Markdown 文件转换为 HTML 字符串,供前端组件使用。

loaders/markdown-loader.ts
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 中使用:

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

给每个文件顶部添加注释头(如版权信息、作者信息等),这在企业项目中非常常见。

loaders/banner-loader.ts
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;
webpack.config.ts — 使用 banner-loader
{
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,提供清晰的错误提示
loader-utils 常用方法
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-utilsgetOptions 已被 this.getOptions() 替代。新项目建议直接使用 Webpack 5 内置 API,loader-utils 主要在维护旧项目时使用。

Plugin 开发

Plugin 的本质

Plugin 是一个带有 apply 方法的对象或类。Webpack 在启动时会调用插件的 apply 方法,并传入 compiler 对象,插件通过在 compiler/compilation 的各种钩子上注册回调来介入构建流程。

最简 Plugin 结构
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异步并行熔断第一个返回值的回调决定结果
Tapable 钩子使用示例
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

这是面试必考的核心概念:

对比项CompilerCompilation
生命周期整个 Webpack 进程只有一个每次构建(包括 watch 触发)都会新建
代表什么Webpack 的编译器实例,包含完整配置一次具体的编译过程,包含模块和资源
主要属性optionshooksinputFileSystemmoduleschunksassetshooks
核心职责启动编译、管理生命周期、创建 Compilation模块构建、依赖分析、代码生成、资源输出
变化频率不变(配置确定后就固定)每次编译都不同(文件变化后内容不同)
类比工厂(不变的基础设施)一次生产过程(原料和产品每次不同)

常用钩子

Compiler 钩子

钩子类型触发时机典型用途
entryOptionSyncBailHook处理 entry 配置后修改入口配置
afterPluginsSyncHook内部插件应用完成后注册额外插件
compileSyncHook开始编译前准备工作
compilationSyncHook创建 Compilation 后在 compilation 上注册钩子
makeAsyncParallelHook从入口开始构建模块添加额外入口
afterCompileAsyncSeriesHook编译完成后分析编译结果
emitAsyncSeriesHook输出文件到目录前修改/添加输出文件
afterEmitAsyncSeriesHook输出文件完成后文件后处理
doneAsyncSeriesHook构建完全完成统计、通知、清理
failedSyncHook构建失败错误上报

Compilation 钩子

钩子类型触发时机典型用途
buildModuleSyncHook开始构建模块前模块构建日志
succeedModuleSyncHook模块构建成功后模块分析
sealSyncHook停止接收新模块最后的模块修改
optimizeSyncHook优化阶段开始自定义优化逻辑
optimizeChunksSyncBailHook优化 chunk自定义分包策略
processAssetsAsyncSeriesHook处理资源(Webpack 5)修改最终输出资源
afterProcessAssetsSyncHook资源处理完成后资源验证
高频钩子

面试和实际开发中最常用的钩子:

  • compiler.hooks.emit — 在文件输出前修改资源
  • compiler.hooks.done — 构建完成后执行统计或通知
  • compiler.hooks.compilation — 获取 compilation 对象,再在其钩子上注册
  • compilation.hooks.processAssets(Webpack 5)— 处理最终输出的资源

手写 FileListPlugin

在构建完成后,输出一个包含所有输出文件信息的清单文件。

plugins/FileListPlugin.ts
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 目录中超过指定天数的旧文件。这在需要保留历史版本但清理过期缓存的场景下非常实用。

plugins/CleanExpiredPlugin.ts
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;

使用方式:

webpack.config.ts — 使用自定义插件
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。

babel-loader 核心原理(简化)
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;
babel-loader 的缓存机制

生产项目中务必开启 cacheDirectory: true,babel-loader 会将转译结果缓存到 node_modules/.cache/babel-loader,在文件未变化时直接读取缓存,可大幅提升构建速度。

css-loader

css-loader 负责解析 CSS 文件中的 @importurl() 引用,将 CSS 转换为 JavaScript 模块。

css-loader 核心原理(简化)
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 机制

style-loader 核心原理(简化)
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 要用 Pitch?

如果 style-loader 在 Normal 阶段执行,它接收到的是 css-loader 输出的 JS 模块代码字符串,无法直接操作 DOM。通过 Pitch 熔断,style-loader 可以自己生成一段 JS 代码,在运行时 require css-loader 的结果并注入 DOM。这是一种非常精妙的设计。

常见 Plugin 原理解析

HtmlWebpackPlugin

自动生成 HTML 文件,并将打包后的 JS/CSS 资源自动注入到 HTML 中。

HtmlWebpackPlugin 核心原理(简化)
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。

MiniCssExtractPlugin 核心原理(简化)
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)
);
}
}
);
});
}
}
MiniCssExtractPlugin vs style-loader
  • 开发环境style-loader:CSS 注入到 <style> 标签,支持 HMR,修改即时生效
  • 生产环境MiniCssExtractPlugin:CSS 提取为独立文件,支持并行加载、缓存、压缩

两者不能同时使用,需要根据环境切换。


常见面试问题

Q1: 如何编写一个自定义 Webpack Loader?

答案

编写一个自定义 Loader 需要遵循以下步骤:

第一步:创建 Loader 函数

loaders/remove-console-loader.ts
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 配置中使用

webpack.config.ts
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')],
},
};

第三步:编写测试

__tests__/remove-console-loader.test.ts
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 的核心原则
  1. 单一职责:每个 Loader 只做一件事
  2. 链式调用:通过管道组合多个 Loader
  3. 无状态:不要在 Loader 中保存状态
  4. 使用 this.getOptions() + schema-utils 校验参数
  5. 合理使用缓存this.cacheable(true)

Q2: 如何编写一个自定义 Webpack Plugin?

答案

编写 Plugin 需要理解 Tapable 钩子系统,并选择合适的钩子注册回调。

完整示例:构建耗时统计插件

plugins/BuildTimePlugin.ts
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;

使用方式

webpack.config.ts
import BuildTimePlugin from './plugins/BuildTimePlugin';

export default {
plugins: [
new BuildTimePlugin({
verbose: true,
warnThreshold: 5000,
onComplete: (data) => {
// 可以将数据上报到监控系统
console.log('Build data:', JSON.stringify(data));
},
}),
],
};

Plugin 开发核心步骤总结

  1. 创建一个类,定义 apply(compiler: Compiler) 方法
  2. apply 中通过 compiler.hooks.xxx.tap/tapAsync/tapPromise 注册钩子
  3. 如果需要操作模块/资源,通过 compiler.hooks.compilation 获取 compilation 对象
  4. 在回调中执行自定义逻辑,注意异步钩子需要调用 callback() 或返回 Promise

Q3: Compiler 和 Compilation 的区别是什么?

答案

这是 Webpack 插件体系中两个最核心的对象,区分它们是理解 Plugin 开发的关键。

Compiler(编译器)

  • 代表 Webpack 的完整配置环境,在 Webpack 启动时创建,贯穿整个生命周期
  • 整个 Webpack 进程中只有一个 Compiler 实例
  • 包含 Webpack 的所有配置信息(options)、文件系统(inputFileSystemoutputFileSystem)、插件等
  • 提供 Webpack 生命周期的顶级钩子(compileemitdone 等)

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));
}
);
});
}
}

核心区别对比

维度CompilerCompilation
创建时机Webpack 启动时创建一次每次编译(包括 watch 触发)都新建
实例数量全局唯一可能有多个(watch 模式)
包含信息全局配置、文件系统、插件列表模块、chunk、依赖图、输出资源
访问方式plugin.apply(compiler)compiler.hooks.compilation.tap(cb)
常用操作注册生命周期钩子、读取配置修改模块、操作资源、自定义分包
类比工厂(基础设施不变)一次生产过程(原料/产品每次不同)
常见误区

不要在 Compiler 钩子中缓存 Compilation 的引用。由于每次重新编译都会创建新的 Compilation,使用旧的引用会导致内存泄漏和数据错误。

Q4: 如何编写一个自定义 Webpack Loader?Loader 的执行顺序是怎样的?

答案

Webpack Loader 的本质是一个导出的函数,接收源文件内容作为参数,返回转换后的内容。编写一个自定义 Loader 需要理解以下核心要点:

Loader 基本结构:

loaders/replace-env-loader.ts
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 操作时使用):

loaders/remote-config-loader.ts
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 阶段。

pitch 熔断示例
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

style-loader 正是利用 pitch 熔断机制实现的。它在 pitch 阶段返回一段 JS 代码(这段代码会 require css-loader 的处理结果并注入 DOM),从而跳过自己的 normal 阶段。

Loader 开发核心工具:

工具用途说明
this.getOptions()获取 Loader 配置项Webpack 5 内置,替代 loader-utilsgetOptions
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) 方法,插件通过在 compilercompilation 的钩子上注册回调来介入构建流程的各个阶段。

Plugin 基本结构:

plugins/BundleAnalyzerPlugin.ts
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 库。compilercompilation 对象上的所有钩子都是 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 钩子:

钩子所属对象类型触发时机典型用途
compileCompilerSyncHook编译开始前准备工作
compilationCompilerSyncHook创建 Compilation 后在 compilation 上注册钩子
emitCompilerAsyncSeriesHook输出文件前修改/添加输出文件
doneCompilerAsyncSeriesHook构建完成统计、通知
processAssetsCompilationAsyncSeriesHook处理资源修改最终产物(Webpack 5)
optimizeChunksCompilationSyncBailHook优化 chunk自定义分包策略

Plugin 开发核心步骤总结:

  1. 创建一个类,定义 apply(compiler: Compiler) 方法
  2. apply 中选择合适的钩子,通过 tap / tapAsync / tapPromise 注册回调
  3. 操作模块/资源时,通过 compiler.hooks.compilation 获取 compilation 对象
  4. 异步钩子中必须调用 callback() 或返回 Promise,否则构建会挂起
  5. 使用 compilation.assets 读取/修改输出资源,使用 compilation.emitAsset() 添加新资源
Webpack 5 中的 processAssets

Webpack 5 引入了 compilation.hooks.processAssets 钩子,替代了之前在 emit 中操作资源的模式。它提供了多个 stage(阶段),让不同的 Plugin 可以按优先级处理资源:

compilation.hooks.processAssets.tap(
{
name: 'MyPlugin',
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, // 汇总阶段
},
(assets) => {
// 在这个阶段处理资源
}
);

相关链接