Skip to content

Webpack 中的 Loader 和 Plugin:使用场景与自定义开发

一、Webpack 的核心架构

Webpack 本质上是一个模块打包器。它的核心流程:

  1. 解析配置:读取 webpack.config.js,合并默认配置
  2. 创建 Compiler:全局唯一的编译引擎实例
  3. 解析入口:从 entry 开始递归构建模块依赖图
  4. Loader 处理:遇到非 JS 文件时,使用对应的 Loader 转换
  5. 构建 Module:每个文件被解析为一个 Module 对象
  6. 生成 Chunk:根据依赖关系和配置将 Module 合并为 Chunk
  7. Plugin 介入:在编译的各个阶段(钩子),Plugin 执行自定义逻辑
  8. 输出资源:将 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 中的 @importurl()
  • style-loader:将 CSS 通过 <style> 标签注入 DOM
  • sass-loader:编译 SCSS/SASS 为 CSS
  • less-loader:编译 Less 为 CSS
  • postcss-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:输出文件并返回 URL
  • url-loader / asset/inline:小文件转为 Data URL
  • raw-loader / asset/source:导入文件为字符串
  • image-webpack-loader:图片压缩

代码质量

  • eslint-loader(已废弃,通常改为 eslint-webpack-plugin
  • stylelint-webpack-plugin

模板/其他

  • html-loader:解析 HTML 文件中的资源引用
  • markdown-loader:Markdown 转 HTML
  • vue-loader:解析 Vue SFC 单文件组件
  • svg-sprite-loader:SVG 图标合成为 sprite
  • thread-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/CSS
  • MiniCssExtractPlugin:提取 CSS 为独立文件
  • CopyWebpackPlugin:复制静态文件到输出目录
  • CompressionWebpackPlugin:生成 gzip/brotli 压缩版本

代码分割与优化

  • SplitChunksPlugin(内置):提取公共依赖到单独的 chunk
  • TerserWebpackPlugin(内置):JS 压缩混淆
  • CssMinimizerWebpackPlugin:CSS 压缩
  • BundleAnalyzerPlugin:可视化分析打包体积
  • ModuleConcatenationPlugin(内置):Scope Hoisting

运行时注入

  • DefinePlugin(内置):定义编译时常量(如 process.env.NODE_ENV
  • ProvidePlugin(内置):自动加载模块(如全局注入 $jquery
  • BannerPlugin(内置):在打包文件头部添加注释
  • NormalModuleReplacementPlugin(内置):替换模块

开发体验

  • HotModuleReplacementPlugin(内置):HMR 热更新
  • ForkTsCheckerWebpackPlugin:在独立进程中进行 TS 类型检查
  • ReactRefreshWebpackPlugin:React Fast Refresh
  • ESLintWebpackPlugin:ESLint 集成
  • StylelintWebpackPlugin:Stylelint 集成

环境与部署

  • DotenvWebpackPlugin / EnvironmentPlugin(内置):环境变量管理
  • WebpackManifestPlugin:生成资源映射表
  • SentryWebpackPlugin:上传 source map 到 Sentry
  • PrerenderSPAPlugin:SPA 预渲染

Webpack 5 内置优化

  • SplitChunksPlugin
  • TerserPlugin
  • CssMinimizerPlugin
  • DefinePlugin
  • ProvidePlugin
  • HotModuleReplacementPlugin
  • ModuleConcatenationPlugin

四、开发自定义 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():声明异步 loader
  • this.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 配置后,可在此修改 entry
  • compiler.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:优化 chunk
  • compilation.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 负责"编排"整个构建流程。

实际场景中的协作:

  1. vue-loader(Loader)解析 .vue 文件,VueLoaderPlugin(Plugin)将解析后的结果注入 webpack 的模块处理流程
  2. css-loader(Loader)处理 CSS 依赖,MiniCssExtractPlugin(Plugin)将 CSS 从 JS bundle 中提取出来
  3. 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();
  });
});