Node.js と Deno と Bun のどれを使えばいいのか

現在注目されているサーバーサイドJavaScriptランタイムといえば、Node.js と Deno と Bun の3つが挙がると思います。この記事ではそれぞれのランタイムを比較し、将来的にどのような使い分けをしていけばいいのか考えます。

# Node.js と Deno と Bun の現在


## npmライブラリにはすべてのランタイムが対応

- Node.js：当然npmライブラリには対応しています。
- Deno：[npmパッケージを配布するCDN](https://zenn.dev/uki00a/books/effective-deno/viewer/how-to-use-npm-packages)や、[npmインポート](https://qiita.com/access3151fq/items/e38f78db2d96a1ddd6cc)を使うことで対応しています。
- Bun：対応しています。

:::note warn
「Denoが急に方針転換をしてnpm対応を始めた」というのはよくある間違いです。
[Big Changes Ahead for Deno](https://deno.com/blog/changes)（Denoの今後の大きな変化）というブログタイトルが「Denoが大幅な方針変更」というタイトルに翻訳されて日本語記事が出たため、急に方針転換をしたと誤解されがちですが、実際は[2019年から対応](https://github.com/denoland/deno/pull/3319)を始めており、今回満を持して登場という形です。
:::

どのランタイムも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周りなど、「非標準の挙動を裏側でライブラリが制御する」という形が多く、エコシステムの独自進化が強いという側面がある気がします。

<!--
Node.jsとしてはどちらのモジュールもサポートしていく方針ですが、

- モジュール解決アルゴリズムが独特（作者のryan dahlが後悔していることのうちの一つに挙げている）
- TypeScriptの拡張子問題
- ESMからCJSをimportできるが、CJSからESMをreqireできない
  - ライブラリをESM化すると既存のCJSアプリケーションから使えないため、移行が進まない
- Fake ESMとNative ESMの挙動の違い
- BabelやwebpackやTSCやJestが裏側で複雑に絡み合っており、設定方法が難解
- 非標準のCSS Modulesが普及してしまっている

などの問題があります。
基本的にブラウザのESMとも従来のCJSとも違う新しい挙動を導入してしまった上に、トランスパイラがESMをCJSに変換して実行する機能を導入したりしたことが遠因で、これらの問題やファイル拡張子問題関連を「裏側でイイ感じに変換すれば大丈夫だよね」という感じでそれぞれのライブラリが解決してきたことが余計問題を複雑化していると思います。
またCSS Modulesは`import css from "foo.css"`という形でCSSファイルがimportできる機能ですが、これは言ってみればライブラリ側が勝手に実装した機能でWeb標準ではありません。ブラウザでCSSをimportした時とは挙動が違ってきます。これはあくまで一例ですが、Node.jsのエコシステムは標準と非標準の2種類の処理があってどちらの挙動を選択するか不明確（そのためにまた設定項目が増える）という、エコシステムの独自進化が強いという一面があると思います。

-->

参考：

- [実践 Node.js Native ESM — Wantedlyでのアプリケーション移行事例](https://www.wantedly.com/companies/wantedly/post_articles/410531)
- [TypeScript 4.7 source map file extension mismatch](https://github.com/microsoft/TypeScript/issues/49335)

### Denoの場合

Denoはブラウザ互換のESMのみがサポートされています。
import文の中にURLを書くという点が最も特徴的で、メリットとしては上記のNode.jsのような複雑な仕組みや設定ファイルが不要でシンプル（import文に指定したパスにあるファイルがimportされるだけ）という点、デメリットとしては単純に書く量が長くなるという点があります。

CJSについては、npmライブラリ内でのみ（Node.jsの挙動に合わせる形で）利用可、それ以外の場所（通常のDenoのコード内）では利用不可となっています。


### Bunの場合

https://twitter.com/jarredsumner/status/1475238259127058433

なんと、Bunでは**ESMとCJSの挙動がミックス**されています。

`import` / `export` が使えたりstrict modeで動作する（ESM挙動）一方で、`require`や`__dirname`なども使える（CJS挙動）ようになっています。

Node.jsでCJS/ESM互換性問題に苦しんでいた人にとっては便利で嬉しいという反応になるのかもしれません。

ただ、Node.jsなどの既存ランタイムではこの様な挙動は実装されていません。
Node.jsでもESM内で`require`を使えるようにしようという提案自体はあったのですが、議論の末却下されています。

https://github.com/nodejs/modules/issues/10

そもそも、こうした状態を避けるために先述の難解な挙動やdual package対応などの多大な労力を払ってきた歴史があるので、単に「便利だから実装しました」という方向性で進めてしまってもいいのか？という疑問があります。

参考
- [Node.jsのES Modulesサポートの現状確認と備え](https://teppeis.hatenablog.com/entry/2017/08/es-modules-in-nodejs)

:::note warn
#### Tips: `import.meta`オブジェクトの違い

ESM内では`import.meta`というメタプロパティが使えます。ここからモジュールのメタ情報を取得できるのですが、取得できる内容にそれぞれ差があります。

<details>
<summary>長いので折り畳み</summary>
<div>

  ##### 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オブジェクト](https://jestjs.io/ja/docs/jest-object)
  - `import.meta.resolve()`: 自身のファイルパスを起点に相対パスを解決する
    - Node.jsと同様

</div>
</details>
上記を見てもらったら分かると思うのですが、各ランタイムで基本的に互換性はありません。
グローバル変数の自動挿入が避けられる傾向にある現在、ランタイム側が自由に機能を実装できる場として使われているようです。

特に`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自身が公式でフレームワークをリリースする](https://fresh.deno.dev/)など最近ホットです。

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`コマンドが[これから動くようになる予定](https://github.com/oven-sh/bun/issues/349)とのことで、こちらも期待です。




# 前提4：速度の劇的な差は無い

まず、Web上に出ている速度比較記事はあまり当てにならなそうです。というのも、

:::note alert

不正確なベンチマーク（例）

- `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があり並列処理されていない」など

:::

<!--

Bunは自社に有利なベンチマークしか公式HPに載せていないし、
Denoは、HTTPサーバーが[公式サイト](https://deno.land/benchmarks#http-server-throughput)ではBun < Denoだが[非公式のベンチマーク](https://github.com/denosaurs/bench)ではDeno < Bunなのが気になる。

-->
こうした不正確なベンチマークが結構ありました。

マイクロベンチマークしたら3倍速かったという話も見かけますが、これはソースコードのある部分の処理が局所的に3倍速いということです。ある処理はNode.jsが最速で、ある処理はDenoが最速で、ある処理はBunが最速という状況らしいので、ソースコード全体で見ると実行速度の劇的な差はそんなにない気がします。

もちろん実行速度の向上はとても大事ですし、速ければ速いほどいいのですが、**現時点でランタイム乗り換えの動機になるほどの劇的な速度差が出ているかはちょっと疑問**です。

というわけで、ランタイムを選択する際に「速さ」が鍵になることは現状ではあまりなさそうです。


:::note
このあたりで面白いのが、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種類の道が用意されています。どれを選ぶかはランタイム次第ですが、とりあえず<b>「Node.js互換系」「Web標準互換系」の2系統に収束していく</b>雰囲気なので、そこでランタイムを選択していく感じになると思います。

他ランタイムとの互換性が重視され、独自の方針を進むランタイムが淘汰されていくというのは、ブラウザ戦争でもあった話です。サーバーサイド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](https://wintercg.org/)というワーキンググループが設立され、仕様が統一されていく流れ
- ランタイム自体に独自性を出すのではなく、周辺ツールやデプロイ先まで巻き込んだ開発体験を提供できるかが鍵

となっていると思います。

収益源の確保という意味でも、デプロイ先となるFaaSやPaaSを運営している所は強いなと感じました。（新規参入側にとってはこれらを作りきるまでのハードルが高いのですが…）

これからもまだまだ新しいランタイムがたくさん出てくると思うので、期待＆注目してます