手把手教你实现webpack核心功能(五):实现Plugin系统与完整打包流程

0 阅读1分钟

三、实战:从零实现一个简易 Tapable

为了彻底理解插件系统,我们不妨亲自动手实现一个迷你的 Tapable。在真实的 Webpack 源码中,Tapable 使用了复杂的代码生成技术(new Function)来动态创建执行函数,以追求极致的性能。为了易于理解,我们使用更直观的 JavaScript 方式来实现。

1. 实现 SyncHook

SyncHook 是最基础的同步钩子。

// Tapable/SyncHook.js
class SyncHook {
  constructor(args = []) {
    this._args = args; // 钩子接受的参数列表
    this.taps = [];    // 存储所有订阅的插件回调
  }

  // 插件订阅钩子
  tap(name, fn) {
    this.taps.push({ name, fn });
  }

  // 触发钩子执行
  call(...args) {
    // 依次同步执行所有插件逻辑
    this.taps.forEach(tap => {
      tap.fn(...args);
    });
  }
}

2. 实现 AsyncSeriesHook

AsyncSeriesHook 是最核心的异步串行钩子,Webpack 许多涉及文件 IO 或网络请求的钩子(如 emit)都使用这种类型。

// Tapable/AsyncSeriesHook.js
class AsyncSeriesHook {
  constructor(args = []) {
    this._args = args;
    this.taps = [];
  }

  // 异步订阅
  tapAsync(name, fn) {
    this.taps.push({ name, fn });
  }

  // 触发异步串行执行
  callAsync(...args) {
    // 最后一个参数通常是 Webpack 完成当前阶段后的回调函数
    const finalCallback = args.pop();
    let index = 0;

    const next = () => {
      if (index === this.taps.length) {
        return finalCallback(); // 所有插件执行完毕,通知 Webpack 继续
      }
      const tap = this.taps[index++];
      // 每个插件执行完后,必须调用 next() 才能进入下一个插件
      tap.fn(...args, next);
    };

    next();
  }
}

通过这两种简单的实现,我们就能够模拟 Webpack 的绝大部分核心逻辑了。

四、核心改造:为 Compiler 注入钩子

现在,我们将这两个钩子引入到我们的 Compiler 类中,让它具备「生命周期」的概念。

1. 改造 Compiler 类

Compiler 是 Webpack 的指挥官。它在启动时会初始化所有插件,并暴露出核心的生命周期钩子。

// Compiler.js
const { SyncHook, AsyncSeriesHook } = require('./Tapable');

class Compiler {
  constructor(options) {
    this.options = options;
    this.hooks = {
      // 这里的参数定义了钩子触发时传递的数据
      run: new AsyncSeriesHook(['compiler']),
      compile: new SyncHook(['params']),
      afterCompile: new SyncHook(['compilation']),
      emit: new AsyncSeriesHook(['compilation']),
      done: new SyncHook(['stats']),
    };

    // 关键步骤:遍历插件并调用 apply
    if (Array.isArray(options.plugins)) {
      options.plugins.forEach(plugin => {
        // 插件必须实现 apply 方法
        plugin.apply(this);
      });
    }
  }

  // 启动打包
  run(callback) {
    // 1. 触发 run 钩子(异步)
    this.hooks.run.callAsync(this, () => {
      console.log('Webpack: 准备开始编译...');
      
      // 2. 触发 compile 钩子(同步)
      this.hooks.compile.call();

      // 3. 执行核心编译流程(之前章节实现的内容)
      const compilation = this.newCompilation();
      compilation.buildGraph(this.options.entry);

      // 4. 触发 afterCompile 钩子
      this.hooks.afterCompile.call(compilation);

      // 5. 触发 emit 钩子(异步:通常用于生成文件)
      this.hooks.emit.callAsync(compilation, () => {
        // 将生成的 bundle 写入磁盘
        this.emitAssets(compilation);
        
        // 6. 触发 done 钩子
        this.hooks.done.call();
        
        callback && callback();
      });
    });
  }
}

2. 引入 Compilation 类

在真实的 Webpack 中,Compiler 代表整个生命周期,而 Compilation 代表单次构建过程。每当文件发生变化重新打包时,Compiler 会创建一个新的 Compilation 实例。

// Compilation.js
class Compilation {
  constructor(compiler) {
    this.compiler = compiler;
    this.options = compiler.options;
    this.assets = {}; // 存储最终输出的所有文件内容
    this.modules = []; // 存储所有模块
  }

  // 构建依赖图
  buildGraph(entry) {
    // ... 之前章节实现的模块解析与依赖图构建逻辑 ...
  }
}

通过引入 Compilation,插件不仅可以监听全局的 Compiler 钩子,还可以通过 compiler.hooks.compilation 监听到单次构建的钩子,从而更精细地控制模块处理过程。

五、实战:实现两个经典 Webpack 插件

为了演示插件的强大能力,我们将亲手实现两个功能齐全的插件:一个用于自动生成 HTML 文件的 HtmlWebpackPlugin,以及一个用于清空目录的 CleanWebpackPlugin

1. 实现 CleanWebpackPlugin

该插件的逻辑非常简单:在构建开始前,清空输出目录。

// plugins/CleanWebpackPlugin.js
const fs = require('fs');
const path = require('path');

class CleanWebpackPlugin {
  apply(compiler) {
    // 监听 emit 钩子(异步串行)
    // 注意:在真实 Webpack 中通常监听 run 或 watchRun
    compiler.hooks.emit.tapAsync('CleanWebpackPlugin', (compilation, callback) => {
      const outputPath = compiler.options.output.path;
      console.log(`CleanWebpackPlugin: 正在清空目录 ${outputPath}...`);
      
      // 递归删除目录内容
      if (fs.existsSync(outputPath)) {
        this.deleteFolderRecursive(outputPath);
      }
      
      callback(); // 执行完毕,通知 Webpack 继续
    });
  }

  deleteFolderRecursive(folderPath) {
    if (fs.existsSync(folderPath)) {
      fs.readdirSync(folderPath).forEach((file) => {
        const curPath = path.join(folderPath, file);
        if (fs.lstatSync(curPath).isDirectory()) {
          this.deleteFolderRecursive(curPath);
        } else {
          fs.unlinkSync(curPath);
        }
      });
      fs.rmdirSync(folderPath);
    }
  }
}

2. 实现简易版 HtmlWebpackPlugin

该插件在打包完成后,根据模板生成一个 index.html,并自动通过 <script> 标签引入打包好的资源。

// plugins/HtmlWebpackPlugin.js
class HtmlWebpackPlugin {
  constructor(options = {}) {
    this.template = options.template || '';
    this.filename = options.filename || 'index.html';
  }

  apply(compiler) {
    // 监听 emit 钩子
    compiler.hooks.emit.tapAsync('HtmlWebpackPlugin', (compilation, callback) => {
      const bundleName = compiler.options.output.filename || 'bundle.js';
      
      // 构建 HTML 内容
      const htmlContent = `
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Webpack App</title>
</head>
<body>
    <div id="app"></div>
    <script src="${bundleName}"></script>
</body>
</html>`;

      // 将生成的 HTML 加入到 assets 列表中
      // Webpack 在 emit 阶段结束后会遍历 assets 并写入文件
      compilation.assets[this.filename] = htmlContent;

      console.log(`HtmlWebpackPlugin: 成功生成 ${this.filename}`);
      callback();
    });
  }
}

通过这两个插件,我们已经能够自动化完成「清理 -> 构建 -> 生成 HTML」的全流程了。

3. 进阶实战:实现 DefinePlugin

DefinePlugin 是 Webpack 最常用的插件之一,它允许在编译时创建全局常量。这在根据开发/生产环境切换 API 地址时非常有用。

// plugins/DefinePlugin.js
class DefinePlugin {
  constructor(definitions) {
    this.definitions = definitions;
  }

  apply(compiler) {
    // 监听 compilation 钩子,获取单次构建对象
    compiler.hooks.compilation.tap('DefinePlugin', (compilation) => {
      // 监听模块解析后的钩子
      // 在我们的简易实现中,可以直接在编译代码阶段进行替换
      compiler.hooks.afterCompile.tap('DefinePlugin', (compilation) => {
        compilation.modules.forEach(module => {
          Object.keys(this.definitions).forEach(key => {
            const value = JSON.stringify(this.definitions[key]);
            // 全局正则替换(实际 Webpack 使用 AST 替换,更安全)
            const regex = new RegExp(`\\b${key}\\b`, 'g');
            module._source = module._source.replace(regex, value);
          });
        });
      });
    });
  }
}

用法示例:

// webpack.config.js
plugins: [
  new DefinePlugin({
    'process.env.NODE_ENV': 'production',
    'VERSION': '1.0.0'
  })
]

通过这个插件,你可以深刻理解:Plugin 并不只是操作文件,它们还可以深入到模块的源代码层面进行干预。


六、深度解析:Webpack 完整打包流程回顾

到目前为止,我们已经实现了一个简易 Webpack 的所有关键组件。现在,让我们站在全局的高度,梳理一遍完整的打包生命周期:

1. 初始化阶段 (Initialization)

  • 合并配置:从配置文件(如 webpack.config.js)和 shell 参数中读取并合并参数,得出最终的配置对象。
  • 加载插件:实例化配置中的所有插件,并调用它们的 apply 方法。插件通过 taptapAsync 将自己的逻辑注册到 Compiler 的各种钩子上。
  • 环境初始化:初始化 Compiler 运行环境,准备好文件系统读写能力。

2. 编译阶段 (Compilation)

  • 开始编译:调用 compiler.run 方法,触发 run 钩子。随后创建 Compilation 实例,触发 compile 钩子。
  • 确定入口:根据配置中的 entry 找出所有的入口文件。
  • 编译模块 (Make):从入口文件开始,调用所有配置的 Loader 对模块进行转换。再利用 Babel 将转换后的代码解析为 AST(抽象语法树),递归寻找模块依赖的模块。
  • 完成模块编译:得到每个模块被翻译后的最终内容以及它们之间的依赖关系图(Dependency Graph)。

3. 输出阶段 (Output)

可以从入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk。再把每个 Chunk 转换成一个单独的文件加入到输出列表(compilation.assets)。

  • 写入文件 (Emit):触发 emit 钩子,这是修改输出内容的最后机会。随后 Webpack 会根据配置中的 output 路径和文件名,将文件内容写入到文件系统。

4. 结束阶段 (Done)

  • 构建完成:触发 done 钩子,输出打包摘要信息。

5. Webpack 内部的「插件化」真相

你可能不知道,当你运行一个最简单的 Webpack 配置时,Webpack 内部已经为你加载了数十个插件:

  • EntryPlugin: 负责处理 entry 配置,启动最初的编译。
  • JsonModulesPlugin: 负责解析 .json 文件。
  • JavascriptModulesPlugin: 负责处理 .js 文件。
  • TemplatedPathPlugin: 负责处理 output.filename 中的 [name][hash] 等占位符。

甚至连 Webpack 的文件解析(Resolver)系统都是通过插件扩展的。这种「一切皆插件」的设计思想,使得 Webpack 具有了近乎无限的生命力。


七、常用钩子一览表 (Cheat Sheet)

为了方便大家查阅,我整理了 Webpack 开发中最常用的几个钩子及其用途:

钩子名称类型触发时机典型用途
environmentSync配置文件加载后清理环境变量
afterPluginsSync所有插件初始化后确保依赖的插件已安装
runAsyncSeries编译开始前读取远程配置、清理目录
compileSync编译器即将创建时拦截编译参数
compilationSync编译对象创建后修改模块构建逻辑
makeAsyncParallel编译进行中动态添加入口模块
sealSync编译完成前优化 Chunk 结构
emitAsyncSeries资源输出到目录前注入 HTML、生成资源清单
doneSync编译完全结束发送通知、统计性能

八、开发者体验:如何调试与优化你的自定义插件

编写插件时,我们经常会遇到「逻辑不生效」或「打包速度慢」的问题。这里分享几个实用的调试与优化技巧。

1. 利用调试器 (Debugger)

不要只依赖 console.log。你可以通过 Node.js 的调试模式启动 Webpack:

node --inspect-brk node_modules/webpack/bin/webpack.js

然后在 Chrome 开发者工具中进行断点调试,查看 compilercompilation 对象中的实时状态。

2. 避免阻塞主线程

在异步钩子(如 emit)中,确保你总是调用了 callback()。如果你的逻辑非常耗时,考虑使用多进程或缓存机制。

3. 插件执行顺序

记住,插件的执行顺序通常与它们在 webpack.config.js 中的注册顺序一致。如果两个插件修改了同一个资源,后注册的插件会覆盖先注册的修改。

4. 性能监控

Speed-measure-webpack-plugin` 来分析每个插件的耗时情况,找出构建瓶颈。

5. 常见坑点 (Common Pitfalls)

  • 忘记调用 callback:在 Async 钩子中,如果不调用 callback(),Webpack 进程会永远挂起。
  • 直接操作文件系统:尽量通过 compilation.assets 修改资源,而不是直接使用 fs.writeFileSync。这样 Webpack 才能正确追踪资源变化并应用后续的优化插件。
  • 误用钩子类型:比如在 compile 钩子(同步)中尝试进行异步操作,会导致后续逻辑在异步完成前就已经执行。

十、常见问题解答 (FAQ)

Q1: 插件执行顺序重要吗?

非常重要。Webpack 按照你在 plugins 数组中定义的顺序依次调用它们的 apply 方法。虽然有些钩子是异步并行的,但初始化顺序决定了谁先订阅钩子。

Q2: 我可以在插件中动态添加 Loader 吗?

不建议这样做。Loader 通常在编译前的 resolve 阶段就已经确定了。如果你需要动态转换代码,建议在 compilation 的相关钩子中直接操作模块源码。

Q3: 为什么 Webpack 5 推荐使用 compiler.webpack 对象?

为了保证插件的兼容性。Webpack 5 将很多核心库(如 sources)直接挂载在了 compiler.webpack 上,这样插件就不需要自己去 require 这些依赖,避免了版本冲突。

Q4: 如何在插件中输出多个文件?

你只需要在 compilation.assets 对象上添加多个 key 即可。Webpack 在 emit 阶段会自动遍历这个对象并生成相应的文件。