Webpack 4 插件:添加模块并从加载器获取结果

Webpack 4 Plugin: Add module and get result from loader

我制作一个 Webpack 4 插件是为了好玩,并试图了解它的内部结构。思路很简单:

  1. 将HTML模板文件解析成树;
  2. <img src="..."><link href="..."> 获取资产路径;
  3. 将资产添加到依赖项以通过 file-loader;
  4. 加载它们
  5. 获取从 file-loader 发出的路径(可能包含哈希)并修复树中的节点;
  6. 将最终的 HTML 字符串发送到文件中。

到目前为止,我卡在了第 4 步。多亏了 parse5,解析模板和提取资产路径很容易,为了加载资产,我使用了 PrefetchPlugin 但现在我不知道如何从 file-loader.[=20= 获得结果]

我需要加载结果,因为它会生成哈希并可能更改资产的位置:

{
  exclude: /\.(css|jsx?|mjs)$/,
  use: [{
    loader: 'file-loader',
      options: {
        name: '[name].[ext]?[sha512:hash:base64:8]`',
    },
  }],
}

不仅如此,我还想稍后使用 url-loader,它可能会生成编码的资产。我正在尝试从 tapAfterCompile.

处的加载器获取结果

插件当前源码如下:

import debug from 'debug'
import prettyFormat from 'pretty-format'
import validateOptions from 'schema-utils'
import {dirname, resolve} from 'path'
import {html as beautifyHtml} from 'js-beautify'
import {minify as minifyHtml} from 'html-minifier'
import {parse, serialize} from 'parse5'
import {PrefetchPlugin} from 'webpack'
import {readFileSync} from 'fs'

let log = debug('bb:config:webpack:plugin:html')

const PLUGIN_NAME = 'HTML Plugin'

/**
 * This schema is used to validate the plugin’s options, right now, all it does
 * is requiring the template property.
 */
const OPTIONS_SCHEMA = {
  additionalProperties: false,
  type: 'object',
  properties: {
    minify: {
      type: 'boolean',
    },
    template: {
      type: 'string',
    },
  },
  required: ['template'],
}

/**
 * Extract an attribute’s value from the node; Returns undefined if the
 * attribute is not found.
 */
function getAttributeValue(node, attributeName) {
  for (let attribute of node.attrs) {
    if (attribute.name === attributeName)
      return attribute.value
  }
  return undefined
}

/**
 * Update a node’s attribute value.
 */
function setAttributeValue(node, attributeName, value) {
  for (let attribute of node.attrs) {
    if (attribute.name === attributeName)
      attribute.value = value
  }
}

/**
 * Recursively walks the parsed tree. It should work in 99.9% of the cases but
 * it needs to be replaced with a non recursive version.
 */
function * walk(node) {
  yield node

  if (!node.childNodes)
    return

  for (let child of node.childNodes)
    yield * walk(child)
}

/**
 * Actual Webpack plugin that generates an HTML from a template, add the script
 * bundles and and loads any local assets referenced in the code.
 */
export default class SpaHtml {
  /**
   * Options passed to the plugin.
   */
  options = null

  /**
   * Parsed tree of the template.
   */
  tree = null

  constructor(options) {
    this.options = options
    validateOptions(OPTIONS_SCHEMA, this.options, PLUGIN_NAME)
  }

  /**
   * Webpack will call this method to allow the plugin to hook to the
   * compiler’s events.
   */
  apply(compiler) {
    let {hooks} = compiler
    hooks.afterCompile.tapAsync(PLUGIN_NAME, this.tapAfterCompile.bind(this))
    hooks.beforeRun.tapAsync(PLUGIN_NAME, this.tapBeforeRun.bind(this))
  }

  /**
   * Return the extracted the asset paths from the tree.
   */
  * extractAssetPaths() {
    log('Extracting asset paths...')

    const URL = /^(https?:)?\/\//
    const TEMPLATE_DIR = dirname(this.options.template)

    for (let node of walk(this.tree)) {
      let {tagName} = node
      if (!tagName)
        continue

      let assetPath
      switch (tagName) {
        case 'link':
          assetPath = getAttributeValue(node, 'href')
          break
        case 'img':
          assetPath = getAttributeValue(node, 'src')
          break
      }

      // Ignore empty paths and URLs.
      if (!assetPath || URL.test(assetPath))
        continue

      const RESULT = {
        context: TEMPLATE_DIR,
        path: assetPath,
      }

      log(`Asset found: ${prettyFormat(RESULT)}`)
      yield RESULT
    }

    log('Done extracting assets.')
  }

  /**
   * Returns the current tree as a beautified or minified HTML string.
   */
  getHtmlString() {
    let serialized = serialize(this.tree)

    // We pass the serialized HTML through the minifier to remove any
    // unnecessary whitespace that could affect the beautifier. When we are
    // actually trying to minify, comments will be removed too. Options can be
    // found in:
    //
    //     https://github.com/kangax/html-minifier
    //
    const MINIFIER_OPTIONS = {
      caseSensitive: false,
      collapseBooleanAttributes: true,
      collapseInlineTagWhitespace: true,
      collapseWhitespace: true,
      conservativeCollapse: false,
      decodeEntities: true,
      html5: true,
      includeAutoGeneratedTags: false,
      keepClosingSlash: false,
      preserveLineBreaks: false,
      preventAttributesEscaping: true,
      processConditionalComments: false,
      quoteCharacter: '"',
      removeAttributeQuotes: true,
      removeEmptyAttributes: true,
      removeEmptyElements: false,
      removeOptionalTags: true,
      removeRedundantAttributes: true,
      removeScriptTypeAttributes: true,
      removeStyleLinkTypeAttributes: true,
      sortAttributes: true,
      sortClassName: true,
      useShortDoctype: true,
    }

    let {minify} = this.options
    if (minify) {
      // Minify.
      serialized = minifyHtml(serialized, {
        minifyCSS: true,
        minifyJS: true,
        removeComments: true,
        ...MINIFIER_OPTIONS,
      })
    } else {
      // Beautify.
      serialized = minifyHtml(serialized, MINIFIER_OPTIONS)
      serialized = beautifyHtml(serialized, {
        indent_char: ' ',
        indent_inner_html: true,
        indent_size: 2,
        sep: '\n',
        unformatted: ['code', 'pre'],
      })
    }

    return serialized
  }

  /**
   * Load the template and parse it using Parse5.
   */
  parseTemplate() {
    log('Loading template...')
    const SOURCE = readFileSync(this.options.template, 'utf8')
    log('Parsing template...')
    this.tree = parse(SOURCE)
    log('Done loading and parsing template.')
  }

  async tapAfterCompile(compilation, done) {
    console.log()
    console.log()
    for (let asset of compilation.modules) {
      if (asset.rawRequest == 'assets/logo.svg')
        console.log(asset)
    }
    console.log()
    console.log()

    // Add the template to the dependencies to trigger a rebuild on change in
    // watch mode.
    compilation.fileDependencies.add(this.options.template)

    // Emit the final HTML.
    const FINAL_HTML = this.getHtmlString()
    compilation.assets['index.html'] = {
      source: () => FINAL_HTML,
      size: () => FINAL_HTML.length,
    }

    done()
  }

  async tapBeforeRun(compiler, done) {
    this.parseTemplate()

    // Add assets to the compilation.
    for (let {context, path} of this.extractAssetPaths()) {
      new PrefetchPlugin(context, path)
        .apply(compiler)
    }

    done()
  }
}

找到答案,加载依赖项后,我可以访问生成的模块的源代码:

// Index the modules generated in the child compiler by raw request.
let byRawRequest = new Map
for (let asset of compilation.modules)
  byRawRequest.set(asset.rawRequest, asset)

// Replace the template requests with the result from modules generated in
// the child compiler.
for (let {node, request} of this._getAssetRequests()) {
  if (!byRawRequest.has(request))
    continue

  const ASSET = byRawRequest.get(request)
  const SOURCE = ASSET.originalSource().source()
  const NEW_REQUEST = execAssetModule(SOURCE)
  setResourceRequest(node, NEW_REQUEST)

  log(`Changed: ${prettyFormat({from: request, to: NEW_REQUEST})}`)
}

并使用 VM 执行模块的源代码:

function execAssetModule(code, path) {
  let script = new Script(code)
  let exports = {}
  let sandbox = {
    __webpack_public_path__: '',
    module: {exports},
    exports,
  }
  script.runInNewContext(sandbox)
  return sandbox.module.exports
}