T-CREATOR

Vite 開発サーバーの内部構造:ミドルウェアとプラグインの流れを図解

Vite 開発サーバーの内部構造:ミドルウェアとプラグインの流れを図解

Vite の開発サーバーは、驚くほど高速なホットモジュール置換(HMR)と即座のサーバー起動を実現していますが、その裏側でどのような仕組みが動いているのか気になったことはありませんか?

本記事では、Vite 開発サーバーの内部アーキテクチャに焦点を当て、ミドルウェアとプラグインがどのように連携してリクエストを処理するのか、その流れを図解を交えながら詳しく解説していきます。Vite の内部構造を理解することで、カスタムプラグインの開発やパフォーマンスチューニングに役立つ知識が得られるでしょう。

背景

Vite が登場した経緯

従来のバンドラーベースの開発環境では、プロジェクトが大規模化するにつれて開発サーバーの起動時間や更新速度が遅くなるという課題がありました。

Webpack のような従来のバンドラーは、開発サーバー起動時にアプリケーション全体をバンドルする必要があったため、プロジェクトの規模に比例して待ち時間が増加していました。この問題を解決するために、Evan You 氏によって Vite が開発されたのです。

Vite の基本アーキテクチャ

Vite は、モダンブラウザのネイティブ ES モジュール(ESM)サポートを活用した開発サーバーです。

開発時はバンドルを行わず、ブラウザからのリクエストに応じて必要なモジュールだけを変換して配信します。この「オンデマンド変換」のアプローチにより、プロジェクトサイズに関わらず高速な起動と更新を実現しているのですね。

以下の図は、Vite の基本的なアーキテクチャを示しています。

mermaidflowchart TB
    browser["ブラウザ"] -->|"ESMリクエスト"| devserver["Vite開発サーバー"]
    devserver -->|"変換済みモジュール"| browser

    subgraph vite["Vite内部"]
        devserver --> middleware["ミドルウェアチェーン"]
        middleware --> plugins["プラグインシステム"]
        plugins --> transform["モジュール変換"]
        transform --> cache["キャッシュレイヤー"]
    end

    subgraph source["ソースファイル"]
        vue[".vue"]
        ts[".ts/.tsx"]
        css[".css"]
        assets["画像/フォント"]
    end

    transform -.->|"必要に応じて読込"| source

この図から、ブラウザのリクエストがミドルウェア、プラグイン、変換処理を経由してレスポンスされる流れがわかります。Vite は必要なファイルだけを読み込み、変換結果はキャッシュされるため、2 回目以降のアクセスはさらに高速になりますよ。

内部で使われている主要技術

Vite の開発サーバーは、以下の技術スタックで構築されています。

#技術役割
1ConnectNode.js の HTTP ミドルウェアフレームワーク
2esbuildTypeScript/JSX の超高速トランスパイラ
3Rollupプラグインインターフェースの基盤
4chokidarファイル監視システム
5wsWebSocket サーバー(HMR 用)

Connect は軽量なミドルウェアフレームワークで、Express の基盤にもなっている信頼性の高いライブラリです。Vite はこれを使ってリクエスト処理パイプラインを構築しています。

課題

モジュール変換の順序制御

開発サーバーでは、様々な種類のファイルを適切な順序で処理する必要があります。

例えば、Vue 単一ファイルコンポーネント(SFC)は、まず HTML テンプレート・スクリプト・スタイルに分割され、それぞれが個別に変換されてから再び統合されます。TypeScript ファイルは JavaScript に変換され、さらにインポートパスの解決が必要になるでしょう。

このような複雑な変換処理を、どのように整理して実行すればよいのでしょうか?

プラグイン間の依存関係

複数のプラグインが協調して動作する場合、実行順序が重要になります。

あるプラグインが別のプラグインの処理結果に依存している場合、正しい順序で実行されなければエラーが発生してしまいます。また、プラグイン同士が競合する可能性もあるため、優先順位の制御が必要です。

キャッシュの一貫性維持

ファイルが更新された際、関連するキャッシュを適切に無効化する必要があります。

例えば、共通ユーティリティファイルを変更した場合、それをインポートしているすべてのモジュールのキャッシュも無効化しなければなりません。この依存関係の追跡と更新伝播をどう実現するかが課題となりますね。

以下の図は、これらの課題がどのように関連しているかを示しています。

mermaidflowchart TB
    req["ブラウザリクエスト"] --> order["変換順序の制御"]
    order --> p1["プラグインA<br/>(例:Vue SFC分割)"]
    p1 --> p2["プラグインB<br/>(例:TypeScript変換)"]
    p2 --> p3["プラグインC<br/>(例:パス解決)"]
    p3 --> check["依存関係チェック"]

    check --> cached{{"キャッシュ<br/>有効?"}}
    cached -->|"はい"| return_cache["キャッシュから返却"]
    cached -->|"いいえ"| transform["変換実行"]
    transform --> save_cache["キャッシュ保存"]
    save_cache --> response["レスポンス"]
    return_cache --> response

    file_change["ファイル変更検知"] -.->|"無効化"| cached

この図から、リクエスト処理におけるプラグインの順序実行、キャッシュ判定、ファイル変更時の無効化という 3 つの課題が密接に関連していることがわかります。

解決策

ミドルウェアチェーンによる階層的処理

Vite は、Connect のミドルウェアパターンを採用してリクエスト処理を階層化しています。

各ミドルウェアは特定の責務を持ち、リクエストを次のミドルウェアに渡すか、レスポンスを返すかを判断します。この設計により、処理フローが明確になり、機能の追加や変更が容易になっているのです。

以下は、Vite の主要なミドルウェアの実行順序を示した図です。

mermaidflowchart TD
    start["HTTPリクエスト"] --> cors["CORSミドルウェア"]
    cors --> proxy["プロキシミドルウェア"]
    proxy --> static["静的ファイルミドルウェア"]
    static --> transform_mw["変換ミドルウェア"]
    transform_mw --> html["HTMLミドルウェア"]
    html --> spa["SPAフォールバック"]

    transform_mw --> plugin_chain["プラグインチェーン実行"]
    plugin_chain --> resolve["resolveId フック"]
    resolve --> load["load フック"]
    load --> transform_hook["transform フック"]

    transform_hook --> response["レスポンス送信"]
    spa --> response
    html --> response
    static --> response

この図から、リクエストが複数のミドルウェア層を通過し、各層で適切な処理が行われることがわかります。変換ミドルウェアの内部では、さらにプラグインチェーンが実行される構造になっていますね。

プラグインフックシステム

Vite は、Rollup のプラグインインターフェースを拡張したフックシステムを採用しています。

プラグインは、特定のタイミングで呼び出される複数のフック関数を実装できます。主要なフックには以下のようなものがあります。

#フック名実行タイミング用途
1configサーバー起動前設定の変更・拡張
2configureServerサーバー構成時ミドルウェアの追加
3resolveIdインポート解決時モジュールパスの解決
4loadファイル読込時カスタムローダー実装
5transform変換時コード変換処理
6handleHotUpdateファイル変更時HMR のカスタマイズ

各フックは、明確に定義された入力と出力のインターフェースを持っているため、プラグイン開発者は期待される動作を理解しやすくなっています。

実行順序の制御メカニズム

プラグインには enforce オプションを指定でき、実行順序を制御できます。

typescript// プラグインの基本構造と実行順序の指定

export default function myPlugin() {
  return {
    name: 'my-custom-plugin',
    enforce: 'pre', // 'pre' | 'post' | undefined

    // 各フックの実装
    resolveId(id) {
      // モジュールIDの解決処理
    },

    load(id) {
      // ファイルの読み込み処理
    },

    transform(code, id) {
      // コード変換処理
    },
  };
}

enforce オプションには以下の値を指定できます。

  • pre: 通常のプラグインより前に実行
  • post: 通常のプラグインより後に実行
  • 未指定: 通常の優先度で実行

これにより、Vite の内部プラグイン、ユーザープラグイン、フレームワークプラグインが適切な順序で実行されるのです。

モジュールグラフによる依存関係管理

Vite は、内部的にモジュールグラフを構築して依存関係を追跡しています。

各モジュールノードには、インポート元(importers)とインポート先(importedModules)の情報が保持されており、ファイル変更時にどのモジュールに影響が及ぶかを瞬時に判断できます。

typescript// Viteの内部モジュールグラフの概念的な構造

class ModuleNode {
  url: string; // モジュールのURL
  file: string | null; // ファイルシステムパス
  importers: Set<ModuleNode>; // このモジュールをインポートしているモジュール
  importedModules: Set<ModuleNode>; // このモジュールがインポートしているモジュール
  transformResult: any; // 変換結果のキャッシュ
  lastHMRTimestamp: number; // 最後のHMR更新時刻
}

このデータ構造により、あるファイルが変更されたときに影響を受けるすべてのモジュールを効率的に特定できるわけですね。

typescript// モジュールグラフの利用例

class ModuleGraph {
  // URL からモジュールノードを取得
  getModuleById(id: string): ModuleNode | undefined {
    return this.urlToModuleMap.get(id);
  }

  // ファイル変更時に影響を受けるモジュールを取得
  getAffectedModules(file: string): Set<ModuleNode> {
    const mod = this.fileToModulesMap.get(file);
    if (!mod) return new Set();

    return this.collectAffectedModules(mod);
  }

  // 再帰的に影響を受けるモジュールを収集
  private collectAffectedModules(
    mod: ModuleNode,
    affected = new Set<ModuleNode>()
  ): Set<ModuleNode> {
    affected.add(mod);
    mod.importers.forEach((importer) => {
      if (!affected.has(importer)) {
        this.collectAffectedModules(importer, affected);
      }
    });
    return affected;
  }
}

ファイル変更検知時には、このモジュールグラフを使って影響範囲を特定し、必要なモジュールだけを HMR 経由でブラウザに送信します。

具体例

リクエスト処理の完全なフロー

実際の Vue コンポーネントファイルがリクエストされたときの処理フローを見ていきましょう。

ブラウザから ​/​src​/​components​/​HelloWorld.vue がリクエストされた場合、以下のような流れで処理が進みます。

mermaidsequenceDiagram
    participant B as ブラウザ
    participant M as ミドルウェア
    participant P as プラグイン
    participant E as esbuild
    participant F as ファイルシステム
    participant C as キャッシュ

    B->>M: GET /src/components/HelloWorld.vue
    M->>P: resolveId('/src/components/HelloWorld.vue')
    P->>F: パス解決
    F-->>P: 絶対パス返却
    P-->>M: 解決済みID

    M->>C: キャッシュ確認
    alt キャッシュあり
        C-->>M: キャッシュ済みモジュール
        M-->>B: 変換済みコード返却
    else キャッシュなし
        M->>P: load(id)
        P->>F: ファイル読込
        F-->>P: .vueファイル内容

        P->>P: SFC解析(script/template/style分割)
        P->>E: TypeScript変換
        E-->>P: JavaScript

        P->>P: transform フック実行
        P-->>M: 変換済みコード
        M->>C: キャッシュ保存
        M-->>B: 変換済みコード返却
    end

このシーケンス図から、リクエストが各レイヤーを通過する様子と、キャッシュの有無による処理の分岐が明確にわかりますね。

カスタムミドルウェアの実装例

開発サーバーにカスタムミドルウェアを追加する場合、configureServer フックを使用します。

typescript// カスタムミドルウェアを追加するプラグイン

import type { Plugin } from 'vite';

export function customMiddlewarePlugin(): Plugin {
  return {
    name: 'custom-middleware',

    configureServer(server) {
      // ミドルウェアを先頭に追加(pre)
      server.middlewares.use((req, res, next) => {
        console.log(`[Request] ${req.method} ${req.url}`);
        next();
      });

      return () => {
        // ミドルウェアを末尾に追加(post)
        server.middlewares.use((req, res, next) => {
          console.log(`[Response] ${req.url}`);
          next();
        });
      };
    },
  };
}

configureServer フックは関数を返すことができ、その関数は内部ミドルウェアがすべて登録された後に実行されます。これにより、Vite の前後どちらにでもミドルウェアを挿入できるのです。

プラグインフックの実装例

実際にカスタム変換を行うプラグインを実装してみましょう。

ここでは、Markdown ファイルを読み込んで React コンポーネントに変換するプラグインを例として示します。

typescript// Markdownプラグインの基本構造

import type { Plugin } from 'vite';

export function markdownPlugin(): Plugin {
  return {
    name: 'vite-plugin-markdown',
    enforce: 'pre', // Viteのデフォルトプラグインより前に実行
  };
}

次に、Markdown ファイルを識別するための resolveId フックを実装します。

typescript// resolveId フックの実装

import type { Plugin } from 'vite';
import path from 'path';

export function markdownPlugin(): Plugin {
  return {
    name: 'vite-plugin-markdown',
    enforce: 'pre',

    resolveId(id, importer) {
      // .md ファイルの場合のみ処理
      if (!id.endsWith('.md')) return null;

      // 相対パスを絶対パスに解決
      if (id.startsWith('.') && importer) {
        return path.resolve(path.dirname(importer), id);
      }

      // null を返すと次のプラグインに処理を委譲
      return null;
    },
  };
}

resolveId フックは、インポートパスを解決する役割を持ちます。null を返すことで、処理を次のプラグインに委譲できますよ。

続いて、ファイルを読み込む load フックを実装しましょう。

typescript// load フックの実装

import type { Plugin } from 'vite';
import fs from 'fs/promises';

export function markdownPlugin(): Plugin {
  return {
    name: 'vite-plugin-markdown',
    enforce: 'pre',

    resolveId(id, importer) {
      // (前述のコード)
    },

    async load(id) {
      // .md ファイルの場合のみ処理
      if (!id.endsWith('.md')) return null;

      // ファイル内容を読み込み
      const markdown = await fs.readFile(id, 'utf-8');

      // 次の transform フックに渡す
      return markdown;
    },
  };
}

load フックでは、ファイルシステムからファイルを読み込みます。返却した内容は次の transform フックに渡されます。

最後に、Markdown を変換する transform フックを実装します。

typescript// transform フックの実装

import type { Plugin } from 'vite';
import { marked } from 'marked'; // Markdownパーサー

export function markdownPlugin(): Plugin {
  return {
    name: 'vite-plugin-markdown',
    enforce: 'pre',

    resolveId(id, importer) {
      // (前述のコード)
    },

    async load(id) {
      // (前述のコード)
    },

    transform(code, id) {
      // .md ファイルの場合のみ処理
      if (!id.endsWith('.md')) return null;

      // MarkdownをHTMLに変換
      const html = marked(code);

      // ReactコンポーネントとしてエクスポートするJavaScriptコードを生成
      const jsCode = `
        import React from 'react'

        export default function MarkdownComponent() {
          return React.createElement(
            'div',
            { dangerouslySetInnerHTML: { __html: ${JSON.stringify(
              html
            )} } }
          )
        }
      `;

      return {
        code: jsCode,
        map: null, // ソースマップ(オプション)
      };
    },
  };
}

この transform フックで Markdown を HTML に変換し、React コンポーネントとして使える JavaScript コードを生成しています。

HMR(ホットモジュール置換)の仕組み

ファイルが変更されたとき、Vite はどのように変更をブラウザに伝えているのでしょうか?

Vite は内部的に WebSocket サーバーを立ち上げており、ブラウザとリアルタイム通信を行っています。

typescript// HMRの基本的な流れを示すコード

import type { Plugin, HmrContext } from 'vite';

export function myHmrPlugin(): Plugin {
  return {
    name: 'my-hmr-plugin',

    handleHotUpdate(ctx: HmrContext) {
      const { file, modules, server } = ctx;

      // 変更されたファイル情報
      console.log(`File changed: ${file}`);

      // 影響を受けるモジュール
      console.log(`Affected modules:`, modules.length);

      // カスタムHMR処理(例:特定のファイルは全体リロード)
      if (file.endsWith('.config.js')) {
        server.ws.send({
          type: 'full-reload',
          path: '*',
        });
        return [];
      }

      // デフォルトのHMR処理を継続
      return modules;
    },
  };
}

handleHotUpdate フックでは、ファイル変更時の動作をカスタマイズできます。空配列を返すと HMR をスキップし、モジュール配列を返すとそれらのモジュールが HMR 対象になりますよ。

以下の図は、HMR の完全なフローを示しています。

mermaidflowchart TB
    editor["エディタでファイル編集"] --> watcher["chokidarファイル監視"]
    watcher --> invalidate["モジュールキャッシュ無効化"]
    invalidate --> moduleGraph["モジュールグラフ探索"]

    moduleGraph --> affected["影響を受けるモジュール特定"]
    affected --> hook["handleHotUpdateフック実行"]

    hook --> decision{{"更新タイプ"}}
    decision -->|全体リロード| full["full-reload メッセージ"]
    decision -->|部分更新| update["update メッセージ"]

    full --> ws["WebSocket送信"]
    update --> ws

    ws --> browser["ブラウザHMRクライアント"]
    browser --> apply["変更適用"]

    apply --> vue{{"フレームワーク<br />対応?"}}
    vue -->|Vue/React等| component["コンポーネント置換"]
    vue -->|CSS| styleNode["スタイル更新"]
    vue -->|その他| reload["ページリロード"]

この図から、ファイル変更検知からブラウザでの更新適用まで、すべてのステップが自動的に実行されることがわかります。Vue や React のような対応フレームワークでは、コンポーネントの状態を保持したまま更新できるのが素晴らしいですね。

プラグインの実行順序の実例

複数のプラグインがある場合、実際にどのような順序で実行されるのか見ていきましょう。

typescript// vite.config.ts でのプラグイン設定例

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { customPlugin } from './plugins/custom';

export default defineConfig({
  plugins: [
    // enforce: 'pre' のプラグインが最初に実行される
    {
      name: 'pre-plugin',
      enforce: 'pre',
      transform(code, id) {
        console.log('1. pre-plugin transform:', id);
        return code;
      },
    },

    // 通常のプラグイン(Viteの内部プラグインの後)
    vue(),

    {
      name: 'normal-plugin',
      transform(code, id) {
        console.log('2. normal-plugin transform:', id);
        return code;
      },
    },

    // enforce: 'post' のプラグインが最後に実行される
    {
      name: 'post-plugin',
      enforce: 'post',
      transform(code, id) {
        console.log('3. post-plugin transform:', id);
        return code;
      },
    },
  ],
});

実際の実行順序は以下のようになります。

markdown実行順序の例(/src/App.vue がリクエストされた場合):

resolveId フック:
  1. pre-plugin resolveId
  2. viteのコア resolveId
  3. @vitejs/plugin-vue resolveId
  4. normal-plugin resolveId
  5. post-plugin resolveId

load フック:
  1. pre-plugin load
  2. @vitejs/plugin-vue load
  3. normal-plugin load
  4. post-plugin load

transform フック:
  1. pre-plugin transform: /src/App.vue
  2. @vitejs/plugin-vue transform: /src/App.vue
  3. normal-plugin transform: /src/App.vue
  4. post-plugin transform: /src/App.vue

各フックは順番に実行され、最初に null 以外の値を返したプラグインの結果が採用されます(transform フックは全プラグインが実行されます)。

パフォーマンス最適化のベストプラクティス

Vite のプラグインやミドルウェアを実装する際の、パフォーマンス最適化のポイントを紹介します。

typescript// 効率的なプラグイン実装のベストプラクティス

import type { Plugin } from 'vite';

export function efficientPlugin(): Plugin {
  // 正規表現はプラグイン初期化時に1度だけ作成
  const targetFileRegex = /\.(custom|special)$/;

  // キャッシュマップを保持
  const transformCache = new Map<string, string>();

  return {
    name: 'efficient-plugin',

    // 1. 早期リターンで不要な処理を避ける
    resolveId(id) {
      if (!targetFileRegex.test(id)) return null;
      // 必要な場合のみ処理続行
    },

    // 2. 非同期処理を適切に使う
    async load(id) {
      if (!targetFileRegex.test(id)) return null;

      // キャッシュチェック
      if (transformCache.has(id)) {
        return transformCache.get(id);
      }

      // 実際の処理
      const result = await heavyOperation(id);
      transformCache.set(id, result);
      return result;
    },

    // 3. 変換は本当に必要な場合のみ実行
    transform(code, id) {
      if (!targetFileRegex.test(id)) return null;
      if (code.includes('/* skip-transform */'))
        return null;

      // 最小限の変換処理
      return transformCode(code);
    },

    // 4. HMR時にキャッシュをクリア
    handleHotUpdate({ file, server }) {
      if (targetFileRegex.test(file)) {
        transformCache.delete(file);
      }
    },
  };
}

async function heavyOperation(id: string): Promise<string> {
  // 重い処理の実装
  return '';
}

function transformCode(code: string): string {
  // コード変換の実装
  return code;
}

このコードでは、早期リターン、キャッシング、正規表現の再利用といった最適化テクニックを使っています。特に、対象外のファイルは即座に null を返すことで、無駄な処理を避けられますよ。

まとめ

本記事では、Vite 開発サーバーの内部構造について、ミドルウェアとプラグインの流れを中心に詳しく解説してきました。

重要なポイントをまとめます。

アーキテクチャの特徴

Vite は、Connect のミドルウェアパターンと Rollup のプラグインシステムを組み合わせた階層的なアーキテクチャを採用しています。リクエストは複数のミドルウェアを順次通過し、各ミドルウェア内でプラグインチェーンが実行される仕組みになっていました。

プラグインフックの役割

プラグインは、resolveIdloadtransformhandleHotUpdateなどのフックを通じて、モジュール解決、読み込み、変換、HMR 処理をカスタマイズできます。enforce オプションにより実行順序を制御でき、複数のプラグインが協調して動作する環境を実現していましたね。

モジュールグラフによる依存管理

内部的に構築されるモジュールグラフは、各モジュール間の依存関係を追跡し、ファイル変更時の影響範囲を効率的に特定します。これにより、必要最小限のモジュールだけを再変換・再送信する HMR が可能になっています。

実装のベストプラクティス

プラグインやミドルウェアを実装する際は、早期リターン、適切なキャッシング、正規表現の再利用などの最適化テクニックが重要です。不要な処理を避けることで、Vite の高速性を損なわない実装ができるでしょう。

Vite の内部構造を理解することで、カスタムプラグインの開発やパフォーマンスチューニング、トラブルシューティングがより効果的に行えるようになります。この知識を活かして、あなたのプロジェクトに最適な開発環境を構築してくださいね。

関連リンク