Loader 和 Plugin 开发实战
从零开发自定义 Loader 和 Plugin,掌握 Webpack 扩展能力
一、Loader 开发
1.1 Loader 基础
Loader 本质:导出一个函数的 Node.js 模块。
javascript
// 最简单的 Loader
module.exports = function(source) {
return source
}Loader Context:
javascript
module.exports = function(source) {
// this 指向 Loader Context
console.log(this.resourcePath) // 当前文件路径
console.log(this.query) // Loader 参数
return source
}1.2 同步 Loader
示例 1:替换文本:
javascript
// replace-loader.js
const { getOptions } = require('loader-utils')
module.exports = function(source) {
const options = getOptions(this) || {}
return source.replace(
new RegExp(options.search, 'g'),
options.replace
)
}使用:
javascript
{
test: /\.js$/,
use: {
loader: path.resolve(__dirname, 'loaders/replace-loader.js'),
options: {
search: 'console.log',
replace: '// console.log'
}
}
}示例 2:添加注释:
javascript
// banner-loader.js
module.exports = function(source) {
const banner = `
/**
* Author: ${this.query.author}
* Date: ${new Date().toLocaleDateString()}
*/
`
return banner + source
}1.3 异步 Loader
使用 callback:
javascript
// async-loader.js
module.exports = function(source) {
const callback = this.async()
setTimeout(() => {
const result = source.toUpperCase()
callback(null, result)
}, 1000)
}使用 Promise:
javascript
// promise-loader.js
module.exports = function(source) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(source.toUpperCase())
}, 1000)
})
}1.4 Raw Loader
处理二进制文件:
javascript
// file-loader.js
module.exports = function(source) {
// source 是 Buffer
const filename = `${Date.now()}.${this.resourcePath.split('.').pop()}`
// 输出文件
this.emitFile(filename, source)
// 返回导出语句
return `module.exports = ${JSON.stringify(filename)}`
}
// 标记为 raw loader
module.exports.raw = true1.5 Pitch Loader
Pitch 方法:
javascript
// style-loader.js
module.exports = function(source) {
// Normal 阶段
return `
const style = document.createElement('style')
style.innerHTML = ${JSON.stringify(source)}
document.head.appendChild(style)
`
}
module.exports.pitch = function(remainingRequest) {
// Pitch 阶段
// 可以提前返回,跳过后续 Loader
return `
import style from ${JSON.stringify('!!' + remainingRequest)}
// 注入样式
`
}1.6 实战案例
案例 1:Markdown Loader:
javascript
// markdown-loader.js
const marked = require('marked')
const hljs = require('highlight.js')
module.exports = function(source) {
// 配置 marked
marked.setOptions({
highlight: (code, lang) => {
return hljs.highlight(code, { language: lang }).value
}
})
// 转换 Markdown
const html = marked(source)
// 返回 ES Module
return `export default ${JSON.stringify(html)}`
}使用:
javascript
{
test: /\.md$/,
use: [
'html-loader',
path.resolve(__dirname, 'loaders/markdown-loader.js')
]
}javascript
// 代码中使用
import content from './README.md'
document.getElementById('app').innerHTML = content案例 2:SVG Sprite Loader:
javascript
// svg-sprite-loader.js
const { getOptions } = require('loader-utils')
module.exports = function(source) {
const options = getOptions(this) || {}
const id = options.symbolId || this.resourcePath.match(/([^/]+)\.svg$/)[1]
// 提取 SVG 内容
const content = source
.replace(/<svg[^>]*>/, '')
.replace(/<\/svg>/, '')
// 生成 symbol
const symbol = `
<symbol id="${id}" xmlns="http://www.w3.org/2000/svg">
${content}
</symbol>
`
// 添加到 sprite
this.emitFile('sprite.svg', symbol)
// 返回使用方式
return `
export default {
id: '${id}',
viewBox: '0 0 24 24',
content: ${JSON.stringify(symbol)}
}
`
}案例 3:国际化 Loader:
javascript
// i18n-loader.js
const yaml = require('js-yaml')
module.exports = function(source) {
// 解析 YAML
const data = yaml.load(source)
// 转换为 JavaScript 对象
return `
export default ${JSON.stringify(data)}
`
}yaml
# zh-CN.yml
common:
hello: 你好
world: 世界javascript
import zhCN from './locales/zh-CN.yml'
console.log(zhCN.common.hello) // 你好1.7 Loader 工具函数
loader-utils:
javascript
const { getOptions, interpolateName } = require('loader-utils')
module.exports = function(source) {
// 获取配置
const options = getOptions(this)
// 生成文件名
const filename = interpolateName(this, '[name].[hash:8].[ext]', {
content: source
})
return source
}schema-utils:
javascript
const { validate } = require('schema-utils')
const schema = {
type: 'object',
properties: {
name: {
type: 'string'
}
}
}
module.exports = function(source) {
const options = getOptions(this)
// 验证配置
validate(schema, options, {
name: 'My Loader',
baseDataPath: 'options'
})
return source
}二、Plugin 开发
2.1 Plugin 基础
Plugin 结构:
javascript
class MyPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
// 注册钩子
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('编译完成')
})
}
}
module.exports = MyPlugin使用:
javascript
plugins: [
new MyPlugin({ name: 'test' })
]2.2 Tapable 钩子
钩子类型:
javascript
const {
SyncHook, // 同步钩子
SyncBailHook, // 同步熔断钩子
SyncWaterfallHook, // 同步瀑布钩子
AsyncSeriesHook, // 异步串行钩子
AsyncParallelHook // 异步并行钩子
} = require('tapable')注册方式:
javascript
// 同步
compiler.hooks.emit.tap('MyPlugin', (compilation) => {
// ...
})
// 异步 - callback
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
// ...
callback()
})
// 异步 - Promise
compiler.hooks.emit.tapPromise('MyPlugin', (compilation) => {
return new Promise((resolve) => {
// ...
resolve()
})
})2.3 Compiler 钩子
常用钩子:
javascript
class MyPlugin {
apply(compiler) {
// 环境准备好
compiler.hooks.environment.tap('MyPlugin', () => {
console.log('环境准备')
})
// 编译前
compiler.hooks.beforeRun.tapAsync('MyPlugin', (compiler, callback) => {
console.log('编译前')
callback()
})
// 开始编译
compiler.hooks.run.tapAsync('MyPlugin', (compiler, callback) => {
console.log('开始编译')
callback()
})
// 生成文件前
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
console.log('生成文件前')
callback()
})
// 编译完成
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('编译完成')
})
}
}2.4 Compilation 钩子
常用钩子:
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
// 构建模块前
compilation.hooks.buildModule.tap('MyPlugin', (module) => {
console.log('构建模块:', module.resource)
})
// 模块构建成功
compilation.hooks.succeedModule.tap('MyPlugin', (module) => {
console.log('模块构建成功:', module.resource)
})
// 优化阶段
compilation.hooks.optimize.tap('MyPlugin', () => {
console.log('优化阶段')
})
// 优化 Chunk
compilation.hooks.optimizeChunks.tap('MyPlugin', (chunks) => {
console.log('优化 Chunk')
})
// 优化资源
compilation.hooks.optimizeAssets.tapAsync('MyPlugin', (assets, callback) => {
console.log('优化资源')
callback()
})
})
}
}2.5 实战案例
案例 1:文件列表插件:
javascript
// FileListPlugin.js
class FileListPlugin {
constructor(options = {}) {
this.filename = options.filename || 'filelist.md'
}
apply(compiler) {
compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
// 获取所有文件
const filelist = Object.keys(compilation.assets)
.map(filename => {
const size = compilation.assets[filename].size()
return `- ${filename} (${(size / 1024).toFixed(2)} KB)`
})
.join('\n')
const content = `# 文件列表\n\n生成时间: ${new Date().toLocaleString()}\n\n${filelist}`
// 添加到输出
compilation.assets[this.filename] = {
source: () => content,
size: () => content.length
}
callback()
})
}
}
module.exports = FileListPlugin案例 2:自动上传插件:
javascript
// UploadPlugin.js
const OSS = require('ali-oss')
class UploadPlugin {
constructor(options) {
this.client = new OSS(options)
}
apply(compiler) {
compiler.hooks.afterEmit.tapAsync('UploadPlugin', (compilation, callback) => {
const assets = Object.keys(compilation.assets)
const uploads = []
assets.forEach(filename => {
const filepath = compilation.assets[filename].existsAt
uploads.push(
this.client.put(filename, filepath)
.then(() => {
console.log(`上传成功: ${filename}`)
})
.catch(err => {
console.error(`上传失败: ${filename}`, err)
})
)
})
Promise.all(uploads).then(() => callback())
})
}
}
module.exports = UploadPlugin案例 3:构建分析插件:
javascript
// AnalyzePlugin.js
class AnalyzePlugin {
apply(compiler) {
compiler.hooks.done.tap('AnalyzePlugin', (stats) => {
const statsJson = stats.toJson({
all: false,
modules: true,
chunks: true,
assets: true
})
// 分析模块
const modules = statsJson.modules
.sort((a, b) => b.size - a.size)
.slice(0, 10)
console.log('\n=== 最大的 10 个模块 ===')
modules.forEach(m => {
console.log(`${m.name}: ${(m.size / 1024).toFixed(2)} KB`)
})
// 分析 Chunk
const chunks = statsJson.chunks
.sort((a, b) => b.size - a.size)
console.log('\n=== Chunk 分析 ===')
chunks.forEach(c => {
console.log(`${c.names.join(', ')}: ${(c.size / 1024).toFixed(2)} KB`)
})
// 分析资源
const totalSize = statsJson.assets.reduce((sum, asset) => sum + asset.size, 0)
console.log('\n=== 构建统计 ===')
console.log(`总大小: ${(totalSize / 1024).toFixed(2)} KB`)
console.log(`文件数: ${statsJson.assets.length}`)
console.log(`模块数: ${statsJson.modules.length}`)
console.log(`Chunk 数: ${statsJson.chunks.length}`)
})
}
}
module.exports = AnalyzePlugin案例 4:注入环境变量插件:
javascript
// InjectEnvPlugin.js
class InjectEnvPlugin {
constructor(env) {
this.env = env
}
apply(compiler) {
compiler.hooks.compilation.tap('InjectEnvPlugin', (compilation) => {
compilation.hooks.optimizeChunkAssets.tapAsync('InjectEnvPlugin', (chunks, callback) => {
chunks.forEach(chunk => {
chunk.files.forEach(filename => {
if (filename.endsWith('.js')) {
const asset = compilation.assets[filename]
const source = asset.source()
// 注入环境变量
let newSource = source
Object.keys(this.env).forEach(key => {
const regex = new RegExp(`process\\.env\\.${key}`, 'g')
newSource = newSource.replace(regex, JSON.stringify(this.env[key]))
})
compilation.assets[filename] = {
source: () => newSource,
size: () => newSource.length
}
}
})
})
callback()
})
})
}
}
module.exports = InjectEnvPlugin2.6 修改产物
修改 Assets:
javascript
class ModifyAssetsPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('ModifyAssetsPlugin', (compilation, callback) => {
// 遍历所有资源
Object.keys(compilation.assets).forEach(filename => {
if (filename.endsWith('.js')) {
const asset = compilation.assets[filename]
const source = asset.source()
// 修改内容
const newSource = source.replace(/console\.log/g, '// console.log')
// 更新资源
compilation.assets[filename] = {
source: () => newSource,
size: () => newSource.length
}
}
})
callback()
})
}
}添加新文件:
javascript
class AddFilePlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('AddFilePlugin', (compilation, callback) => {
const content = 'console.log("Hello from plugin")'
compilation.assets['extra.js'] = {
source: () => content,
size: () => content.length
}
callback()
})
}
}删除文件:
javascript
class RemoveFilePlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('RemoveFilePlugin', (compilation, callback) => {
// 删除 LICENSE 文件
Object.keys(compilation.assets).forEach(filename => {
if (filename.includes('LICENSE')) {
delete compilation.assets[filename]
}
})
callback()
})
}
}三、调试技巧
3.1 调试 Loader
方法 1:console.log:
javascript
module.exports = function(source) {
console.log('=== Loader Debug ===')
console.log('Resource:', this.resourcePath)
console.log('Source length:', source.length)
return source
}方法 2:Node.js 调试:
json
// package.json
{
"scripts": {
"debug": "node --inspect-brk ./node_modules/webpack/bin/webpack.js"
}
}javascript
// loader 中设置断点
module.exports = function(source) {
debugger // 断点
return source
}方法 3:loader-runner:
javascript
// test-loader.js
const { runLoaders } = require('loader-runner')
const path = require('path')
const fs = require('fs')
runLoaders({
resource: path.resolve(__dirname, 'test.js'),
loaders: [
path.resolve(__dirname, 'loaders/my-loader.js')
],
context: {},
readResource: fs.readFile.bind(fs)
}, (err, result) => {
console.log(result.result)
})3.2 调试 Plugin
方法 1:console.log:
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tap('MyPlugin', (compilation) => {
console.log('=== Plugin Debug ===')
console.log('Assets:', Object.keys(compilation.assets))
console.log('Chunks:', compilation.chunks.size)
})
}
}方法 2:Node.js 调试:
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tap('MyPlugin', (compilation) => {
debugger // 断点
// ...
})
}
}四、最佳实践
4.1 Loader 最佳实践
1. 单一职责:
javascript
// ❌ 不好:一个 Loader 做太多事
module.exports = function(source) {
const compiled = compile(source)
const minified = minify(compiled)
return minified
}
// ✅ 好:拆分为多个 Loader
// compile-loader.js
module.exports = function(source) {
return compile(source)
}
// minify-loader.js
module.exports = function(source) {
return minify(source)
}2. 缓存结果:
javascript
module.exports = function(source) {
// 标记为可缓存
this.cacheable && this.cacheable()
return transform(source)
}3. 处理依赖:
javascript
module.exports = function(source) {
// 添加依赖文件
this.addDependency(path.resolve(__dirname, 'config.json'))
return source
}4. 错误处理:
javascript
module.exports = function(source) {
try {
return transform(source)
} catch (err) {
// 同步错误
this.emitError(err)
return source
}
}
// 异步错误
module.exports = function(source) {
const callback = this.async()
transform(source)
.then(result => callback(null, result))
.catch(err => callback(err))
}4.2 Plugin 最佳实践
1. 命名规范:
javascript
// 使用 PascalCase
class MyWebpackPlugin {}
// 钩子名称使用插件名
compiler.hooks.emit.tap('MyWebpackPlugin', () => {})2. 错误处理:
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
try {
// 插件逻辑
callback()
} catch (err) {
callback(err)
}
})
}
}3. 性能优化:
javascript
class MyPlugin {
apply(compiler) {
// 只在生产环境运行
if (compiler.options.mode !== 'production') {
return
}
compiler.hooks.emit.tap('MyPlugin', (compilation) => {
// ...
})
}
}4. 配置验证:
javascript
const schema = {
type: 'object',
properties: {
filename: { type: 'string' }
},
required: ['filename']
}
class MyPlugin {
constructor(options) {
validate(schema, options, { name: 'MyPlugin' })
this.options = options
}
}五、发布到 npm
5.1 准备工作
package.json:
json
{
"name": "my-webpack-loader",
"version": "1.0.0",
"description": "My custom webpack loader",
"main": "index.js",
"keywords": ["webpack", "loader"],
"author": "Your Name",
"license": "MIT",
"peerDependencies": {
"webpack": "^5.0.0"
}
}README.md:
markdown
# my-webpack-loader
## 安装
npm install my-webpack-loader -D
## 使用
// webpack.config.js
{
test: /\.txt$/,
use: 'my-webpack-loader'
}
## 配置
options: {
name: 'value'
}5.2 发布
bash
# 登录 npm
npm login
# 发布
npm publish
# 更新版本
npm version patch # 1.0.0 -> 1.0.1
npm version minor # 1.0.0 -> 1.1.0
npm version major # 1.0.0 -> 2.0.0
npm publish面试高频题
1: 如何编写一个 Loader?
答案:
javascript
module.exports = function(source) {
// 1. 获取配置
const options = this.getOptions()
// 2. 转换源代码
const result = transform(source, options)
// 3. 返回结果
return result
}关键点:
- 导出一个函数
- 接收源代码字符串
- 返回转换后的代码
- 使用
this.async()处理异步
2: Loader 的执行顺序是什么?
答案:
从右到左,从下到上:
javascript
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader']
}
// 执行顺序:
// postcss-loader → css-loader → style-loaderPitch 和 Normal:
- Pitch:从左到右
- Normal:从右到左
3: 如何编写一个 Plugin?
答案:
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
// 插件逻辑
console.log('生成文件前')
callback()
})
}
}
module.exports = MyPlugin关键点:
- 实现
apply方法 - 通过
compiler.hooks注册钩子 - 使用
tap、tapAsync或tapPromise
4: Loader 和 Plugin 有什么区别?
答案:
Loader:
- 文件转换器
- 处理单个文件
- 在模块加载时执行
- 返回转换后的内容
Plugin:
- 功能扩展器
- 介入整个构建流程
- 通过钩子系统工作
- 可以访问 Compiler 和 Compilation
5: 如何在 Loader 中处理异步操作?
答案:
javascript
// 方法 1:使用 callback
module.exports = function(source) {
const callback = this.async()
asyncTransform(source, (err, result) => {
callback(err, result)
})
}
// 方法 2:返回 Promise
module.exports = function(source) {
return asyncTransform(source)
}6: 如何在 Plugin 中修改输出文件?
答案:
javascript
class ModifyPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('ModifyPlugin', (compilation, callback) => {
// 修改现有文件
const asset = compilation.assets['main.js']
const source = asset.source()
const newSource = source.replace(/old/g, 'new')
compilation.assets['main.js'] = {
source: () => newSource,
size: () => newSource.length
}
callback()
})
}
}7: 什么是 Loader Context?
答案:
Loader Context 是 Loader 函数中的 this 对象,提供了很多有用的方法和属性:
javascript
module.exports = function(source) {
this.resourcePath // 当前文件路径
this.query // Loader 参数
this.async() // 异步回调
this.cacheable() // 标记可缓存
this.emitFile() // 输出文件
this.addDependency() // 添加依赖
return source
}8: 如何调试 Loader 和 Plugin?
答案:
方法 1:console.log:
javascript
module.exports = function(source) {
console.log('Debug:', this.resourcePath)
return source
}方法 2:Node.js 调试:
bash
node --inspect-brk ./node_modules/webpack/bin/webpack.js方法 3:loader-runner:
javascript
const { runLoaders } = require('loader-runner')
runLoaders({
resource: './test.js',
loaders: ['./my-loader.js']
}, (err, result) => {
console.log(result)
})9: 如何在 Plugin 中访问模块信息?
答案:
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
compilation.hooks.succeedModule.tap('MyPlugin', (module) => {
console.log('模块路径:', module.resource)
console.log('模块大小:', module.size())
console.log('模块依赖:', module.dependencies)
})
})
}
}10: 如何编写一个支持配置的 Loader?
答案:
javascript
const { getOptions } = require('loader-utils')
const { validate } = require('schema-utils')
const schema = {
type: 'object',
properties: {
name: { type: 'string' }
}
}
module.exports = function(source) {
// 获取配置
const options = getOptions(this) || {}
// 验证配置
validate(schema, options, { name: 'MyLoader' })
// 使用配置
return source.replace(/NAME/g, options.name)
}使用:
javascript
{
test: /\.js$/,
use: {
loader: 'my-loader',
options: {
name: 'MyApp'
}
}
}