loader をつくって学ぶ Webpack

loader をつくって学ぶ Webpack

このエントリーは Webpack 4.26.1 を基準に書かれました。

Webpack とは

Webpack は loader と呼ばれるモジュールを使って、任意のファイルやデータを JavaScript 内で使えるようにすることを主たる目的としたプログラムです。最新の文法を使った JavaScript や CSS などを一般に普及しているウェブブラウザで機能させるために、最近のフロントエンド開発でほぼ確実に用いられています。

Webpack の設定の難

Webpack で機能する loader が数多く存在します。そしてそれらを自由に組み合わせてファイルを加工できます。loader の多様さと組み合わせの自由さは大きな利点ですが、一方で、設定項目は多く、設定ファイルは入り組みがちです。設定項目を一から調べて書くということはあまりなく、コピペで組み立てていくというのが一般的な Webpack 設定ファイル作成方法ではないでしょうか。

そのような設定をしていると、いざエラーが起こった場合、このエラーの原因は何かを速やかに理解することが難しくなります。エラーメッセージで検索し、似たような問題に対する解決方を見つけられなければお手上げとなってしまいます。あるいは的はずれな検索に多くの時間を割いてしまうでしょう。

本エントリーの目的

そこで今回は自分の手で loader を造ることによって、loader が作用する仕組みを追ってみました。また loader では完結できない処理があったため、plugin も造ることになりました。最終的には以下のようなことを学べたと思います。

  • loader での入力出力
  • 結果ファイルを Nodejs で扱うために書き出しの方式を変更する方法
  • 最終的には plugin でどうとでもなるということ

markdown 内リンクを挿入する loader を造る

今回は題材として md-indexing-loader を造っていきたいと思います。

この loader は markdown を受けとり、その見出しへのリンクをリスト上に構成して markdown の任意の位置に挿入する、という作用を持ちます。

Webpack の最小限の設定とサンプルファイル

単純に index.md をエントリーポイントとし index.md を出力する設定をします。loader はパッケージ化せず、./md-indexing-loader/index.js に配置します。

const path = require('path')

module.exports = {
  mode: 'production',
  entry: './src/index.md',
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: 'index.md',
  },
  module: {
    rules: [
      {
        test: /\.md$/,
        use: ['./md-indexing-loader/index.js'],
      },
    ],
  },
}
# Section 1

text

## Section 1-2

text

### Section 1-2-1

text

## Section 1-3

text

# Section 2

text

# Section 3

text

## Section 3-1

まず最小限の loader を造り、どのような処理が必要か知る

処理

何が入力されてどのように出力するのかを見るために、まずは最小限の loader を書きます。loader は入力として utf-8 文字列を受けとり、なんらかの値を返したり返さなかったりします。

module.exports = function (content, map, meta) {
  const json = JSON.stringify(content)
  return `module.exports = ${json}`
}

出力

一般的な loader では最終的に JSON.parse された上で JavaScript の式や文として出力されます。Webpack が最終的に出力ものは以下のようになります。

(Webpack のヘルパーメソッド部は省略しています)

/***/ (function(module, exports) {

module.exports = "# Section 1\n\ntext\n\n## Section 1-2\n\ntext\n\n### Section 1-2-1\n\ntext\n\n## Section 1-3\n\ntext\n\n# Section 2\n\ntext\n\n# Section 3\n\ntext\n\n## Section 3-1\n"

/***/ })

今回は index.md に markdown をそのまま出力することを目的としているので、本文の解析などの処理の他にも、結果の返し方も変更する必要があります。

なお、この最小限の loader の結果をそのまま JavaScript で扱いたい場合は、module.exports によりデフォルト値として読みこまれるので import を用いて以下のように使えます (これは raw-loader が行っていることとほぼ同じです)。

import md from './index.md' // module.exports = "# Section 1\n\n..."
console.log(md)             // => "# Section 1\n\n..."

必要な処理はなにか

ごく基本的な動きを知ることで、この loader に何が必要かを把握できました。

  • 入力された文字列を解析してインデックスを作成する
  • 作成したインデックスと本体を合わせた markdown を作成する
  • 出力した文字列を JavaScript ではなく markdown で出力する

loader に必要な処理を実装する

入力された markdown を解析してインデックスを挿入する

入力された markdown から見出しに当たる部分を抽出し、インデックスとして構成しながらアンカリングのために <a name="" /> を挿入していきます。

文字列 to 文字列の加工なので、特に難しい部分はありません (本題ではないので厳密な抽出はしていません)。

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 name="anchor-${index}"></a>${s}`)
  }

  const combined = `${indexing}\n\n${replaced}`

  const json = JSON.stringify(combined)
  return `module.exports = ${json}`
}

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

出力した文字列を JavaScript ではなく markdown ファイルで出力する

loader から直接ファイルを出力する

ファイルを出力する方法として、Webpack から提供される emitFile という関数があります。これを用いると config ファイルに設定された output に従った場所にファイルが出力されます。ただし、この時は最終的に出力される index.md は使用できません。そこで、ひとまず index.with-index.md として結果を出力します。

ハードコーディングされたファイル名で出力するのはよくないので、実際に入力されたデータの持ち主のファイル名を変形します。ファイル名といったような loader 内で活用できる各種情報は Loader API に一覧されています。loader が何を参照しているのかがわかるので一読しておくと良いでしょう。

 module.exports = function (content, map, meta) {
 // snip

   const combined = `${indexing}\n\n${replaced}`
+  const fileName = path.basename(this.resourcePath, '.md')
+  this.emitFile(`${fileName}.with-index.md`, combined)

 // snip
 }

無事、以下のようなファイルが出力されました。

- [Section 1](#anchor-0)
  - [Section 1-2](#anchor-19)
    - [Section 1-2-1](#anchor-41)
  - [Section 1-3](#anchor-66)
- [Section 2](#anchor-88)
- [Section 3](#anchor-107)
  - [Section 3-1](#anchor-126)


# <a name="anchor-0"></a>Section 1
<!-- snip -->

出力される index.md 自体を markdown にする

さて、上の方法でファイルは出力されたものの、本来の結果ファイルである index.md は依然として JavaScript ファイルとして出力されています。markdown ファイルを入力し markdown ファイルを出力するための loader ですから、出力結果として markdown を得られる仕組みを提供する必要があります。

そこで本来のファイル名で出力するための plugin を書きます。pluign は loader にはできないあらゆることを解決するために作成されます。

They also serve the purpose of doing anything else that a loader cannot do.

JavaScript ファイルではなく markdown ファイルとして出力するための plugin を書く

loader と同じように、まず最小限の plugin を書き、必要な処理を処理を実装します。

webpack に設定を追加

 const path = require('path')
+const MdLoaderPlugin = require('../md-indexing-loader/plugin')

 module.exports = {
   mode: 'production',
   entry: './src/index.md',
   output: {
     path: path.resolve(__dirname, '../dist'),
     filename: 'index.md',
   },
   module: {
     rules: [
       {
         test: /\.md$/,
         use: [
           './md-indexing-loader/index.js',
         ],
       },
     ],
   },
+  plugins: [new MdLoaderPlugin()]
 }

最小限の plugin

Plugin は起動時にインスタンス化され、Webpack の complier オブジェクトを引数として apply を呼ばれます。complier を使って Webpack のライフサイクルに従ったイベントフックに関数を設定することで、任意のタイミングで任意の処理を行えます。

今回は出力結果のファイルに対する処理となるので、compilation のさらに細分化されたイベントの needAdditionalPass にフックしました。この段階ではすべての処理が終えられているため、compilation に十分な情報がセットされています。

class MdLoaderPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('MdLoaderPlugin', compilation => {
      compilation.hooks.needAdditionalPass.tap('MdLoaderPluginA', () => {
        console.log(compilation)
      })
    })
  }
}

module.exports = MdLoaderPlugin

index.md を Nodejs で読めるようにする

Webpack でのコンパイル中では import で読み込んで値を扱えますが、コンパイル後はブラウザで起動されることが前提となっているのでクロージャーですべてが隔離されています。そのためデフォルトの設定では index.md の値に直接アクセスできません。

そこで Nodejs のライブラリのように外部から読みこまれる形でコンパイルするように設定を変更しなければなりません。

Webpack の設定をライブラリモードに変更

Nodejs の読みこみを前提としているため、commonjs モードで出力します。

 module.exports = {
 // snip
   output: {
+    library: 'index',
+    libraryTarget: 'commonjs',
     path: path.resolve(__dirname, '../dist'),
     filename: 'index.md',
   },
 // snip
 }

これにより index.md の出力結果に exports["index"] = が加えられ、require で読みこんだ際には index を識別子としてアクセスできるようになります。

plugin が index.md を markdown ファイルに書き換える処理を書く

まず初期情報として出力ファイル名とライブラリ名を引数として与えるように、Webpack の設定を変更します。汎用的な plugin ではさまざまな動的処理によりファイルを判別しています。

 module.exports = {
 // snip
-  plugins: [new MdLoaderPlugin()],
+  plugins: [new MdLoaderPlugin('index.md', 'index')],
 }

あとは compilation.assets から結果ファイルパスを調べ、それを値として読みこみます。読みこんだ値は素の markdown ですから、それをそのままファイルに上書きすれば markdown ファイルが完成します。

const fs = require('fs')

class MdLoaderPlugin {
  constructor(target, name) {
    this.target = target
    this.name = name
  }

  apply(compiler) {
    compiler.hooks.compilation.tap('MdLoaderPlugin', compilation => {
      compilation.hooks.needAdditionalPass.tap('MdLoaderPluginA', () => {
        const { existsAt } = compilation.assets[this.target]
        const md = require(existsAt)[this.name]

        fs.writeFileSync(existsAt, md)
      })
    })
  }
}

module.exports = MdLoaderPlugin

loader 内のファイル出力は不要になったので削除します。

 module.exports = function (content, map, meta) {
 // snip

   const combined = `${indexing}\n\n${replaced}`
-  const fileName = path.basename(this.resourcePath, '.md')
-  this.emitFile(`${fileName}.with-index.md`, combined)

 // snip
 }

出力結果

- [Section 1](#anchor-0)
  - [Section 1-2](#anchor-19)
    - [Section 1-2-1](#anchor-41)
  - [Section 1-3](#anchor-66)
- [Section 2](#anchor-88)
- [Section 3](#anchor-107)
  - [Section 3-1](#anchor-126)


# <a name="anchor-0"></a>Section 1

text

## <a name="anchor-19"></a>Section 1-2

text

### <a name="anchor-41"></a>Section 1-2-1

text

## <a name="anchor-66"></a>Section 1-3

text

# <a name="anchor-88"></a>Section 2

text

# <a name="anchor-107"></a>Section 3

text

## <a name="anchor-126"></a>Section 3-1

まとめ

今回はエラー処理などを完全に無視して書きましたが、本来は他の loader との連携に当たっては入力の型チェックや出力内容の統一など、様々な処理が増えることになります。様々な loader を組み合わせていくなかで発生する不可解なエラー、そしてそれの追いづらさの理由が少しわかった気がします。

自分で複雑な loader を書くことはあまりないかもしれません。しかし、複雑な設定が必要な loader で頭を悩ませるよりもプロジェクトドメインにそったシンプルな使い捨ての loader や plugin を書くのが最も適した解決になる局面もありそうだと思いました。

Webpack の設定はつらいことで有名です。できればあまり近寄りたくはなかったのですが、こうして簡単な loader と plugin を作成することにより、いざとなったら介入できる (かもしれない) という心の余裕ができました。設定をコピペするときも、その設定内容の意図が見えるようになるような気がします。

行ったことは簡単で単純でしたが、得たものは多かったと感じました。Webpack に苦手意識があるかたは、是非一度 loader を造ってみることをおすすめします。