初探webpack之编写plugin

webpack通过plugin机制让其使用更加灵活,以适应各种应用场景,当然也大大增加了webpack的复杂性,在webpack运行的生命周期中会广播出许多事件,plugin可以hook这些事件,在合适的时机通过webpack提供的API改变其在处理过程中的输出结果。

描述

webpack是一个现代JavaScript应用程序的静态模块打包器module bundler,当webpack处理应用程序时,它会递归地构建一个依赖关系图dependency graph,其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个bundle

使用webpack作为前端构建工具通常可以做到以下几个方面的事情:

  • 代码转换: TypeScript编译成JavaScriptSCSS编译成CSS等。
  • 文件优化: 压缩JavaScriptCSSHTML代码,压缩合并图片等。
  • 代码分割: 提取多个页面的公共代码、提取首屏不需要执行部分的代码让其异步加载。
  • 模块合并: 在采用模块化的项目里会有很多个模块和文件,需要构建功能把模块分类合并成一个文件。
  • 自动刷新: 监听本地源代码的变化,自动重新构建、刷新浏览器页面,通常叫做热重载Hot Reload
  • 代码校验: 在代码被提交到仓库前需要校验代码是否符合规范,以及单元测试是否通过。
  • 自动发布: 更新完代码后,自动构建出线上发布代码并传输给发布系统。

webpack应用中有两个核心:

  • 模块转换器,用于把模块原内容按照需求转换成新内容,可以加载非js模块;
  • 扩展插件,在webpack构建流程中的特定时机注入扩展逻辑来改变构建结果或做你想要的事情。

本文编写的就是编写一个简单的webpack插件,设想一个简单的场景,假如我们实现了一个多页的Vue应用,每个打包的页面都会共享一个相同的头部和底部,也就是顶部navigation bar和底部的footer。因为类似于Vue这种框架都是在运行时才会加载出来头部与底部,而这部分代码实际上完全可以作为一个独立的公用子项目去开发,没必要在多页应用的每个页面都引用一次组件再让框架去解析组件。另外在多页应用页面之间跳转时,如果编写一个头部组件在每个页面组件内部去引用的话,很容易因为需要加载解析JS的时间比较长从而出现导航栏闪烁的问题。

如果要解决上边提到的问题的话,可以采用的一个方案就是使用静态页面片,我们可以将头部和底部的页面片在webpack打包的时候将其注入到要打包完成的html页面中,这样的话不但可以节省一些框架解析组件的JS消耗,而且还可以有更好的SEO表现。虽然只是一个头部与底部并未承载多少信息,但是如果是在SSR场景下大量的重复CPU任务,提升一点对于整体来说还是有一个比较大的提高的,就像图形学中画线的算法一样,架不住运算次数太多。此外这样可以比较好的解决组件头部闪烁的问题,因为其是随着HTML一并返回的,所以能立即渲染在页面上不需要JS的加载解析,同样对于骨架屏而言也是可以采用webpack注入页面片的这种方案加载,文中涉及到的所有代码都在https://github.com/WindrunnerMax/webpack-simple-environment

实现

搭建环境

初探webpack,那么便从搭建简单的webpack环境开始,首先是初始化并安装依赖。

$ yarn init -y
$ yarn add -D webpack webpack-cli cross-env

首先可以尝试一下webpack打包程序,webpack可以零配置进行打包,目录结构如下:

webpack-simple
├── package.json
├── src
│ ├── index.js
│ └── sum.js
└── yarn.lock
// src/sum.js
export const add = (a, b) => a + b;
// src/index.js
import { add } from "./sum";
console.log(add(1, 1));

之后写入一个打包的命令。

// package.json
{
// ...
"scripts": {
"build": "webpack"
},
// ...
}

执行npm run build,默认会调用node_modules/.bin下的webpack命令,内部会调用webpack-cli解析用户参数进行打包,默认会以src/index.js作为入口文件。

$ npm run build

执行完成后,会出现警告,这里还提示我们默认modeproduction,此时可以看到出现了dist文件夹,此目录为最终打包出的结果,并且内部存在一个main.js,其中webpack会进行一些语法分析与优化,可以看到打包完成的结构是。

// src/main.js
(()=>{"use strict";console.log(2)})();

配置webpack

当然我们打包时一般不会采用零配置,此时我们就首先新建一个文件webpack.config.js。既然webpack说默认modeproduction,那就先进行一下配置解决这个问题,因为只是一个简单的webpack环境我们就不区分webpack.dev.jswebpack.prod.js进行配置了,简单的使用process.env.NODE_ENVwebpack.config.js中区分一下即可。在这里我们主要关心dist打包过后的文件,在这里就不进行dev环境的处理以及webpack-dev-server的搭建了,cross-env是用以配置环境变量的插件。

// package.json
{
// ...
"scripts": {
"build": "cross-env NODE_ENV=production webpack --config webpack.config.js"
},
// ...
}
const path = require("path");
module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path:path.resolve(__dirname, "dist")
}
}

不过按照上边的需求来说,我们不光是需要处理js文件的,还需要处理html文件,这里就需要使用html-webpack-plugin插件。

$ yarn add -D html-webpack-plugin

之后在webpack.config.js中进行配置,简单配置一下相关的输入输出和压缩信息,另外如果要是想每次打包删除dist文件夹的话可以考虑使用clean-webpack-plugin插件。

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path:path.resolve(__dirname, "dist")
},
plugins:[
new HtmlWebpackPlugin({
title: "Webpack Template",
filename: "index.html", // 打包出来的文件名 根路径是`module.exports.output.path`
template: path.resolve("./public/index.html"),
hash: true, // 在引用资源的后面增加`hash`戳
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
minifyCSS: true,
minifyJS: true,
},
inject: "body", // `head`、`body`、`true`、`false`
scriptLoading: "blocking" // `blocking`、`defer`
})
]
}

编写插件

之后到了正文环节,此时我们要编写一个插件去处理上边提到的需求,具体实现来看,我们需要的是首先在html中留下一个类似于<!-- inject:name="head" -->的标记注释,之后在webpack打包时对于html文件进行一次正则匹配,将注释相关的信息替换成页面片,通过name进行区分到底要加载哪一个页面片。另外个人感觉实际上编写webpack插件的时候还是首先参考其他人编写的webpack插件的实现,自己去翻阅文档成本查阅各种hook的成本有点高。

对于这个插件我们直接在根目录建立一个static-page-slice.js,插件由一个构造函数实例化出来,构造函数定义apply方法,在webpack处理插件的时候,apply方法会被webpack compiler调用一次。apply方法可以接收一个webpack compiler对象的引用,从而可以在回调函数中访问到compiler对象。一个最基础的Plugin的结构是类似于这样的:

class BasicPlugin{
// 在构造函数中获取用户给该插件传入的配置
constructor(options){
this.options = options || {};
} // `Webpack`会调用`BasicPlugin`实例的`apply`方法给插件实例传入`compiler`对象
apply(compiler){
compiler.hooks.someHook.tap("BasicPlugin", (params) => {
/* ... */
});
}
} // 导出 Plugin
module.exports = BasicPlugin;

在开发plugin时最常用的两个对象就是compilercompilation,它们是pluginwebpack之间的桥梁,compilercompilation的含义如下:

  • compiler对象包含了webpack环境所有的的配置信息,包含optionsloadersplugins这些信息,这个对象在webpack启动时候被实例化,它是全局唯一的,可以简单地把它理解为webpack实例。
  • compilation对象包含了当前的模块资源、编译生成资源、变化的文件等,当webpack以开发模式运行时,每当检测到一个文件变化,一次新的compilation将被创建,compilation对象也提供了很多事件回调供插件做扩展,通过compilation也能读取到compiler对象。

compilercompilation的区别在于: compiler代表了整个webpack从启动到关闭的生命周期,而compilation只是代表了一次新的编译,与之相关的信息可以参考https://webpack.docschina.org/api/compiler-hooks/

webpack就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果,这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理,插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理,webpack通过tapable来组织这条复杂的生产线https://github.com/webpack/tapable

在这里我们选择在compiler钩子的emit时期处理资源文件,即是在输出assetoutput目录之前执行,在此时要注意emit是一个AsyncSeriesHook也就是异步的hook,所以我们需要使用TapabletapAsync或者tapPromise,如果选取的是同步的hook,则可以使用tap

class StaticPageSlice {
constructor(options) {
this.options = options || {};
}
apply(compiler) {
compiler.hooks.emit.tapPromise("StaticPageSlice", compilation => {
return new Promise(resolve => {
console.log("StaticPageSlice is being called")
resolve();
})
});
}
} module.exports = StaticPageSlice;

接下来我们正式开始处理逻辑,首先此处我们需要先判断这个文件的类型,我们只需要处理html文件,所以我们需要先一下是否为html文件,之后就是一个正则匹配的过程,匹配到注释信息以后,将其替换为页面片,这里的页面片我们就直接在此处使用Promise模拟一下异步过程就好,之后便可以在webpack中引用并成功打包了。

// static-page-slice.js
const simulateRemoteData = key => {
const data = {
header: "<div>HEADER</div>",
footer: "<div>FOOTER</div>",
}
return Promise.resolve(data[key]);
} class StaticPageSlice {
constructor(options) {
this.options = options || {}; // 传递参数
}
apply(compiler) {
compiler.hooks.emit.tapPromise("StaticPageSlice", compilation => {
return new Promise(resolve => {
const cache = {};
const assetKeys = Object.keys(compilation.assets);
for (const key of assetKeys) {
const isLastAsset = key === assetKeys[assetKeys.length - 1];
if (!/.*\.html$/.test(key)) {
if (isLastAsset) resolve();
continue;
}
let target = compilation.assets[key].source();
const matchedValues = target.matchAll(/<!-- inject:name="(\S*?)" -->/g); // `matchAll`函数需要`Node v12.0.0`以上
const tags = [];
for (const item of matchedValues) {
const [tag, name] = item;
tags.push({
tag,
name,
data: cache[name] ? cache[name] : simulateRemoteData(name),
});
}
Promise.all(tags.map(item => item.data))
.then(res => {
res.forEach((data, index) => {
const tag = tags[index].tag;
const name = tags[index].name;
if (!cache[name]) cache[name] = data;
target = target.replace(tag, data);
});
})
.then(() => {
compilation.assets[key] = {
source() {
return target;
},
size() {
return this.source().length;
},
};
})
.then(() => {
if (isLastAsset) resolve();
});
}
});
});
}
} module.exports = StaticPageSlice;
// webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const StaticPageSlice = require("./static-page-slice"); module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path:path.resolve(__dirname, "dist")
},
plugins:[
new HtmlWebpackPlugin({
title: "Webpack Template",
filename: "index.html", // 打包出来的文件名 根路径是`module.exports.output.path`
template: path.resolve("./public/index.html"),
hash: true, // 在引用资源的后面增加`hash`戳
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
minifyCSS: true,
minifyJS: true,
},
inject: "body", // `head`、`body`、`true`、`false`
scriptLoading: "blocking" // `blocking`、`defer`
}),
new StaticPageSlice({
url: "https://www.example.com/"
})
]
}

之后便可以看到打包前后的html文件的差别了。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<!-- inject:name="header" -->
<div id="app"></div>
<!-- inject:name="footer" -->
<!-- built files will be auto injected -->
</body>
</html>
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><title>Webpack Template</title></head><body><div>HEADER</div><div id=app></div><div>FOOTER</div><!-- built files will be auto injected --><script src=index.js?7e2c7994f2e0891ec351></script></body></html>

webpack5对于hooks有一次更新,使用上边的插件会提示:

(node:5760) [DEP_WEBPACK_COMPILATION_ASSETS] DeprecationWarning: Compilation.assets will be frozen in future, all modifications are deprecated.
BREAKING CHANGE: No more changes should happen to Compilation.assets after sealing the Compilation.
Do changes to assets earlier, e. g. in Compilation.hooks.processAssets.
Make sure to select an appropriate stage from Compilation.PROCESS_ASSETS_STAGE_*.

所以我们可以根据其提示提前将资源进行处理,可以实现同样的效果。

// static-page-slice.js
const simulateRemoteData = key => {
const data = {
header: "<div>HEADER</div>",
footer: "<div>FOOTER</div>",
};
return Promise.resolve(data[key]);
}; class StaticPageSlice {
constructor(options) {
this.options = options || {}; // 传递参数
}
apply(compiler) {
compiler.hooks.thisCompilation.tap("StaticPageSlice", compilation => {
compilation.hooks.processAssets.tapPromise(
{
name: "StaticPageSlice",
stage: compilation.constructor.PROCESS_ASSETS_STAGE_ADDITIONS,
additionalAssets: true,
},
assets => this.replaceAssets(assets, compilation)
);
});
} replaceAssets(assets, compilation) {
return new Promise(resolve => {
const cache = {};
const assetKeys = Object.keys(assets);
for (const key of assetKeys) {
const isLastAsset = key === assetKeys[assetKeys.length - 1];
if (!/.*\.html$/.test(key)) {
if (isLastAsset) resolve();
continue;
}
let target = assets[key].source();
const matchedValues = target.matchAll(/<!-- inject:name="(\S*?)" -->/g); // `matchAll`函数需要`Node v12.0.0`以上
const tags = [];
for (const item of matchedValues) {
const [tag, name] = item;
tags.push({
tag,
name,
data: cache[name] ? cache[name] : simulateRemoteData(name),
});
}
Promise.all(tags.map(item => item.data))
.then(res => {
res.forEach((data, index) => {
const tag = tags[index].tag;
const name = tags[index].name;
if (!cache[name]) cache[name] = data;
target = target.replace(tag, data);
});
})
.then(() => {
compilation.assets[key] = {
source() {
return target;
},
size() {
return this.source().length;
},
};
})
.then(() => {
if (isLastAsset) resolve();
});
}
});
}
} module.exports = StaticPageSlice;

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://webpack.docschina.org/concepts/
https://juejin.cn/post/6854573216108085261
https://webpack.docschina.org/api/plugins/
https://juejin.cn/post/6844903942736838670
https://segmentfault.com/a/1190000012840742
https://segmentfault.com/a/1190000021821557
https://webpack.docschina.org/api/compilation-hooks/
https://webpack.docschina.org/api/normalmodulefactory-hooks/

初探webpack之编写plugin的更多相关文章

  1. 初探webpack之编写loader

    初探webpack之编写loader loader加载器是webpack的核心之一,其用于将不同类型的文件转换为webpack可识别的模块,即用于把模块原内容按照需求转换成新内容,用以加载非js模块, ...

  2. 初探webpack之从零搭建Vue开发环境

    初探webpack之搭建Vue开发环境 平时我们可以用vue-cli很方便地搭建Vue的开发环境,vue-cli确实是个好东西,让我们不需要关心webpack等一些繁杂的配置,然后直接开始写业务代码, ...

  3. Webpack学习-Plugin

    原文地址:http://wushaobin.top/2019/03/15/webpackPlugin/ 什么是Plugin? 在Webpack学习-工作原理(上)一文中我们就已经介绍了Plugin的基 ...

  4. webpack入门(四)——webpack loader 和plugin

    什么是loader loaders是你用在app源码上的转换元件.他们是用node.js运行的,把源文件作为参数,返回新的资源的函数. 例如,你可以用loaders告诉webpack加载 coffee ...

  5. 我的NopCommerce之旅(9): 编写Plugin实例

    一.基础介绍 ——In computing, a plug-in (or plugin) is a set of software components that add specific abili ...

  6. webpack练手项目之easySlide(一):初探webpack (转)

    最近在学习webpack,正好拿了之前做的一个小组件,图片轮播来做了下练手,让我们一起来初步感受下webpack的神奇魅力.     webpack是一个前端的打包管理工具,大家可以前往:http:/ ...

  7. 初探webpack之环境配置

    先感叹一句,前端的发展真是太快了,ng和bb还没怎么学好就要过时了.现在感觉react当是未来的一个大方向. 以前一直用的grunt,不过前段时间作者已经停止更新了.正好webpack风头正盛,咱也不 ...

  8. Webpack4 学习笔记一初探Webpack

    前言 此内容是个人学习笔记,以便日后翻阅.非教程,如有错误还请指出 Webpack 打包文件 支持JS模块化 模式: production(0配置默认), development(生产环境) 更详细的 ...

  9. webpack练手项目之easySlide(一):初探webpack

    最近在学习webpack,正好拿了之前做的一个小组件,图片轮播来做了下练手,让我们一起来初步感受下webpack的神奇魅力.     webpack是一个前端的打包管理工具,大家可以前往:http:/ ...

随机推荐

  1. linux修改源镜像地址

    1.1 CentOS修改yum源镜像地址为:mirrors.163.com (也可以改为阿里云镜像) 1.首先备份系统自带yum源配置文件/etc/yum.repos.d/CentOS-Base.re ...

  2. 线程间协作的两种方式:wait、notify、notifyAll和Condition

    转载自海子: 在前面我们将了很多关于同步的问题,然而在现实中,需要线程之间的协作.比如说最经典的生产者-消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者 ...

  3. 接口测试进阶接口脚本使用--apipost(预/后执行脚本)

    预执行脚本的作用时间 预执行脚本是一个请求发送前执行的脚本. 预执行脚本的作用 预执行脚本可以完成以下作用: 编写JS函数等实现复杂计算: 变量的打印 定义.获取.删除.清空环境变量 定义.获取.删除 ...

  4. 多源最短路径,一文搞懂Floyd算法

    前言 在图论中,在寻路最短路径中除了Dijkstra算法以外,还有Floyd算法也是非常经典,然而两种算法还是有区别的,Floyd主要计算多源最短路径. 在单源正权值最短路径,我们会用Dijkstra ...

  5. 第一次实战:XX漫画的XSS盲打

    第一次实战:XX漫画的XSS盲打 XSS盲打 盲打是一种惯称的说法,就是不知道有没有XSS漏洞存在的情况下,不顾一切的输入XSS代码在留言啊投诉窗口啊之类的地方,尽可能多的尝试XSS的语句,就叫盲打. ...

  6. golang error错误处理

    error定义 数据结构 go语言error是一普通的值,实现方式为简单一个接口. // The error built-in interface type is the conventional i ...

  7. 查看局域网内所有的主机名、MAC地址和IP地址

    查看所有 IP at MAC $ arp -a ? (10.125.49.187) at 18:81:e:eb:ef:c0 on en0 ifscope [ethernet] ? (10.125.50 ...

  8. Git 系列教程(6)- 查看 commit 提交历史

    查看提交历史 在提交了若干更新,又或者克隆了某个项目之后,如何查看提交历史 git log 官方栗子 运行下面的命令获取该项目: git clone https://github.com/scha 运 ...

  9. Intel® QAT 加速卡之数据面流程(图)

    QAT数据面流程 sessionSetupData数据结构 pOpData数据结构

  10. 法术迸发(Spellburst)

    描述 法术迸发 (EN:Spellburst ) 是一种在<通灵学园>中加入的关键字异能,在玩家打出一张法术牌后触发,只能触发一次. 若随从在法术结算过程中死亡,则不会触发效果 思路 首先 ...