使用 SVG 图标: (2) 编写 Webpack plugin

1,744 阅读3分钟

上篇文章中,主要讨论了 gulp-svg-sprites 的使用以及 Icon 组件的编写。上文中我们是手动复制 SVG symbol 文件的内容粘贴到 index.html。这样操作起来十分不方便,所以在本篇文章中我们通过编写 webpack 插件去实现操作的自动化。

1. webpack 插件基础知识

编写一个 webpack 插件也许没想象中复杂!不信你看 react-dev-utils 中的 InterpolateHtmlPlugin。 这个插件代码量不超过 50 行!

如何编写 webpack 插件呢?官方文档给出了非常好的示例。简单一点来说,插件是一个 class。这个 class 有一个名为 apply 的方法。webpack 及其插件在其编译的过程中会触发很多的事件。apply 方法中,我们通过编写回调函数来对某一阶段的数据进行处理。在下文中,我们将以实例来说明。

2. 编写第一个 webpack 插件

编写一个插件,首先要考虑到两点问题:

  1. 插件实现的什么功能
  2. webpack 及其插件给在编译过程中会触发哪些事件,在事件的回调中可以访问到哪些信息

我们的第一个插件要实现的功能是把 sprite.symbol.svg 文件的内容插入到 index.html 中。在示例项目中,使用了 html-webpack-plugin来处理 HTML 文件。该插件提供了几个事件,其中有一个事件 html-webpack-plugin-before-html-processing。在 html-webpack-plugin-before-html-processing 事件回调函数中可以获取到 index.html 内容。通过 fs.readFile 读取 SVG 文件的内容,再把 SVG 文件内容写入到 index.html 中的内容中即可。

const fs = require('fs')
function SvgSymbolInline (options = {}) {
  this.options = {
    path: 'svg/symbol/svg/sprite.symbol.svg'
  }
}
SvgSymbolInline.prototype.apply = function (compiler) {
  const self = this
  compiler.plugin('compilation', function (compilation) {
    compilation.plugin('html-webpack-plugin-before-html-processing', function (htmlPluginData, callback) {
      self.insertSvg(htmlPluginData.html).then(function (html) {
        htmlPluginData.html = html
        callback(null, htmlPluginData)
      })
    })
  })
}

SvgSymbolInline.prototype.insertSvg = function (html) {
  const self = this
  return new Promise(function (resolve, reject) {
    fs.readFile(self.options.path, 'utf8', function (err, data) {
      if (err) throw err
      // 去除 symbol 文件头部的 xml 信息,设置元素隐藏
      data = data.replace(/<\?xml.*?>/, '').replace(/(<svg.*?)(?=>)/, '$1 style="display:none;" ')
      // 把 symbol 的内容插入 html 中
      html = html.replace(/(<body\s*>)/i, `$1${data}`)
      resolve(html)
    })
  })
}

在 webpack 的配置文件添加该插件,那么现在生成的 index.html 中的 body 部分包含了 SVG 文件的内容。但这样做存着一点点问题,SVG 图片不能被浏览器缓存。所以接下来编写第二个插件,尝试解决这个问题。

3. 编写第二个 webpack 插件

第二个插件功能是把 SVG 文件添加为 webpack 的资源,并且在 index.html 的 head 中通过 link 标签引入该 SVG 图片。

如何在 webpack 插件中,添加一个文件呢?webpack 的 complier 对象有一个 emit 事件,可以在该事件的回调中添加一个文件。

let symbolFileName = ''
function SvgSymbolLink () {
  this.options = {
    path: 'svg/symbol/svg/sprite.symbol.svg'
  }
}
SvgSymbolLink.prototype.apply = function (compiler) {
  const self = this
  compiler.plugin('emit', function (compilation, callback) {
    self.getSvgContent().then(function (content) {
      symbolFileName = `static/icon-symbol.svg`
      compilation.assets[symbolFileName] = {
        source: function () {
          return content
        },
        size: function () {
          return content.length
        }
      }
      callback()
    })
  })
}
SvgSymbolLink.prototype.getSvgContent = function () {
  const self = this
  return new Promise(function (resolve, reject) {
    fs.readFile(self.options.path, 'utf8', function (err, data) {
      if (err) throw err
      // 去除 symbol 文件头部的 xml 信息
      data = data.replace(/<\?xml.*?>/, '')
      resolve(data)
    })
  })
}

在 webpack 的配置中,引入插件。执行 npm run build,在项目的构建输出文件夹下就出现了一个名为 icon-symbol.svg 的文件。

效果如下

当开发环境时,我们还可以根据文件的内容生成一串 hash。在文件名后面添加这串 hash,如果文件内容有变动时,浏览器就会请求新生成的文件了。

const crypto = require('crypto')
SvgSymbolLink.prototype.hash = function (content) {
  return crypto.createHash('md5').update(content).digest('hex').substr(0, 20)
}
SvgSymbolLink.prototype.apply = function (compiler) {
  // ...
  compiler.plugin('emit', function (compilation, callback) {
    self.getSvgContent().then(function (content) {
      const hash =  process.env.NODE_ENV === 'production' ? self.hash(content) : ''
      symbolFileName = `static/icon-symbol.svg${hash ? `?h=${hash}` : ''}`
      // ...
    })
  })
}

现在进行第二步。通过设置 link 标签的 rel="preload" 可以让浏览器提前加载资源。按照编写第一个插件的思路,也是在 html-webpack-plugin 的事件回调中添加 link 标签。html-webpack-plugin 会向 index.html 中插入 link 与 script 标签,需要在插入标签之前,添加一个新的 link。html-webpack-plugin-alter-asset-tags 正是我们需要的。在该事件的回调函数中,htmlPluginData 的 head 部分包含了 link 标签数组。向 head 数组再添加一个新的标签即可。

SvgSymbolLink.prototype.apply = function (compiler) {
  const self = this
  // ...
  compiler.plugin('compilation', function (compilation) {
    compilation.plugin('html-webpack-plugin-alter-asset-tags', function (htmlPluginData, callback) {
      const head = htmlPluginData.head
      head.push({
        tagName: 'link',
        selfClosingTag: true,
        attributes: {
          href: '/' + symbolFileName,
          rel: 'preload',
          as: 'image',
          type: 'image/svg+xml',
          crossorigin: 'anonymous'
        }
      })
      callback(null, htmlPluginData)
    })
  })
}

最后,index.html 内容如下。

<head>
    <link href="./static/icon-symbol.svg" rel="preload" as="image" type="image/svg+xml" crossorigin="anonymous">
</head>

以这种方式引用文件,需要调整 Icon 组件。

<template>
  <svg :class="iconClassName" :style="{color: this.color}">
    <use :xlink:href="/static/icon-symbol.svg#' + type"></use>
  </svg>
</template>

完整的代码在这里

4. 总结

本文介绍了开发 webpack 插件的一些知识,并根据项目需求,编写了两个 webpack 插件。如果对项目中的某一个流程觉得不太满意,可以尝试通过编写 loader 或者 插件来解决问题。编写 webpack 插件没有想象中那么困难,只要你愿意去动手做。Don't repeat yourself

一些问题

使用 preload 的话,在控制台会出现类似于 The resource [xxxx] was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it wasn't preloaded for nothing 这样的警告信息。这条警告信息出现的原因是 preload 的资源未被使用,可是即便我是在页面中使用了这个 SVG 文件还是有这个警告,所以后来把 link 的 rel 属性设置为 prefetch。GitHub 与 Stack Overflow 上相关的讨论链接1链接2。如果你们知道答案,可以告诉我。🙃

参考资料