初探webpack之编写plugin
webpack
通过plugin
机制让其使用更加灵活,以适应各种应用场景,当然也大大增加了webpack
的复杂性,在webpack
运行的生命周期中会广播出许多事件,plugin
可以hook
这些事件,在合适的时机通过webpack
提供的API
改变其在处理过程中的输出结果。
描述
webpack
是一个现代JavaScript
应用程序的静态模块打包器module bundler
,当webpack
处理应用程序时,它会递归地构建一个依赖关系图dependency graph
,其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个bundle
。
使用webpack
作为前端构建工具通常可以做到以下几个方面的事情:
代码转换: TypeScript
编译成JavaScript
、SCSS
编译成CSS
等。
文件优化: 压缩JavaScript
、CSS
、HTML
代码,压缩合并图片等。
代码分割: 提取多个页面的公共代码、提取首屏不需要执行部分的代码让其异步加载。
模块合并: 在采用模块化的项目里会有很多个模块和文件,需要构建功能把模块分类合并成一个文件。
自动刷新: 监听本地源代码的变化,自动重新构建、刷新浏览器页面,通常叫做模块热替换HMR
。
代码校验: 在代码被提交到仓库前需要校验代码是否符合规范,以及单元测试是否通过。
自动发布: 更新完代码后,自动构建出线上发布代码并传输给发布系统。
在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
作为入口文件。
执行完成后,会出现警告,这里还提示我们默认mode
为production
,此时可以看到出现了dist
文件夹,此目录为最终打包出的结果,并且内部存在一个main.js
,其中webpack
会进行一些语法分析与优化,可以看到打包完成的结构是。
// src/main.js
( ( ) => { "use strict" ; console . log ( 2 ) } ) ( ) ;
配置webpack
当然我们打包时一般不会采用零配置,此时我们就首先新建一个文件webpack.config.js
。既然webpack
说默认mode
是production
,那就先进行一下配置解决这个问题,因为只是一个简单的webpack
环境我们就不区分webpack.dev.js
和webpack.prod.js
进行配置了,简单的使用process.env.NODE_ENV
在webpack.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
时最常用的两个对象就是compiler
和compilation
,它们是plugin
和webpack
之间的桥梁,compiler
和compilation
的含义如下:
compiler
对象包含了webpack
环境所有的的配置信息,包含options
、loaders
、plugins
这些信息,这个对象在webpack
启动时候被实例化,它是全局唯一的,可以简单地把它理解为webpack
实例。
compilation
对象包含了当前的模块资源、编译生成资源、变化的文件等,当webpack
以开发模式运行时,每当检测到一个文件变化,一次新的compilation
将被创建,compilation
对象也提供了很多事件回调供插件做扩展,通过compilation
也能读取到compiler
对象。
compiler
和compilation
的区别在于: compiler
代表了整个webpack
从启动到关闭的生命周期,而compilation
只是代表了一次新的编译,与之相关的信息可以参考https://webpack.docschina.org/api/compiler-hooks/
。
webpack
就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果,这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理,插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理,webpack
通过tapable
来组织这条复杂的生产线https://github.com/webpack/tapable
。
在这里我们选择在compiler
钩子的emit
时期处理资源文件,即是在输出asset
到output
目录之前执行,在此时要注意emit
是一个AsyncSeriesHook
也就是异步的hook
,所以我们需要使用Tapable
的tapAsync
或者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/