Open6

Deep Dive into Qwik - qwikloader

AkiraAkira

qwikloader

QwikのResumabilityの一番コアになっていそうな qwikloader をまずは読み解きたい。@builder.io/qwik@1.17.0時点でのコード。

1.イベント型定義

https://github.com/QwikDev/qwik/blob/8296f56062acb15f30b521cd7fc75124e682cf53/packages/qwik/src/core/render/jsx/types/jsx-qwik-events.ts より。

import type {
  QwikErrorEvent,
  QwikSymbolEvent,
  QwikVisibleEvent,
} from './core/render/jsx/types/jsx-qwik-events';

QwikErrorEventはモジュールロード失敗時に発火するイベント型で、エラー種別は'sync' | 'async' | 'no-symbol'が定義されている。 エラーコンテキストとしてQwikSymbolEventの詳細情報も含む。

QwikSymbolEventはモジュールが遅延ロードされたときに発火するイベントで、以下のプロパティを持つ。

  • symbol: ロードされたシンボル名
  • element: 対象要素
  • reqTime: リクエスト時刻
  • qBase, qManifest, qVersion, href: モジュール解決に必要なメタ情報

QwikVisibleEventは要素が画面に表示されたときにqwik-loaderが発火するイベント。実装にIntersection Observer APIを使用しており、useVisibleTask$で使用される。

Intersection Observer APIとは

ブラウザネイティブのAPIで、ターゲット要素とビューポート(または指定した祖先要素)との交差状態を非同期的に監視する仕組み。

主な用途は以下の通りで、ブラウザ操作に応じてリソースを読み込む(ざっくり)。

  1. 遅延ロード(Lazy Loading)

    • 画像がビューポートに入ったら読み込む
    • コンポーネントが表示されたらJavaScriptを読み込む
  2. 無限スクロール

    • リストの最後の要素が見えたら次のデータを取得
  3. アニメーション制御

    • 要素が見えたらアニメーション開始
  4. 広告の可視性トラッキング

QwikはuseVisibleTask$でこのAPIを使用しているようで、画面外のコンポーネントはJavaScriptをロードせず、スクロールして表示されたタイミングで初めてコードを読み込むようになっている。これにより、初期ロード時のJavaScriptサイズを最小化している。

2. コンテナ型定義

https://github.com/QwikDev/qwik/blob/8296f56062acb15f30b521cd7fc75124e682cf53/packages/qwik/src/core/container/container.ts より。

import type { QContainerElement } from './core/container/container';

QContainerElement (container.ts:200-202)

export interface QContainerElement extends Element {
  _qwikjson_?: any;
}

この型は、Qwikアプリケーションのルートとなるコンテナ要素を表している。通常のDOM要素(Element)を拡張し、Qwik特有の状態管理機能を追加している。

_qwikjson_プロパティの役割

_qwikjson_プロパティには、アプリケーションの実行状態がシリアライズ(JSON形式に変換)されて保存される。

具体的には、以下のような情報が含まれる。

  • コンポーネントの状態(useStoreなどで作成)
  • Signalの値
  • コンテキスト(useContextで作成)の値
  • 実行待ちのタスク情報(useTask$useVisibleTask$

この仕組みにより、サーバーサイドレンダリング(SSR)で生成されたHTMLに状態情報が埋め込まれ、クライアント側でJavaScriptが読み込まれた際に、アプリケーションを「再開(Resume)」できるようになっている。従来のフレームワークのような「再実行(Re-execute)」ではなく、中断した場所から続行できるため、初期ロード時のJavaScript実行コストを大幅に削減できる。

なお、シリアライゼーションとデシリアライゼーションの具体的な実装については後述する。

3. コンテキスト型定義

https://github.com/QwikDev/qwik/blob/8296f56062acb15f30b521cd7fc75124e682cf53/packages/qwik/src/core/state/context.ts より。

import type { QContext } from './core/state/context';

QContextは、Qwikの各要素(主にコンポーネント)に紐づくランタイムコンテキスト情報を格納する型。ReactのFiberに相当する、Qwikのコンポーネントツリーを管理する中核的なデータ構造となっている。

この型が保持する情報により、Qwikは以下を実現している。

  • 遅延ロード: コンポーネントのコードが必要になるまでロードを遅延
  • イベントハンドリング: イベント発火時に適切なハンドラを特定・実行
  • 状態管理: コンポーネントの状態とフックの実行順序を保持
  • コンテキスト伝播: 親から子へのコンテキスト情報の伝達

主要なプロパティ

  • $element$: DOM要素への参照
    • このコンテキストが紐づくDOM要素
    • イベント発火時の起点となる要素を特定するために使用
  • $componentQrl$: コンポーネントのQRL(遅延ロード可能な参照)
    • コンポーネント関数への参照をQRL形式で保持
    • 必要になるまでコンポーネントコードをロードしない(後述)
  • $props$: コンポーネントProps
    • 親コンポーネントから渡されたプロパティ
    • Resumability実現のため、シリアライズ可能な形式で保持
  • $seq$: フックの順序データ(useSequentialScopeで管理)
    • useStateuseStoreなどのフックが呼ばれた順序を管理
    • Resumability時に正しい順序でフックを復元するために必要
  • $tasks$: 実行待ちタスク
    • useTask$useVisibleTask$で登録されたタスクの配列
    • コンポーネントのライフサイクルに応じて実行される
  • $contexts$: useContextProviderで定義されたコンテキスト
    • ReactのuseContextに相当する機能のためのコンテキストマップ
    • 親コンポーネントから子コンポーネントへ値を伝播
  • $parentCtx$: 親コンポーネントのコンテキスト
    • コンポーネントツリーの親子関係を表現
    • コンテキスト検索やイベントバブリングで使用
  • li: イベントリスナー配列
    • この要素に登録されているイベントリスナーのリスト
    • イベントデリゲーション(後述)で使用

4. カスタムWindow型拡張

type qWindow = Window & {
  qwikevents: {
    events: Set<string>;
    roots: Set<Node>;
    push: (...e: (string | (EventTarget & ParentNode))[]) => void;
  };
};

この型は、Qwikのイベントシステムをグローバルに管理するためのWindow拡張型。ブラウザのグローバルオブジェクトwindowqwikeventsプロパティを追加し、ページ全体でイベント処理を一元管理する。

なぜグローバルなイベント管理が必要なのか

従来のフレームワークでは、各コンポーネントが個別にイベントリスナーを登録するため、コンポーネント数に比例してイベントリスナーの数が増加する。これに対してQwikは イベントデリゲーション パターンを採用し、ドキュメントルートで一括してイベントを捕捉する。

この方式により、個別のイベントリスナー登録が不要となり、初期ロード時のJavaScript実行量が削減される。イベント発火時には、単一のイベントハンドラが動的にハンドラを特定してパス解決を実行することで、非同期的なハンドラローディングを実現している。

プロパティの詳細

  • events: Set<string>
    • 登録済みイベント名のセット(例: 'click', 'input', 'submit'など)を表す。ページ内で使用されているイベントタイプを追跡するために使用される。
    • イベントデリゲーションの際、このセットに含まれるイベントのみをルートレベルでリッスンする。
  • roots: Set<Node>
    • Qwikコンテナのルート要素集合。マイクロフロントエンドの文脈で使用されるプロパティであり、1ページ内に複数の独立したQwikアプリケーションが共存可能になる。
    • 例えば、ヘッダー部分はQwikアプリA、メインコンテンツはQwikアプリB、のような混合構成を採用しても、Qwikはrootsに各アプリのQwikルートを登録する。
  • push(...e: (string | (EventTarget & ParentNode))[])
    • イベント名やルート要素を動的に追加するメソッド。
    • qwik-loaderが読み込まれる前にHTMLで定義されたイベントやルートを受け取る。
    • 使用タイミングとしては以下。これにより、JavaScriptのロードを待たずにHTMLだけで初期状態を宣言できる。
      1. SSRで生成されたHTMLに含まれるインラインスクリプトがpushを呼び出し
      2. qwik-loaderがロード完了後、キューされた情報を処理

イベントデリゲーションの動作フロー

  1. ページロード時、eventsセットに登録されたイベントタイプに対して、各roots要素でイベントリスナーを設定
  2. ユーザーがクリックなどのアクションを実行
  3. イベントがバブリングで上位に伝播
  4. ルート要素でイベントをキャッチ
  5. イベントのtargetから該当するQContextを特定
  6. 必要に応じてハンドラコードを遅延ロード
  7. ハンドラを実行

この仕組みの詳細な実装は後述する。

AkiraAkira

useVisibleTask$の内部実装

前述のQwikVisibleEventとIntersection Observer APIが、実際にどのようにuseVisibleTask$で使われているかを見ていく。

1. useVisibleTask$の仕組み

useVisibleTask$は、コンポーネントが画面に表示されたタイミングでJavaScriptを遅延ロードして実行するフック。

基本的な使い方

import { component$, useVisibleTask$ } from '@builder.io/qwik';

export default component$(() => {
  useVisibleTask$(() => {
    // この関数は、コンポーネントが画面に表示されたときに実行される
    console.log('Component is now visible!');

    // クリーンアップ関数を返すことも可能
    return () => {
      console.log('Component cleanup');
    };
  });

  return <>Heavy Component</>;
});

実行戦略のオプション

// use-task.ts:193
export type VisibleTaskStrategy =
  | 'intersection-observer'  // デフォルト: 画面に表示されたとき
  | 'document-ready'         // ドキュメント読み込み完了時
  | 'document-idle';         // ブラウザがアイドル時

この実行戦略はuseVisibleTask$の第二引数で指定できる。

// デフォルト: 画面に表示されたら実行
useVisibleTask$(() => {
  console.log('Visible!');
});

// ドキュメント読み込み完了後すぐ実行
useVisibleTask$(() => {
  console.log('Document ready!');
}, { strategy: 'document-ready' });

// ブラウザがアイドルになったら実行
useVisibleTask$(() => {
  console.log('Browser idle!');
}, { strategy: 'document-idle' });

2. 実装の流れ

2.1 コンポーネント側

export const useVisibleTaskQrl = (qrl: QRL<TaskFn>, opts?: OnVisibleTaskOptions): void => {
  const { val, set, i, iCtx, elCtx } = useSequentialScope<Task<TaskFn>>();
  const eagerness = opts?.strategy ?? 'intersection-observer';  // デフォルト戦略

  // タスクを作成
  const task = new Task(TaskFlagsIsVisibleTask, i, elCtx.$element$, qrl, undefined);

  // 戦略に応じたイベントハンドラを登録
  useRunTask(task, eagerness);
};

2.2 イベントハンドラの登録

const useRunTask = (
  task: SubscriberEffect,
  eagerness: VisibleTaskStrategy | EagernessOptions | undefined
) => {
  if (eagerness === 'visible' || eagerness === 'intersection-observer') {
    // 'qvisible'イベントにハンドラを登録
    useOn('qvisible', getTaskHandlerQrl(task));
  } else if (eagerness === 'load' || eagerness === 'document-ready') {
    useOnDocument('qinit', getTaskHandlerQrl(task));
  } else if (eagerness === 'idle' || eagerness === 'document-idle') {
    useOnDocument('qidle', getTaskHandlerQrl(task));
  }
};

この関数により、要素にon:qvisible属性が付与される。この時点ではまだ実行対象のJavaScriptコードはロードされていない。

2.3 Intersection Observerの初期化

const processReadyStateChange = () => {
  const readyState = doc.readyState;
  if (!hasInitialized && (readyState == 'interactive' || readyState == 'complete')) {
    // ドキュメント準備完了

    if (events.has('qvisible')) {
      // on:qvisible属性を持つ要素を全て取得
      const results = querySelectorAll('[on\\:qvisible]');

      // Intersection Observerを作成
      const observer = new IntersectionObserver((entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {  // 要素が表示されたら
            observer.unobserve(entry.target);  // 監視解除(一度だけ実行)
            dispatch(entry.target, '', createEvent<QwikVisibleEvent>('qvisible', entry));
          }
        }
      });

      // 全ての対象要素を監視開始
      results.forEach((el) => observer.observe(el));
    }
  }
};

3. 実行フロー図

┌─────────────────────────────────────────────┐
│ コンポーネント側                               │
│ useVisibleTask$(() => {                     │
│   console.log('Component visible!');        │
│ });                                         │
└──────────────┬──────────────────────────────┘
               │
               ▼
┌──────────────────────────────────────────────┐
│ use-task.ts                                  │
│ useVisibleTaskQrl()                          │
│  ├─ strategy = 'intersection-observer'       │
│  └─ useRunTask(task, 'intersection-observer')│
└──────────────┬───────────────────────────────┘
               │
               ▼
┌──────────────────────────────────────────────┐
│ use-task.ts                                  │
│ useRunTask()                                 │
│  └─ useOn('qvisible', handler)               │
│     └─ HTML: <div on:qvisible="...">         │
└──────────────┬───────────────────────────────┘
               │
               ▼ ドキュメント準備完了時
┌──────────────────────────────────────────────┐
│ qwikloader.ts                                │
│ processReadyStateChange()                    │
│  ├─ querySelectorAll('[on\\:qvisible]')      │
│  ├─ new IntersectionObserver(...)            │
│  └─ observer.observe(el)                     │
└──────────────┬───────────────────────────────┘
               │
               ▼ 要素が画面に表示されたとき
┌──────────────────────────────────────────────┐
│ IntersectionObserver callback                │
│  ├─ entry.isIntersecting === true            │
│  ├─ observer.unobserve(entry.target)         │
│  └─ dispatch('qvisible', entry)              │
└──────────────┬───────────────────────────────┘
               │
               ▼
┌──────────────────────────────────────────────┐
│ qwikloader.ts                                │
│ dispatch()                                   │
│  └─ JavaScriptモジュールを遅延ロード             │
│     └─ import(uri)                           │
│        └─ useVisibleTask$のコールバック実行.    │
└──────────────────────────────────────────────┘

4. useVisibleTask$の要点

4.1 HTML属性として記録

useVisibleTask$を使うと、要素にon:qvisible属性が付与される。この時点ではJavaScriptコードはまだロードされていない。SSR時にHTML内にメタデータとして埋め込まれる。

4.2 qwikloaderが監視を開始

ドキュメント読み込み完了時(readyState === 'interactive'または'complete')に

  1. on:qvisible属性を持つ全要素を検索
  2. Intersection Observerで監視開始

4.3 表示時にコードをロード

要素が画面に表示される(entry.isIntersecting === true)と

  1. 該当JavaScriptモジュールをimport()で遅延ロード
  2. useVisibleTask$のコールバックを実行

4.4 一度だけ実行

observer.unobserve(entry.target)で監視解除するため、同じ要素が再び表示されても実行されない。

5. Resumabilityとの関係

この仕組みにより、Qwikは以下のパフォーマンス最適化を実装している。

  1. 初期ロードの最小化

    • 画面外のコンポーネントのJavaScriptは一切ロードしない
    • ユーザーが実際に見る部分だけを優先的にロード
  2. インタラクティブまでの時間短縮

    • 必要最小限のJavaScriptのみ実行
    • ブラウザの負荷を大幅に削減
  3. Hydrationなしでの再開

    • SSRで生成されたHTMLにメタデータ(on:qvisible属性など)を埋め込み
    • JavaScriptをロードせずとも、どのタイミングで何を実行すべきかを把握
    • 必要になった時点で初めてコードをロードして実行(Resume)

これがQwikのResumability(再開可能性)の核心部分であり、従来のHydrationベースのフレームワークとの最大の違いとなっている。

AkiraAkira

qwikloaderのイベントシステム

qwikloader.tsの核心部分である、グローバルイベントシステムの基礎とイベントディスパッチの仕組みを見ていく。

1. グローバル変数の初期化

// qwikloader.ts
const doc = document as Document & { __q_context__?: [Element, Event, URL] | 0 };
const win = window as unknown as qWindow;
const events = new Set<string>();
const roots = new Set<EventTarget & ParentNode>([doc]);

let hasInitialized: number;

調査したところ、各変数は次の役割を持っているようだ。

  • doc
    • 拡張されたDocument型
    • __q_context__プロパティで現在のイベントコンテキストを保持
    • [Element, Event, URL] | 0の型について
      • 配列の場合:現在処理中のイベント情報
      • 0の場合:qwikloaderが読み込まれたがまだイベント処理していない状態
  • win
    • qWindow型にキャストされたwindowオブジェクト
    • qwikeventsプロパティでイベント管理システムにアクセス
  • events
    • 登録済みのイベント名を管理するSet
    • 例:'click', 'input', 'qvisible'など
  • roots
    • Qwikコンテナのルート要素を管理するSet
    • 初期値は[doc](ドキュメント自体)
    • Shadow DOMやマイクロフロントエンドで複数のルートを管理可能
  • hasInitialized
    • ドキュメントの初期化完了フラグ
    • 0:未初期化、1:初期化済み

2. クエリセレクタとShadow DOMサポート

// qwikloader.ts:33-46
const nativeQuerySelectorAll = (root: ParentNode, selector: string) =>
  Array.from(root.querySelectorAll(selector));

const querySelectorAll = (query: string) => {
  const elements: Element[] = [];
  roots.forEach((root) => elements.push(...nativeQuerySelectorAll(root, query)));
  return elements;
};

const findShadowRoots = (fragment: EventTarget & ParentNode) => {
  processEventOrNode(fragment);
  nativeQuerySelectorAll(fragment, '[q\\:shadowroot]').forEach((parent) => {
    const shadowRoot = parent.shadowRoot;
    shadowRoot && findShadowRoots(shadowRoot);
  });
};

querySelectorAll

Qwik独自のquerySelectorAll関数は、通常のDOMのquerySelectorAllとは異なり、全ての登録済みルート(roots)に対してクエリを実行する。これにより、マイクロフロントエンドのように複数のQwikアプリケーションが同一ページ内で共存する場合でも、それぞれのアプリケーション領域内の要素を正しく検索できるようになっている。

例えば、ページ内に2つのQwikアプリが存在する場合、rootsには2つのルート要素が登録されており、querySelectorAllは両方のルートに対してクエリを実行して結果を統合する。

findShadowRoots

findShadowRoots関数は、再帰的にShadow DOMツリーを探索する。具体的には、[q:shadowroot]属性を持つ要素のShadow Rootを検出し、見つかった各Shadow RootをprocessEventOrNode経由でrootsに追加していく。

3. イベントディスパッチの仕組み

3.1 dispatch関数の全体像

dispatch関数は、Qwikのイベントシステムの中核を担う関数である。この関数は、発火したイベントに対応するハンドラを見つけ出し、適切に実行する責務を持つ。

ハンドラの実行には2つの経路が用意されているようで、1つ目は「キャッシュ済みハンドラ経路」で、既に一度ロードされて要素のQContextに保存されているハンドラを直接実行するもの。これはJSチャンクのダウンロードが必要ないので高速に動作する。

2つ目は「遅延ロード経路」で、まだロードされていないハンドラについてHTML属性からQRL(Qwik URL)を読み取り、動的にモジュールをインポートして実行する経路である。こちらはJSの実行までにダウンロードの待機が必要になる。

// qwikloader.ts
const dispatch = async (
  element: Element & { _qc_?: QContext | undefined },
  onPrefix: string,
  ev: Event,
  eventName = ev.type
) => {
  const attrName = 'on' + onPrefix + ':' + eventName;
  
  // イベント制御属性の処理
  if (element.hasAttribute('preventdefault:' + eventName)) {
    ev.preventDefault();
  }
  if (element.hasAttribute('stoppropagation:' + eventName)) {
    ev.stopPropagation();
  }
  
  // 1. キャッシュ済みハンドラの実行
  const ctx = element._qc_;
  const relevantListeners = ctx && ctx.li.filter((li) => li[0] === attrName);
  if (relevantListeners && relevantListeners.length > 0) {
    for (const listener of relevantListeners) {
      const results = listener[1].getFn([element, ev], () => element.isConnected)(ev, element);
      // ... 実行処理
    }
    return;
  }
  
  // 2. HTML属性からの遅延ロード
  const attrValue = element.getAttribute(attrName);
  if (attrValue) {
    // ... モジュールロード処理
  }
};

3.2 イベント制御属性

Qwikの大きな特徴の1つとして、HTMLのデフォルト挙動をキャンセルする責務は初期化時にQwik側が実施してくれる点がある。

従来のフレームワークでは、preventDefault()stopPropagation()を呼び出すためにJavaScriptコードをロードして実行する必要があった。しかしQwikでは、HTML属性としてpreventdefault:stoppropagation:を指定することで、JavaScriptのロード前にイベントの挙動を制御できる。

<!-- preventdefaultの例 -->
<form preventdefault:submit on:submit="./handler.js#onSubmit">
  <!-- フォーム送信をキャンセル -->
</form>

<!-- stoppropagationの例 -->
<div on:click="./parent.js#onClick">
  <button stoppropagation:click on:click="./child.js#onClick">
    <!-- クリックイベントが親に伝播しない -->
  </button>
</div>

例えば上記のフォームの例では、preventdefault:submit属性により、./handler.js#onSubmitがまだロードされていない段階でも、qwikloaderがフォーム送信をキャンセルする。その後、非同期的にハンドラをロードして実行する。このアプローチによって、即座にイベント制御を効かせられるので、UXの向上と初期ロードの最小化を両立している。

3.3 キャッシュ済みハンドラの実行

// qwikloader.ts
const ctx = element._qc_;
const relevantListeners = ctx && ctx.li.filter((li) => li[0] === attrName);
if (relevantListeners && relevantListeners.length > 0) {
  for (const listener of relevantListeners) {
    // listener[1] holds the QRL
    const results = listener[1].getFn([element, ev], () => element.isConnected)(ev, element);
    const cancelBubble = ev.cancelBubble;
    if (isPromise(results)) {
      await results;
    }
    // forcing async with await resets ev.cancelBubble to false
    if (cancelBubble) {
      ev.stopPropagation();
    }
  }
  return;
}

このコードでは、まず要素に紐づくQContextelement._qc_から取得している。QContextは前章で説明した通り、各要素に紐づくコンテキスト情報を格納するオブジェクトで、その中のliプロパティにはイベントリスナー配列が格納されている。

リスナー配列の各要素は[attrName, QRL]というタプル構造になっており、イベント属性名(on:clickなど)とQRL(Qwik URL)のペアを保持している。該当するイベント名のリスナーが見つかった場合、listener[1]でQRLを取り出し、getFn()メソッドで実際の関数を取得する。

getFn()の第1引数には[element, ev]を渡し、第2引数にはelement.isConnectedを返す関数を渡している。このisConnectedチェックにより、イベント処理中に要素がDOMツリーから削除された場合でも、安全にハンドラを実行できるように設計されているようだ。

取得した関数は(ev, element)という引数で即座に実行される。もし実行結果がPromiseであれば、awaitで完了を待機する。また、非同期処理によってev.cancelBubbleがリセットされる問題に対応するため、事前にcancelBubbleの値を保存しておき、必要に応じてstopPropagation()を呼び出している。

3.4 dispatch関数の2つの実行経路

dispatch関数が持つ2つの実行経路について、それぞれのフローを詳しく見ていく。

経路1:キャッシュ済みハンドラ

この経路は、既にロード済みのハンドラを再実行する際に使われる高速な経路である。

要素にイベント発生
  ↓
element._qc_.li から該当リスナーを検索
  ↓
QRL.getFn() で関数を取得
  ↓
即座に実行(import不要)

まず、イベントが発火した要素の_qc_プロパティ(QContext)から、登録済みリスナー配列liを取得する。次に、該当するイベント名(on:clickなど)に一致するリスナーを検索し、見つかった場合はQRLgetFn()メソッドで関数本体を取得する。この時点で関数は既にメモリ上に存在するため、ネットワークリクエストやモジュールのインポートは不要で、即座に実行できる。

経路2:遅延ロード

この経路は、まだ一度もロードされていないハンドラを初めて実行する際に使われる経路である。

要素にイベント発生
  ↓
HTML属性(on:click="...")を読み取り
  ↓
QRL(Qwik URL)を解析
  ↓
import() でモジュールをロード
  ↓
ハンドラを実行

キャッシュにハンドラが見つからない場合、dispatch関数はHTML要素の属性(例:on:click="./handler.js#onClick")を読み取る。この属性値はQRL(Qwik URL)形式で記述されており、モジュールのパスとexport名を含んでいる。qwikloaderはこのQRLを解析し、動的インポート(import())でモジュールをロードして、指定されたexportを取得し実行する。

AkiraAkira

QRLと遅延ロードの仕組み

前章でイベントディスパッチの概要を見たが、この章ではQRL(Qwik URL)の解決プロセスと、モジュールの遅延ロードの詳細を掘り下げていきたい。

1. 遅延ロードとモジュールインポート

1.1 QRLの解決プロセス全体

// qwikloader.ts
const attrValue = element.getAttribute(attrName);
if (attrValue) {
  const container = element.closest('[q\\:container]')! as QContainerElement;
  const qBase = container.getAttribute('q:base')!;
  const qVersion = container.getAttribute('q:version') || 'unknown';
  const qManifest = container.getAttribute('q:manifest-hash') || 'dev';
  const base = new URL(qBase, doc.baseURI);
  
  for (const qrl of attrValue.split('\n')) {
    const url = new URL(qrl, base);
    const symbol = url.hash.replace(/^#?([^?[|]*).*$/, '$1') || 'default';
    
    // 同期ハンドラ vs 非同期ハンドラ
    const isSync = qrl.startsWith('#');
    
    if (isSync) {
      // インラインハンドラの取得
      const hash = container.getAttribute('q:instance')!;
      handler = ((doc as any)['qFuncs_' + hash] || [])[Number.parseInt(symbol)];
    } else {
      // 動的インポート
      emitEvent<QwikSymbolEvent>('qsymbol', eventData);
      const uri = url.href.split('#')[0];
      const module = import(/* @vite-ignore */ uri);
      resolveContainer(container);
      handler = (await module)[symbol];
    }
    
    // ハンドラの実行
    if (handler) {
      doc.__q_context__ = [element, ev, url];
      const results = handler(ev, element);
      if (isPromise(results)) {
        await results;
      }
    }
  }
}

1.2 QRL(Qwik URL)の構造

QRL(Qwik URL)は、Qwikにおける遅延ロード可能な参照を表現するための、URL形式の特殊な文字列。通常のURLと同様に、パス部分とハッシュ部分から構成されている。

./path/to/module.js#symbolName

構成要素:
- パス部分: ./path/to/module.js(モジュールファイルのパス)
- ハッシュ部分: #symbolName(export名を指定)

パス部分はJavaScriptモジュールのファイルパスを示し、ハッシュ部分はそのモジュール内のどのexportを参照するかを指定する。この形式により、qwikloaderは必要なタイミングで適切なモジュールをロードし、指定された関数を実行できる。

例えば、handlers.jsonClickを参照する場合は例1のようになる。

<!-- 例1: 単一ハンドラの場合 -->
<button on:click="./handlers.js#onClick">
  Click me
</button>

<!-- 例2: 複数ハンドラの場合(改行区切り) -->
<button on:click="./handler1.js#onClick
./handler2.js#logClick">
  Multiple handlers
</button>

1.3 QRL解決のステップ

ステップ1:コンテナ情報の取得

イベントが発生した要素が所属するQwikコンテナを特定し、そのメタデータを取得することが最初のステップのようだ。

const container = element.closest('[q\\:container]')! as QContainerElement;
const qBase = container.getAttribute('q:base')!;
const qVersion = container.getAttribute('q:version') || 'unknown';
const qManifest = container.getAttribute('q:manifest-hash') || 'dev';

closest()メソッドで最も近い[q:container]属性を持つ祖先要素を検索する。このコンテナ要素には、モジュール解決に必要な情報が属性として埋め込まれている。q:baseはモジュールファイルのベースパス、q:versionはアプリケーションのバージョン、q:manifest-hashはビルド時に生成されるマニフェストのハッシュ値である。

HTML上では以下のように記述されている。

<div q:container q:base="/build/" q:version="1.0.0" q:manifest-hash="abc123">
  <!-- Qwikアプリケーション -->
</div>
ステップ2:ベースURLの構築

次に、取得したコンテナ情報を使って、モジュール解決用の完全なURLを構築する。

const base = new URL(qBase, doc.baseURI);

new URL()の第1引数にはq:base属性の値(例:/build/)を、第2引数には現在のページのベースURLを指定する。doc.baseURIは、HTMLの<base>タグで指定されたURLか、指定がなければページ自体のURLとなるようで、これら2つを組み合わせることで、相対パスのQRLを絶対URLに解決するためのベースURLが完成する、という仕組みになっている。

例えば、doc.baseURIhttps://example.com/で、qBase/build/の場合、結果はhttps://example.com/build/となる。

ステップ3:複数QRLの処理

Qwikでは、1つの要素に複数のイベントハンドラを登録できる。属性値内で改行区切りで複数のQRLを記述できるため、それらを順次処理する必要がある。

for (const qrl of attrValue.split('\n')) {
  const url = new URL(qrl, base);
  const symbol = url.hash.replace(/^#?([^?[|]*).*$/, '$1') || 'default';
  // ...
}

まず、属性値を改行(\n)で分割して個々のQRLを取り出す。次に、各QRLをステップ2で構築したベースURLを基準として完全なURLオブジェクトに変換する。そして、URLのハッシュ部分から正規表現でシンボル名(export名)を抽出する。シンボル名が指定されていない場合は、'default'をデフォルト値として使用する。

このループにより、例えばon:click="./handler1.js#onClick\n./handler2.js#logClick"のような複数ハンドラの記述にも対応できるようになっている。

ステップ4:同期 vs 非同期判定

QRLには2種類の形式があり、それによってハンドラの取得方法が異なる。この判定は非常にシンプルになっていて、QRLが#記号で始まるかどうかで決まるようだ。

const isSync = qrl.startsWith('#');

#で始まるQRL(例:#0)は同期ハンドラを示し、HTMLと一緒に配信されるwindow.qFuncs_*配列から即座に関数を取得できる。一方、#で始まらないQRL(例:./handler.js#onClick)は非同期ハンドラを示し、動的インポート(import())でモジュールをロードする必要がある。

この判定により、小さな関数はインライン化してネットワークリクエストを減らし、大きな関数は別ファイルとして遅延ロードする仕組みになっている。

ステップ5:ハンドラの取得と実行

ステップ4の判定結果に基づき、同期ハンドラと非同期ハンドラのどちらかの方式でハンドラを取得する。同期ハンドラの場合はwindow.qFuncs_*配列から即座に関数を取り出し、非同期ハンドラの場合はimport()でモジュールをロードしてからexportを取得する。

2. 同期ハンドラ(インラインコード)

2.1 インラインハンドラの仕組み

同期ハンドラ(インラインハンドラ)は、HTMLと一緒に配信される小さな関数群を指す。これらの関数はwindowオブジェクトのqFuncs_*プロパティに配列として格納されており、ネットワークリクエストなしで即座にアクセスできる。

if (isSync) {
  const hash = container.getAttribute('q:instance')!;
  handler = ((doc as any)['qFuncs_' + hash] || [])[Number.parseInt(symbol)];
  if (!handler) {
    importError = 'sync';
    error = new Error('sym:' + symbol);
  }
}

まず、コンテナ要素のq:instance属性から識別子(ハッシュ値)を取得する。次に、この識別子を使ってwindow.qFuncs_<hash>というプロパティにアクセスし、配列として格納されている関数群を取得する。シンボル名(QRLのハッシュ部分、例:#00)を配列のインデックスとして解析し、該当する関数を取り出す。

インラインハンドラが適している利用シーンは、以下のようなケースが該当していそう。

  • 数行程度の小さな関数で、別ファイルに分けるほどではない処理
  • 初期表示時に必ず実行される高頻度の処理で、ネットワーク遅延を避けたい場合
<!-- インラインハンドラ -->
<button on:click="#0">Simple handler</button>

<script>
window.qFuncs_xyz = [
  function(ev, el) { console.log('Handler 0'); },
  function(ev, el) { console.log('Handler 1'); },
];
</script>

2.2 インラインハンドラの実行フロー

インラインハンドラの実行は非常にシンプルで、以下のフローで処理が進む。

1. QRL "#0" を検出
   ↓
2. q:instance属性から識別子を取得(例:xyz)
   ↓
3. window.qFuncs_xyz[0] にアクセス
   ↓
4. 即座に実行(非同期処理なし)

まず、QRLが#0のような形式であることを確認する。次に、コンテナ要素のq:instance属性から識別子(例:xyz)を取得する。そして、window.qFuncs_xyzという配列にアクセスし、インデックス0の関数を取り出す。最後に、取り出した関数を即座に実行する。

この方式の最大の利点は、ネットワークリクエストが一切不要で、最速の実行速度を実現できる点であり、小さな処理(数行程度)には最適な選択肢となる。ただし、欠点として初期HTMLのサイズが増加するため、大きな関数には不向き。

3. 非同期ハンドラ(動的インポート)

3.1 動的インポートの仕組み

if (!isSync) {
  emitEvent<QwikSymbolEvent>('qsymbol', eventData);
  const uri = url.href.split('#')[0];
  try {
    const module = import(/* @vite-ignore */ uri);
    resolveContainer(container);
    handler = (await module)[symbol];
    if (!handler) {
      importError = 'no-symbol';
      error = new Error(`${symbol} not in ${uri}`);
    }
  } catch (err) {
    importError ||= 'async';
    error = err as Error;
  }
}

3.2 動的インポートの実行フロー

非同期ハンドラの実行は、同期ハンドラよりも複雑なステップを踏むが、大きなコードを必要な時だけロードできるという利点がある。

1. QRL "./handlers.js#onClick" を検出
   ↓
2. qsymbolイベントを発火(トラッキング用)
   ↓
3. URLからハッシュを除去(./handlers.js)
   ↓
4. import(uri) で ESモジュールをロード
   ↓
5. resolveContainer() で状態を復元
   ↓
6. module[symbol] でexportを取得
   ↓
7. ハンドラを実行

まず、QRLが./handlers.js#onClickのようなパス形式であることを検出する。次に、トラッキングやデバッグ目的でqsymbolイベントを発火する。その後、URLからハッシュ部分(#onClick)を除去し、純粋なモジュールパス(./handlers.js)を取得する。

ここで動的インポート(import())を使ってESモジュールを非同期にロードする。モジュールのロードと並行して、resolveContainer()を呼び出してアプリケーション状態を復元する。モジュールのロードが完了したら、シンボル名(onClick)を使って該当するexportを取得し、最後にハンドラを実行する。

3.3 /* @vite-ignore */コメントの意味

動的インポートのコード内に、特殊なコメント/* @vite-ignore */が含まれている。

const module = import(/* @vite-ignore */ uri);

このコメントは、Viteビルドツールに対する指示である。通常、Viteはimport()ステートメントを検出すると、ビルド時に静的解析を行い、依存関係を解決してコードをチャンクに分割する。しかし、Qwikの場合は実行時にURLが動的に決定されるため、ビルド時には具体的なパスが分からない。

そこで@vite-ignoreコメントを付けることで、Viteに「このimport()は静的解析をスキップしてください」と指示する。これにより、Viteはこの動的インポートを変換せずにそのまま出力し、実行時にブラウザが直接モジュールをロードする。

Qwikのアーキテクチャでは、この実行時の動的解決が不可欠であるため、@vite-ignoreの指定は必須となる。

3.4 エラーハンドリング

qwikloaderは、ハンドラの取得・実行時に発生しうる3種類のエラーを明確に区別して処理するように設計されている。

let importError: undefined | 'sync' | 'async' | 'no-symbol';

1つ目は'sync'エラーで、同期ハンドラ(インラインハンドラ)がwindow.qFuncs_*配列に見つからない場合に発生する。

if (!handler) {
  importError = 'sync';
  error = new Error('sym:' + symbol);
}

2つ目は'async'エラーで、動的インポート自体が失敗した場合(ネットワークエラー、ファイルが存在しない等)に発生する。

catch (err) {
  importError ||= 'async';
  error = err as Error;
}

3つ目は'no-symbol'エラーで、モジュールのロードは成功したが、指定されたexport名が存在しない場合に発生する。

if (!handler) {
  importError = 'no-symbol';
  error = new Error(`${symbol} not in ${uri}`);
}

これらのエラーが発生すると、qerrorイベントを発火してアプリケーション側に通知する。また、コンソールにもエラーを出力し、複数ハンドラの処理ループを抜ける。

if (!handler) {
  emitEvent<QwikErrorEvent>('qerror', {
    importError,
    error,
    ...eventData,
  });
  console.error(error);
  break;  // ループを抜ける(後続ハンドラは実行しない)
}

このエラーハンドリングによって、エラーの原因を開発者に伝える仕組みをとっている。

4. コンテナ状態の解決

4.1 resolveContainerの役割

qwikloaderにはresolveContainer関数が用意されており、これはQwik Containerの状態解決を行うもののようだ。

// qwikloader.ts
const resolveContainer = (containerEl: QContainerElement) => {
  if (containerEl._qwikjson_ === undefined) {
    const parentJSON = containerEl === doc.documentElement ? doc.body : containerEl;
    let script = parentJSON.lastElementChild;
    while (script) {
      if (script.tagName === 'SCRIPT' && script.getAttribute('type') === 'qwik/json') {
        containerEl._qwikjson_ = JSON.parse(
          script.textContent!.replace(/\\x3C(\/?script)/gi, '<$1')
        );
        break;
      }
      script = script.previousElementSibling;
    }
  }
};

4.2 Resumabilityの鍵

resolveContainer関数は、SSR時にシリアライズされた状態をクライアント側で復元する役割を担っている。これがあることで、JavaScriptを再実行せずにアプリケーションを再開できる。

SSR時には、アプリケーションの状態が以下のようにJSONとしてシリアライズされ、HTML内に埋め込まれる。

<div q:container>
  <!-- アプリケーションのHTML -->

  <script type="qwik/json">
  {
    "ctx": {...},
    "objs": [...],
    "subs": [...]
  }
  </script>
</div>

クライアント側では、初回のイベント発火時にresolveContainer()が実行され、以下のステップで状態を復元する。

まず、<script type="qwik/json">タグを探索し、その中身のJSON文字列を取得する。次に、JSONを解析してcontainerEl._qwikjson_プロパティにキャッシュする。最後に、この状態データを使ってQContextなどのオブジェクトを復元する。

従来のHydrationでは、全コンポーネントのJavaScriptをロードして再実行し、DOMツリー全体を再構築する必要があった。一方、QwikのResumabilityでは、必要な部分のJavaScriptのみをロードし、状態を直接復元するだけで済む。これにより、初期ロードが大幅に軽量化される。

従来のHydration QwikのResumability
全コンポーネントのJSをロード 必要な部分のみロード
全コンポーネントを再実行 状態を直接復元
初期ロードが重い 初期ロードが軽量

4.3 エスケープ処理の詳細

resolveContainer関数内で、JSONのパース時に特殊なエスケープ処理が施されている。

.replace(/\\x3C(\/?script)/gi, '<$1')

この処理が必要な理由を考えてみたが、HTML内の<script>タグ内に</script>という文字列があると、ブラウザがそこでタグが閉じたと誤認識してしまうから、という風に一旦は結論付けた。例えば、シリアライズされた状態データの中に</script>という文字列が含まれていると、HTMLパーサーは意図せずそこで<script>タグを終了してしまい、残りのJSONが壊れてしまう。

この問題を回避するため、SSR時には以下のようにエスケープ処理を行う。

// SSR時にエスケープ
const json = JSON.stringify(state);
const escaped = json.replace(/<(\/?script)/gi, '\\x3C$1');

// 出力例
// "</script>" → "\x3C/script>"

<文字を16進数エスケープシーケンス\x3Cに置き換えることで、ブラウザは<script></script>として認識しなくなる。

クライアント側では、resolveContainer関数がこのエスケープを元に戻す。

// クライアント側でデコード
const decoded = escaped.replace(/\\x3C(\/?script)/gi, '<$1');

// "\x3C/script>" → "</script>"

この双方向のエスケープ処理により、JSONデータに任意の文字列が含まれていても、安全にHTMLに埋め込んで復元できる。

5. イベント処理のフロー

5.1 イベントバブリングのシミュレーション

Qwikは、ブラウザネイティブのイベントバブリングに頼らず、独自のバブリング機構を実装しているのを見つけた。これは、グローバルイベントデリゲーションと組み合わせることで、より効率的かつ柔軟なイベント処理を実現するためだと考えられる。

通常のDOMイベントでは、イベントはターゲット要素から親要素へと自動的に伝播(バブリング)する。

<div>              ← (3) 最後に親要素
  <button>         ← (1) 最初にターゲット要素
  </button>        ← (2) 次に子要素
</div>

Qwikでは、このバブリングをJavaScriptで明示的にシミュレーションしている。以下のようにprocessDocumentEventが定義されているのだが、while文の中を見てほしい。

// qwikloader.ts
const processDocumentEvent = async (ev: Event) => {
  let type = camelToKebab(ev.type);
  let element = ev.target as Element | null;
  broadcast('-document', ev, type);
  
  while (element && element.getAttribute) {
    const results = dispatch(element, '', ev, type);
    let cancelBubble = ev.cancelBubble;
    if (isPromise(results)) {
      await results;
    }
    cancelBubble ||=
      cancelBubble || ev.cancelBubble || element.hasAttribute('stoppropagation:' + ev.type);
    element = ev.bubbles && cancelBubble !== true ? element.parentElement : null;
  }
};
while (element && element.getAttribute) {
  dispatch(element, '', ev, type);
  // stopPropagationチェック
  element = ev.bubbles && cancelBubble !== true ? element.parentElement : null;
}

whileループでターゲット要素から親要素へと順次dispatch関数を呼び出し、各要素のハンドラを実行する。cancelBubbleがtrueになるか、親要素がなくなるまでループを継続する。

この独自実装をする意図としては、stoppropagation:属性による宣言的な伝播制御が可能になること、そして非同期ハンドラ(動的インポート)でも正しくイベントを伝播できる点が利点として上がるからではないかと考えられる。ブラウザネイティブのバブリングでは、非同期処理の完了を待つことができないが、Qwikのシミュレーションではawaitで待機してから次の要素に進める。

5.2 broadcast関数の役割

broadcast関数は、document/windowレベルのグローバルイベントハンドラを処理する特殊な関数である。

// qwikloader.ts
const broadcast = (infix: string, ev: Event, type = ev.type) => {
  querySelectorAll('[on' + infix + '\\:' + type + ']').forEach((el) => {
    dispatch(el, infix, ev, type);
  });
};

この関数は、指定されたinfix(例:-document-window)を含む属性を持つ全ての要素を検索し、それぞれに対してdispatch関数を実行する。これにより、特定のスコープ(documentまたはwindow)でイベントを待ち受ける全てのハンドラを一括で呼び出せる。

HTML上では、以下のように記述することで、document/windowレベルのイベントハンドラを登録できる。

<!-- documentレベル -->
<div on-document:click="./global.js#onDocumentClick">
  Global click handler
</div>

<!-- windowレベル -->
<div on-window:scroll="./scroll.js#onScroll">
  Scroll handler
</div>

イベント処理の実行順序は明確に定義されている。まず、broadcast('-document', ev)が最初に実行され、on-document:click属性を持つ全ての要素にディスパッチされる。次に、イベントターゲットから親要素へとバブリングシミュレーションが行われ、on:click属性を持つ要素に順次ディスパッチされる。この仕組みにより、アナリティクスのトラッキングなどのグローバルな処理と、個別要素の処理を明確に分離できる。

5.3 ウィンドウイベントの処理

windowオブジェクトで発生するイベント(scrollresizeなど)は、processWindowEvent関数で処理されるようになっている。

// qwikloader.ts
const processWindowEvent = (ev: Event) => {
  broadcast('-window', ev, camelToKebab(ev.type));
};

この関数は非常にシンプルで、単にbroadcast関数を-windowというinfixで呼び出すだけ。

ここがシンプルなのは、windowオブジェクト自体がDOMツリーのルート(最上位)であるため、バブリングシミュレーションが不要であること。そして、documentイベントの場合はターゲット要素から親要素へのバブリングが必要だが、windowイベントではその必要がないことがあるのではないかと思う。

AkiraAkira

qwikloaderの初期化とアーキテクチャ

最後に、qwikloader全体の初期化プロセスとアーキテクチャ的な特徴をまとめたい。

1. 初期化プロセス

1.1 初期化のタイミング

以下のような関数定義があり、ここでreadyStateの監視を行っているようだ。

// qwikloader.ts
const processReadyStateChange = () => {
  const readyState = doc.readyState;
  if (!hasInitialized && (readyState == 'interactive' || readyState == 'complete')) {
    roots.forEach(findShadowRoots);
    hasInitialized = 1;
    
    emitEvent('qinit');
    const riC = win.requestIdleCallback ?? win.setTimeout;
    riC.bind(win)(() => emitEvent('qidle'));
    
    if (events.has('qvisible')) {
      // Intersection Observer の設定(前述)
    }
  }
};

qwikloaderが初期化を行うタイミングは、document.readyStateの値によって決まっていた。readyStateには3つの状態があり、それぞれ異なるロード段階を示す。

loading状態は、ドキュメントがまだ読み込み中であることを示す。この段階ではHTMLの解析が完了しておらず、DOMツリーもまだ構築されていない。

interactive状態は、HTML解析が完了し、DOMツリーが構築済みであることを示す。qwikloaderはこのタイミングで起動するようだが、画像やCSSなどの外部リソースはまだロード中かもしれない。

complete状態は、画像やCSSを含む全てのリソースの読み込みが完了したことを示す。

qwikloaderはDOMツリーができていればイベントリスナーの設定ができるので、interactiveで起動するようになっているようだ。

1.2 イベントの発火順序

qwikloaderは、初期化時に3つのカスタムイベント(qinitqidleqvisible)を段階的に発火する。

// 1. qinit(ドキュメント準備完了)
emitEvent('qinit');

// 2. qidle(ブラウザがアイドル状態)
const riC = win.requestIdleCallback ?? win.setTimeout;
riC.bind(win)(() => emitEvent('qidle'));

// 3. qvisible(要素が画面に表示)
// Intersection Observer経由で発火

まず、qinitイベントがDOM構築完了時に即座に発火される。このイベントは、初期化処理やuseTask$フックでの処理に使用されているようだ。

次に、ブラウザのメインスレッドがアイドル状態になったタイミングでqidleイベントが発火される。PreFetchの時などに使うのだろうか。

最後に、qvisibleイベントは、特定の要素が画面に表示されたタイミングでIntersection Observer経由で発火される。このイベントは、useVisibleTask$フックで使用されている。

イベント タイミング 用途
qinit DOM構築完了 初期化処理、useTask$
qidle ブラウザアイドル 低優先度処理、プリフェッチ
qvisible 要素表示 useVisibleTask$

これらのイベントを段階的に発火することで、処理の優先度を明確にし、重要な処理から順に実行できる。

1.3 requestIdleCallbackのフォールバック

qidleイベントの発火には、requestIdleCallbackAPIが使用されている。

const riC = win.requestIdleCallback ?? win.setTimeout;

requestIdleCallbackは、ブラウザのメインスレッドがアイドル状態(他の処理が実行されていない状態)のときにコールバックを実行するAPIである。これにより、ユーザーの操作を妨げることなく、低優先度の処理を実行できるようにしているようだ。

Null合体演算子でフォールバックが実装されているのは、Safari等の一部ブラウザはrequestIdleCallbackをサポートしていないためだと思われる。setTimeoutは即座に実行されるわけではないが、次のイベントループで実行されるため、一定の遅延効果があるのだろう。

Qwikでは、この仕組みを利用して処理の優先度を3段階に分けている。

High Priority: qinit(即座に実行)
  ↓
Medium Priority: ユーザーインタラクション
  ↓
Low Priority: qidle(アイドル時に実行)

最も優先度が高いのはqinitで、これは即座に実行される。次に優先されるのはユーザーインタラクションで、ユーザーの操作に即座に応答する。最後に、ブラウザがアイドル状態になったときにqidleが実行され、プリフェッチなどの低優先度処理が行われる。

2. イベント登録システム

2.1 動的な登録システムの仕組み

processEventOrNode関数は、実行時にイベントやルート要素を動的に追加できる関数として実装されている。

// qwikloader.ts
const processEventOrNode = (...eventNames: (string | (EventTarget & ParentNode))[]) => {
  for (const eventNameOrNode of eventNames) {
    if (typeof eventNameOrNode === 'string') {
      // イベント名の場合
      if (!events.has(eventNameOrNode)) {
        roots.forEach((root) =>
          addEventListener(root, eventNameOrNode, processDocumentEvent, true)
        );
        addEventListener(win, eventNameOrNode, processWindowEvent, true);
        events.add(eventNameOrNode);
      }
    } else {
      // ルート要素の場合
      if (!roots.has(eventNameOrNode)) {
        events.forEach((eventName) =>
          addEventListener(eventNameOrNode, eventName, processDocumentEvent, true)
        );
        roots.add(eventNameOrNode);
      }
    }
  }
};

この関数には2つの使い方がある。まず、新しいイベント名を登録する使い方である。

processEventOrNode('mousemove');

この呼び出しにより、以下の処理が実行される。まず、登録済みの全てのルート要素(roots)に対してmousemoveイベントのリスナーを追加する。次に、windowオブジェクトにもmousemoveリスナーを追加する。最後に、eventsSetに'mousemove'を追加して、登録済みイベントとして記録する。

次に、新しいルート要素を登録する使い方である。

const newRoot = document.querySelector('#micro-app');
processEventOrNode(newRoot);

この呼び出しにより、別の処理が実行される。まず、新しいルート要素(newRoot)に対して、既に登録済みの全イベント(events内の全イベント名)のリスナーを追加する。次に、roots SetにnewRootを追加して、管理対象のルートとして記録する。

この双方向の仕組みにより、イベントとルートのどちらを先に登録しても、最終的には全ての組み合わせでリスナーが登録される。この仕組みは、マイクロフロントエンドアーキテクチャに特に有用であると思われ、複数の独立したQwikアプリケーションが同一ページ内に共存する場合、それぞれのアプリを動的に登録できる。

<!-- メインアプリ -->
<div q:container id="main-app">
  <!-- ... -->
</div>

<!-- 動的に追加されるマイクロアプリ -->
<div q:container id="micro-app">
  <!-- ... -->
</div>

<script>
// マイクロアプリの登録
const microApp = document.querySelector('#micro-app');
window.qwikevents.push(microApp);
</script>

この例では、メインアプリとは別に、後から追加されるマイクロアプリがある。マイクロアプリをDOMに追加した後、window.qwikevents.push()を呼び出すだけで、qwikloaderがそのアプリ内のイベントも管理するようになる。これにより、独立したアプリケーションを段階的にロードしたり、条件に応じて表示したりできる。

2.2 キャプチャフェーズでのイベント捕捉

qwikloaderは、イベントリスナーを登録する際に、第4引数にtrueを指定してキャプチャフェーズで捕捉する。

addEventListener(root, eventName, processDocumentEvent, true);
//                                                      ^^^^
//                                                      capture: true

DOMイベントには3つのフェーズがあり、キャプチャフェーズ、ターゲットフェーズ、バブルフェーズの順に進む。

Capture Phase(外→内):
  document → div → button  ← qwikloaderはここで捕捉

Target Phase:
  button

Bubble Phase(内→外):
  button → div → document

キャプチャフェーズは、イベントがルート(document)から発火元の要素へと「降りていく」フェーズである。この順序で制御しているのは、ルートレベルで全てのイベントを捕捉することで、アプリケーション全体のイベントを一元管理できるからだろうか。

3. qwikloaderの起動シーケンス

3.1 起動コード全体

// qwikloader.ts
if (!('__q_context__' in doc)) {
  doc.__q_context__ = 0;
  const qwikevents = win.qwikevents;
  
  if (qwikevents) {
    if (Array.isArray(qwikevents)) {
      processEventOrNode(...qwikevents);
    } else {
      processEventOrNode('click', 'input');
    }
  }
  
  win.qwikevents = {
    events: events,
    roots: roots,
    push: processEventOrNode,
  };
  
  addEventListener(doc, 'readystatechange', processReadyStateChange);
  processReadyStateChange();
}

3.2 起動の4ステップ

ステップ1:多重起動の防止

qwikloaderの起動シーケンスの最初のステップは、以下のようになっている。

if (!('__q_context__' in doc)) {
  doc.__q_context__ = 0;
  // ...
}

おそらく多重起動を防ぐための機構か。これを追加していると、何らかの理由で複数のqwikloaderスクリプトがページに読み込まれたとしても、doc.__q_context__プロパティの存在をチェックすることで、最初の1つだけが実行される。最初のqwikloaderがdoc.__q_context__0で初期化すると、2つ目以降のqwikloaderはこのif文の条件がfalseになるため、内部の処理をスキップすることができる。

ステップ2:事前登録イベントの処理

qwikloaderが読み込まれる前に、アプリケーション側がイベントを事前登録できる仕組みがある。

const qwikevents = win.qwikevents;
if (qwikevents) {
  if (Array.isArray(qwikevents)) {
    processEventOrNode(...qwikevents);
  } else {
    processEventOrNode('click', 'input');
  }
}

この仕組みにより、qwikloaderがロードされる前に、必要なイベント名を配列としてキューイングできる。

<script>
// qwikloader読み込み前に実行される可能性がある
window.qwikevents = ['click', 'input', 'scroll'];
</script>

<script src="/qwikloader.js"></script>

上記の例では、qwikloaderが読み込まれる前にwindow.qwikeventsに配列を設定している。qwikloaderは起動時にこの配列をチェックし、存在する場合はスプレッド演算子(...)で展開してprocessEventOrNodeに渡す。これにより、キューイングされた全てのイベントが一度に登録される。

もしwindow.qwikeventsが存在しない、または配列でない場合は、デフォルトとして最も一般的なclickinputイベントを登録する。

else {
  processEventOrNode('click', 'input');
}
ステップ3:グローバルシステムの確立

事前登録イベントの処理が完了すると、qwikloaderはwindow.qwikeventsを配列から管理オブジェクトに置き換える。

win.qwikevents = {
  events: events,
  roots: roots,
  push: processEventOrNode,
};

この変換により、初期化前の単純な配列が、実行時に動的な操作が可能な管理オブジェクトへと変わる。

// 前:配列(キューイング用)
window.qwikevents = ['click'];

// 後:管理オブジェクト
window.qwikevents = {
  events: Set(['click', 'input']),
  roots: Set([document]),
  push: processEventOrNode  // 動的追加用メソッド
};

// 使用例
window.qwikevents.push('mousemove');  // イベント追加
window.qwikevents.push(newRootElement);  // ルート追加

管理オブジェクトには3つのプロパティがある。eventsは登録済みイベント名のSetで、現在どのイベントが監視されているかを確認できる。rootsは管理対象のルート要素のSetで、複数のQwikアプリケーションが共存する場合にも対応できる。そしてpushprocessEventOrNode関数への参照で、実行時に新しいイベントやルートを追加するためのAPIとして機能する。

ステップ4:初期化トリガー

起動シーケンスの最後のステップは、ドキュメントの準備状態を監視し、適切なタイミングで初期化処理を実行することである。

addEventListener(doc, 'readystatechange', processReadyStateChange);
processReadyStateChange();

まず、readystatechangeイベントにリスナーを登録する。これにより、ドキュメントのreadyStateが変化したときにprocessReadyStateChange関数が呼び出される。このリスナーは、qwikloaderがHTMLの解析完了前にロードされた場合に備えて設定される。

processReadyStateChange()を即座に実行しているのは、qwikloaderがロードされた時点で既にドキュメントの準備が完了している可能性があるからだろうか。jQueryから生JSに書き換えたらこんな感じにはなるが。

4. アーキテクチャ的な特徴

4.1 イベントデリゲーション

Qwikのアプローチは従来のようなイベントリスナーを複数配置する方式では無く、単一のイベントリスナーを登録する。

// ルート要素でグローバル捕捉
document.addEventListener('click', (ev) => {
  // イベントターゲットを調べて適切なハンドラを実行
  const handler = findHandlerForElement(ev.target);
  if (handler) handler(ev);
}, true);

Qwikは、ルート要素(通常はdocument)に1つだけリスナーを登録し、全てのイベントをキャプチャフェーズで捕捉する。そして、イベントターゲットを調べて、その要素に対応するハンドラを実行する。この方式の利点は以下の通りである。

項目 従来型 Qwikのデリゲーション
メモリ使用量 要素数に比例 固定(ルート数のみ)
動的要素対応 リスナー再登録必要 自動対応
Hydration 全要素に必要 不要

メモリ使用量は、要素数に関わらず固定(ルート数のみ)で済み、動的に追加された要素も自動的にイベントデリゲーションの対象となる。従来のフレームワークでは、SSRで生成されたHTMLに対して全要素のイベントリスナーを再登録する必要があったが、Qwikではその必要がない。

4.2 宣言的なイベント制御

Qwikの特徴の1つに、宣言的なイベント制御がある。この仕組みにより、JavaScriptがロードされる前からイベントの挙動を制御できる。

以下のフォームの例において、HTMLが表示された時点ではqwikloader.js(約1KB)がロード済みだが、form.jsはまだロードされていない状態となる。つまり、実際のフォーム送信処理のJavaScriptは、まだブラウザに存在しない状態である。

<form
  preventdefault:submit
  on:submit="./form.js#onSubmit"
>
  <input name="email" />
  <button type="submit">Submit</button>
</form>

ユーザーがSubmitボタンをクリックすると、以下のフローで処理が進む。まず、qwikloaderがキャプチャフェーズでsubmitイベントを捕捉する。次に、preventdefault:submit属性を検出し、即座にev.preventDefault()を実行する。この時点ではまだform.jsがロードされていないが、フォームのデフォルト動作(ページリロード)は既に阻止されている。その後、qwikloaderがform.jsを動的にロードし、ロード完了後にonSubmitハンドラを実行する。

この方式によって、JavaScriptロード前でもイベント制御が可能となる。フォームのデフォルト動作を阻止するために、JavaScriptをロードして実行する必要がない。

4.3 Resumabilityの実現

従来のHydrationベースのフレームワークでは、以下のフローでクライアント側の処理が進む。

Server:
  コンポーネントツリー → HTML生成

Client:
  HTML表示
  ↓
  全JSロード
  ↓
  コンポーネントツリーを再構築(Hydration)
  ↓
  イベントリスナー登録
  ↓
  インタラクティブ

サーバー側でコンポーネントツリーからHTMLを生成し、クライアント側ではそのHTMLを表示する。しかし、ここからが問題である。クライアント側で全てのJavaScriptをロードし、コンポーネントツリーを再構築(Hydration)し、全ての要素にイベントリスナーを登録して、ようやくインタラクティブになる。この一連の処理には、数百ミリ秒から数秒かかることもある。

一方、QwikのResumabilityはResumabilityという異なるアプローチを取る。

Server:
  コンポーネントツリー → HTML生成
                      → 状態をJSON化(<script type="qwik/json">)

Client:
  HTML表示 + qwikloader.js
  ↓(ユーザー操作まで待機)
  ユーザーがクリック
  ↓
  必要なハンドラのみロード
  ↓
  JSONから状態復元(必要な部分のみ)
  ↓
  イベント処理実行

サーバー側では、HTMLの生成に加えて、アプリケーションの状態を<script type="qwik/json">としてシリアライズしてHTML内に埋め込む。クライアント側では、HTMLとqwikloader.js(約1KB)を表示し、即座にインタラクティブになる。ユーザーが操作するまで、JavaScriptをロードする必要がない。ユーザーがクリックなどの操作を行うと、その時点で初めて必要なハンドラだけをロードし、JSONから状態を復元して、イベント処理を実行する。