关于 Webpack

在讲 Plugin 之前,我们先来了解下 Webpack。本质上,Webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。它能够解析我们的代码,生成对应的依赖关系,然后将不同的模块达成一个或多个 bundle。

Webpack 的基本概念包括了如下内容:

了解完 Webpack 的基本概念之后,我们再来看下,为什么我们会需要 Plugin。

Plugin 的作用

我先举一个我们政采云内部的案例:

在 React 项目中,一般我们的 Router 文件是写在一个项目中的,如果项目中包含了许多页面,不免会出现所有业务模块 Router 耦合的情况,所以我们开发了一个 Plugin,在构建打包时,该 Plugin 会读取所有的文件夹下的 index.js 文件,再合并到一起形成一个统一的 Router 文件,轻松解决业务耦合问题。这就是 Plugin 的应用(具体实现会在最后一小节说明)。

来看一下我们合成前项目代码结构:

├── package.json
├── README.md
├── zoo.config.js
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .stylelintrc
├── build (Webpack 配置目录)
│   └── webpack.dev.conf.js
├── src
│   ├── index.hbs
│   ├── main.js (入口文件)
│   ├── common (通用模块,包权限,统一报错拦截等)
│       └── ...
│   ├── components (项目公共组件)
│       └── ...
│   ├── layouts (项目顶通)
│       └── ...
│   ├── utils (公共类)
│       └── ...
│   ├── routes (页面路由)
│   │   ├── Hello (对应 Hello 页面的代码)
│   │   │   ├── config (页面配置信息)
│   │   │       └── ...
│   │   │   ├── models (dva数据中心)
│   │   │       └── ...
│   │   │   ├── services (请求相关接口定义)
│   │   │       └── ...
│   │   │   ├── views (请求相关接口定义)
│   │   │       └── ...
│   │   │   └── index.js (router定义的路由信息)
├── .eslintignore
├── .eslintrc
├── .gitignore
└── .stylelintrc

再看一下经过 Plugin 合成 Router 之后的结构:

├── package.json
├── README.md
├── zoo.config.js
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .stylelintrc
├── build (Webpack 配置目录)
│   └── webpack.dev.conf.js
├── src
│   ├── index.hbs
│   ├── main.js (入口文件)
│   ├── router-config.js (合成后的router文件)
│   ├── common (通用模块,包权限,统一报错拦截等)
│       └── ...
│   ├── components (项目公共组件)
│       └── ...
│   ├── layouts (项目顶通)
│       └── ...
│   ├── utils (公共类)
│       └── ...
│   ├── routes (页面路由)
│   │   ├── Hello (对应 Hello 页面的代码)
│   │   │   ├── config (页面配置信息)
│   │   │       └── ...
│   │   │   ├── models (dva数据中心)
│   │   │       └── ...
│   │   │   ├── services (请求相关接口定义)
│   │   │       └── ...
│   │   │   ├── views (请求相关接口定义)
│   │   │       └── ...
├── .eslintignore
├── .eslintrc
├── .gitignore
└── .stylelintrc

总结来说 Plugin 的作用总结如下:

了解完 Plugin 的大致作用之后,我们来聊一聊如何创建一个 Plugin。

创建一个 Plugin

Hook

在聊创建 Plugin 之前,我们先来聊一下什么是 Hook。

Webpack 在编译的过程中会触发一系列流程,而在这样一连串的流程中,Webpack 把一些关键的流程节点暴露出来供开发者使用,这就是 Hook,可以类比 React 的生命周期钩子。

Plugin 就是在这些 Hook 上暴露出方法供开发者做一些额外操作,在写 Plugin 的时候,也需要先了解我们应该在哪个 Hook 上做操作。

如何创建 Plugin

我们先来看一下 Webpack 官方给的案例:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
    apply(compiler) {
        // 代表开始读取 records 之前执行
        compiler.hooks.run.tap(pluginName, compilation => {
            console.log("webpack 构建过程开始!");
        });
    }
}

从上面的代码我们可以总结如下内容:

Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;

Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。
—— 摘自「深入浅出 Webpack」
// tap方法的type是sync,tapAsync方法的type是async,tapPromise方法的type是promise
// 源码取自Hook工厂方法:lib/HookCodeFactory.js
create(options) {
  this.init(options);
  let fn;
  // Webpack 通过new Function 生成函数
  switch (this.options.type) {
    case "sync":
      fn = new Function(
        this.args(), // 生成函数入参
        '"use strict";\n' +
        this.header() + // 公共方法,生成一些需要定义的变量
        this.contentWithInterceptors({ // 生成实际执行的代码的方法
          onErrorerr => `throw ${err};\n`// 错误回调
          onResultresult => `return ${result};\n`// 得到值的时候的回调
          resultReturnstrue,
          onDone() => "",
          rethrowIfPossibletrue
        })
      );
      break;
    case "async":
      fn = new Function(
        this.args({
          after"_callback"
        }),
        '"use strict";\n' +
        this.header() + // 公共方法,生成一些需要定义的变量
        this.contentWithInterceptors({ 
          onErrorerr => `_callback(${err});\n`// 错误时执行回调方法
          onResultresult => `_callback(null, ${result});\n`// 得到结果时执行回调方法
          onDone() => "_callback();\n" // 无结果,执行完成时
        })
      );
      break;
    case "promise":
      let errorHelperUsed = false;
      const content = this.contentWithInterceptors({
        onErrorerr => {
          errorHelperUsed = true;
          return `_error(${err});\n`;
        },
        onResultresult => `_resolve(${result});\n`,
        onDone() => "_resolve();\n"
      });
      let code = "";
      code += '"use strict";\n';
      code += this.header(); // 公共方法,生成一些需要定义的变量
      code += "return new Promise((function(_resolve, _reject) {\n"// 返回的是 Promise
      if (errorHelperUsed) {
        code += "var _sync = true;\n";
        code += "function _error(_err) {\n";
        code += "if(_sync)\n";
        code +=
          "_resolve(Promise.resolve().then((function() { throw _err; })));\n";
        code += "else\n";
        code += "_reject(_err);\n";
        code += "};\n";
      }
      code += content; // 判断具体执行_resolve方法还是执行_error方法
      if (errorHelperUsed) {
        code += "_sync = false;\n";
      }
      code += "}));\n";
      fn = new Function(this.args(), code);
      break;
  }
  this.deinit(); // 清空 options 和 _args
  return fn;
}

Webpack 共提供了以下十种 Hooks,代码中所有具体的 Hook 都是以下这 10 种中的一种。

// 源码取自:lib/index.js
"use strict";

exports.__esModule = true;
// 同步执行的钩子,不能处理异步任务
exports.SyncHook = require("./SyncHook");
// 同步执行的钩子,返回非空时,阻止向下执行
exports.SyncBailHook = require("./SyncBailHook");
// 同步执行的钩子,支持将返回值透传到下一个钩子中
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
// 同步执行的钩子,支持将返回值透传到下一个钩子中,返回非空时,重复执行
exports.SyncLoopHook = require("./SyncLoopHook");
// 异步并行的钩子
exports.AsyncParallelHook = require("./AsyncParallelHook");
// 异步并行的钩子,返回非空时,阻止向下执行,直接执行回调
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
// 异步串行的钩子
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
// 异步串行的钩子,返回非空时,阻止向下执行,直接执行回调
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
// 支持异步串行 && 并行的钩子,返回非空时,重复执行
exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
// 异步串行的钩子,下一步依赖上一步返回的值
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
// 以下 2 个是 hook 工具类,分别用于 hooks 映射以及 hooks 重定向
exports.HookMap = require("./HookMap");
exports.MultiHook = require("./MultiHook");

举几个简单的例子:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
    apply(compiler) {
        compiler.hooks.run.tapAsync(pluginName, (compilation, callback) => {
            setTimeout(() => {
              console.log("webpack 构建过程开始!");
              callback(); // callback 方法为了让构建继续执行下去,必须要调用
            }, 1000);
        });
    }
}

对于一些同步的方法,推荐直接使用 tap 进行注册方法,对于异步的方案,tapAsync 通过执行 callback 方法实现回调,如果执行的方法返回的是一个 Promise,推荐使用 tapPromise 进行方法的注册。

Hook 的类型可以通过官方 API 查询,地址传送门:https://www.webpackjs.com/api/compiler-hooks/?fileGuid=3tGHdrykRgwCyTP8

// 源码取自:lib/SyncHook.js
const TAP_ASYNC = () => {
  throw new Error("tapAsync is not supported on a SyncHook");
};

const TAP_PROMISE = () => {
  throw new Error("tapPromise is not supported on a SyncHook");
};

function SyncHook(args = [], name = undefined{
  const hook = new Hook(args, name);
  hook.constructor = SyncHook;
  hook.tapAsync = TAP_ASYNC;
  hook.tapPromise = TAP_PROMISE;
  hook.compile = COMPILE;
  return hook;
}

讲解完具体的执行方法之后,我们再聊一下 Webpack 流程以及 Tapable 是什么。

Webpack && Tapable

Webpack 运行机制

要理解 Plugin,我们先大致了解 Webpack 打包的流程

Tapable

Tapable 是 Webpack 核心工具库,它提供了所有 Hook 的抽象类定义,Webpack 许多对象都是继承自 Tapable 类。比如上面说的 tap、tapAsync 和 tapPromise 都是通过 Tapable 进行暴露的。源码如下(截取了部分代码):

// 第二节 “创建一个 Plugin” 中说的 10 种 Hooks 都是继承了这两个类
// 源码取自:tapable.d.ts
declare class Hook<T, R, AdditionalOptions = UnsetAdditionalOptions> {
  tap(options: string | Tap & IfSet<AdditionalOptions>, fn: (...args: AsArray<T>) => R): void;
}

declare class AsyncHook<T, R, AdditionalOptions = UnsetAdditionalOptions> extends Hook<T, R, AdditionalOptions> {
  tapAsync(
    options: string | Tap & IfSet<AdditionalOptions>,
    fn: (...args: Append<AsArray<T>, InnerCallback<Error, R>>) => void
  ): void;
  tapPromise(
    options: string | Tap & IfSet<AdditionalOptions>,
    fn: (...args: AsArray<T>) => Promise<R>
  ): void;
}

常见 Hooks API

可以参考 Webpack:https://www.webpackjs.com/api/compiler-hooks/?fileGuid=3tGHdrykRgwCyTP8

本文列举一些常用 Hooks 和其对应的类型

Compiler Hooks

Compilation Hooks

Plugin 在项目中的应用

讲完这么多理论知识,接下来我们来看一下 Plugin 在项目中的实战:如何将各个子模块中的 router 文件合并到 router-config.js 中。

背景:

在 React 项目中,一般我们的 Router 文件是写在一个项目中的,如果项目中包含了许多页面,不免会出现所有业务模块 Router 耦合的情况,所以我们开发了一个 Plugin,在构建打包时,该 Plugin 会读取所有文件夹下的 Router 文件,再合并到一起形成一个统一的 Router Config 文件,轻松解决业务耦合问题。这就是 Plugin 的应用。

实现:

const fs = require('fs');
const path = require('path');
const _ = require('lodash');

function resolve(dir{
  return path.join(__dirname, '..', dir);
}

function MegerRouterPlugin(options{
  // options是配置文件,你可以在这里进行一些与options相关的工作
}

MegerRouterPlugin.prototype.apply = function (compiler{
  // 注册 before-compile 钩子,触发文件合并
  compiler.plugin('before-compile', (compilation, callback) => {
    // 最终生成的文件数据
    const data = {};
    const routesPath = resolve('src/routes');
    const targetFile = resolve('src/router-config.js');
    // 获取路径下所有的文件和文件夹
    const dirs = fs.readdirSync(routesPath);
    try {
      dirs.forEach((dir) => {
        const routePath = resolve(`src/routes/${dir}`);
        // 判断是否是文件夹
        if (!fs.statSync(routePath).isDirectory()) {
          return true;
        }
        delete require.cache[`${routePath}/index.js`];
        const routeInfo = require(routePath);
        // 多个 view 的情况下,遍历生成router信息
        if (!_.isArray(routeInfo)) {
          generate(routeInfo, dir, data);
        // 单个 view 的情况下,直接生成
        } else {
          routeInfo.map((config) => {
            generate(config, dir, data);
          });
        }
      });
    } catch (e) {
      console.log(e);
    }

    // 如果 router-config.js 存在,判断文件数据是否相同,不同删除文件后再生成
    if (fs.existsSync(targetFile)) {
      delete require.cache[targetFile];
      const targetData = require(targetFile);
      if (!_.isEqual(targetData, data)) {
        writeFile(targetFile, data);
      }
    // 如果 router-config.js 不存在,直接生成文件
    } else {
      writeFile(targetFile, data);
    }

    // 最后调用callback,继续执行 webpack 打包
    callback();
  });
};
// 合并当前文件夹下的router数据,并输出到 data 对象中
function generate(config, dir, data{
  // 合并 router
  mergeConfig(config, dir, data);
  // 合并子 router
  getChildRoutes(config.childRoutes, dir, data, config.url);
}
// 合并 router 数据到 targetData 中
function mergeConfig(config, dir, targetData{
  const { view, models, extraModels, url, childRoutes, ...rest } = config;
  // 获取 models,并去除 src 字段
  const dirModels = getModels(`src/routes/${dir}/models`, models);
  const data = {
    ...rest,
  };
  // view 拼接到 path 字段
  data.path = `${dir}/views${view ? `/${view}` : ''}`;
  // 如果有 extraModels,就拼接到 models 对象上
  if (dirModels.length || (extraModels && extraModels.length)) {
    data.models = mergerExtraModels(config, dirModels);
  }
  Object.assign(targetData, {
    [url]: data,
  });
}
// 拼接 dva models
function getModels(modelsDir, models{
  if (!fs.existsSync(modelsDir)) {
    return [];
  }
  let files = fs.readdirSync(modelsDir);
  // 必须要以 js 或者 jsx 结尾
  files = files.filter((item) => {
    return /\.jsx?$/.test(item);
  });
  // 如果没有定义 models ,默认取 index.js
  if (!models || !models.length) {
    if (files.indexOf('index.js') > -1) {
      // 去除 src
      return [`${modelsDir.replace('src/''')}/index.js`];
    }
    return [];
  }
  return models.map((item) => {
    if (files.indexOf(`${item}.js`) > -1) {
      // 去除 src
      return `${modelsDir.replace('src/''')}/${item}.js`;
    }
  });
}
// 合并 extra models
function mergerExtraModels(config, models{
  return models.concat(config.extraModels ? config.extraModels : []);
}
// 合并子 router
function getChildRoutes(childRoutes, dir, targetData, oUrl{
  if (!childRoutes) {
    return;
  }
  childRoutes.map((option) => {
    option.url = oUrl + option.url;
    if (option.childRoutes) {
      // 递归合并子 router
      getChildRoutes(option.childRoutes, dir, targetData, option.url);
    }
    mergeConfig(option, dir, targetData);
  });
}

// 写文件
function writeFile(targetFile, data{
  fs.writeFileSync(targetFile, `module.exports = ${JSON.stringify(data, null2)}`'utf-8');
}

module.exports = MegerRouterPlugin;

结果:

合并前的文件:

module.exports = [
  {
    url'/category/protocol',
    view'protocol',
  },
  {
    url'/category/sync',
    models: ['sync'],
    view'sync',
  },
  {
    url'/category/list',
    models: ['category''config''attributes''group''otherSet''collaboration'],
    view'categoryRefactor',
  },
  {
    url'/category/conversion',
    models: ['conversion'],
    view'conversion',
  },
];

合并后的文件:

module.exports = {
  "/category/protocol": {
    "path""Category/views/protocol"
  },
  "/category/sync": {
    "path""Category/views/sync",
    "models": [
      "routes/Category/models/sync.js"
    ]
  },
  "/category/list": {
    "path""Category/views/categoryRefactor",
    "models": [
      "routes/Category/models/category.js",
      "routes/Category/models/config.js",
      "routes/Category/models/attributes.js",
      "routes/Category/models/group.js",
      "routes/Category/models/otherSet.js",
      "routes/Category/models/collaboration.js"
    ]
  },
  "/category/conversion": {
    "path""Category/views/conversion",
    "models": [
      "routes/Category/models/conversion.js"
    ]
  },
}

最终项目就会生成 router-config.js文件

结尾

希望大家看完本章之后,对 Webpack Plugin 有一个初步的认识,能够上手写一个自己的 Plugin 来应用到自己的项目中。

文章中如有不对的地方,欢迎指正。

看完两件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我两件小事

1.点个「在看」,让更多人也能看到这篇内容(点了在看」,bug -1 😊

招贤纳士

政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 40 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。

如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com


本文分享自微信公众号 - 政采云前端团队(Zoo-Team)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

06-17 10:20