Webpack 插件基础
Webpack 插件是一个具有 apply
方法的 JavaScript 类(或构造函数)。当 Webpack 启动时,它会创建插件的实例,并调用其 apply
方法,同时传入一个 compiler
对象作为参数。
compiler
对象是 Webpack 的核心,它包含了整个构建生命周期的钩子 (hooks)。插件通过在这些钩子上注册回调函数来在构建过程的不同阶段执行自定义逻辑。
主要的 compiler
钩子类型:
- 同步钩子 (SyncHook) : 回调函数按顺序同步执行。
- 异步串行钩子 (AsyncSeriesHook) : 回调函数按顺序异步执行,一个完成后下一个才开始。
- 异步并行钩子 (AsyncParallelHook) : 回调函数并行异步执行。
插件通常使用 tap
, tapAsync
, 或 tapPromise
方法来注册回调,具体取决于钩子的类型和回调是同步还是异步。
compiler
vs compilation
:
compiler
对象代表了完整的 Webpack 配置和生命周期,它在 Webpack 启动时被创建一次,并且在整个构建过程中持续存在。compilation
对象代表了一次具体的构建过程(例如,在 watch 模式下,每次文件变更都会触发一次新的 compilation)。它包含了当前构建的模块、资源 (assets)、依赖关系等信息。插件通常在compiler
的某个钩子(如compilation
钩子)中获取compilation
对象,然后在compilation
对象的钩子上注册更细粒度的操作。
项目结构
我们将创建一个名为 MyCustomWebpackPlugins
的插件集,它包含几个独立的插件功能。
go
my-custom-webpack-plugins/
├── src/
│ ├── BuildInfoPlugin.js // 插件1: 输出构建信息和清单
│ ├── AssetCompressionPlugin.js // 插件2: 压缩特定资源
│ └── SimpleBannerPlugin.js // 插件3: 在文件头部添加注释
├── utils/
│ └── logger.js // 可选的日志工具
├── package.json
└── index.js // 插件主入口,导出各个插件
1. 初始化项目和安装依赖
perl
mkdir my-custom-webpack-plugins
cd my-custom-webpack-plugins
npm init -y
```bash
开发 Webpack 插件时,通常会将 `webpack` 作为 `peerDependency` 或 `devDependency`。
```bash
npm install webpack --save-dev
# 如果需要压缩功能,可能需要 zlib, brotli 等
npm install zlib brotli --save # 示例,具体根据压缩算法选择
2. 插件主入口文件 (index.js
)
这个文件将导出我们创建的各个插件类。
js
// my-custom-webpack-plugins/index.js
"use strict";
const BuildInfoPlugin = require('./src/BuildInfoPlugin');
const AssetCompressionPlugin = require('./src/AssetCompressionPlugin');
const SimpleBannerPlugin = require('./src/SimpleBannerPlugin');
module.exports = {
BuildInfoPlugin,
AssetCompressionPlugin,
SimpleBannerPlugin,
// 你也可以导出一个包含所有插件的集合,方便一次性引入
// AllPlugins: [BuildInfoPlugin, AssetCompressionPlugin, SimpleBannerPlugin]
};
3. 创建自定义插件
插件 1: BuildInfoPlugin.js
目标:
- 在控制台输出构建开始和结束的时间,以及总耗时。
- 生成一个
build-manifest.json
文件,包含构建生成的所有资源(assets)的列表及其大小。 - 提供选项来配置输出文件名和是否在控制台打印详细日志。
js
// my-custom-webpack-plugins/src/BuildInfoPlugin.js
"use strict";
const fs = require('fs');
const path = require('path');
const { Compilation } = require('webpack'); // 用于类型提示和访问静态属性
const PLUGIN_NAME = "BuildInfoPlugin";
class BuildInfoPlugin {
constructor(options = {}) {
this.options = Object.assign(
{
outputFileName: "build-manifest.json",
logToConsole: true,
assetDetails: true, // 是否在 manifest 中包含详细的 asset 信息
},
options
);
this.startTime = null;
}
apply(compiler) {
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);
// 1. 记录构建开始时间
// 'environment' 或 'afterEnvironment' 钩子,在准备好环境后触发
compiler.hooks.environment.tap(PLUGIN_NAME, () => {
this.startTime = Date.now();
if (this.options.logToConsole) {
logger.info("Build process started...");
}
});
// 2. 在 'compilation' 钩子中访问 compilation 对象
// 这个钩子在创建新的 compilation 时触发
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
if (this.options.logToConsole) {
logger.info("Compilation started.");
}
// 3. 使用 'processAssets' 钩子来处理和添加 manifest 文件
// 这是 Webpack 5 中推荐的处理 assets 的钩子
// 它在 compilation 已经生成了 assets 列表之后,但在优化和序列化之前触发
compilation.hooks.processAssets.tapAsync(
{
name: PLUGIN_NAME,
// STAGE_ADDITIONAL 确保在其他插件可能已经添加完 assets 之后运行
// STAGE_REPORT 也可以,用于生成报告性质的 asset
stage: Compilation.PROCESS_ASSETS_STAGE_REPORT,
},
(assets, callback) => {
if (this.options.logToConsole) {
logger.info(`Generating build manifest: ${this.options.outputFileName}`);
}
const manifest = {
buildTimestamp: new Date().toISOString(),
buildHash: compilation.hash,
outputPath: compilation.outputOptions.path,
publicPath: compilation.outputOptions.publicPath,
assets: {},
chunks: [],
entrypoints: {}
};
// 收集 assets 信息
for (const assetName in assets) {
if (Object.hasOwnProperty.call(assets, assetName)) {
const asset = assets[assetName];
manifest.assets[assetName] = {
size: asset.size(), // asset.source().size() 也可以
emitted: compilation.emittedAssets.has(assetName) // 检查 asset 是否会被写入文件系统
};
if (this.options.assetDetails) {
manifest.assets[assetName].info = asset.info; // Webpack 5 的 asset info
}
}
}
// 收集 chunks 信息
compilation.chunks.forEach(chunk => {
const chunkData = {
id: chunk.id,
name: chunk.name,
files: Array.from(chunk.files),
auxiliaryFiles: Array.from(chunk.auxiliaryFiles || []),
size: chunk.modules.reduce((sum, mod) => sum + (mod.size() || 0), 0), // 估算模块总大小
modules: []
};
if (this.options.assetDetails) {
chunkData.modules = Array.from(chunk.modulesIterable).map(module => ({
id: module.id,
identifier: module.identifier ? module.identifier().slice(0, 100) + '...' : 'N/A', // 模块标识符,可能很长
size: module.size(),
reasons: module.reasons ? module.reasons.map(r => r.module ? r.module.identifier().slice(0,50) + '...' : r.type) : []
}));
}
manifest.chunks.push(chunkData);
});
// 收集 entrypoints 信息
for (const [name, entrypoint] of compilation.entrypoints) {
manifest.entrypoints[name] = {
chunks: entrypoint.chunks.map(chunk => chunk.id),
assets: entrypoint.getFiles().map(file => ({ name: file, size: assets[file] ? assets[file].size() : 0 })),
};
}
const manifestContent = JSON.stringify(manifest, null, 2);
// 使用 compilation.emitAsset 来添加新的 asset
compilation.emitAsset(
this.options.outputFileName,
new compiler.webpack.sources.RawSource(manifestContent)
);
if (this.options.logToConsole) {
logger.info(`Build manifest "${this.options.outputFileName}" generated successfully.`);
}
callback(); // 异步钩子需要调用 callback
}
);
});
// 4. 在 'done' 钩子中记录构建结束时间和总耗时
// 这个钩子在整个构建过程(包括所有 assets 的写入)完成后触发
compiler.hooks.done.tap(PLUGIN_NAME, (stats) => {
const endTime = Date.now();
const duration = (endTime - this.startTime) / 1000; // 秒
if (this.options.logToConsole) {
logger.info(`Build process finished in ${duration.toFixed(2)} seconds.`);
if (stats.hasErrors()) {
logger.error("Build completed with errors.");
} else if (stats.hasWarnings()) {
logger.warn("Build completed with warnings.");
} else {
logger.info("Build completed successfully.");
}
}
// 可以在这里做一些构建完成后的清理工作或通知
// 例如,将 manifest 文件写入磁盘(如果 emitAsset 不够用,或者需要更复杂的写入逻辑)
// 但通常 emitAsset 就足够了
});
// 5. 处理 watch 模式下的重新编译
// 'watchRun' 钩子在 watch 模式下,文件变更触发新的编译之前执行
compiler.hooks.watchRun.tapAsync(PLUGIN_NAME, (compilerInstance, callback) => {
this.startTime = Date.now(); // 重置开始时间
if (this.options.logToConsole) {
logger.info("Watch mode: Recompilation started...");
}
// compilerInstance === compiler
callback();
});
}
}
module.exports = BuildInfoPlugin;
代码讲解 (BuildInfoPlugin.js
):
-
constructor(options = {})
:- 接收用户配置,并与默认配置合并。
outputFileName
: 生成的 manifest 文件名。logToConsole
: 是否在控制台打印日志。assetDetails
: 是否在 manifest 中包含更详细的资源和模块信息。
-
apply(compiler)
:-
compiler.getInfrastructureLogger(PLUGIN_NAME)
: 获取 Webpack 内置的日志工具,推荐使用。 -
compiler.hooks.environment.tap(...)
:- 在 Webpack 环境准备好后触发,用于记录构建开始时间。
-
compiler.hooks.compilation.tap(...)
:- 当一个新的
compilation
对象创建时触发。插件在这里可以访问到compilation
对象。
- 当一个新的
-
compilation.hooks.processAssets.tapAsync(...)
:-
这是 Webpack 5 中处理和添加新资源的核心钩子。
-
stage: Compilation.PROCESS_ASSETS_STAGE_REPORT
: 指定此操作在资源处理流程中的阶段,REPORT
阶段适合生成报告性质的文件。 -
回调函数接收
assets
对象 (当前compilation
中的所有资源) 和callback
(因为是异步钩子)。 -
Manifest 内容:
buildTimestamp
: 构建时间戳。buildHash
: 本次构建的哈希值。outputPath
,publicPath
: Webpack 配置的输出路径和公共路径。assets
: 一个对象,键是资源名,值是资源信息(大小,是否发射,Webpack 5 的asset.info
)。chunks
: 包含每个 chunk 的 ID, name, files, modules 等信息。entrypoints
: 包含每个入口点的信息,如关联的 chunks 和输出文件。
-
JSON.stringify(manifest, null, 2)
: 将 manifest 对象格式化为 JSON 字符串。 -
compilation.emitAsset(filename, source)
: 向 Webpack 的输出中添加一个新的资源。compiler.webpack.sources.RawSource
: Webpack 提供的 Source 对象之一,用于表示原始内容。
-
-
compiler.hooks.done.tap(...)
:- 在整个构建(包括文件写入)完成后触发。
- 回调函数接收
stats
对象,包含了构建的统计信息。 - 计算并打印总构建时长。
- 根据
stats
对象判断构建是否成功、有警告或错误。
-
compiler.hooks.watchRun.tapAsync(...)
:- 在
watch
模式下,当文件发生变化,Webpack 即将开始一次新的编译时触发。 - 用于重置
startTime
以便正确计算每次重新编译的时长。
- 在
-
插件 2: AssetCompressionPlugin.js
目标:
- 在 Webpack 构建完成后,对指定的资源类型(如
.js
,.css
)进行压缩(例如使用 Gzip)。 - 生成压缩后的文件(如
main.js.gz
),并保留原始文件。 - 提供选项来配置压缩算法、压缩级别、目标文件扩展名等。
js
// my-custom-webpack-plugins/src/AssetCompressionPlugin.js
"use strict";
const zlib = require('zlib');
const { Compilation, sources } = require('webpack'); // sources 用于创建新的 asset source
const PLUGIN_NAME = "AssetCompressionPlugin";
class AssetCompressionPlugin {
constructor(options = {}) {
this.options = Object.assign(
{
extensions: ["js", "css"], // 要压缩的文件扩展名
algorithm: "gzip", // 压缩算法: 'gzip', 'brotliCompress' (zlib)
deleteOriginalAssets: false, // 是否删除原始文件 (通常不推荐)
compressionOptions: {}, // 传递给 zlib 的选项
filename: "[path][base].gz", // 压缩后文件名模板, [file] 是原始文件名
// [path] 原始路径, [name] 原始文件名不含扩展名, [ext] 原始扩展名, [base] 原始文件名含扩展名
threshold: 0, // 只处理大于此大小(字节)的资源
minRatio: 0.8, // 只有当压缩后大小/原始大小 < minRatio 时才保留压缩文件
},
options
);
// 根据算法选择 zlib 方法
if (typeof this.options.algorithm === 'string') {
switch (this.options.algorithm.toLowerCase()) {
case 'gzip':
this.compressionFunction = zlib.gzip;
if (!this.options.compressionOptions.level) this.options.compressionOptions.level = zlib.constants.Z_BEST_COMPRESSION;
break;
case 'brotli':
case 'brotlicompress':
this.compressionFunction = zlib.brotliCompress;
// brotli 有自己的参数,例如 zlib.constants.BROTLI_PARAM_QUALITY
break;
// 可以添加 deflate 等其他 zlib 支持的算法
default:
throw new Error(`[${PLUGIN_NAME}] Unsupported compression algorithm: ${this.options.algorithm}`);
}
} else if (typeof this.options.algorithm === 'function') {
this.compressionFunction = this.options.algorithm; // 允许传入自定义压缩函数
} else {
throw new Error(`[${PLUGIN_NAME}] 'algorithm' option must be a string or a function.`);
}
}
apply(compiler) {
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tapPromise(
{
name: PLUGIN_NAME,
// STAGE_OPTIMIZE_SIZE 适合在资源大小优化阶段进行压缩
// 或 STAGE_ADDITIONAL 如果只是添加新资源
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
},
async (assets) => {
logger.info(`Starting asset compression using ${this.options.algorithm}...`);
const assetNames = Object.keys(assets);
const compressionPromises = [];
for (const name of assetNames) {
// 检查扩展名是否匹配
const ext = name.split(".").pop().toLowerCase();
if (!this.options.extensions.includes(ext)) {
continue;
}
const asset = compilation.getAsset(name);
if (!asset) {
logger.warn(`Asset ${name} not found during compression.`);
continue;
}
const originalSource = asset.source.buffer(); // 获取 buffer
const originalSize = originalSource.length;
// 检查大小阈值
if (originalSize < this.options.threshold) {
logger.info(`Skipping ${name} (size ${originalSize}B < threshold ${this.options.threshold}B).`);
continue;
}
const task = async () => {
try {
const compressedBuffer = await new Promise((resolve, reject) => {
this.compressionFunction(originalSource, this.options.compressionOptions, (err, result) => {
if (err) return reject(err);
resolve(result);
});
});
const compressedSize = compressedBuffer.length;
const ratio = compressedSize / originalSize;
if (ratio >= this.options.minRatio) {
logger.info(`Skipping ${name} (compression ratio ${ratio.toFixed(2)} >= minRatio ${this.options.minRatio}). Original: ${originalSize}B, Compressed: ${compressedSize}B`);
return;
}
// 生成新的文件名
const newFilename = this.options.filename
.replace(/[path]/g, name.substring(0, name.lastIndexOf('/') + 1))
.replace(/[base]/g, name.substring(name.lastIndexOf('/') + 1))
.replace(/[file]/g, name)
.replace(/[name]/g, name.substring(name.lastIndexOf('/') + 1, name.lastIndexOf('.')))
.replace(/[ext]/g, ext);
// 添加压缩后的 asset
compilation.emitAsset(
newFilename,
new sources.RawSource(compressedBuffer),
{
...asset.info, // 复制原始 asset info
compressed: true, // 添加一个标记
related: { original: name } // 关联原始 asset
}
);
logger.info(`Compressed ${name} to ${newFilename}. Original: ${originalSize}B, Compressed: ${compressedSize}B, Ratio: ${ratio.toFixed(2)}`);
if (this.options.deleteOriginalAssets) {
compilation.deleteAsset(name);
logger.info(`Deleted original asset: ${name}`);
}
} catch (error) {
logger.error(`Error compressing asset ${name}: ${error.message}`);
compilation.errors.push(new compiler.webpack.WebpackError(`[${PLUGIN_NAME}] ${error.message}`));
}
};
compressionPromises.push(task());
}
await Promise.all(compressionPromises);
logger.info("Asset compression finished.");
}
);
});
}
}
module.exports = AssetCompressionPlugin;
代码讲解 (AssetCompressionPlugin.js
):
-
constructor(options = {})
:extensions
: 要压缩的文件扩展名数组。algorithm
: 压缩算法,可以是'gzip'
,'brotliCompress'
(zlib 支持的函数名) 或自定义函数。deleteOriginalAssets
: 是否删除原始文件(通常不建议,除非服务器配置能自动处理)。compressionOptions
: 传递给zlib
对应压缩函数的选项 (如level
for gzip)。filename
: 压缩后文件名的模板。支持[path]
,[base]
,[file]
,[name]
,[ext]
占位符。threshold
: 文件大小阈值,小于此值的文件不压缩。minRatio
: 最小压缩率,只有当compressedSize / originalSize < minRatio
时才保留压缩文件。- 构造函数中会根据
algorithm
字符串选择对应的zlib
方法,或直接使用用户传入的自定义压缩函数。
-
apply(compiler)
:-
compilation.hooks.processAssets.tapPromise(...)
:-
使用
tapPromise
因为压缩是异步操作,并且我们希望并行处理多个资源。 -
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE
: 在资源大小优化阶段执行压缩。 -
回调函数是
async
函数,内部使用await Promise.all()
来等待所有压缩任务完成。 -
遍历
assets
:-
获取每个资源的名称和内容 (
asset.source.buffer()
)。 -
检查文件扩展名和大小是否满足压缩条件。
-
执行压缩:
-
调用
this.compressionFunction
(如zlib.gzip
) 进行压缩。这是一个异步操作,所以包装在Promise
中。 -
检查压缩率是否满足
minRatio
。 -
生成新文件名 : 根据
this.options.filename
模板和原始文件名信息生成压缩文件的名称。 -
compilation.emitAsset(newFilename, new sources.RawSource(compressedBuffer), assetInfo)
: 添加压缩后的资源。sources.RawSource
: 用于包装二进制的Buffer
。assetInfo
: 可以为新资源添加额外信息,如compressed: true
。
-
如果
deleteOriginalAssets
为true
,则调用compilation.deleteAsset(name)
删除原始资源。
-
-
错误处理:捕获压缩过程中的错误,并使用
compilation.errors.push()
将其报告给 Webpack。
-
-
-
插件 3: SimpleBannerPlugin.js
目标:
- 在生成的 JS 和 CSS 文件的顶部添加一个注释横幅(例如版权信息、构建日期)。
- 横幅内容可以通过选项配置,可以是静态字符串或返回字符串的函数。
- 提供选项来包含或排除特定文件。
js
// my-custom-webpack-plugins/src/SimpleBannerPlugin.js
"use strict";
const { Compilation, sources } = require('webpack');
const micromatch = require('micromatch'); // 用于文件名匹配
const PLUGIN_NAME = "SimpleBannerPlugin";
class SimpleBannerPlugin {
constructor(options = {}) {
if (typeof options === 'string') {
options = { banner: options }; // 兼容 Webpack 内置 BannerPlugin 的简单用法
}
this.options = Object.assign(
{
banner: "/* My Awesome App - Built by MyCustomWebpackPlugins */", // 默认横幅
raw: false, // 如果为 true,banner 将作为原始代码插入,否则会尝试添加注释标记
entryOnly: false, // 只为入口 chunk 的文件添加横幅
include: /.(js|css)$/, // 正则表达式、字符串或数组,用于匹配要添加横幅的文件
exclude: /.(map)$/, // 正则表达式、字符串或数组,用于排除文件
footer: false, // 如果为 true,则添加到文件末尾而不是开头
},
options
);
if (typeof this.options.banner !== 'function') {
const bannerText = this.options.banner;
this.options.banner = () => bannerText; // 将静态字符串转换为函数
}
}
apply(compiler) {
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
// STAGE_ADDITIONS 适合在其他处理之后,添加像 banner 这样的内容
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
logger.info("Adding banners to assets...");
const bannerContent = this.options.banner({
hash: compilation.hash,
chunk: null, // 在 asset 层面,具体 chunk 可能不直接可用,除非遍历 chunks
filename: null, // 同上
basename: null,
query: null,
// 你可以在这里添加更多构建相关的信息
buildDate: new Date().toISOString()
});
for (const name in assets) {
if (Object.hasOwnProperty.call(assets, name)) {
// 检查 include/exclude 规则
if (!this.shouldProcessFile(name, compilation)) {
continue;
}
const asset = compilation.getAsset(name);
if (!asset) continue;
let currentBannerContent = bannerContent;
// 如果 banner 是函数,并且希望每次都为特定文件生成(例如包含文件名)
// 可以在这里重新调用 this.options.banner,并传入文件信息
// if (typeof this.options.banner === 'function' && this.options.banner.length > 0) {
// const fileInfo = { filename: name, hash: compilation.hash, ... };
// currentBannerContent = this.options.banner(fileInfo);
// }
let finalBanner = currentBannerContent;
if (!this.options.raw) {
// 根据文件类型添加注释
const ext = name.split(".").pop().toLowerCase();
if (ext === 'js' || ext === 'mjs' || ext === 'cjs') {
finalBanner = `/*!\n * ${currentBannerContent.replace(/*//g, '*\/')}\n */\n`;
} else if (ext === 'css') {
finalBanner = `/*!\n * ${currentBannerContent.replace(/*//g, '*\/')}\n */\n`;
} else {
// 对于未知类型,或者 raw 为 false 但无法确定注释风格,可以跳过或使用通用注释
logger.warn(`Cannot determine comment style for ${name}, banner might not be correctly formatted.`);
}
}
const originalSource = asset.source;
let newSource;
if (this.options.footer) {
newSource = new sources.ConcatSource(originalSource, "\n", finalBanner);
} else {
newSource = new sources.ConcatSource(finalBanner, originalSource);
}
compilation.updateAsset(name, newSource, {
...asset.info, // 保留原有 info
bannerAdded: true
});
logger.info(`Banner added to ${name}.`);
}
}
logger.info("Banner processing finished.");
}
);
});
}
/**
* 检查文件是否应该被处理
* @param {string} filename
* @param {Compilation} compilation
* @returns {boolean}
*/
shouldProcessFile(filename, compilation) {
// 1. 检查 entryOnly
if (this.options.entryOnly) {
let isEntryAsset = false;
for (const entrypoint of compilation.entrypoints.values()) {
if (entrypoint.getFiles().includes(filename)) {
isEntryAsset = true;
break;
}
}
if (!isEntryAsset) return false;
}
// 2. 检查 include
if (this.options.include) {
if (!micromatch.isMatch(filename, this.options.include)) {
return false;
}
}
// 3. 检查 exclude
if (this.options.exclude) {
if (micromatch.isMatch(filename, this.options.exclude)) {
return false;
}
}
return true;
}
}
module.exports = SimpleBannerPlugin;
代码讲解 (SimpleBannerPlugin.js
):
-
constructor(options = {})
:banner
: 字符串或返回字符串的函数。函数可以接收一个包含构建信息的对象。raw
: 布尔值。如果为true
,banner
文本将直接插入;否则,插件会尝试根据文件类型添加注释标记 (/* ... */
)。entryOnly
: 布尔值。如果为true
,只为入口 chunk 生成的文件添加横幅。include
: 字符串、正则表达式或数组,用于匹配需要添加横幅的文件名 (使用micromatch
库)。exclude
: 同上,用于排除文件。footer
: 布尔值。如果为true
,横幅添加到文件末尾。- 构造函数会将静态的
banner
字符串转换为一个返回该字符串的函数,以统一处理。
-
apply(compiler)
:-
compilation.hooks.processAssets.tap(...)
:-
使用同步的
tap
,因为修改资源内容通常是同步操作。 -
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS
: 在资源基本处理完毕后,适合添加像 banner 这样的内容。 -
获取横幅内容 : 调用
this.options.banner()
函数获取横幅文本。可以传递一些构建时信息给这个函数。 -
遍历
assets
:- 调用
this.shouldProcessFile(name, compilation)
辅助方法判断当前文件是否需要处理。 - 格式化横幅 : 如果
this.options.raw
为false
,根据文件扩展名 (js, css) 为横幅文本添加注释符号。 - 创建新 Source : 使用
webpack.sources.ConcatSource
将横幅和原始资源内容连接起来。ConcatSource
可以高效地连接多个 Source 对象而无需立即序列化它们。 compilation.updateAsset(name, newSource, assetInfo)
: 更新现有的资源。
- 调用
-
-
-
shouldProcessFile(filename, compilation)
:- 一个辅助方法,用于根据
entryOnly
,include
,exclude
选项判断是否处理该文件。 entryOnly
的判断逻辑:遍历compilation.entrypoints
,检查文件名是否属于某个入口点的输出文件。include
/exclude
使用micromatch
库进行灵活的文件名匹配。你需要安装它:npm install micromatch --save-dev
。
- 一个辅助方法,用于根据
4. 在 Webpack 配置中使用插件
假设你已经通过 npm link
或本地路径安装了你的插件包 my-custom-webpack-plugins
。
js
// webpack.config.js
const path = require('path');
const { BuildInfoPlugin, AssetCompressionPlugin, SimpleBannerPlugin } = require('my-custom-webpack-plugins');
// 或者,如果你的插件发布到 npm:
// const { BuildInfoPlugin, AssetCompressionPlugin, SimpleBannerPlugin } = require('eslint-plugin-my-custom-rules');
module.exports = {
mode: 'production', // 或 'development'
entry: './src/index.js', // 你的项目入口
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true, // 清理输出目录
},
module: {
rules: [
// ... 你的 loaders
{
test: /.css$/,
use: ['style-loader', 'css-loader'] // 示例
}
]
},
plugins: [
new BuildInfoPlugin({
outputFileName: "build-stats.json",
logToConsole: true,
assetDetails: true, // 获取更详细的 manifest
}),
new AssetCompressionPlugin({
extensions: ["js", "css"],
algorithm: "gzip", // 或 'brotliCompress' 如果安装了 brotli 依赖并希望使用
compressionOptions: {
level: 9, // zlib.constants.Z_BEST_COMPRESSION
},
filename: "[path][base].gz", // main.js -> main.js.gz
threshold: 1024, // 只压缩大于 1KB 的文件
minRatio: 0.8,
// deleteOriginalAssets: false, // 通常保留原始文件
}),
new SimpleBannerPlugin({
banner: (data) => {
return `
MyApp - ${path.basename(data.filename || 'unknown file')}
Version: 1.0.0
Build Date: ${data.buildDate}
Hash: ${data.hash}
(c) 2024 My Company
`.trim();
},
include: /.(js|css)$/,
exclude: [/runtime..*.js$/, /.map$/], // 排除 runtime chunk 和 sourcemap
entryOnly: false, // 为所有匹配的文件添加
raw: false, // 自动添加注释
footer: false,
}),
// ... 其他插件
],
// ... 其他 webpack 配置
};
使用说明:
- 确保你的插件包 (
my-custom-webpack-plugins
) 能够被你的项目正确引入 (通过npm link
,相对路径安装,或发布到 npm 后安装)。 - 在
webpack.config.js
中require
你的插件类。 - 在
plugins
数组中new
你的插件实例,并传入配置选项。
5. 测试插件 (概念)
测试 Webpack 插件通常涉及:
-
单元测试 : 对于插件内部的纯逻辑函数(如
SimpleBannerPlugin
中的shouldProcessFile
或AssetCompressionPlugin
中的文件名生成逻辑),可以像测试普通 JavaScript 函数一样进行单元测试。 -
集成测试: 这是更主要的部分。你需要:
- 创建一个或多个简单的 Webpack 配置,使用你的插件。
- 准备一些输入文件。
- 使用 Webpack Node API 以编程方式运行 Webpack 构建。
- 检查构建输出(例如,生成的文件内容、manifest 文件内容、控制台输出等)是否符合预期。
- 可以使用
memfs
或类似的内存文件系统库来避免实际的磁盘 I/O,使测试更快。 - Webpack 官方提供了一个
@webpack-contrib/test-utils
包,可能包含一些有用的工具。
示例集成测试思路 (使用 webpack
API 和 memfs
):
js
// __tests__/BuildInfoPlugin.test.js (伪代码)
const webpack = require('webpack');
const { Volume } = require('memfs'); // 内存文件系统
const BuildInfoPlugin = require('../src/BuildInfoPlugin'); // 假设路径正确
describe('BuildInfoPlugin', () => {
it('should generate a build-manifest.json file', (done) => {
const compiler = webpack({
mode: 'development',
entry: '/entry.js', // 虚拟入口
output: {
path: '/dist',
filename: 'bundle.js',
},
plugins: [
new BuildInfoPlugin({ outputFileName: 'manifest.json', logToConsole: false })
]
});
const fs = new Volume(); // 使用内存文件系统
compiler.outputFileSystem = fs;
compiler.inputFileSystem = fs; // 如果需要虚拟输入文件
// 创建虚拟入口文件
fs.mkdirSync('/src');
fs.writeFileSync('/entry.js', 'console.log("hello");');
compiler.run((err, stats) => {
if (err) return done(err);
if (stats.hasErrors()) return done(new Error(stats.toJson().errors.join('\n')));
expect(fs.existsSync('/dist/manifest.json')).toBe(true);
const manifestContent = fs.readFileSync('/dist/manifest.json', 'utf-8');
const manifest = JSON.parse(manifestContent);
expect(manifest).toHaveProperty('buildTimestamp');
expect(manifest.assets).toHaveProperty('bundle.js');
// ... 更多断言
done();
});
});
});
这需要安装 memfs
和测试框架如 jest
。
总结
我们创建了三个独立的 Webpack 插件:
BuildInfoPlugin
: 记录构建信息并生成资源清单。AssetCompressionPlugin
: 对指定资源进行压缩。SimpleBannerPlugin
: 在文件头部(或尾部)添加自定义注释。
每个插件都利用了 Webpack 的 compiler
和 compilation
钩子,在构建过程的不同阶段执行任务。它们都支持通过构造函数选项进行配置。这些插件的代码加上详细的注释和讲解,应该能达到相当的行数,并且为你提供了创建更复杂 Webpack 插件的坚实基础。
你可以根据实际需求扩展这些插件的功能,例如:
BuildInfoPlugin
: 添加更多统计信息,支持不同的输出格式 (XML, YAML)。AssetCompressionPlugin
: 支持更多压缩算法,更复杂的缓存策略。SimpleBannerPlugin
: 支持从文件读取横幅内容,更细致的模板变量。