parcelでビルド結果から別ファイルを出力するプラグインをつくるまでの道のり

Misoca Advent Calendar 2017 - Qiita : 18日目のエントリーです。


以前、RailsでWebpackerをparcelに置き換えるにはどうしたら?という記事をQiitaに書いた。

qiita.com

この中で、しれっと以下のようなことやっている。

  • ビルド結果からfingerprint付きのファイルを出力したい
  • マッピングが定義されたjsonファイルを吐き出したい
  • というプラグインを作った

実際に作ったプラグインはこれ。

github.com

コード自体は大したことないが、作成する際にドキュメントに書かれている方法だけでは厳しく、parcel自体のコードを読むことでそれっぽいものが作れたという経緯があった。なかなか面白かったので完成までの道のりを記事にしてみる。

欲しかったもの

元の記事にも書いてある内容と同じになりますが、簡単に言うと entry.jsというファイルをビルドした場合に、

  • entry-aUjc10lx83jjXwlechKSaxa3Le9kSjfe.js
  • manifest.json

のようなファイルを吐き出し、manifest.jsonには

{
  "entry.js": "entry-aUjc10lx83jjXwlechKSaxa3Le9kSjfe.js"
}

が記録されている、みたいなことをしたい。

プロダクションで利用する際に、ビルド結果に差分があった場合にキャッシュではなく新しいファイルを参照するように、fingerprintが付与されたファイルを配信したいが、都度scriptタグのsrcを書き換えるのはメンテナンスのコストが高く、別途jsonファイルにマッピングを記録し読み替えるようにしたい、といった具合。

つまり、

  • View上 : 何らかのヘルパー経由でentry.jsを指定
  • 何らかのヘルパー : manifest.json経由で実際に読み込むjsファイルを解決
  • ブラウザ : entry-aUjc10lx83jjXwlechKSaxa3Le9kSjfe.js をロード

といった流れになる。

parcel自体の機能で実現できないか?

そのような機能はまだサポートされていないように見えた。

オフィシャルドキュメントのProductionビルドのあたりを見てみると、htmlからjsをロードした状態とし、htmlをビルド対象とすることでjsのファイル名をユニークなものにしてくれる、いった機能はすでに提供されているっぽい。

SPAを構築する場合などではこれで充分そうだけど、今回の目的とは少し違うようだった。残念。

参考: https://parceljs.org/production.html#set-the-public-url-to-serve-on

自力でなんとかする

ポイントとしては、 「ビルド結果が出力された後の時点で実行したい」といった一点かと思う。

parcelのオフィシャルドキュメントを見ていると、3つの拡張方法が存在するようで、以下のように理解した。

  • Asset
    • ファイルに応じてパース、依存解決、transformなどを行い、変換後コードを生成する
  • Packager
    • 出力ファイルタイプに応じて、Assetで変換したものを取り纏めて実際のファイル出力を行う
  • Plugin
    • parcelビルドを非標準的な方法で自由に拡張する場合に利用する

単純に扱えるファイルの種類を増やしたい場合などはAsset/Packagerを利用するのが正しいアプローチのようだが、今回実現したいことにはファイルタイプは関係なく、純粋に出力結果となるファイルのみを考慮したいので、Pluginを利用するのが正しそう。

プラグインを作る

まずはドキュメントに記載されているプラグインの説明を見てみる。 (https://parceljs.org/plugins.html#plugin-api)

Parcel plugins are very simple. They are simply modules that export a single function, which is called by Parcel automatically during initialization. The function receives as input the Bundler object, and can do configuration such as registering asset types and packagers.

Publish this package on npm using the parcel-plugin- prefix, and it will be automatically detected and loaded as described below.

記載されているサンプルコードも抜粋。

module.exports = function (bundler) {
  bundler.addAssetType('ext', require.resolve('./MyAsset'));
  bundler.addPackager('foo', require.resolve('./MyPackager'));
};

上記を見るからに、単一のプラグインをpackage.jsonに追加するだけで、一気に対応するサポートファイルを追加したりすることができるようだ。

これはこれでとても便利そうですが、Asset/Packageは今回は使わないので、違うアプローチを取る必要がありそう。

では、引数で与えられている bundler が一体何者なのかを追ってみる。

Bundler

実際のコードは以下。

https://github.com/parcel-bundler/parcel/blob/master/src/Bundler.js

Bundlerクラス自体は、CLIから実行した際にインスタンス化されており、外部からはbundleメソッドのみがコールされる。

parcelによるビルド処理本体の起点であり、本体といっても良さそう。

ただ、addAssetType/addPackagerといったメソッドは存在するが、他にそれらしいメソッドは存在せず。どうやってビルド終了時に処理を実行させれば良いのだろう?

そこで、ポイントとなるのはクラス定義となる部分。

class Bundler extends EventEmitter {

nodejsを利用している方ならお馴染みの、EventEmitterを継承している。 (https://nodejs.org/api/events.html)

つまり、プラグインが受け取るbundlerは何らかのタイミングでイベントをemitしてくる(かもしれない)ことがわかる。

emitしている箇所

以下の2つが該当する。

  • this.emit('buildEnd'); - code
  • this.emit('bundled', bundle); - code

ビルド終了時に処理をしたいので、buildEndかな?と思ったけど、buildEndは成功・エラーにかかわらずemitされてしまう模様。

引数のbundleを参照することで引数に指定した出力ディレクトリなども動的に解決することが出来るので、bundledを参照するのが正しそう。

最終的に完成したプラグインのコード

というわけで、上記を元に作ったプラグインが以下。それほど大きくないので全部貼ります。

const path = require('path');
const hasha = require('hasha');
const fs = require('fs');

module.exports = function (bundler) {
  const logger = bundler.logger;

  const readManifestJson = (path) => {
    if (!fs.existsSync(path)) {
      logger.status('✨', 'create manifest file');
      return {};
    };

    logger.status('🖊', 'update manifest file');

    try {
      return JSON.parse(fs.readFileSync(path, 'utf8'));
    } catch(e) {
      logger.error('manifest file is invalid');
      throw e; 
    }
  };

  bundler.on('bundled', (bundle) => {
    const dir = path.dirname(bundle.name);

    logger.status('📦', 'PackageManifestPlugin');
    logger.status('📁', `     dir : ${dir}`);

    const f = bundle.name;
    const hash = hasha.fromFileSync(f, { algorithm: 'sha256' });
    const ext = path.extname(f);
    const basename = path.basename(f, ext);
    const hashFile = path.join(dir, `${basename}-${hash}${ext}`);

    logger.status('✓', `  bundle : ${bundle.name}`);
    logger.status('✓', `        => ${hashFile}`);    

    // create hash included bundle file
    fs.createReadStream(f).pipe(fs.createWriteStream(path.resolve(dir, hashFile)));

    const manifestPath = path.resolve(dir, 'parcel-manifest.json');

    logger.status('📄', `manifest : ${manifestPath}`);

    const manifestValue = readManifestJson(manifestPath);
    manifestValue[path.relative(dir, f)] = path.relative(dir, hashFile);

    fs.writeFileSync(manifestPath, JSON.stringify(manifestValue));
  });
};

parcel-plugin-bundle-manifest/BundleManifestPlugin.js at master · mugi-uno/parcel-plugin-bundle-manifest · GitHub

だいたい以下のような流れで処理してる

  • bundler.on('bundled', ... が処理の起点
  • bundle.name で出力されたファイルへのパスを取得する
  • ファイル内容からhashを作成し、fingerprint付きのファイルをコピーする
  • manifest.jsonファイルを作成する。すでに存在する場合はマージする
  • manifest.jsonファイルを書き込む

終わってみれば大した内容ではない。

あとは、名称を parcel-plugin-xxx といった形でnpmに公開すると、parcelビルド時にpackage.jsonのdependenciesを元に自動的にプラグインが実行されるようになる。

最初は parcel-plugin-manifest とかにしようかと思ったけど、私が一等地っぽい名前を取ってしまうのは如何なものかと思ったので、parcel-plugin-bundle-manifestにした。

www.npmjs.com

作ってみて

EventEmitter周りとかは後々追記される可能性もありそうだけど、ドキュメントだけではなく、コードも見るのが大事だよな〜と改めて感じた。

プラグインを作る過程の中で、基本的な動作とかもなんとなく理解できるようになってくるので、とても勉強になってよかった。

manifestファイルの吐き出しについては、もしかすると将来的にparcel自体の機能としてサポートされるようになるかもしれない。自分としてもそちらのほうが安心なので、ぜひ期待したい。

parcelについて

少しズレるけど、parcel自体についての個人的な所感。(個人的なものです)

まるで設定なしで魔法の如く動いているように理解してしまいそうだけど、実際にはwebpackのloaderなりpluginなりがやってくれていたことを裏側で隠蔽して意識しないで良いようにしてくれているだけなので、フロントエンドのビルド周りで理解しないといけないことが格別減るというわけではないと思う。

(知らないまま使っても良さそうだけど、ハマったときに即死しそう。)

でも、ゼロコンフィグで動かそうというのはとても快適に感じたし、cssファイルをエントリーに取ってビルドできる点などは、webpackのExtractTextPluginにつらみを感じていたので「そうそう、これがやりたかったんだよ!!」という気持ちになった。

これからも出来ることは増えていくだろうし、webpackに取って代わる日が来るのも有り得るかもしれない。

とりあえずどうなっても良いように、webpackへのガッツリ依存している部分を少しずつ外しておくことで、いつか幸せになれる日が来ることを信じている。


さて、明日は弊社代表の@toyoshiがラズパイで頑張った話を書いてくれるようです。

前回のエントリが400近いブックマーク数になるほど読まれていたので、今回も注目ですね!!