简单写几个 webpack 插件吧

我们简单写几个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 树的工具即可。这样的工具有很多,常用于爬虫,比如 cheerioPuppeteerjsdomhtmlparser2 等等。我这里选择 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 不完全兼容问题。

相关推荐
吕彬-前端38 分钟前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱40 分钟前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai1 小时前
uniapp
前端·javascript·vue.js·uni-app
bysking2 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓2 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_4112 小时前
无网络安装ionic和运行
前端·npm
理想不理想v2 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
酷酷的阿云2 小时前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
微信:137971205872 小时前
web端手机录音
前端
齐 飞2 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb