本文首发于政采云前端团队博客:手把手带你入门Webpack Plugin前端
https://www.zoo.team/article/webpack-plugin

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

Webpack 的基本概念包括了以下内容:git
-
Entry:Webpack 的入口文件,指的是应该从哪一个模块做为入口,来构建内部依赖图。 -
Output:告诉 Webpack 在哪输出它所建立的 bundle 文件,以及输出的 bundle 文件该如何命名、输出到哪一个路径下等规则。 -
Loader:模块代码转化器,使得 Webpack 有能力去处理除了 JS、JSON 之外的其余类型的文件。 -
Plugin:Plugin 提供执行更广的任务的功能,包括:打包优化,资源管理,注入环境变量等。 -
Mode:根据不一样运行环境执行不一样优化参数时的必要参数。 -
Browser Compatibility:支持全部 ES5 标准的浏览器(IE8 以上)。
了解完 Webpack 的基本概念以后,咱们再来看下,为何咱们会须要 Plugin。web
Plugin 的做用
我先举一个咱们政采云内部的案例:json
在 React 项目中,通常咱们的 Router 文件是写在一个项目中的,若是项目中包含了许多页面,难免会出现全部业务模块 Router 耦合的状况,因此咱们开发了一个 Plugin,在构建打包时,该 Plugin 会读取全部的文件夹下的 index.js 文件,再合并到一块儿造成一个统一的 Router 文件,轻松解决业务耦合问题。这就是 Plugin 的应用(具体实现会在最后一小节说明)。api
来看一下咱们合成前项目代码结构:promise
├── 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 的做用总结以下:微信
-
提供了 Loader 没法解决的一些其余事情 -
提供强大的扩展方法,能执行更广的任务
了解完 Plugin 的大体做用以后,咱们来聊一聊如何建立一个 Plugin。app
建立一个 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 构建过程开始!");
});
}
}
从上面的代码咱们能够总结以下内容:
-
Plugin 其实就是一个类。 -
类须要一个 apply 方法,执行具体的插件方法。 -
插件方法作了一件事情就是在 run 这个 Hook 上注册了一个同步的打印日志的方法。 -
apply 方法的入参注入了一个 compiler 实例,compiler 实例是 Webpack 的支柱引擎,表明了 CLI 和 Node API 传递的全部配置项。 -
Hook 回调方法注入了 compilation 实例,compilation 可以访问当前构建时的模块和相应的依赖。
Compiler 对象包含了 Webpack 环境全部的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局惟一的,能够简单地把它理解为 Webpack 实例;
Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被建立。Compilation 对象也提供了不少事件回调供插件作扩展。经过 Compilation 也能读取到 Compiler 对象。
—— 摘自「深刻浅出 Webpack」
-
compiler 实例和 compilation 实例上分别定义了许多 Hooks,能够经过 实例.hooks.具体Hook
访问,Hook 上还暴露了 3 个方法供使用,分别是 tap、tapAsync 和 tapPromise。这三个方法用于定义如何执行 Hook,好比 tap 表示注册同步 Hook,tapAsync 表明 callback 方式注册异步 hook,而 tapPromise 表明 Promise 方式注册异步 Hook,能够看下 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({ // 生成实际执行的代码的方法
onError: err => `throw ${err};\n`, // 错误回调
onResult: result => `return ${result};\n`, // 获得值的时候的回调
resultReturns: true,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
case "async":
fn = new Function(
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() + // 公共方法,生成一些须要定义的变量
this.contentWithInterceptors({
onError: err => `_callback(${err});\n`, // 错误时执行回调方法
onResult: result => `_callback(null, ${result});\n`, // 获得结果时执行回调方法
onDone: () => "_callback();\n" // 无结果,执行完成时
})
);
break;
case "promise":
let errorHelperUsed = false;
const content = this.contentWithInterceptors({
onError: err => {
errorHelperUsed = true;
return `_error(${err});\n`;
},
onResult: result => `_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");
举几个简单的例子:
-
上面官方案例中的 run 这个 Hook,会在开始读取 records 以前执行,它的类型是 AsyncSeriesHook,查看源码能够发现,run Hook 既能够执行同步的 tap 方法,也能够执行异步的 tapAsync 和 tapPromise 方法,因此如下写法也是能够的:
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tapAsync(pluginName, (compilation, callback) => {
setTimeout(() => {
console.log("webpack 构建过程开始!");
callback(); // callback 方法为了让构建继续执行下去,必需要调用
}, 1000);
});
}
}
-
再举一个例子,好比 failed 这个 Hook,会在编译失败以后执行,它的类型是 SyncHook,查看源码能够发现,调用 tapAsync 和 tapPromise 方法时,会直接抛错。
对于一些同步的方法,推荐直接使用 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 打包的流程
-
咱们打包的时候,会先合并 Webpack config 文件和命令行参数,合并为 options。 -
将 options 传入 Compiler 构造方法,生成 compiler 实例,并实例化了 Compiler 上的 Hooks。 -
compiler 对象执行 run 方法,并自动触发 beforeRun、run、beforeCompile、compile 等关键 Hooks。 -
调用 Compilation 构造方法建立 compilation 对象,compilation 负责管理全部模块和对应的依赖,建立完成后触发 make Hook。 -
执行 compilation.addEntry() 方法,addEntry 用于分析全部入口文件,逐级递归解析,调用 NormalModuleFactory 方法,为每一个依赖生成一个 Module 实例,并在执行过程当中触发 beforeResolve、resolver、afterResolve、module 等关键 Hooks。 -
将第 5 步中生成的 Module 实例做为入参,执行 Compilation.addModule() 和 Compilation.buildModule() 方法递归建立模块对象和依赖模块对象。 -
调用 seal 方法生成代码,整理输出主文件和 chunk,并最终输出。

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
Hook | type | 调用 |
---|---|---|
run | AsyncSeriesHook | 开始读取 records 以前 |
compile | SyncHook | 一个新的编译(compilation)建立以后 |
emit | AsyncSeriesHook | 生成资源到 output 目录以前 |
done | SyncHook | 编译(compilation)完成 |
Compilation Hooks
Hook | type | 调用 |
---|---|---|
buildModule | SyncHook | 在模块构建开始以前触发。 |
finishModules | SyncHook | 全部模块都完成构建。 |
optimize | SyncHook | 优化阶段开始时触发。 |
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, null, 2)}`, '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源创计划”,欢迎正在阅读的你也加入,一块儿分享。