タスクランナーを gulp.js から Bun に移行

いまどき gulp? と思うかもしれませんが、自分でもそう思っていたので書き換えました。

実はここ (IROIRO) はずっと gulp.js を使って HTMLやアセット類を生成、それをサーバーにデプロイという流れで運用してきました。(実行環境は WSL)

元々は メインサイト 用にかれこれ 10年くらい前に作ったもの。

最初は確か Grunt を使っていたのですが、どこかのタイミングで Gulp に移行して、ちょこちょこ改修しながら秘伝のスープ的に使ってきました。(この辺の経緯はもはや記憶が曖昧)

ですが流石に構成が古くなってきたのと、そもそも gulp の開発がもう何年も止まってしまっているので、結構前から移行を検討してきました。

やってること

実際に行っていることはそれほど複雑ではなく、

  • CMSからの記事データの取得
  • 記事データと pug からの完全に静的な HTML の生成
  • アセット類 (JavaScript、css等) の生成

といった程度のもの。アプリケーション的な機能はほぼないので、記事の生成用にデータを加工している部分以外はそれほど重い処理は行っていません。

pug も今更といった感じですが、単純な HTML を書くという目的には手軽さや mixin による分割などが便利なので採用しています。

JavaScript は WebPack を使っていましたが、1、2ファイルで収まる程度の小規模なものなので実はわざわざ使わなくてもよい程度の規模。(一応、アプリ的なものでも使えるようにもなってはいます。)

SCSS も先日の書き換えで SASS としての機能はほとんど使わなくなったものの、mixin はまだちょっと使いたいかなといった程度。

といった感じで、中身も大分枯れた技術スタックだったりはします。

ちなみにデプロイは本番用にビルドした上で firebase deploy で行っているので、特にタスクには含まれていません。

移行先の検討

結構前から移行は検討していたものの、踏み出すにはイマイチ決め手に欠けるという状況が続いていました。

ちなみに移行の条件としては

  • 少なくとも Gulp よりは新しいか、何かしらのメンテナンスが継続している
  • 移行コストをなるべく軽く(重くなるならそれ相当の理由が欲しい)

保守性の改善が一番の目的で、パフォーマンスや開発効率の改善的な事はそこまで考慮していません。

他のタスクランナー

何か新しいタスクランナー的なものが出てきてないかチョイチョイ探していたのですが、結局今でも Grunt と Gulp ぐらいしかなさそうです。

(なんか別の言語だとあるのかもしれませんが、移行コストの事があるので探してはいません。)

Webフレームワークの導入

NextJS 辺りを導入して、全部乗っかってしまう案。

今から新規にもっと機能リッチなサイトだったりサービス的を新規に立ち上げるなら基本的にはこれなんですが、静的コンテンツがメインで、かつ既存のプロジェクトからの移行をするにはほぼ全面的に書き換えなくてはならず、コストが高くなるので抵抗がありました。

node script へ書き換え

gulp のタスクを単純な Node.js スクリプトに分解し、npm から起動する案。

gulp 自体が JavaScript なので、おそらくこれが現実的な解だろうとして考えていたのですが、gulp だとお手軽だったディレクトリーやファイルに対する一括処理がなんとなくシンドイのであまり乗り気でありませんでした。

しかし、後述の bun shell や標準ライブラリーによってこの負担が軽減されそうだったことがわかり、最終的には bun を使う形で採用することにしました。

Bun

Bun はJavaScript の実行環境で、ランタイム、バンドラー、npm 互換のパッケージマネージャーなどが一通り用意されています。

Zig という言語で開発されていて、実行速度の速さをウリにしており、TypeScript や JSX のトランスパイラを内臓してるので ts、tsx、jsx といったファイルを直接実行することができます。

インストールは

$ curl -fsSL https://bun.sh/install | bash

# または

$ npm install -g bun

を実行するだけ。あとは node コマンドの代わりに bun コマンドを利用すれば、bun のスクリプトとして実行されます。

最初に触れた通り、パッケージマネージャーやバンドラー、テストランナーなどといったツール群が標準で用意されており、HTTPサーバーや SQLite ドライバーなども標準 API として提供されているので外部のツールやライブラリーを導入しなくても一通りのことが実行可能です。

もちろん Webフレームワークや他のデータベースなど、足りない部分は外部ライブラリーやツールも利用できます。

Bun Shell

そして冒頭でも触れた通り、今回の移行の きっかけになったのがシェルを直接呼べる Bun shell という機能。

2024.1.20 にリリースされた v1.0.24 で搭載されたばかりの出来立てほやほやで、この時に 公式ブログでの紹介記事 を何かで見かけて興味を持ったのが、今回採用に至った直接のきっかけでもあります。

一部のコマンドは bun の独自実装によってマルチプラットフォームで動作する他、入出力結果を JavaScript側に取り込んだり渡したりすることが手軽に行えます。

例えば ファイルの一覧を文字列として読み込む場合は

import { $ } from 'bun';

const files = await $`ls`.text();

といった感じで非常に簡潔に書くことができます。

詳しい使い方は 公式のドキュメント を見てみてください。

機能的にはまだまだ不十分だったり、若干動作が暗転しなかったりという部分もあるんですが、なんとなく心理的に重い処理が 1行で実現できてしまうのが大分お手軽です。

そして採用

普段なら出たばかりの機能はしばらく様子見するんですが

  • 元々 Bun には興味があって触るきっかけが欲しかった
  • お試しの規模感としてはちょうどよい
  • 標準ライブラリーが割と充実しているので思っていたよりも書き換えが楽そう
  • ウリにしているだけあって動作が速い
  • どうせ個人用なんだから最悪戻ればいい

といった理由で Bun を使ったスクリプト群として置き換えることにしました。

実際の移行

基本的には gulp のタスクを bun のスクリプトに分解して npm scripts から呼び出すように変更、外部ライブラリーは gulp のプラグインの形で利用していたものを、個々のパッケージから直に呼び出すように書き換えました。

ちなみに今回は TypeScript は使わず、JavaScript のままで動かしています。pug に渡す locale やオプション用のオブジェクトなどが可変なものが多く、1ショットのスクリプトなのでそこまで入り組んでいないというものあり、まずは移行することを目標にしてそのまま持ってくることにしました。

ただ書いてる途中でやっぱり補完は欲しくなったので、どっかで書き換えるかもしれません。

ここから、いくつが具体的な内容を挙げていきます。

ファイルの再帰処理とパイプ処理

おそらくこれが gulp の一番のメリットだったんじゃないかという点。

gulp.src("src/scss/**/*.scss")
  .pipe(sass(options))
  .pipe(gulp.dest("assets/css/"));

みたいな感じで、ディレクトリー内のファイルを再帰的に処理することができました。

ちなみに上記の例は src ディレクトリー内の scssファイルを SASS でトランスパイルして assets/css ディレクトリーに書き出すというもの。読み込み元のディレクトリー構成も維持されたまま出力されます。

これを bun で書くとこんな感じに

const glob = new Glob("**/*.scss");
for await (const srcFile of glob.scan("src/scss")) {
    
    const result = sass.compile(`src/${srcFile}`, options);
    
    // パスの取得
    const path = path.dirname(srcFile);
    // 拡張子を除いたファイル名の取得
    const fileName = path.basename(srcFile, path.extname(srcFile));
    
    // assets 配下にディレクトリ構造を維持しつつ、css ファイルとして保存
    write(`assets/css/${path}/${fileName}.css`, result.css);
}

Glob を使ってファイルを反復処理しています。

この例では拡張子の変更が若干メンドウ。まあこの場合は単に正規表現で末尾を置き換えるだけでもよさそうですが。

write はBun の標準APIで用意されている関数で、保存先のディレクトリーが存在しない場合にはディレクトリーも含めて作成してくれます。

mkdir がまだ Bun Shell には未実装で、$ から呼び出そうとするとうまく動いてくれずに悩んだのですが、この関数で一発解決しました。

これを Bun Shell で ls を使ってやろうとするとこうなります。

for await (const srcFile of $`ls src/**/*.scss`.lines()) {
  const result = sass.compile(srcFile, options);
  const path = path.dirname(srcFile);
  const fileName = path.basename(srcFile, path.extname(srcFile));
  write(`${srcFile.replace(/^src/, "assets/css/")${path}/${fileName}.css`, result.css); 
}

ほぼ同じですが、パスに対象ディレクトリーも含まれているので追加で置き換える必要があります。この辺りが若干面倒だったので Glob を使う形にしています。

ちなみに実際には下記のようなラッパー関数を作り

async function iterateGlob(pattern, scanPath, func) {
  const glob = new Glob(pattern);
  for await (const path of glob.scan(scanPath)) {
    await func(path);
  }
}

Glob の生成を省略できるようにしています。

await iterateGlob("**/*.scss", config.src.styles, async (srcFile) => {
  // process
});

Webpack

Webpack は Bun の組み込みのバンドラーである Bun.build で置き換えました。

ただ最初の方でも書きましたが、ここのスクリプトはあまり大したことをしておらず、TypeScript すら使ってない素の JavaScript で書いてしまっています。(他の所では TypeScript なんです。昔からあるやつだけなんです。)

なのでビルドの必要もあまりなく、単に import の解決や minimize をやっている程度です。

Bun.build はまだベータということもあって機能がそこまで充実していない印象なので、複雑な構成の場合は Vite 辺りを導入した方がよさそうです。(公式サイトに サンプル があります。)

ファイル変更の監視

Gulp だと gulp.watch を使って

gulp.watch(`src/scss/**/*.@(css|scss)`, gulp.task('styles'));

といった形で書くことができます。

Bun の場合は node の fs.watch が利用できますが、Glob での指定ができないので大人しく外部ライブラリーを使用します。

gulp.watch は chokidar が使われているようですが、今回は glob-watcher というのを使ってみました。というか別のライブラリーというか単に chokidar のラッパーで、もう少し手軽に書けるようになったものです。

watch([`src/scss/**/*.{scss,css}`], function (done) { 
  $`bun styles`;
  done();  
});

$bun [script] を使って別ファイルの bun を呼び出しています。

gulp のようなタスクの概念はないので 1タスク 1ファイルとなりますが、まあそれは大人しくファイルを分ければよいかと思います。(タスクが多くなると整理が大変そうですが)

dev server

意外なことに、というか Web専用フレームワークというわけではないので当たり前ですが、確認用の dev サーバー的なものがありそうでありません。

一応 HTTPサーバーとして Bun.serve が用意されているのですが、シンプルにリクエストに対してレスポンスを返すだけ程度の実装で、ディレクトリーを指定すれば勝手に返してくれるみたいな親切なことはしてくれません。

まあ何かライブラリーを使えばいいのですが、公式サイトにあった ファイルを読み込んでそれを返すサンプル で事足りるので、index.html の処理を加えて使うことにしました。(AI に手伝ってもらいました)

Bun.serve({
  async fetch(req) {
    const path = new URL(req.url).pathname;

    // 拡張子が含まれていない場合は index.html を返す
    if (!path.split("/").pop().includes(".")) {
      return new Response(Bun.file(`${config.dest.root}${path}/index.html`));
    }    

    // ファイル名指定が無い時に index.html を追加
    const file = Bun.file(`./dist${path.endsWith("/") ? path + "index.html" : path}`); 
    
    return new Response(file);
  },
});

・・・って書いてから、firebase 使ってるんだから、普通に firebaseで見れるようにすればいいじゃん と気づきました...まあ使ってないのもあるので、それ用に。(あと起動や動作も bun の方が速いです。)

同時実行

監視とdev server を同時に起動させたいとき、gulp では gulp.series と gulp.parallel でタスクの逐次処理、並列処理を行うことができました。

gulp.task("dev",
  gulp.series(
    "build",
    gulp.parallel("watch", "serve")
  )
)

上記の例では、最初に build を行ってから、watch と serve を同時に実行しています。

bun の場合は npm script で bun watch & bun serve すれば同時起動が可能ですが、ctrl+c で停止した際にプロセスが残ってしまいます。

なので npm-run-all という外部ライブラリーを使用し

"build": "bun scripts/build.js",
"serve": "bun scripts/serve.js",
"watch": "bun scripts/watch.js",
"dev": "npm-run-all build --parallel watch serve" 

という形で記載して、gulp の時と同じような動作をさせるようにしています。


というわけで、この記事が新しい仕組みで生成された最初の記事となります。

あまり複雑なことをしていなかったこともあって、最初に思ってた以上にスムーズに移行することができました。

さらに実行速度もちゃんと図ってないですが数秒程度は早くなり、Bun の実行速度の恩恵を受けることもできました。

Bun を触っての感触としては、標準で一通り揃っていて、 bun shell のような野心的な機能も追加されていて勢いは感じるのですが、なんとなくまだカチッとしたシステム、業務プロジェクト等での採用は若干抵抗を感じました。

ただ、手元でちょっとした処理を行いたいような時には、一通りの機能が揃い、手軽に作成できて実行速度も速いという強みが効いてきそうなので、ひとまず個人用途でチョイチョイ触っていきたいと思いました。

まだ Gulp を使っていて移行を検討している場合は、選択肢の一つとして検討してみてはいかがでしょうか。

以上、よろしくお願いいたします。