簡単な 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 を無視した結果を返せます。
例えば b
の pitch
で値を返した場合は以下のようなフローになります。
┌ 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-loader
や raw-loader
が必要なのかがわかりました。また、基本的な機能を把握することにより、アプリケーションに必要な適切な加工やサブファイルの書き出しが簡単に行えることがわかりました。
loader の設定変更だけではどうにもならないようなアプリケーション特有の特殊な処理を、独自の loader で手早く済ませることも可能になりました。検索とコピペを繰り返すのみで思うようにならず複雑さばかりを感じていたときよりも、より身近な存在になったと思います。
みなさまも一度簡単な loader を書いて Webpack と仲良くなってみてはいかがでしょうか。