まえがき
最近 esbuild を使っていくつかプロダクトを作ったときに得た知見をまとめようと思います。
公式ドキュメント: Deno instead of node
esbuild の思想と限界
いきなり限界の話をするのもどうかと思いますが、esbuild が何に使えて何に使えないかを知ることは重要です。esbuild は高速なビルドツールとして Vite や Amazon CDK の内部に使用されるなどデファクトスタンダードの地位を確立しつつあります。しかし内部的に使用されているということは esbuild 単体での能力ではある程度規模の大きなアプリケーションを作成するのは難しいことを示しています。
実際に公式の FAQ では次のように述べられています:
Think of esbuild as a "linker" for the web. It knows how to transform and bundle JavaScript and CSS. But the details of how your source code ends up as plain JavaScript or CSS may need to be 3rd-party code.
訳: esbuild は Web 用の「リンカ」と考えてください。esbuild は JavaScript と CSS を変換しバンドルする方法を知っています。しかし JavaScript や CSS の最終出力を詳細に制御するにはサードパーティのコードが必要かもしれません。
I'm hoping that plugins will allow the community to add major features (e.g. WebAssembly import) without needing to contribute to esbuild itself. However, not everything is exposed in the plugin API and it may be the case that it's not possible to add a particular feature to esbuild that you may want to add. This is intentional; esbuild is not meant to be an all-in-one solution for all frontend needs.
esbuild 開発者はプラグインによって主要な機能 (WebAssembly のインポートなど) が esbuild 自体にコントリビュートすることなく追加できることを願っています。しかしながら、すべての機能が API として公開されているわけではなく、追加したい機能が esbuild に追加することができないこともあるでしょう。これは意図的で、esbuild はすべてのフロントエンドの要求を満たすオールインワンの解決策ではないということを意味します。
すなわち esbuild はトランスパイルとバンドルに特化しているため、すべての作業を esbuild で行うことは想定されていません (これは Webpack との大きな違いと考えています)。以下のセクションではビルド結果の出力やファイルのコピーを行う例を示していますが、本来 esbuild とは別に実行されるべき処理だと想定されているということに留意する必要があります。
Import Map について
Deno ではブラウザ互換のインポート (import
文を使ったインポート; いわゆる ESM インポート) が可能で、以下のようなインポートをすることが可能です。
import * as lodash from 'https://deno.land/x/lodash@4.17.19/lodash.js';
これに加えて、 import map にも対応しています。次に示すように import map を用意することでモジュール名を指定してインポートする (エイリアスを用意する) ことができます。型注釈のインポートにも利用できます。
{
"imports": {
"lodash": "https://deno.land/x/lodash@4.17.19/lodash.js",
"lodash/types": "npm:@types/lodash@4.17.19"
}
}
// @deno-types="lodash/types"
import * as lodash from 'lodash';
このファイルを Deno で実行する際には deno.json の importMap
プロパティにファイルパスを指定するか、import map の内容を直接記述します。
{
"importMap": "./import_map.json"
}
{
// deno.json のトップレベル
"imports": {
"lodash": "https://deno.land/x/lodash@4.17.19/lodash.js",
"lodash/types": "npm:@types/lodash@4.17.19"
}
}
または CLI オプションで次のように指定します。
$deno run --importmap import_map.json some.ts
また、vscode では deno.importmap
を指定することで補完を効かせることができます。deno.json
から自動で読み取ってくれるようです。これは Deno 本体で import maps を利用する方法ですが 外部モジュールを使う では esbuild でバンドルする方法を紹介します。
Web ビルド最小構成
基本的には Vite などのフレームワークを利用することを推奨します。
とりあえず最小の構成でビルドするサンプルです。公式ドキュメントにある通り、esbuild.stop()
を呼ぶ必要があります。
import * as esbuild from 'esbuild';
import * as posix from 'posix';
await esbuild.build({
entryPoints: [posix.resolve('src', 'script.ts')],
bundle: true,
outdir: posix.resolve('dist'),
minify: true,
sourcemap: 'external',
});
await esbuild.stop();
ファイルの変更を監視 (watch) する
esbuild.BuildContext.watch
を呼び出すことで watch モードになります。処理は返却されるので入力待ちなどでメインプロセスを維持する必要があります。以下のソースでは Enter キーを押すことで強制再ビルドさせる機能を追加しています。q を入力したときに終了するなども可能です。
const ctx = await esbuild.context(config);
await ctx.watch();
console.log('Watching...');
for await (const chunk of Deno.stdin.readable) {
const text = decoder.decode(chunk).trim();
if (text === 'r') {
// rebuild
await ctx.rebuild().catch(() => {});
} else if (text === 'q') {
// quit
await ctx.dispose();
break;
}
}
ビルドしながらサーバーをホスト (serve) する
esbuild.BuildContext.serve
を呼び出すことで HTTP サーバーをホストすることができます。上述 の watch モードと同様にメインプロセスを維持する必要があります。
const ctx = await esbuild.context(config);
const { host, port } = await ctx.serve({
servedir: config.outdir
});
console.log(`Serving on ${host}:${port}`);
// ...
また、esbuild.BuildContext.watch
を続けて呼び出すことで watch モードで HTTP サーバーを建てることができます。
const ctx = await esbuild.context(config);
const { host, port } = await ctx.serve({
servedir: config.outdir
});
console.log(`Serving on ${host}:${port}`);
await ctx.watch();
console.log('Watching...');
esbuild の serve モードは Server Sent Events によるホットリロード用のエンドポイント (/esbuild
) を提供します。そのためクライアントに次の記述を追加することでホットリロードが可能です (公式 docs)。
new EventSource('/esbuild').addEventListener('change', () => location.reload())
ポストプロセス (Babel など) を使う
onEnd
コールバックで出力結果を加工することが可能です。その際には BuildOptions.write
を false
にする必要があります。こうすることで BuildResult.outputFiles
から出力結果を参照できるようになります。次のレポジトリは Babel を使用する例です。
JSX/TSX (React, preact など) を使う
deno.json
に次のように JSX と JSX Fragment のファクトリの設定を記述します。
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxFactory": "react.createElement",
"jsxFragmentFactory": "react.Fragment"
},
}
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxFactory": "preact.h",
"jsxFragmentFactory": "preact.Fragment"
},
}
同様の設定を esbuild に対しても行います。
import denoConfig from './deno.json' assert { type: 'json' };
const config: esbuild.BuildOptions = {
// ...
jsxFactory: denoConfig.compilerOptions.jsxFactory,
jsxFragment: denoConfig.compilerOptions.jsxFragmentFactory,
};
ビルドされる側のファイルでは次のように利用します。
/** @jsxImportSource preact */
// @deno-types="preact/types"
import * as preact from 'preact'; // この import は必須 (のはず)
const renderApp = function(target: HTMLElement) {
preact.render(<App />, target);
}
ビルド結果を出力する (プラグイン)
単純には onEnd
コールバックを利用することで実装可能です (参考)。ただしこの方法では onEnd
コールバック内でポストプロセスを行う処理を待たないため、このようなプラグインがある場合は esbuild の外で結果を出力する必要があります。
なお、onEnd
コールバックによる実装のプラグインを公開しています。
外部モジュールを使う
プラグインを使ってバンドルする方法
esbuild_deno_loader
プラグインを利用します。このプラグインではリモート URL、npm モジュール、jsr モジュール、import maps の解決ができます。
esbuild-plugin-cache-deno
を利用します。このプラグインはリモート URL のインポートを解決・キャッシュするほか、importmap に基づいてモジュール名を解決する機能があります。
https://deno.land/x/esbuild_plugin_cache_denohttps://github.com/Tsukina-7mochi/esbuild-plugin-cache-deno
外部モジュールのままにする方法
ビルドオプションの external
フィールドを設定することで外部ファイルとして設定することができ、読み込み時に解決するようになります (公式ドキュメント)。
const config: esbuild.BuildOptions = {
// ...
external: ['https://esm.sh/preact*'],
};
特定の拡張子を外部モジュール化するには loader による設定も可能です。
const config: esbuild.BuildOptions = {
// ...
loader: {
'.html': 'external',
},
};
loader として copy
を指定することでファイルをコピーすることができます。
const config: esbuild.BuildOptions = {
// ...
loader: {
'.png': 'copy',
}
};
glob を使ってコピー対象を指定するプラグインも公開しています。
import copyPlugin from 'esbuild-plugin-sass';
import importmap from './import_map.json' assert { type: 'json' };
const config: esbuild.BuildOptions = {
// ...
plugins: [
copyPlugin({
// base directory of source files
baseDir: './src',
// base directory of destination files
baseOutDir: './dist',
// files should be copied
files: [
{ from: 'imgs/*', to: 'imgs/[name][ext]' },
{ from: 'wasm/*', to: 'wasm/[name][ext]' },
],
}),
],
};
Sass を使う
Sass/SCSS ファイルをバンドルするにはプラグインが必要です。プラグインの onLoad
コールバックで .sass
, .scss
ファイルを読み込む際に Sass コンパイラを呼ぶ必要があります。
筆者の作成したものですが、 Deno, Node.js の両方に対応したプラグインが存在します。
import { sassPlugin } from "jsr:@tsukina-7mochi/esbuild-plugin-sass";
// 単体のファイルとして出力する
const config: esbuild.BuildOptions = {
// ...
plugins: [
sassPlugin(),
],
};
// スクリプトを使ってページに埋め込む
const config: esbuild.BuildOptions = {
// ...
plugins: [
sassPlugin({ loader: 'css' }),
],
};
Deno 向けにビルドする
Deno 標準のバンドラ deno bundle
コマンドは非推奨となっており、esbuild や rollup などの利用が推奨されています。
esbuild の場合ブラウザ向けビルドや Node.js 向けビルドではインポートが CommonJS 形式になってしまうため、ビルドオプションに platform
と target
を指定します (参考ソース)。
const config: esbuild.BuildOptions = {
// ...
platform: 'neutral',
target: 'deno1',
};
GitHub Actions で linter と formatter を使う
denoland/setup-deno
を使います。deno lint
で静的解析、 deno fmt --check
で formatter によるチェックを行えます。
name: lint, check format
on: push
permissions:
contents: read
jobs:
lint-and-fmt:
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- uses: actions/checkout@v3
- name: Setup Deno
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Lint
run: deno lint
- name: Check format
run: deno fmt --check