この記事の対象読者
JavaScript は書けるけれど、ランタイムやエンジンの説明をしろと言われると詰まる人に向けた記事です。
- 「Node.js は JavaScript のエンジンですか?」と聞かれて、うまく答えられない
- ブラウザで動いたコードが Node.js で動かない理由がわかっていない
- Vercel や Cloudflare の Edge Runtime で
fsやprocessが使えなくて戸惑った -
setTimeoutやfetchが JavaScript の機能なのか知らない
前提知識は JavaScript を書いたことがあることです。Node.js も触っていると読みやすいですが、必須ではありません。
逆に、V8 の中身(JITの最適化とかGCのアルゴリズムとか)を知りたい人には向きません。ここで扱うのは、どこまでがエンジンでどこからがランタイムか、という線引きの話だけです。
「Node.js は JavaScript のエンジンでしょうか?」
答えは No です。Node.js はランタイムで、エンジンは中に入っている V8 のことです。
とはいえ、その境界はどこなんでしょうか?setTimeout はどっち側なのか。Promise は?イベントループは?
わかりやすくするために、コードを例に説明してみます。Node.js には vm という標準モジュールがあって、ホストAPIが一切ない「素のV8コンテキスト」を作れます。そこで何が使えて何が使えないかを見れば、その境界が見えてくるかもしれません。
検証環境: Node.js v20.11.1 / macOS
まず境界を見てみる
vm.createContext({}) に空オブジェクトを渡すと、何も注入されていないコンテキストが手に入ります。あとはそこで typeof を並べるだけ。
const vm = require("node:vm");
const ctx = vm.createContext({}); // ホストAPIを一切入れない
const names = [
"Promise", "JSON", "Math", "Symbol", "Map", "Proxy", "Reflect", "Intl",
"setTimeout", "setInterval", "queueMicrotask", "fetch", "process",
"Buffer", "URL", "TextEncoder", "structuredClone", "AbortController",
];
for (const n of names) {
console.log(n.padEnd(18), vm.runInContext(`typeof ${n}`, ctx));
}
結果です。
Promise function
JSON object
Math object
Symbol function
Map function
Proxy function
Reflect object
Intl object
setTimeout undefined
setInterval undefined
queueMicrotask undefined
fetch undefined
process undefined
Buffer undefined
URL undefined
TextEncoder undefined
structuredClone undefined
AbortController undefined
きれいに二分されました。
上半分(Promise、JSON、Math、Symbol、Map、Proxy、Reflect、Intl)は素のコンテキストにもあります。ECMAScript 仕様(ECMA-262)が定義しているもの、つまりエンジンが持っている部分です。
下半分は全部 undefined。setTimeout も fetch も process も、ECMAScript の仕様には一行も書かれていません。普段使えているのは、ホスト環境が足してくれているからです。
呼んでみると、そもそも存在しないと怒られます。
vm.runInContext("setTimeout(() => {}, 0)", ctx);
// ReferenceError: setTimeout is not defined
素のコンテキストのグローバルは、数えたら62個しかありませんでした。普段書いている JavaScript は、思っているよりホスト環境に依存しています。
エンジンとランタイムの役割分担
図にするとこう。
| エンジン | ランタイム | |
|---|---|---|
| 準拠する仕様 | ECMAScript(ECMA-262) | HTML仕様、各ランタイムの独自仕様 |
| 主な仕事 | パース、JITコンパイル、実行、GC | イベントループ、タイマー、I/O、モジュール解決 |
| 提供するもの |
Object Array Promise JSON Math
|
setTimeout fetch process window
|
| 例 | V8、JavaScriptCore、SpiderMonkey、Hermes | Node.js、Deno、Bun、ブラウザ、Cloudflare Workers |
エンジンがやるのは、JavaScript という言語を実行することだけ。ファイルを読むことも、通信することも、時間を測ることもできません。全部ランタイム側の仕事です。
組み合わせは1対1ではない
エンジンとランタイムは別々に選べます。以下はその表です。
| ランタイム | エンジン |
|---|---|
| Node.js | V8 |
| Deno | V8 |
| Bun | JavaScriptCore |
| Chrome / Edge | V8 |
| Firefox | SpiderMonkey |
| Safari | JavaScriptCore |
| Cloudflare Workers(workerd) | V8 |
| React Native | Hermes |
ここで目を引くところが2つあります。
まず、Node.js と Chrome は同じ V8 なのに書けるコードが違う。document は Chrome にあって Node にない。process はその逆です。エンジンが同じでも、ホストが違えば使えるAPIは変わります。
もうひとつ、Bun だけエンジンが違います。Bun は起動が速いとよく言われますが、理由のひとつはこれです。JavaScriptCore は起動時間とメモリ使用量に強い。ランタイムを Zig で書いたから速い、という話だけではありません。
イベントループはどちらにあるか
イベントループはエンジンにありません。
V8 にそれは含まれていません。Node.js のイベントループは libuv というライブラリが持っており、ブラウザのそれは HTML仕様が定義しています。同じ V8 を積んでいても、この部分は完全に別物です。
じゃあ非同期処理は全部ランタイム側かというと、そうでもない。Promise のジョブキューは ECMAScript 仕様の側にあります。
これも確かめられます。ホストAPIが何もないコンテキストで Promise.then が動くかどうか。
const vm = require("node:vm");
const log = [];
const ctx = vm.createContext({ report: (s) => log.push(s) });
vm.runInContext(`
report("sync-1");
Promise.resolve().then(() => report("microtask"));
report("sync-2");
`, ctx);
console.log("実行直後:", JSON.stringify(log));
setTimeout(() => console.log("次のティック:", JSON.stringify(log)), 0);
実行直後: ["sync-1","sync-2"]
次のティック: ["sync-1","sync-2","microtask"]
setTimeout すら存在しないコンテキストで、Promise.then は動きました。
しかも実行されたのは vm.runInContext() が返ってきた後です。ホスト側に制御が戻ってから動いている。
分担はこうなっているようです。
- ジョブキューの存在と、コールバックが積まれる順序 → ECMAScript 仕様(エンジン)
- キューをいつ流すか → ホスト(ランタイム)
だから Promise.then 同士の実行順はどの環境でも同じ。でも setTimeout との前後関係はホスト次第です。マイクロタスクとマクロタスクの話が環境ごとに微妙に食い違って見えるのは、たぶんこの構造のせいです。
境界はきれいに割り切れない
ここまで「エンジンはECMAScript、それ以外はホスト」で通してきましたが、例外があります。console です。
さっきの検証で、console は素のコンテキストにも存在していました。ECMAScript の仕様に console はないのに。
正体を調べます。
const vm = require("node:vm");
const ctx = vm.createContext({});
console.log("Nodeのconsoleと同一か:", vm.runInContext("console", ctx) === console);
vm.runInContext(`console.log("vm内から出力")`, ctx);
Nodeのconsoleと同一か: false
別物でした。しかも console.log を呼んでも何も出ません("vm内から出力" はどこにも現れませんでした)。
おそらく V8 がデバッグ用(インスペクタ)に持っている console で、ECMAScript 仕様のものではないはずです。ここは仕様まで追いきれていないので断言は避けますが、出力先をホストが繋いでいないから何も起きない、という理解で辻褄は合います。普段ターミナルに文字が出るのは、Node.js が出力先を用意してくれているからです。
「エンジンは言語機能だけ」は原則としては正しいです。ただ実際のエンジンは、こういうデバッグ支援みたいな付随機能も抱えています。きれいに二分できるわけではなく、原則と例外があるようです。
これが分かると何が説明できるか
境界が見えると、実務で困る場面に説明がつきます。
ブラウザ用のコードが Node.js で動かない。
window も document も localStorage も、エンジンではなくブラウザというホストの持ち物です。実際、Node.js 本体のグローバルにもこれらはありませんでした(typeof window は undefined)。同じ V8 でも、ホストが違えば使えない。
Edge Runtime(Vercel、Cloudflare Workers)で Node.js の API が使えない。
Cloudflare Workers はエンジンこそ V8 ですが、ランタイムは workerd という別物。fs も process も Node.js が提供しているものなので、ホストが変われば消えます。「同じ JavaScript なのになぜ」と思うところですが、変わったのはエンジンではなくランタイムのほうです。
Node.js のバージョンを上げると新しい構文が使える。
構文を実装しているのはエンジン側です。Node.js を上げると同梱の V8 も上がるので、使える構文が増えます。逆に fetch のようなAPIが増えるのは Node.js の仕事なので、こちらはエンジンとは無関係に足されていきます。
Babel とポリフィルの守備範囲が違う。
Babel は新しい構文を古い構文に書き換えるツールで、これはエンジン側への対処。core-js のようなポリフィルは、存在しないAPIを実装して足すものです。構文が足りないのかAPIが足りないのかで打つ手が変わるのは、この境界があるからです。
まとめ
-
setTimeoutもfetchもprocessも ECMAScript の仕様にはない。ホスト環境が足しているもの - 素のV8コンテキストのグローバルは62個だけ。
PromiseやJSONはあるが、setTimeoutを呼ぶとReferenceError - エンジンは ECMAScript の実装(パース、JIT、実行、GC)。ファイルも通信も時間も扱えない
- ランタイムはエンジン+ホストAPI(イベントループ、タイマー、I/O)
- 組み合わせは1対1ではない。Node と Chrome は同じ V8、Bun だけ JavaScriptCore
- イベントループはランタイム側(Nodeはlibuv、ブラウザはHTML仕様)。ただし Promise のジョブキューは ECMAScript 側で、いつ流すかだけホストが決める
- 境界は完全には割り切れない。
consoleはV8が持っているのに仕様外で、ホストが繋がないと何も出ない
「同じ JavaScript なのに動かない」に出くわしたら、まず足りないのがエンジンの機能なのかホストのAPIなのかを切り分けると良いかもしれません。
参考
- ECMA-262(ECMAScript Language Specification)
- Node.js
vmモジュール - libuv