おふくろさまより愛をこめて

mmmpa ふとしです。誠実なプログラミングを心がけたい。

簡単な Webpack loader をいくつか書いて Webpack と仲良くなる

JavaScript2 Advent Calendar 2018 - Qiita 8 日目のエントリーです。

このエントリーは Webpack 4.26.1 を基準に書かれました。このエントリー用に書いたコードは mmmpa/create-webpack-loader-practice にあります。

Webpack の設定の複雑さには毎回目が回ります。ある目的があってその目的に合う設定を見つけられればコピペで済みますが、それに loader を加えたくなった途端に複雑さが襲ってくるような印象があります。

基本的に Webpack loader はあるファイルを加工して別の状態にするのが主な作用なので、ひとつひとつの働きは把握しやすいはずです。そこで簡単な loader をつくりつつ loader 何ができるのかを見ていきたいと思います。

基本的な入力/出力

基本的に Webpack は JavaScript を出力することを目的としています。loader が処理するファイルは特に設定がないかぎり utf-8 文字列で入力され、最終的には JavaScript に import できる形で出力されることが望まれています。

以下は対象のテキストファイルの文字列を反転して出力する loader です。

module.exports = function (content, map, meta) {
  let result = ''
  for (let i = content.length; i; result += content[--i]) {}
  return `module.exports = ${JSON.stringify(result)}`
}

module.exports = ... という JavaScript で処理できる形の文字列として出力されています。Webpack の処理により、以下のように JavaScript 内ではモジュールとして使用することができます。

# index.txt
abcde
import indexText from './index.txt'
console.log(indexText) //=> edcba

Webpack が loader に提供するメソッドは this 経由で使用できる

loader から Webpack が提供する機能を利用することができます。例えば content 以外の情報を返す loader の場合は this.callback (Loader API::callback) というメソッドを使用します。

this は Webpack の context でなくてはならないため、loader の定義でアロー関数を使うことはできないので注意が必要です。

module.exports = c => this.callback(null, c, {}, {}) //-> TypeError: this.callback is not a function

loader に与えられた options 見る

options として最大文字数 max と省略記号 ellipsis を受けとり、長い文字列を省略する loader を書いてみます。

loader には 2 通りの options の渡し方があります。

一つは config ファイルでオブジェクトとして渡す形式です。

use: {
  loader: './truncate-loader.js',
  options: { max: 10, ellipsis: '...' },
},

もう一つはクエリ文字列として渡す方式です。

use: './truncate-loader.js?max=10&ellipsis=...',

this.query で参照した場合、それぞれの生の値が loader に渡されます。前者は { max: 10 } として得られ、後者は ?max=10 として得られます。option の与えられ方によって値が変わるので、これを直接は使えません。

loaderUtils.getOptions を使う

これらを JavaScript から利用しやすくするように正規化する loaderUtils.getOptions (loaderUtils.getOptions) というメソッドが用意されています。

loaderUtils.getOptions を利用することで、常に同じアクセス方法で options を参照できるようになります。ただし、クエリ文字列として渡された値は型情報を持たないので 常に文字列 となることに注意しなければなりません (前記の例だと { max: '10' } として得られます)。

(厳密等価演算子 === などがないかぎり特に問題にならないところが JavaScript ですね)

const loaderUtils = require('loader-utils')

module.exports = function (content, map, meta) {
  const {
    max: raw = 20,
    ellipsis = ' (snip)',
  } = loaderUtils.getOptions(this)

  // クエリ文字列から渡される値は常に文字列になるので注意する
  // この例だとなくても動く
  const max = +raw

  const s = content.length <= max
    ? content
    : (content).slice(0, max - ellipsis.length) + ellipsis

  return `module.exports = ${JSON.stringify(s)}`
}

非同期な処理を行う

例えば Nodejs ではファイル IO は非同期に行うことが推奨されています。普通の関数宣言で loader を定義すると、読みこみ完了を待つことなく処理が進んでしまい意図した処理をすることができません。

以下のような json を読みこんで禁止語句を置き換える loader を書いてみます。

{
  "ass": "3 letters",
  "asshole": "7 letters"
}

this.async() を使う

this.async() (Loader API::async) で処理終了通知用の関数を得られます。非同期処理済ませ処理が終わった段階でその関数を呼ぶことで loader の処理の完了とすることができます。

得られる関数の引数は this.callback (Loader API::callback) と同一です。

const util = require('util')
const fs = require('fs')
const readFile = util.promisify(fs.readFile)
const loaderUtils = require('loader-utils')

function censor(content, list) {
  return Object.keys(list).reduce(
    (a, k) => a.replace(new RegExp(`\\b${k}\\b`, 'gm'), list[k]),
    content,
  )
}

module.exports = function (content, map, meta) {
  const callback = this.async()
  const { deniedList } = loaderUtils.getOptions(this)

  readFile(deniedList).then(s => {
    const result = censor(content, JSON.parse(s))

    callback(null, `module.exports = ${JSON.stringify(result)}`)
  })
}

async/await を使う

Webpack は loader の結果として Promise を受けつけます。よって await を使用できます。

//
// snip
//
module.exports = async function (content, map, meta) {
  const { deniedList } = loaderUtils.getOptions(this)

  const list = await readFile(deniedList)
  const result = censor(content, JSON.parse(list))

  return `module.exports = ${JSON.stringify(result)}`
}

ファイルを書き出す

loader は独自にファイルを出力することができます。アプリケーション実行時には行いたくないがリソースファイルには含めたくない、といったサブデータを先行して書き出しておきたい場合などに便利です。

this.emitFile を使う

this.emitFile (Loader API::emitFile) を使うと Webpack の設定に従った output 用のディレクトリにファイルが書き出されます。Writing a Loader::Guidelines にあるように、絶対パスの使用は避けましょう。

以下の loader は markdown の見出しを元に目次を作成します。markdown には目次用の <a /> を埋めこみ次の loader に渡します。一方で目次自体は別ファイルとして書き出しています。

const path = require('path')
const marked = require("marked");

module.exports = function (content, map, meta) {
  const reg = /^(#+)( *)(.+)/mg

  let replaced = content
  let match
  let indexing = ''
  while (match = reg.exec(content)) {
    const [r, n, , s] = match
    const { index } = match
    indexing += `${toSpace(n)}- [${s}](#anchor-${index})\n`
    replaced = replaced.replace(r, `${n} <a id="anchor-${index}"></a>${s}`)
  }

  const base = path.basename(this.resourcePath, '.md')
  this.emitFile(`${base}.with-index.html`, marked(indexing))

  return replaced
}

function toSpace(h) {
  let s = ''
  for (let n = h.length - 1; n--;) {
    s += '  '
  }
  return s
}

pitch を理解する

use: [
  './a-loader.js',
  './b-loader.js',
  './c-loader.js',
],

通常 loader は末尾から作用します。上記の例だと c -> b -> a の順に処理を進めます。しかし実際の処理を始める前に、まず先頭から pitch というメソッドを起動するサイクルがあります。

┌ pitch ┐                    ┌ default ┐
a -> b -> c -> load resource -> c -> b -> a

pitch に定義したメソッドでは loader で使うためのデータをセットできます。また undefined 以外の値を返すことで自分より後ろの loader を無視した結果を返せます。

例えば bpitch で値を返した場合は以下のようなフローになります。

┌ pitch
├─┐    ┌ default
a -> b -> a

本来発生するリソースファイルの読みこみも無視されることに注意して実装する必要があります。

censor-loader を無視する block-loader を書く

pitch の仕組みを使って、任意のファイルの場合は censor-loader の処理を無視する block-loader を書きます。

webpack config

指定のファイルでは検閲置換を行わない、というストーリーを想定します。

use: [
  './reverse-loader.js',
  {
    loader: './block-loader.js',
    options: { blockingList: ['pitch-block.txt'] },
  },
  {
    loader: './censor-loader.js',
    options: { deniedList: './deniedWords.json' },
  },
],

block-loader.js

指定ファイル以外では loader としての作用を持たなくてはならないので、受けつけた content をそのまま返すデフォルトメソッドを定義します。

指定ファイルが来た場合はリソースの読みこみが行われないことを踏まえ、独自にリソースファイルの読みこみを行いました。これで pitch-block.txt には検閲が行われなくなりました。

const path = require('path')
const util = require('util')
const fs = require('fs')
const readFile = util.promisify(fs.readFile)
const loaderUtils = require('loader-utils')

module.exports = function (content, map, meta) {
  return content
}

module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  const { blockingList } = loaderUtils.getOptions(this)

  return blockingList.includes(path.basename(this.resourcePath))
    ? readFile(this.resourcePath)
    : undefined
}

ところで pitch で async/await を使うとあらゆる場合で Promise返した という扱いになり常にブロックされます。この場合だと、ブロックしていないつもりでも reverse-loader には censor-loader の処理結果ではなく undefined がわたってしまうので注意しましょう。

まとめ

loader に必要な機能を使いつつ、いくつかの loader を書きました。

基本的な処理を追うことによって、例えば、なぜ markdown-loader に必ず html-loaderraw-loader が必要なのかがわかりました。また、基本的な機能を把握することにより、アプリケーションに必要な適切な加工やサブファイルの書き出しが簡単に行えることがわかりました。

loader の設定変更だけではどうにもならないようなアプリケーション特有の特殊な処理を、独自の loader で手早く済ませることも可能になりました。検索とコピペを繰り返すのみで思うようにならず複雑さばかりを感じていたときよりも、より身近な存在になったと思います。

みなさまも一度簡単な loader を書いて Webpack と仲良くなってみてはいかがでしょうか。