Skip to content

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 = true

1.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 = InjectEnvPlugin

2.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-loader

Pitch 和 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 注册钩子
  • 使用 taptapAsynctapPromise

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'
    }
  }
}