我们简单写几个webpack plugin,简单感受一下plugin的基本构成。
CleanWebpackPlugin
这个插件的功能很简单:就是每次打包的时候,自动清除上一次打包的内容。主要需要考虑的问题有两点:
- 清除时机:如果打包失败,我们一般是希望保留上一次打包的结果。所以我们清除操作触发的时机是:emit,也就是即将输出资源之前。
- 删除文件夹:在 node 中,没有提供一个 API 能直接删除一个文件夹下的所有内容,不像 Linux 的
rm -rf
。同时如果文件夹下有内容,文件夹删除操作会失败。所以需要我们自己去实现一段递归删除的逻辑(不过也可以使用webpack
compiler.outputFileSystem
提供的rmdirSync
方法直接删除目录)。
结合以上两点,我们的插件编写如下:
js
const path = require('path')
class CleanWebpackPlugin {
apply(compiler) {
const fs = compiler.outputFileSystem
compiler.hooks.emit.tap('CleanWebpackPlugin', (compilation) => {
// 删除输出目录
const outputPath = compiler.options.output.path
// fs.rmdirSync(outputPath, { recursive: true })
this.removeFolder(outputPath, fs)
})
}
removeFolder(folderPath, fs) {
if (!fs.existsSync(folderPath)) {
return
}
fs.readdirSync(folderPath).forEach((file) => {
const curPath = path.join(folderPath, file)
if (fs.lstatSync(curPath).isDirectory()) {
// 如果是文件夹
this.removeFolder(curPath, fs) // 递归删除文件夹
} else {
// 如果是文件
fs.unlinkSync(curPath) // 删除文件
}
})
fs.rmdirSync(folderPath) // 删除文件夹
}
}
module.exports = CleanWebpackPlugin
值得一提的是:compiler.outputFileSystem
是在原生的 fs
上进行了一定的封装,所以我们一般使用 compiler.outputFileSystem
代替 fs
进行文件操作。
HTMLWebpackPlugin
这个插件的功能就是:根据我们配置的模版 HTML 文件去生成一个自动引入 JS 文件的 HTML 文件。
我们可以简单想到,要完成这个功能的思路一般来说应该是这样的:首先我们需要解析我们的模版 HTML 成 AST,然后分析我们的打包资源,拿到那些需要输出的 js 文件信息,然后结合 output 配置拼接出一个完整的 js 资源路径,最后根据这个路径去生成 script 标签,插入到之前的 AST 中。
其实我们有一种取巧的方式,我们知道 script 标签是添加到 head 或者 body 中,那么我们可以匹配 </head>
或者 </body>
这两个结束标签,然后把我们需要添加的 script 拼上这个结束标签,再进行 replace。
具体代码如下:
js
// 取巧的方式
const path = require('path')
const defaultHTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Webpack App</title>
</head>
<body></body>
</html>
`
const defaultOptions = {
title: 'Webpack App',
}
class HTMLWebpackPlugin {
constructor(options) {
this.options = { ...defaultOptions, ...options }
}
apply(compiler) {
this.fs = compiler.outputFileSystem
const outputPath = compiler.options.output.path || './dist'
const entryPath = compiler.options.entry.main.import[0]
const { filename, template } = this.initOptions(outputPath, entryPath)
compiler.hooks.emit.tap('HtmlWebpackPlugin', (compilation) => {
// 获取所有资源文件名
const allAssets = Object.keys(compilation.assets)
// 过滤出JavaScript资源
const jsAssets = allAssets.filter((asset) => asset.endsWith('.js'))
const jsScript = jsAssets
.map(
(asset) => `<script src="${path.join(outputPath, asset)}"></script>`
)
.join('\n')
let htmlContent = template
if (htmlContent.includes('</head>')) {
htmlContent = htmlContent.replace('</head>', jsScript + '\n</head>')
} else if (htmlContent.includes('</body>')) {
htmlContent = htmlContent.replace('</body>', jsScript + '\n</body>')
}
const filenameDir = path.dirname(filename)
if (!this.fs.existsSync(filenameDir)) {
this.fs.mkdirSync(outputPath, { recursive: true })
}
this.fs.writeFileSync(filename, htmlContent)
})
}
initOptions(outputPath, entryPath) {
let { filename, template } = this.options
if (!filename) {
filename = path.join(outputPath, 'index.html')
}
if (template) {
const entryDir = path.dirname(entryPath)
template = path.join(entryDir, template)
template = this.fs.readFileSync(template, 'utf-8')
} else {
template = defaultHTML
}
return { filename, template }
}
}
module.exports = HTMLWebpackPlugin
对于 AST 的方式,HTML 文档转 AST 的结果其实就是我们熟知的 DOM 对象 。所以我们只需要找一款能在 node 环境下将 HTML 文件解析成 DOM 树的工具即可。这样的工具有很多,常用于爬虫,比如 cheerio
、Puppeteer
、jsdom
、htmlparser2
等等。我这里选择 jsdom
,因为我之前写爬虫的时候经常用这个工具,其实都大同小异,只是具体 api 有点小差异而已。
js
// 转AST方式
compiler.hooks.emit.tap('HtmlWebpackPlugin', (compilation) => {
// 获取所有资源文件名
const allAssets = Object.keys(compilation.assets)
// 过滤出JavaScript资源
const jsAssets = allAssets.filter((asset) => asset.endsWith('.js'))
const jsScript = jsAssets
.map((asset) => `<script src="${path.join(outputPath, asset)}"></script>`)
.join('\n')
// ---------------------修改部分-----------------------
let htmlContent = template
// if (htmlContent.includes('</head>')) {
// htmlContent = htmlContent.replace('</head>', jsScript + '\n</head>')
// } else if (htmlContent.includes('</body>')) {
// htmlContent = htmlContent.replace('</body>', jsScript + '\n</body>')
// }
const htmlDom = new JSDOM(template)
const headDom = htmlDom.window.document.querySelector('head')
const bodyDom = htmlDom.window.document.querySelector('body')
if (headDom) {
headDom.innerHTML += jsScript
} else if (bodyDom) {
bodyDom.innerHTML += jsScript
}
htmlContent = htmlDom.serialize()
// ---------------------修改部分-----------------------
const filenameDir = path.dirname(filename)
if (!fs.existsSync(filenameDir)) {
fs.mkdirSync(outputPath, { recursive: true })
}
fs.writeFileSync(filename, htmlContent)
})
HtmlWebpackInlineSourcePlugin
这个插件的作用就是将打包的资源 以内联的方式插入到 html 文件中。我们这里简单实现两种比较简单的 js、css 资源转化成内联吧,同时我们再追加一个小功能,就是提供资源体积大小配置,当小于这个体积才去转化成内联的方式。
内联加载与独立标签加载的权衡
我们知道:HTML 文件本身不会被浏览器缓存,但 HTML 页面中引用的资源(例如样式表、JavaScript 文件、图片等)可以被浏览器缓存。所以单独加载具有缓存的优势。
其次我们知道浏览器具有支持多个网络请求同时执行的能力,所以独立加载也具有充分利用浏览器资源的优势。但是这里面也有一个权衡,就是网络请求的创建、销毁的消耗与网络传输消耗的权衡 。比如:如果将资源拆分成 7 份,而我们浏览器支持的网络请求最大并行数量是 6(Google Chrome 和 Chromium),那么如果使用拆分的方案就需要花费两个创建销毁时间,比内联方案多一个创建销毁时间,这时候有可能会增加首次加载白屏时长。
对于上面的权衡问题,我们有一种一般经验方案:如果资源体积比较小,就内联,如果资源体积比较大,就拆分。
具体实现
由于我们需要将资源插入到 html 文件中,所以很显然我们需要之前的 HTMLWebpackPlugin
插件的配合。根据官方的 HTMLWebpackPlugin
插件文档可以发现,其实这个插件暴露了一些 hooks 给用户,让用户去实现一下自定义操作:
我们的思路是这样的,我们先使用原生的 html-webpack-plugin
库,一方面方便我们熟悉 API 的使用,另一方面也方便我们测试。
我们就使用 alterAssetTagGroups 这个 hook 吧,这个 hooks 可以拿到 head 和 body 标签中的 tag-asset map,我们通过修改这个 map,实现单独加载(默认情况)到内联的转换。
js
const HTMLWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
const defaultOptions = {
inlineSource: '.(js|css)$',
maxSize: 10 * 1024, // 10KB
}
class HtmlWebpackInlineSourcePlugin {
constructor(options) {
this.options = { ...options, ...defaultOptions }
this.initOptions()
}
apply(compiler) {
compiler.hooks.compilation.tap(
'HtmlWebpackInlineSourcePlugin',
(compilation) => {
const hooks = HTMLWebpackPlugin.getHooks(compilation)
hooks.alterAssetTagGroups.tap(
'HtmlWebpackInlineSourcePlugin',
(assets) => {
const { headTags, bodyTags } = assets
assets.headTags = this.getInlinkChunk(headTags, compilation.assets)
assets.bodyTags = this.getInlinkChunk(bodyTags, compilation.assets)
}
)
}
)
}
getInlinkChunk(tags = [], assets) {
// tag:
// {
// tagName: 'div',
// voidTag: false,
// attributes: { },
// closeTag: true,
// innerHTML: '',
// }
const { inlineSource, maxSize } = this.options
return tags.map((tag) => {
if (tag.tagName !== 'script' && tag.tagName !== 'link') {
return tag
}
const src = tag.attributes.src
const href = tag.attributes.href
if (!src && !href) {
return tag
}
const filename = src || href
const asset = assets[filename]
if (!inlineSource.test(filename) || !asset.size() > maxSize) {
return tag
}
delete assets[filename]
return { ...tag, innerHTML: asset.source() }
})
}
initOptions() {
const { inlineSource, maxSize } = this.options
this.options.inlineSource = new RegExp(inlineSource)
}
}
module.exports = HtmlWebpackInlineSourcePlugin
增强 HTMLWebpackPlugin
与第三方包测试没有问题之后,我们再来增强我们自己的 HTMLWebpackPlugin
插件,然后替代第三方包。
js
class HTMLWebpackPlugin {
apply(compiler) {
compiler.hooks.emit.tap('HtmlWebpackPlugin', (compilation) => {
// 其他代码
htmlContent = htmlDom.serialize()
// 触发alterAssetTagGroups钩子
if (HTMLWebpackPlugin._hooks.alterAssetTagGroups) {
const dom = new JSDOM(htmlContent)
const headDom = dom.window.document.querySelector('head')
const bodyDom = dom.window.document.querySelector('body')
const head = [...headDom.children].map((child) =>
this.packJsDomChild(child)
)
const body = [...bodyDom.children].map((child) =>
this.packJsDomChild(child)
)
const assets = {
headTags: head,
bodyTags: body,
}
HTMLWebpackPlugin._hooks.alterAssetTagGroups.call(assets)
headDom.innerHTML = assets.headTags
.map((dom) => this.tidyInnerHTML(dom))
.join('\n')
bodyDom.innerHTML = assets.bodyTags
.map((dom) => this.tidyInnerHTML(dom))
.join('\n')
htmlContent = dom.serialize()
}
const filenameDir = path.dirname(filename)
if (!fs.existsSync(filenameDir)) {
fs.mkdirSync(outputPath, { recursive: true })
}
fs.writeFileSync(filename, htmlContent)
})
}
packJsDomChild(child) {
const item = {
tagName: child.tagName.toLocaleLowerCase(),
attributes: {},
_obj: child,
}
if (child.attributes?.src?.value) {
item.attributes.src = child.attributes.src.value
}
if (child.attributes?.href?.value) {
item.attributes.href = child.attributes.href.value
}
return item
}
tidyInnerHTML(dom) {
if (!dom.innerHTML) {
return dom._obj.outerHTML
}
dom._obj.innerHTML = dom.innerHTML
dom._obj.removeAttribute('src')
dom._obj.removeAttribute('href')
return dom._obj.outerHTML
}
}
HTMLWebpackPlugin.getHooks = (compilation) => {
class AlterAssetTagGroups extends SyncHook {}
const hooks = {
beforeAssetTagGeneration: new SyncHook(),
alterAssetTags: new SyncHook(),
alterAssetTagGroups: new AlterAssetTagGroups(['assets']),
afterTemplateExecution: new SyncHook(),
beforeEmit: new SyncHook(),
afterEmit: new SyncHook(),
}
// 我们简单处理,将其挂在class上
HTMLWebpackPlugin._hooks = hooks
HTMLWebpackPlugin._compilation = compilation
return hooks
}
module.exports = HTMLWebpackPlugin
总的思路就是通过 getHooks 收集 hooks,等到输出 html 资源的时候,检查一下有没有 hooks,有的话就执行,根据执行结果再组装出新的 html 资源。
但是 jsdom 有一个很坑的地方,是他生成的对象属性可读,但是不可枚举 的。最重要的是:对于一些普通的值的读取,它比普通 dom 对象多套了一层 value,比如 dom.attributes.src.value
,为此我特意抽了一个方法(packJsDomChild)来处理 jsdom 与真实 dom API 不完全兼容问题。