Appearance
Webpack 中的 Loader 和 Plugin:使用场景与自定义开发
一、Webpack 的核心架构
Webpack 本质上是一个模块打包器。它的核心流程:
- 解析配置:读取 webpack.config.js,合并默认配置
- 创建 Compiler:全局唯一的编译引擎实例
- 解析入口:从 entry 开始递归构建模块依赖图
- Loader 处理:遇到非 JS 文件时,使用对应的 Loader 转换
- 构建 Module:每个文件被解析为一个 Module 对象
- 生成 Chunk:根据依赖关系和配置将 Module 合并为 Chunk
- Plugin 介入:在编译的各个阶段(钩子),Plugin 执行自定义逻辑
- 输出资源:将 Chunk 写入文件系统
Tapable:Webpack 的插件引擎
Webpack 本质上是基于 Tapable 的事件驱动架构。Tapable 提供了多种钩子类型:
- SyncHook:同步串行,不关心返回值
- SyncBailHook:同步串行,遇到非 undefined 返回值就停止
- SyncWaterfallHook:同步串行,上一个返回值传给下一个
- SyncLoopHook:同步循环,直到所有插件返回 undefined
- AsyncParallelHook:异步并行
- AsyncParallelBailHook:异步并行,有返回值就停止
- AsyncSeriesHook:异步串行
- AsyncSeriesBailHook:异步串行,有返回值就停止
- AsyncSeriesWaterfallHook:异步串行瀑布流
Compiler 和 Compilation 对象上都挂载了这些钩子。
二、Loader 详解
2.1 Loader 的本质
Loader 就是一个接收源文件内容、返回转换后内容的函数。它运行在 Node.js 环境中。
核心职责:
- 内容转换(TypeScript → JavaScript、SCSS → CSS、Markdown → HTML)
- 文件预处理(添加 polyfill、注入运行时代码)
- 资源处理(图片压缩、字体文件转换)
2.2 Loader 的执行顺序
Loader 的执行为从右到左,从下到上(compose 模式):
javascript
// 配置
use: ['style-loader', 'css-loader', 'sass-loader']
// 实际执行顺序
sass-loader → css-loader → style-loader每个 loader 通过 pitch 方法和 normal 方法组成。pitch 阶段从左到右执行,normal 阶段从右到左执行。
2.3 常见 Loader 使用场景
样式处理:
css-loader:解析 CSS 中的@import和url()style-loader:将 CSS 通过<style>标签注入 DOMsass-loader:编译 SCSS/SASS 为 CSSless-loader:编译 Less 为 CSSpostcss-loader:PostCSS 处理(autoprefixer、CSS Modules 等)mini-css-extract-plugin.loader:提取 CSS 为单独文件
JavaScript/TypeScript 编译:
babel-loader:Babel 转译(ES6+ → ES5,JSX 转换)ts-loader:TypeScript 编译esbuild-loader:使用 esbuild 进行快速转译swc-loader:使用 SWC(Rust)进行快速转译
资源处理:
file-loader/asset/resource:输出文件并返回 URLurl-loader/asset/inline:小文件转为 Data URLraw-loader/asset/source:导入文件为字符串image-webpack-loader:图片压缩
代码质量:
eslint-loader(已废弃,通常改为eslint-webpack-plugin)stylelint-webpack-plugin
模板/其他:
html-loader:解析 HTML 文件中的资源引用markdown-loader:Markdown 转 HTMLvue-loader:解析 Vue SFC 单文件组件svg-sprite-loader:SVG 图标合成为 spritethread-loader:将后续 loader 放入 worker 池并行执行
2.4 Loader 的作用域
Loader 只能访问单个文件的内容,无法访问整个模块图。如果需要跨文件的信息(比如分析所有模块的依赖关系),需要用 Plugin。
三、Plugin 详解
3.1 Plugin 的本质
Plugin 是一个实现了 apply 方法的类。apply 方法接收 compiler 对象,通过在 compiler 的钩子上注册回调来介入构建流程。
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tap('MyPlugin', (compilation) => {
// 在资源输出前执行自定义逻辑
});
}
}Plugin 的核心能力:
- 访问 compilation 对象(模块图、chunk 信息、资源列表)
- 在构建的任意阶段介入
- 修改/添加/删除资源
- 生成额外文件
- 注入运行时代码
3.2 常见 Plugin 使用场景
资源输出:
HtmlWebpackPlugin:生成 HTML 文件,自动注入打包后的 JS/CSSMiniCssExtractPlugin:提取 CSS 为独立文件CopyWebpackPlugin:复制静态文件到输出目录CompressionWebpackPlugin:生成 gzip/brotli 压缩版本
代码分割与优化:
SplitChunksPlugin(内置):提取公共依赖到单独的 chunkTerserWebpackPlugin(内置):JS 压缩混淆CssMinimizerWebpackPlugin:CSS 压缩BundleAnalyzerPlugin:可视化分析打包体积ModuleConcatenationPlugin(内置):Scope Hoisting
运行时注入:
DefinePlugin(内置):定义编译时常量(如process.env.NODE_ENV)ProvidePlugin(内置):自动加载模块(如全局注入$→jquery)BannerPlugin(内置):在打包文件头部添加注释NormalModuleReplacementPlugin(内置):替换模块
开发体验:
HotModuleReplacementPlugin(内置):HMR 热更新ForkTsCheckerWebpackPlugin:在独立进程中进行 TS 类型检查ReactRefreshWebpackPlugin:React Fast RefreshESLintWebpackPlugin:ESLint 集成StylelintWebpackPlugin:Stylelint 集成
环境与部署:
DotenvWebpackPlugin/EnvironmentPlugin(内置):环境变量管理WebpackManifestPlugin:生成资源映射表SentryWebpackPlugin:上传 source map 到 SentryPrerenderSPAPlugin:SPA 预渲染
Webpack 5 内置优化:
SplitChunksPluginTerserPluginCssMinimizerPluginDefinePluginProvidePluginHotModuleReplacementPluginModuleConcatenationPlugin
四、开发自定义 Loader
4.1 Loader 的基本结构
javascript
/**
* @param {string|Buffer} source 源文件内容
* @return {string|Buffer} 转换后的内容
*/
module.exports = function(source) {
// 获取 loader 配置项
const options = this.getOptions();
// 处理 source
const result = source.replace(/foo/g, 'bar');
// 返回结果
return result;
};Loader 的 this 上下文(loader context)提供了丰富的 API:
this.getOptions():获取 loader 配置this.callback(err, result, sourceMap, meta):返回多个值this.async():声明异步 loaderthis.emitFile(name, content, sourceMap):输出额外文件this.resourcePath:当前处理的文件路径this.loaders:loader 的执行信息this.cacheable():启用缓存this.addDependency():添加文件依赖this.addContextDependency():添加目录依赖
4.2 同步 Loader
javascript
module.exports = function(source) {
const options = this.getOptions();
const result = someTransform(source, options);
return result;
};4.3 异步 Loader
javascript
module.exports = function(source) {
const callback = this.async();
someAsyncTransform(source, (err, result) => {
if (err) return callback(err);
callback(null, result);
});
};4.4 Raw Loader(处理二进制文件)
javascript
module.exports = function(source) {
// source 是 Buffer
const optimized = imagemin(source);
return optimized;
};
// 标记为 raw loader
module.exports.raw = true;4.5 Pitch Loader
Pitch 方法在 normal 方法之前执行(从左到右),可以用于:
- 提前返回结果,跳过后续 loader
- 在 normal 执行前注入元数据
javascript
module.exports.pitch = function(remainingRequest, precedingRequest, data) {
// data 可以在 pitch 和 normal 之间共享
data.value = 42;
// 如果 return 一个值,跳过后续 loader 的 pitch 和 normal 阶段
// return 'module.exports = "skipped"';
};4.6 开发示例:env-replace-loader
将源代码中的占位符替换为环境变量值:
javascript
module.exports = function(source) {
const options = this.getOptions({});
const replacements = options.replacements || {};
let result = source;
for (const [key, value] of Object.entries(replacements)) {
const regex = new RegExp(`__${key}__`, 'g');
result = result.replace(regex, value);
}
return result;
};配置:
javascript
{
test: /\.js$/,
use: {
loader: 'env-replace-loader',
options: {
replacements: {
API_URL: process.env.API_URL || 'https://api.default.com',
VERSION: require('./package.json').version,
}
}
}
}4.7 开发示例:i18n-auto-loader
自动提取中文文本并替换为国际化 key:
javascript
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
module.exports = function(source) {
const ast = parser.parse(source, { sourceType: 'module' });
const messages = {};
let counter = 0;
traverse(ast, {
StringLiteral(path) {
// 检测中文字符串
if (/[一-龥]/.test(path.node.value)) {
const key = `i18n_key_${counter++}`;
messages[key] = path.node.value;
path.replaceWith(
// 替换为国际化函数调用
// t('i18n_key_0')
);
}
}
});
// 提取的 messages 可以 emit 为一个 JSON 文件
if (Object.keys(messages).length > 0) {
const messagesJson = JSON.stringify(messages, null, 2);
this.emitFile('i18n-extracted.json', messagesJson);
}
return generate(ast).code;
};五、开发自定义 Plugin
5.1 Plugin 的基本结构
javascript
class MyPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
// 在 compiler 钩子上注册逻辑
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
// 处理 compilation
callback();
});
}
}5.2 Compiler 钩子(构建流程级)
关键 compiler 钩子(按执行顺序):
text
environment → afterEnvironment → entryOption → afterPlugins → afterResolvers
→ initialize → beforeRun → run → watchRun → normalModuleFactory
→ contextModuleFactory → beforeCompile → compile → thisCompilation
→ compilation → make → finishMake → afterCompile → shouldEmit → emit
→ afterEmit → done重要钩子详解:
compiler.hooks.entryOption:读取 entry 配置后,可在此修改 entrycompiler.hooks.compile:开始编译前compiler.hooks.compilation:compilation 创建后,用于注册 compilation 钩子compiler.hooks.make:开始构建模块图compiler.hooks.emit:输出资源到 output 目录前,可修改资源compiler.hooks.afterEmit:资源输出完成后compiler.hooks.done:编译完成
5.3 Compilation 钩子(模块处理级)
Compilation 表示一次编译过程(watch 模式下每次重新编译都会创建新的 compilation 对象)。
关键 compilation 钩子:
compilation.hooks.buildModule:模块开始构建compilation.hooks.succeedModule:模块构建成功compilation.hooks.failedModule:模块构建失败compilation.hooks.optimize:优化阶段开始compilation.hooks.optimizeModules:优化模块compilation.hooks.optimizeChunks:优化 chunkcompilation.hooks.optimizeTree:异步优化树compilation.hooks.processAssets:处理资源(Webpack 5 新增,替代废弃的 asset 钩子)compilation.hooks.afterProcessAssets:资源处理完成
5.4 compilation.hooks.processAssets 详解(Webpack 5)
Webpack 5 引入了 processAssets 钩子系统来替代旧的 emit / afterEmit 等钩子。
javascript
const { Compilation } = require('webpack');
compilation.hooks.processAssets.tap(
{
name: 'MyPlugin',
// 在不同阶段执行
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, // 添加额外资源
// stage: Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS, // 预处理
// stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE, // 优化
// stage: Compilation.PROCESS_ASSETS_STAGE_REPORT, // 生成报告
},
(assets) => {
// assets 是一个对象,key 是文件名,value 是 Source 对象
for (const [filename, source] of Object.entries(assets)) {
// source.source() → 内容字符串
// source.size() → 字节大小
}
}
);5.5 开发示例:BuildInfoPlugin
生成一个包含构建信息的 JSON 文件:
javascript
class BuildInfoPlugin {
constructor(options = {}) {
this.filename = options.filename || 'build-info.json';
}
apply(compiler) {
compiler.hooks.emit.tapAsync('BuildInfoPlugin', (compilation, callback) => {
const buildInfo = {
version: require('./package.json').version,
buildTime: new Date().toISOString(),
hash: compilation.hash,
modules: compilation.modules.size,
chunks: compilation.chunks.size,
assets: Object.keys(compilation.assets),
};
const json = JSON.stringify(buildInfo, null, 2);
// 添加到输出资源
compilation.assets[this.filename] = {
source: () => json,
size: () => json.length,
};
callback();
});
}
}5.6 开发示例:UnusedFilesPlugin
检测项目中未被引用的文件:
javascript
const glob = require('glob');
const path = require('path');
class UnusedFilesPlugin {
constructor(options = {}) {
this.options = {
directory: options.directory || 'src',
pattern: options.pattern || '**/*.@(js|jsx|ts|tsx|css|scss|png|jpg|svg)',
...options,
};
}
apply(compiler) {
compiler.hooks.afterEmit.tapAsync('UnusedFilesPlugin', (compilation, callback) => {
const projectRoot = compiler.context;
const allFiles = glob.sync(this.options.pattern, {
cwd: path.join(projectRoot, this.options.directory),
absolute: true,
});
// 从 compilation 获取所有用到的文件
const usedFiles = new Set();
compilation.modules.forEach((module) => {
if (module.resource) {
usedFiles.add(module.resource);
}
});
// 计算差集
const unused = allFiles.filter(f => !usedFiles.has(f));
if (unused.length > 0) {
console.warn(`\n[UnusedFilesPlugin] 发现 ${unused.length} 个未被引用的文件:`);
unused.forEach(f => console.warn(` - ${path.relative(projectRoot, f)}`));
}
callback();
});
}
}六、Loader 和 Plugin 的协作
Loader 负责"翻译"单个文件,Plugin 负责"编排"整个构建流程。
实际场景中的协作:
vue-loader(Loader)解析 .vue 文件,VueLoaderPlugin(Plugin)将解析后的结果注入 webpack 的模块处理流程css-loader(Loader)处理 CSS 依赖,MiniCssExtractPlugin(Plugin)将 CSS 从 JS bundle 中提取出来babel-loader(Loader)转译 JSX,ReactRefreshWebpackPlugin(Plugin)注入 HMR 运行时
选择 Loader 还是 Plugin 的判断标准:
- 只处理某种文件类型的内容 → Loader
- 需要访问模块图/chunk/整个编译流程 → Plugin
- 需要输出额外的文件 → Plugin
- 需要在构建的开始/结束/特定阶段执行逻辑 → Plugin
七、调试与测试
Loader 测试
javascript
const compiler = require('./test-utils/compiler');
test('env-replace-loader', async () => {
const stats = await compiler('test/fixture.js', {
loader: {
test: /\.js$/,
use: {
loader: path.resolve(__dirname, '../src/env-replace-loader.js'),
options: { replacements: { API_URL: 'https://test.api.com' } },
},
},
});
const output = stats.toJson().modules[0].source;
expect(output).not.toContain('__API_URL__');
expect(output).toContain('https://test.api.com');
});Plugin 测试
Plugin 的测试思路是模拟 compiler/compilation 对象,或使用 webpack 的 Node API 进行集成测试:
javascript
const webpack = require('webpack');
const MyPlugin = require('../src/my-plugin');
test('plugin generates build info', (done) => {
const compiler = webpack({
entry: './test/fixture.js',
plugins: [new MyPlugin()],
});
compiler.run((err, stats) => {
expect(err).toBeNull();
// 验证插件效果
const assets = Object.keys(stats.compilation.assets);
expect(assets).toContain('build-info.json');
done();
});
});