現在注目されているサーバーサイドJavaScriptランタイムといえば、Node.js と Deno と Bun の3つが挙がると思います。この記事ではそれぞれのランタイムを比較し、将来的にどのような使い分けをしていけばいいのか考えます。
Node.js と Deno と Bun の現在
npmライブラリにはすべてのランタイムが対応
- Node.js:当然npmライブラリには対応しています。
- Deno:npmパッケージを配布するCDNや、npmインポートを使うことで対応しています。
- Bun:対応しています。
「Denoが急に方針転換をしてnpm対応を始めた」というのはよくある間違いです。
Big Changes Ahead for Deno(Denoの今後の大きな変化)というブログタイトルが「Denoが大幅な方針変更」というタイトルに翻訳されて日本語記事が出たため、急に方針転換をしたと誤解されがちですが、実際は2019年から対応を始めており、今回満を持して登場という形です。
どのランタイムもnpmパッケージを使う仕組み自体は整っているので、この部分で目立った差は無いと思われます。
ただし、DenoとBunはNode.jsに存在する関数をポリフィルすることでnpmライブラリを動かしています。このポリフィルがDeno / Bun共に未完成で、それぞれ一部動かないライブラリがあります。
モジュールシステムの対応に差
JS界隈の大きな流れとして、かつて使われていたモジュールシステムであるCommon JS(require
使用)から新しいモジュールシステムであるES Modules(import
使用)への移行というものがあります。この移行に関するスタンスが各ランタイム間で分かれています。
※以下、Common JSは「CJS」、ES Modulesは「ESM」と表記します。
Node.jsの場合
現在もCJSを使っているライブラリが多く、ESM移行が課題となっています。
Node.jsとしてはどちらのモジュールもサポートしていく方針ですが、BabelやwebpackやTSCやJestが裏側で複雑に絡み合っており、設定方法が難解です。
TypeScriptの拡張子周りや、CSS Modules周り、Fake ESM周りなど、「非標準の挙動を裏側でライブラリが制御する」という形が多く、エコシステムの独自進化が強いという側面がある気がします。
参考:
Denoの場合
Denoはブラウザ互換のESMのみがサポートされています。
import文の中にURLを書くという点が最も特徴的で、メリットとしては上記のNode.jsのような複雑な仕組みや設定ファイルが不要でシンプル(import文に指定したパスにあるファイルがimportされるだけ)という点、デメリットとしては単純に書く量が長くなるという点があります。
CJSについては、npmライブラリ内でのみ(Node.jsの挙動に合わせる形で)利用可、それ以外の場所(通常のDenoのコード内)では利用不可となっています。
Bunの場合
なんと、BunではESMとCJSの挙動がミックスされています。
import
/ export
が使えたりstrict modeで動作する(ESM挙動)一方で、require
や__dirname
なども使える(CJS挙動)ようになっています。
Node.jsでCJS/ESM互換性問題に苦しんでいた人にとっては便利で嬉しいという反応になるのかもしれません。
ただ、Node.jsなどの既存ランタイムではこの様な挙動は実装されていません。
Node.jsでもESM内でrequire
を使えるようにしようという提案自体はあったのですが、議論の末却下されています。
そもそも、こうした状態を避けるために先述の難解な挙動やdual package対応などの多大な労力を払ってきた歴史があるので、単に「便利だから実装しました」という方向性で進めてしまってもいいのか?という疑問があります。
参考
Tips: import.meta
オブジェクトの違い
ESM内ではimport.meta
というメタプロパティが使えます。ここからモジュールのメタ情報を取得できるのですが、取得できる内容にそれぞれ差があります。
長いので折り畳み
Node.js
-
import.meta.url
: 自身のファイルのURL(file:///
始まり) -
import.meta.resolve()
: 自身のファイルパスを起点に相対パスを解決する-
--experimental-import-meta-resolve
フラグが必要 - 返り値は
file:///
始まり - ⚠️非同期関数
-
Deno
-
import.meta.url
: 自身のファイルのURL(file:///
やhttps://
始まり) -
import.meta.main
: エントリポイントのファイルかどうか -
import.meta.resolve()
: 自身のファイルパスを起点に相対パスを解決する- 返り値は
file:///
やhttps://
始まり - 同期関数
- import-mapを使用したパスの解決も可能
- 返り値は
Bun
-
import.meta.main
: 常にfalseを返している模様 -
import.meta.dir
: 自身のファイルがあるディレクトリ -
import.meta.file
: 自身のファイルのファイル名 -
import.meta.path
: 自身のファイルの絶対パス -
import.meta.url
: 自身のファイルのURL(file:///
始まり) -
import.meta.require()
: CJSのrequire
関数と同等, -
import.meta.resolve()
: 自身のファイルパスを起点に相対パスを解決する- ⚠️返り値は絶対パス(
/
始まり) - ⚠️非同期関数
- ⚠️返り値は絶対パス(
-
import.meta.resolveSync()
: 自身のファイルパスを起点に相対パスを解決する- ⚠️返り値は絶対パス(
/
始まり) - 同期関数
- ⚠️返り値は絶対パス(
- ※import.metaの
prototype
がnullでない(仕様違反)
Chrome 105
-
import.meta.url
: 自身のファイルのURL(https://
始まり) -
import.meta.resolve()
: 自身のファイルパスを起点に相対パスを解決する- 返り値は
file:///
やhttps://
始まり - 同期関数
- import-mapを使用したパスの解決も可能
- 返り値は
Jest
-
import.meta.url
: 自身のファイルのURL(https://
始まり) -
import.meta.jest
: Jestオブジェクト -
import.meta.resolve()
: 自身のファイルパスを起点に相対パスを解決する- Node.jsと同様
特にimport.meta.resolve()
の実装がランタイムごとに大きく違っています。特にWeb標準に従っているChrome&Denoと、Node.jsの初期実装(実験的API)の系譜を汲むBun等のランタイムの間で、大きく実装が異なります。
これは実はWeb標準仕様が固まったのがつい最近だからです。この辺は時間が経過してAPIが安定してきたらNode.jsのフラグが外れ、Web標準側に実装が統一されていくと思います。
フロントエンド開発の違い
フロントエンド開発に必要なものは
- エディタに補完や診断を出す機能(language server)
- ソースコードをブラウザ向けに変換する機能
です。これらの機能をサーバーサイドJSランタイムとそのツールチェインが提供しています。
Node.jsの場合
Node.jsでフロントエンド開発をする場合、Node.jsの構文に合わせてプログラムを書き、それをブラウザ向けに変換して使うという形になります。
tsserver、rollup、babel、webpack、esbuild、swcなどがメジャーだと思います。こちらは普通に使われているやり方なので特に問題はないと思います。
最近はフレームワークに全部お任せすることが多いかもしれません。
Denoの場合
DenoにおいてもNode.jsと同様、esbuildやswcを使ったフロントエンド開発が行われています。
Node.jsと同じく様々なフレームワークが使えるようになっているほか、Deno自身が公式でフレームワークをリリースするなど最近ホットです。
TypeScript対応については、deno lsp
コマンドでlanguage serverが起動したり、deno check
コマンドで型チェックを走らせたりすることができます。
また、Node.jsとはそもそもimport文のパス解決から違うので、既存のNode.js向けツールチェインをそのまま使うことはできません。Deno向けにカスタマイズされたものがあるのでそれを利用することになります。
もう1つDenoの特徴として、ビルドステップを避ける傾向があるということが言えます。各フレームワークが色々工夫して"no build step"を実現しているので面白いです。
Bunの場合
Bunの歴史はフロントエンド開発ツールから始まったとのことで、フロントエンド向けの機能が結構組み込まれています。(Bun.Transpiler
でトランスパイルできたり、bun --hot
コマンドでホットリロードできる)
また、Next.js向けの専用アダプターをBun側で用意しているため、Next.jsが動きます。
逆に言うとアダプターが提供されていないフレームワークは動かないので、Node.jsとの互換性はDenoのほうが一歩リードみたいな状況です。
ただこれもあと数年でNode.js互換性のレベルが上がってくれば、十分選択肢として上がってくるレベルになるかもしれません。
TypeScript対応について、Bun自身は型チェックする機構を持っていない(トランスパイルのみ)のですが、tsc
コマンドがこれから動くようになる予定とのことで、こちらも期待です。
前提4:速度の劇的な差は無い
まず、Web上に出ている速度比較記事はあまり当てにならなそうです。というのも、
不正確なベンチマーク(例)
-
time
コマンドなどを用いてコマンドの開始から終了までをベンチマークしている場合
→TypeScriptを直接実行できるランタイムでは、内部的にTypeScriptをJavaScriptに変換する際にかかる時間も計測されてしまっており不正確 - 頻繁な
console.log
は、stdoutへの書き込みという重い操作になるので、例えばfor文の中でconsole.logしているとそちらに結果が引っ張られ不正確になる - 不要なawaitがあってサーバーが並列処理されていない
- Node.js/Bun/Denoなどのランタイム側が実装しているAPIではなく、V8やJavaScriptCoreといったエンジン側が提供するAPIでベンチマークしている
例)Node.jsとDenoの間でArray#map
のベンチマークをする場合、Array#map
はエンジン側が実装しているものなので、V8エンジンの速さとV8エンジンの速さを比べている - 片方に有利なベンチマークになっている
例)「片方はFFI版、もう片方はwasm版を使用している」や、「片方のサーバーは並列処理されているが、もう片方は不要なawaitがあり並列処理されていない」など
こうした不正確なベンチマークが結構ありました。
マイクロベンチマークしたら3倍速かったという話も見かけますが、これはソースコードのある部分の処理が局所的に3倍速いということです。ある処理はNode.jsが最速で、ある処理はDenoが最速で、ある処理はBunが最速という状況らしいので、ソースコード全体で見ると実行速度の劇的な差はそんなにない気がします。
もちろん実行速度の向上はとても大事ですし、速ければ速いほどいいのですが、現時点でランタイム乗り換えの動機になるほどの劇的な速度差が出ているかはちょっと疑問です。
というわけで、ランタイムを選択する際に「速さ」が鍵になることは現状ではあまりなさそうです。
このあたりで面白いのが、RustやZigやC++で実装したから高速、JavaScriptで実装したから低速というわけではないというところです。というのも、JS <-> Rust/Zig/C++ <-> v8間でデータを転送する処理が結構ボトルネックになっているらしく、あえてJSで実装して処理をJSで完結させて高速化しているところもあるようです。
(以上を踏まえて、)どのJSランタイムを使えばいいのか?
JSランタイムは複数使い分けが当たり前に?
現在Node.js と Deno と Bunの3つのランタイムについて話していますが、実際はCloudflare WorkersやGoogle Apps Script、AWS CloudFront FunctionsもJSランタイムのうちの一つです。
現状でもこうした複数ランタイムがあるものの、用途によって複数のランタイムを使い分けている状況だと思います。この先サーバーサイドJSランタイムが複数出てきても、同じように用途別に使い分けていく形になると思います。
そもそもJSランタイムはパイが大きいので、ニッチな方向に特化していくことで十分生き残れるのではないかと思います。どれか1つのランタイムが覇権を取って他が淘汰されるという未来はあまりないと私は考えています。
NodeとWeb、2つの互換性
複数ランタイムが共存する中でシェアを伸ばしていくには、既存ランタイムとの互換性が重要です。
例えば新しいランタイムが出現した時に、HTTPリクエストやsetTimeoutなど全ての関数が全く新しいAPIになっていたらどうでしょうか?
1からその使い方を学ぶ必要があり、ユーザーの学習コストが高すぎます。加えて、独自のAPIを設計するための労力や、その設計を仕様として維持するための労力、それをテストする労力が非常に重たいコストになります。
そんな事をするくらいなら、既存のNode.jsのAPI仕様やWeb標準仕様に乗っかってしまおうというのが最近Web標準APIが流行っている理由の一つだと思われます。
Node.jsのAPIにはリファレンスと独自のテストスイートがありますし、Web標準APIもWPTというテストスイートがあります。DenoやBunなどの後発ランタイムは、こうした既存のAPI設計に乗っかると共に、そのテストスイートを通すことでAPIの正確性を担保しています。
既存ランタイムとの互換性を取るとして、Node.jsと互換性を取るのか、Web標準と互換性を取るのか、両方と互換性を取るのか、3種類の道が用意されています。どれを選ぶかはランタイム次第ですが、とりあえず「Node.js互換系」「Web標準互換系」の2系統に収束していく雰囲気なので、そこでランタイムを選択していく感じになると思います。
他ランタイムとの互換性が重視され、独自の方針を進むランタイムが淘汰されていくというのは、ブラウザ戦争でもあった話です。サーバーサイドJSランタイムもブラウザ戦争と同じ道を進むのかなーと予測しています。
周辺ツール系とデプロイ先が用意されていると強い
上では、ランタイム間の互換性を取っていく方向が強いと話しました。そうなるとランタイムごとの差別化が難しいと思われるかもしれませんが、ここで差別化の鍵になるのが周辺ツールとデプロイ先の確保だと思います。
周辺ツールについて
ここでいう周辺ツールとは、フォーマッターやリンター、テストツールといったものです。
Node.jsはエコシステムが成熟しており周辺ツールが充実していますが、後発ランタイムにはこうした機能が最初は存在しないこともあります。逆に言うと、こうした機能を最初から用意しているフレームワークやランタイムは結構優位に立っている感じがあります。
デプロイ先について
デプロイ先の確保も重要です。
Node.jsは各PaaS / FaaSが対応しているから良いとして、後発ランタイムはやはりデプロイ先に悩むことが予想されます。
最近ではJSランタイムを作っている会社がデプロイ先やフレームワークまで用意して、一貫した開発体験を提供する所も増えています。(CloudflareやVercelなんかもこの方向性ですね)
総括すると、ランタイム自体に独自性を出すのではなく(むしろ互換性を取る流れ)、「ランタイム外のツール系やデプロイ先をどこまで使いやすくするか」が戦いの主戦場になっています。
まとめ
ランタイムを選ぶ際に考慮すべきこと
- モジュールシステム周り
- ESMしか対応していないのか、CJSも使えるのか
- フロントエンドフレームワーク周り
- Node.js / Deno / Bunでそれぞれ使えるものが違うが、どれを選んでも出来ないことはない
- ネット上にある情報量の差が大きい
- どのようなAPIが用意されているか
- Node.js系のAPIが使えるのか、Web系のAPIが使えるのか
- 周辺ツール系の充実度
- デプロイ先が確保されているか
- ローカル開発~デプロイの一貫した開発体験が提供されているか
逆に、ランタイムを選ぶ際に考慮しなくていいこと
- npmライブラリ対応
- 基本どのランタイムも一応npmライブラリを動かす仕組みを持っている
- ただし、Node.js系API(例:
child_process.spawn
など)の対応が少ないと、動かないnpmライブラリが出てくる - どうしても使いたいライブラリがある場合は動くかどうか試した方がいいかも?
- 速度
- もちろん速い方がいいというのは前提な上で
- 「全ての処理が3倍速くなる」みたいな劇的な差は現状発生していない
- これがボトルネックになることは無さそう
- 「向こうのランタイムを選んでおけば時間内に処理できたのに…」みたいな事は発生しなさそう
今後のJSランタイム
上でも書きましたが、
- 全体的に、既存ランタイムとの互換性を高めていく流れになっている
- winter CGというワーキンググループが設立され、仕様が統一されていく流れ
- ランタイム自体に独自性を出すのではなく、周辺ツールやデプロイ先まで巻き込んだ開発体験を提供できるかが鍵
となっていると思います。
収益源の確保という意味でも、デプロイ先となるFaaSやPaaSを運営している所は強いなと感じました。(新規参入側にとってはこれらを作りきるまでのハードルが高いのですが…)
これからもまだまだ新しいランタイムがたくさん出てくると思うので、期待&注目してます