esbuild の機能が足りないならプラグインを自作すればいいじゃない
Rollup から esbuild に乗り換えた null です。
- [Web フロントエンド] esbuild が爆速すぎて webpack / Rollup にはもう戻れない
https://www.kabuku.co.jp/developers/ultrafast-tsx-build-tool-esbuild
Webpack や Rollup の遅さにイライラしている方はたくさんいらっしゃると思います。かといってモジュールバンドラーの乗り換えなんてそう簡単にできるものでもありません。まして esbuild には機能 x がないじゃないですか、上記記事によればプラグインシステムもないし…
時代は変わりました。上記記事はすっかり古くなってしまいました。当時(v0.5.3)未実装だったいくつかの重要な機能が現在(v0.8.29)では利用可能になっています。
特筆すべきは CSS ローダーとプラグイン機構が組み込まれたことでしょう。多くのユースケースを[自力で]カバーできるようになりました。
※ プラグイン API はまだ実験的機能です。 v1.0.0 までに仕様が変わるかもしれません。
CSS ローダー
- esbuild – Content Types # CSS
https://esbuild.github.io/content-types/#css
esbuild は CSS をエントリポイントにできるようになりました。スクリプトから CSS をインポートすることもできます。スクリプトからインポートした CSS は結合されて出力先ディレクトリに出力されます(たとえば dist/app.js
が生成される設定であれば、合わせて dist/app.css
が生成されます)。
CSS に含まれる @import
も解決されて結合されますし、 url()
で指定された画像やフォントは出力ディレクトリへコピーすることも Data URL 形式で埋め込むこともできます。
プラグイン API
「公式読め」に尽きます。
- esbuild – Plugins
https://esbuild.github.io/plugins/
が、記事の前提になるので軽く紹介します。次の例は上記公式ページの最初に出てくるプラグイン実装です。
let envPlugin = {
name: 'env',
setup(build) {
// Intercept import paths called "env" so esbuild doesn't attempt
// to map them to a file system location. Tag them with the "env-ns"
// namespace to reserve them for this plugin.
build.onResolve({ filter: /^env$/ }, args => ({
path: args.path,
namespace: 'env-ns',
}))
// Load paths tagged with the "env-ns" namespace and behave as if
// they point to a JSON file containing the environment variables.
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify(process.env),
loader: 'json',
}))
},
}
プラグインは name
という文字列プロパティと setup
メソッドを持つオブジェクトである必要があります。
setup()
に渡される第一引数 build
が持つ 2 つの関数 onResolve()
と onLoad()
にコールバックを渡すことでコンパイルに介入します。
onResolve()
にはモジュールのパスと名前空間を解決するためのコールバックを渡します。
onLoad()
にはモジュールのパスと名前空間から実際のコンテンツを解決するためのコールバックを渡します。
この「名前空間」がちょっと分かりにくいと感じるのですが、私は onResolve()
と onLoad()
を結び付けるための識別子だと解釈しています。 onResolve()
で「このプラグインで解決するモジュールだよ」という印を名前空間という形で付けておき、 onLoad()
で印付きモジュールを実コンテンツに変換します。上記例の envPlugin
でもそのように使われているのが読み取れるかと思います。(onResolve()
で実コンテンツを返せるようになっていれば不要な概念だったんじゃないかと思わなくもないような…)
onResolve()
で名前空間を指定しなかったり "file"
を指定した場合はファイルシステムからコンテンツが読み込まれます。
ちなみに esbuild パッケージには TypeScript の型定義が含まれており、プラグイン開発においても型の恩恵を受けられます。また、 JavaScript だけでなく Go 言語でもプラグインを実装できるようです。
プラグインでできないこと
esbuild のプラグインでできるのは個々のモジュールの解決だけです。 esbuild 内部の AST の操作、複数モジュールにまたがる処理、バンドル後のコンテンツへの介入などはできないようです。たとえばインポートしている CSS がすべて解決されて結合された後で cssnano や clean-css のような最適化をかけたい場合、現状のプラグインシステムでは実現できないと思います(私の勘違いだったらごめんなさい)。
バンドル後のコンテンツを操作したい場合は、プラグインという形ではなく、 esbuild によるビルド実行後に別途処理すれば良いと思います。私はあるプロダクトのビルドスクリプトにおいて、 esbuild によるビルド後に PurgeCSS で tailwindcss のパージを実行するようにしています。
Sass プラグインを作ってみる
先述の通り esbuild は CSS をサポートしていますが、 CSS メタ言語のトランスパイルは未サポートです。
ということで Sass(SASS / SCSS)のトランスパイルをするプラグインを書いてみました。
const sass = require('sass')
const sassPlugin = options => ({
name: 'esbuild-plugin-sass',
setup(build) {
build.onLoad({ filter: /\.s[ac]ss$/ }, ({ path }) => {
return new Promise(resolve => {
sass.render({ ...options, file: path }, (err, result) => {
resolve({
contents: result?.css,
loader: 'css',
errors: err ? [{ text: err.message }] : undefined,
})
})
})
})
},
})
onLoad()
コールバックで sass.render()
を呼んで Promise
として返しているだけです。若干コールバック地獄感はありますが(苦笑)、自分では複雑なことは何もしていません。
Sass に限らず altJS や CSS メタ言語のトランスパイルプラグインはとてもシンプルに実装できそうなことが分かりますね。トランスパイラーの API がシンプルであれば、ですが。
プラグインができたら esbuild.build()
の plugins
オプションに渡します。
const esbuild = require('esbuild')
esbuild.build({
entryPoints: ['src/app.ts'],
outdir: 'dist',
bundle: true,
minify: true,
treeShaking: 'ignore-annotations',
plugins: [sassPlugin()],
}).catch(() => process.exit(1))
// src/app.ts
import './a.scss'
import './b.scss'
// ...
ビルドスクリプトを実行すると、 src/a.scss
と src/b.scss
がそれぞれトランスパイルされた上で結合され、 dist/app.css
に出力されます。
Linaria プラグインを作ってみる
Zero-runtime CSS in JS ライブラリ Linaria はスクリプトを(Babel で)トランスパイルしつつ CSS を吐きます。
その CSS も esbuild にバンドルしてもらえばいいんだよなー、と思って実装してみました。 あくまで試しに実装してみただけで npm には公開していません。もし興味あればコピペでも clone でも fork でも何でもしてください。
https://github.com/luncheon/esbuild-plugin-linaria
既存のプラグイン
非公式プラグインの公式まとめがあります。
- esbuild/community-plugins: Community plugins for esbuild
https://github.com/esbuild/community-plugins
まだまだ充実しているとは言い難いです。チャンス(!?)
Svelte や Solid のコンポーネントを読み込んでコンパイルするプラグインも公開されています。
- esbuild-svelte
https://github.com/EMH333/esbuild-svelte - esbuild-plugin-solid
https://github.com/amoutonbrady/esbuild-plugin-solid
GLSL をミニファイして読み込むプラグインなんかもあります。
- esbuild-plugin-glsl
https://github.com/vanruesc/esbuild-plugin-glsl
まとめ
esbuild のプラグイン機構を紹介しました。
esbuild 本体ではサポートされていない形式のファイルも、プラグインさえあれば[作れば]インポートできるようになります。必要なら JavaScript や TypeScript のトランスパイルを Babel や Sucrase、 swc に移譲することもできます。 Webpack や Rollup、 Parcel から乗り換えるハードルは下がったのではないでしょうか。
プラグインはまだ実験的機能という位置付けであり、今後 API 仕様が変わる可能性があります。が、 API 仕様が変わったとしても実現できることが減る可能性は少ないんじゃないかと私は思います(個人の想像です)。
もちろん、プラグインの処理が遅ければビルドに時間がかかってしまいます。それでも、少なくともバンドルとミニファイは爆速です。プラグインを使うとしても、他のバンドラーから esbuild に乗り換えることでビルド時間を削減できる可能性は大いにあると思います。
パイプライン処理について (2021/02/04 追記)
現状の esbuild プラグインの仕組みでは、複数のプラグインをつなぎ込むパイプライン処理ができません。たとえば Sass 変換後に PostCSS 変換したい場合、「Sass プラグイン」と「PostCSS プラグイン」を使えば良さげに思えますが、実際には単一のプラグインで Sass 変換後に PostCSS 変換しなければなりません。
この問題に対し、プラグインによる変換をパイプライン処理するためのプラグインが第三者によって公開されています。
- nativew/esbuild-plugin-pipe: Pipe esbuild plugins output.
https://github.com/nativew/esbuild-plugin-pipe
もしいまプラグインを作るなら esbuild-plugin-pipe でも使えるようにしておくと便利かもしれません。
esbuild 本体に対してもそれっぽい提案があります。本体で対応してくれたらいいなぁ。
- Plugin onTransform API · Issue #647 · evanw/esbuild
https://github.com/evanw/esbuild/issues/647
その他の記事
Other Articles
関連職種
Recruit