JavaScript実行環境の判定大全:ブラウザ/Node.js/Deno/Workers/Edgeで動くコード設計

JavaScript実行環境の判定大全:ブラウザ/Node.js/Deno/Workers/Edgeで動くコード設計

はじめに (対象読者・この記事でわかること)

本記事は、ブラウザ・Node.js・Deno・Bun・Cloudflare Workers・Service Workers・Edge Functionsなど、複数のJavaScript実行環境で動くコードを書きたいフロント/バックエンド開発者やライブラリ作者を対象としています。実行環境の自動判定や環境差分の吸収、ESM/CJS・グローバルAPI差の取り扱い、SSR/ISR・Edge/Worker特性の考慮など、現場でつまずきがちな要点を網羅します。読み終えると、環境を安全に判定するパターン・アンチパターン、条件分岐よりも抽象化やCapability Detectionを優先する設計、バンドラ設定や型定義での補助など、移植性の高い実装ができるようになります。

開発環境・前提知識

この記事で紹介する内容は、以下の環境で動作確認をしています。

カテゴリ バージョン/情報
OS macOS Sonoma 14.x
言語/FW Node.js 20.x / 22.x, Deno 1.43+, Bun 1.1+
ライブラリ TypeScript 5.6+, Vite 5, esbuild 0.20+

また、この記事を読み進める上で、以下の知識があるとスムーズです。 * JavaScript/TypeScriptの基本とモジュール(ESM/CJS) * Web/Fetch/Streamsなどの標準APIの概要


なぜ「実行環境判定」が難しいのか:概要と背景

JavaScriptは「どこでも動く」言語ですが、実際には実行環境ごとにグローバルやAPI、モジュール解決、I/O権限、スレッド/イベントループの特性が異なります。従来は「typeof window !== 'undefined'」でブラウザ判定するだけで十分でしたが、今はService Worker/Cloudflare Workers/Edge RuntimeのようにDOMなし・windowあり/なしが混在し、Node.jsもfetch/WHATWG Streams/undiciやWeb Cryptoなどを取り込み、境界が曖昧になっています。さらに、BunやDenoはNode互換を進めながら独自APIも提供し、バンドラやSSRのコンテキストでは「ビルド時・実行時」で条件が変わることもあります。

そこで重要なのは「環境名の判定」ではなく「使いたい機能(能力=Capability)の存在」を検出する設計です。例えば「ファイルが読みたい」ならfsやDeno.readTextFileの存在を検知して抽象化レイヤーで差を吸収する、Web向けにはFile System Access APIやfetchを利用する、といった方針です。これによりif (node) ... else ... の分岐地獄や、将来の環境追加による破綻を回避できます。本記事は、判定の実践法と抽象化パターン、そして落とし穴を具体例で示します。


実行環境の具体的な判定と移植性の高い実装パターン

ここが記事のメインパートです。現代的な「能力検出」を核に、やむを得ず環境名を判別する場合の安全策、TypeScriptでの型サポート、バンドラ設定、テスト戦略まで順に解説します。

ステップ1: まず「機能の存在」で分岐する(Capability Detection)

最優先は機能検出です。典型例を示します。

(機能検出: fetch/ReadableStream/Web Crypto)

export const hasFetch = typeof globalThis.fetch === "function";
export const hasReadableStream = typeof globalThis.ReadableStream !== "undefined";
export const hasWebCrypto = typeof globalThis.crypto?.subtle !== "undefined";

(ストレージAPI: Node/Deno/Browserの抽象化)

export interface KeyValueStore {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
}

export function createStore(): KeyValueStore {
  // Cloudflare Workers/Service Workers/Edge などで KV/Cache API を使う場合は別途注入
  // ここでは最小例として環境ごとの代替を用意
  if (typeof localStorage !== "undefined") {
    return {
      async get(k) { return localStorage.getItem(k); },
      async set(k, v) { localStorage.setItem(k, v); },
    };
  }
  // Node: fsにフォールバック
  if (typeof process !== "undefined" && process.versions?.node) {
    const fs = await import("node:fs/promises");
    const path = ".kv.json";
    let cache: Record<string, string> = {};
    try {
      cache = JSON.parse(await fs.readFile(path, "utf8"));
    } catch {}
    return {
      async get(k) { return cache[k] ?? null; },
      async set(k, v) {
        cache[k] = v;
        await fs.writeFile(path, JSON.stringify(cache), "utf8");
      },
    };
  }
  // Deno
  if (typeof Deno !== "undefined" && Deno.readTextFile) {
    const path = ".kv.json";
    let cache: Record<string, string> = {};
    try {
      cache = JSON.parse(await Deno.readTextFile(path));
    } catch {}
    return {
      async get(k) { return cache[k] ?? null; },
      async set(k, v) {
        cache[k] = v;
        await Deno.writeTextFile(path, JSON.stringify(cache));
      },
    };
  }
  // 最後の手段: メモリ
  const mem = new Map<string, string>();
  return {
    async get(k) { return mem.get(k) ?? null; },
    async set(k, v) { mem.set(k, v); },
  };
}

ポイント: - 使いたいAPIがあるかを先に調べる。 - 環境名ではなく能力で分岐することで新環境にも適応しやすい。 - インタフェースを定義して差分を隠す。

(ESM限定の注意)

/**
 * ESM環境では import.meta.url や import.meta.resolve が使える場合がある。
 * NodeのCJS由来の __dirname/__filename はESMでは直接使えないため代替を使う。
 */
export const here = new URL(".", import.meta.url);

ステップ2: それでも必要な「環境名の判別」安全レシピ

一部の最適化やバグ回避で環境名が必要な場合の最小限レシピです。直接windowやdocument存在だけを根拠にすると、Worker/Edgeで誤判定します。

(ブラウザのDOMありページコンテキスト)

export const isBrowserDOM =
  typeof window !== "undefined" &&
  typeof document !== "undefined" &&
  typeof navigator !== "undefined";

(Service Worker / Web Worker / Worklet 系)

export const isWorkerLike =
  typeof self !== "undefined" &&
  typeof Window === "undefined" && // Windowクラスが無い
  typeof DedicatedWorkerGlobalScope !== "undefined";

(Node.js)

export const isNode =
  typeof process !== "undefined" &&
  !!(process.versions && process.versions.node);

(Deno)

export const isDeno =
  typeof Deno !== "undefined" &&
  typeof Deno.version?.deno === "string";

(Bun)

export const isBun =
  typeof Bun !== "undefined" &&
  typeof Bun.version === "string";

(Cloudflare Workers/Edge Runtimeの示唆)

export const isCloudflareWorkers =
  typeof WebSocketPair !== "undefined" && // CF特有
  typeof navigator === "undefined";       // DOMなし

export const isEdgeLike =
  typeof globalThis.fetch === "function" &&
  typeof process === "undefined" &&       // Nodeではない
  typeof Deno === "undefined";            // Denoでもない

注意点: - 仕様外の独自グローバルに依存すると壊れやすい。最終手段として使う。 - SSR中の「ビルド時」やテスト環境(JSDOM, happy-dom)は偽陽性/偽陰性になり得る。

ステップ3: 条件付きエクスポート・ツリーシェイクとバンドラ設定

Nodeやブラウザで別実装を配布するなら、package.jsonのexportsやブラウザ向けフィールド、条件付きエクスポートを活用します。

(package.jsonの例)

{
  "name": "universal-lib",
  "type": "module",
  "exports": {
    ".": {
      "browser": "./dist/browser.mjs",
      "worker": "./dist/worker.mjs",
      "deno": "./dist/deno.mjs",
      "default": "./dist/node.mjs"
    }
  }
}

Viteやesbuildではdefineで分岐を削除できます。

(Vite config例)

export default {
  define: {
    __BROWSER__: JSON.stringify(true),
  }
}

(コード側)

declare const __BROWSER__: boolean;

if (__BROWSER__) {
  // DOM実装
} else {
  // Node/Edge実装
}

ポイント: - ビルド時定数でデッドコード除去を促す。 - Tree Shaking可能な形に保つため副作用を避ける。

ステップ4: I/Oと暗号・ネットワークのポータブルAPIで揃える

近年はWeb標準APIがサーバでも使えます。まずは標準APIを優先し、不足時のみフォールバックを。

(fetch/Request/Responseの標準化)

async function getJson(url: string) {
  const res = await fetch(url, { headers: { "accept": "application/json" } });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

(Web Crypto優先, Nodeフォールバック)

export async function sha256(input: Uint8Array): Promise<ArrayBuffer> {
  if (crypto?.subtle?.digest) {
    return crypto.subtle.digest("SHA-256", input);
  }
  // 古いNode用フォールバック
  const { createHash } = await import("node:crypto");
  return createHash("sha256").update(input).digest().buffer;
}

(Streams: WHATWGを使う)

export async function toUint8Array(stream: ReadableStream<Uint8Array>) {
  const reader = stream.getReader();
  const chunks: Uint8Array[] = [];
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    chunks.push(value);
  }
  const total = chunks.reduce((a, c) => a + c.byteLength, 0);
  const out = new Uint8Array(total);
  let off = 0;
  for (const c of chunks) { out.set(c, off); off += c.byteLength; }
  return out;
}

ステップ5: SSR/Edge/Worker特有の罠と対処

ステップ6: 型定義とテスト戦略

(TypeScriptのDOM型を環境ごとに切り替える) tsconfigでlibを分割: - ブラウザ: ["ES2022", "DOM", "DOM.Iterable"] - Node: ["ES2022"] + @types/node - Worker/Edge: ["ES2022", "WebWorker"]

環境別にテストを回す: - ブラウザAPI: Vitest + happy-dom/JSDOM - Node: Vitest/Jestのnode環境 - Deno: deno test - Workers: Miniflare/Cloudflare Workerd - CIで各ランタイムをMatrix実行

ハマった点やエラー解決

エラー内容:

ReferenceError: window is not defined

解決策: - 直行参照を避け、typeof window !== "undefined" でガード。 - 可能なら依存注入に切り替え、DOM依存コードを分離。

エラー内容:

TypeError: crypto.subtle is undefined (Node older runtime)

解決策: - Web Cryptoがなければnode:cryptoへフォールバック。 - Node 20+ でcrypto.webcrypto.subtleを優先利用。

エラー内容:

Cannot use import statement outside a module

解決策: - "type": "module" と拡張子.mjsの整合を取る。 - CJS/ESMを条件付きエクスポートで分離。

エラー内容:

Dynamic require of "fs" is not supported

解決策: - ブラウザバンドルにfsを含めない。ビルド時定数と条件付きimportでツリーシェイク。


まとめ

本記事では、JavaScriptの実行環境を「名前で判定する」のではなく、「利用したい機能の存在を検出して抽象化する」設計を中心に解説しました。
- Capability Detectionを最優先に、環境差をインタフェースで吸収する。
- やむを得ない環境名判定は最小限・安全なシグナルで行う。
- 標準API優先・条件付きエクスポート・ビルド時定数・型/テスト分離で保守性を高める。

これにより、ブラウザ/Node/Deno/Bun/Workers/Edgeなど多様なランタイムに耐えるコードが書けます。今後は、各環境のベンチマーク比較、Edge特化の性能チューニング、KV/Cache/耐障害ストレージの抽象化ライブラリ設計も掘り下げる予定です。


参考資料