Qwik 2.0 RC で何が変わったのか、HTML コメントまで削った設計のこだわりを読んでみた

Qwik 2.0 RC で何が変わったのか、HTML コメントまで削った設計のこだわりを読んでみた

RC が出た Qwik 2.0 を v1 と同じ starter でビルドし、SSR HTML を比べました。HTML コメント 60 個が 0 個になり、状態は末尾の qwik/state と qwik/vnode へ。load までに読む JS は 59 KB から 9 KB に減る一方、バンドルは増えています。
2026.10.03

はじめに

こんにちは、クラスメソッド製造ビジネステクノロジー部の森茂です。

フロントエンドフレームワークの Qwik が、2026 年 10 月 1 日に 2.0 の RC(Release Candidate)に到達しました。npm では @qwik.dev/core@2.0.0-rc.0 として公開されていて、bun create qwik@rc で試せます。

https://next.qwik.dev/blog/qwik-2-rc/

ちなみに、同じ 2026 年 10 月 1 日(UTC)には SvelteKit 3 と Remix 3.0.0 も正式版が出ています。npm の公開時刻で見ると Qwik の RC が 15:49、SvelteKit 3 が 17:22、Remix 3.0.0 が 22:58 で、7 時間ほどの間にメジャーが 3 つ並びました。狙ったわけではないと思いますが、Qwik 2 と SvelteKit 3 はどちらも 2026 年 3 月に出た Vite 8 を前提にしています。Remix 3 は逆に React にも Vite にも依存しない、web primitives の上に組んだ単一依存のパッケージとして出直しました。フレームワークの作り直しが同じ日に重なったのは、偶然と必然が半々といったところでしょうか。

自分は v1 の頃に Qwik を触って、hydration をしないで起動する resumability の発想がかなり気に入っていました。ただ、2024 年 2 月に設計記事「Towards Qwik 2.0」が出てから、2024 年 11 月に alpha、2025 年 6 月に beta と進んだあとが長く続きました。RC まで約 2 年 8 か月です。その間も v1 系は 1.20.1(2026 年 9 月 23 日)まで更新が続いていたので止まっていたわけではないのですが、v2 の本番はいつ来るのかと気にしていた一人です。

さらに話が逸れますが、Qwik の名前をよく見かけたのは、2023 年の signals 論争のころだった気がします。2022 年末に Preact と Qwik が signals を取り入れ、2023 年 2 月に Angular が追随し、Svelte も 5 の runes で signals ベースになりました。Miško Hevery さんが Builder.io のブログに書いた「useSignal() is the Future of Web Frameworks」(2023 年 2 月)も、自分のタイムラインではよく回ってきた記憶があります。一方の React は signals を API としては採らず、コンパイラ(当時の Forget、いまの React Compiler)に同じ役割を持たせる道を選びました。その後、signals を JavaScript 標準にする TC39 の提案が 2024 年 4 月に Stage 1 に入りました。設計には Angular、Vue、Solid、Preact、Svelte、Qwik などのメンテナが名を連ねています。ただし 2026 年 10 月時点でも Stage 1 のままです。論争としては静かになり、React 以外では signals が標準装備になった、というのが自分の見立てです。Qwik の v2 も、その signals の実装をゼロから書き直したところから始まっています。

RC の告知を読むと、v2 は機能を足したリリースというより、v1 の仕掛けの中心にあった HTML コメントのマーカーを「重い」と判断して捨て、状態と DOM の写しを HTML 末尾の文字列に詰め直したリリースでした。JavaScript をユーザーが触るまで動かさないという一点のために、ここまで手を入れるのかという、ある意味で狂気じみた(賞賛として)こだわりが見えます。

この記事では、Qwik が譲らなかった設計、その設定を v1 で支えていた仕組みの重さ、v2 がそれをどう作り直したかを、v1 と v2 の starter を同じ構成でビルドして HTML を見比べながら紹介します。Qwik を知らない人にも、フレームワークがどこまで「ブラウザに仕事をさせない」ことに執着できるのかが伝わるといいなと思っています。

Qwik が譲らない resumability と、それを v1 で支えていたコメントマーカーの重さ

一般的なフレームワークの SSR は、サーバーで HTML を作ったあと、ブラウザで同じコンポーネントのコードを読み込んで実行します。そこでイベントリスナーを付け直し、コンポーネントの木と状態を復元します。これが hydration で、アプリが大きくなるほど起動時に実行する JavaScript が増えます。

Qwik はこの hydration をしません。公式ドキュメントの言い方では、サーバーで実行を「一時停止」し、ブラウザで「再開」します。リスナーは on:click="./chunk.js#handler" のような属性として HTML に書き込まれ、コンポーネントの境界、アプリの状態、どの状態がどのコンポーネントに影響するかの購読関係まで HTML にシリアライズされます。ブラウザ側で最初に動くのは Qwikloader という小さなスクリプトだけです。document にグローバルなリスナーを 1 つ登録し、クリックされた要素の属性を見てから該当するチャンクを import します。

これを成立させているのが Optimizer です。コードの中の $ が付いた箇所(component$、onClick$、useComputed$ など)を見つけて、その中身を別のチャンクに切り出します。開発者がコード分割を設計しなくても、クロージャの粒度で遅延ロードの境界が自動で生まれます。公式ドキュメントの「Think Qwik」には「bundle size should not be something that developers should think about」とあります。v1.0 の告知も、初期 JavaScript のコストを一定に保つことを約束していました。RC の告知はこれを、動画のストリーミングのように O(1) で反応し始めると表現しています。

hydration する一般的な SSR と resumable な Qwik でブラウザがやることの比較
上段が hydration する一般的な SSR、下段が Qwik。Qwik は HTML に入った listener と状態をそのまま使い、Qwikloader を動かした時点で操作できる。コードは押した chunk だけ取得する。

上の図の下段を成り立たせていた「HTML に入った listener と状態」を運んでいたのが、v1 の HTML の各所に書かれていたコメントでした。

Qwik v1 の HTML(告知の簡略化した例)
<main>
  <!--qv q:id=7 q:key=counter-->
    Count:
    <!--t=8-->123<!---->
    !
    <button on:click="...">+1</button>
  <!--/qv-->
</main>
<script type="qwik/json">{...}</script>

<!--qv--> がコンポーネントの境界、<!--t=8--> が signal で更新されるテキストの位置を示すマーカーです。RC の告知はこれを「cute little comments」と呼びつつ、はっきりその重さを認めています。順序どおりの 1 パスで書き出す必要があり、CPU とメモリに負担をかけ、反応性は平均的なフレームワークより遅く、順序外のストリーミングが不可能で、遅い fetch がその下のすべてを止めていた、と。

v2 はコメントを消して HTML 末尾の文字列に置き換えた

v2 の答えは、マーカーの運び手そのものを捨てることでした。

Qwik v2 の HTML(告知の簡略化した例)
<main>
  Count: 123!
  <button on:click="...">+1</button>
</main>
<script type="qwik/state">[...]</script>
<script type="qwik/vnode">...</script>

本文からコメントが消え、代わりに HTML 末尾に qwik/state(状態)と qwik/vnode(仮想ノードの情報)の 2 本のスクリプトが置かれます。ブラウザ側では仮想ツリーを小さな VNode オブジェクトとしてメモリに持ち、signal が変わったら HTML を再パースせずに該当するテキストノードや属性だけを更新します。大きな更新は分割され、スケジューラが 15 ms ごとにブラウザへ制御を返すので、長い描画の裏でクリックやタイピングが待たされません。

Qwik v1 と v2 の HTML で境界と状態の印をどこに置くかの比較
左が v1、右が v2 の HTML。v1 は境界と更新位置の印をコメントとして本文に埋め、v2 は本文を素の HTML にして末尾の qwik/state と qwik/vnode に集約した。

この方針は 2024 年 2 月の設計記事「Towards Qwik 2.0」で先に説明されていました。vnode の情報を 9 文字程度のエンコード文字列に詰める、要素の参照に ID 属性ではなく深さ優先の連番を使う、vnode は必要になった時点で遅延生成する、オブジェクトではなく配列で持って JIT が最適化しやすい形にする。どれも「ブラウザに余計な仕事をさせない」の一点に向いた判断です。

告知と設計記事の内容を v1 と並べると次のようになります。

観点 v1 v2
パッケージ @builder.io/qwik、@builder.io/qwik-city @qwik.dev/core、@qwik.dev/router
状態の置き場 <script type="qwik/json"> 1 本 qwik/state と qwik/vnode の 2 本
境界とテキストの印 HTML コメント 末尾のエンコード文字列
要素の参照 q:id 属性 深さ優先の連番
vnode HTML を走査して構築 必要になった時点で生成
更新 HTML を再パース メモリ上の vnode から直接
長い処理 分割なし 15 ms ごとにブラウザへ譲る
ストリーミング 順序どおり 順序外(experimental)
Optimizer Rust Rust と TypeScript(opt-in)
Vite 7 系 8 系(Rolldown)

このうち要素の参照と vnode の 2 行は 2024 年の設計記事に書かれた方針で、残りは RC 告知の説明です。RC の実装がそのままかは、次の章で手元の HTML から確かめられた項目だけ断定しています。

v1 と v2 の starter を同じ構成でビルドして HTML を見比べてみた

告知の例は簡略化されているので、実際のビルド出力で確かめました。create-qwik の playground starter は v1 も v2 も同じ構成で、ソースの差は import 元と、v2 側の root.tsx と entry.ssr.tsx の書き換えだけです。

bun create qwik@latest playground qwik1-playground   # @builder.io/qwik 1.20.1 + Vite 7.3.1
bun create qwik@rc     playground qwik2-playground   # @qwik.dev/core 2.0.0-rc.0 + Vite 8.2.1

それぞれ bun install のあと bun run build.client と bun run build.preview で本番ビルドし、bun x vite preview で配信したトップページを curl で保存しました。Counter コンポーネントの周辺を抜き出すと、v1 はこうです。

v1 1.20.1 のトップページ(Counter 周辺を抜粋)
<!--qv q:id=d q:key=5Go3:H1_2-->
<div class="_counter-wrapper_43sys_1" q:key="no_1">
<button class="button-dark button-small" on:click="q-DV-9VYlM.js#s_D04jAYuCnhM[0 1]" q:id="e">-</button>
<!--qv q:id=f q:key=7gzr:no_0-->
<div class="_wrapper_1v6hy_1" q:key="cu_0">
...
<span class="_value_1v6hy_9">70</span>
</div>
<!--/qv-->
<button class="button-dark button-small" on:click="q-DV-9VYlM.js#s_LkCVrojX09Y[0 1]" q:id="g">+</button>
</div>
<!--/qv-->

同じ箇所が v2 ではこうなります。

v2 2.0.0-rc.0 のトップページ(同じ箇所)
<div :="no_1" class="_counter-wrapper_43sys_1">
<button q:ps="5" : class="button-dark button-small" q-e:click="q-BymhPPa_.js#_run#6">-</button>
<div :="cu_0" class="_wrapper_1v6hy_1">
...
<span : class="_value_1v6hy_9">70</span>
</div>
<button q:ps="7" : class="button-dark button-small" q-e:click="q-BymhPPa_.js#_run#8">+</button>
</div>

コメントも q:id も q:key も消えています。代わりに目を引くのが、属性名が : だけの属性です。ソースを追うと Q_PROPS_SEPARATOR という定数で、SSR は動的な属性を書いたあとに : を書き、要素に key があれば :="no_1" のように値に入れ、その後に固定の属性を書いています。v1 で q:key と q:id が担っていた役割を、1 文字の属性名が引き受けた形です。属性名を 1 文字まで削るところに、HTML のバイト数と走査コストへの執着が出ています。

ページ全体を数えた結果が次の表です。

観点 v1 1.20.1 v2 2.0.0-rc.0
HTML コメントの数 60(<!--qv が 28) 0
q:id 属性の数 20 0
q:key 属性の数 42 0
: 属性の数 0 104
状態スクリプト qwik/json 324 B qwik/state 4,007 B + qwik/vnode 831 B
HTML 総バイト(gzip 後) 19,191 B(7,115 B) 22,991 B(8,716 B)
クライアントバンドル 50 ファイル、115,020 B 66 ファイル、193,491 B
Qwikloader 3,100 B 5,222 B

トップページの HTML に残る印の数を v1 と v2 で比べた棒グラフ
同じ playground starter のトップページで数えた印の数。v1 のコメント 60 個、q:id 20 個、q:key 42 個が v2 では 0 個になり、代わりに「:」属性が 104 個付く。

正直に書くと、HTML は小さくなっていません。コメントが消えたぶんより、末尾の qwik/state と qwik/vnode が増えたぶんのほうが大きく、gzip 後でも約 1.6 KB 増えています。クライアントバンドルも core のチャンクが 52 KB から 112 KB に増え、Qwikloader も 3.1 KB から 5.2 KB になりました。公式ドキュメントにある「about 1 kb minified」は初期の記述で、1.20.1 の時点でも 3 KB です。v2 の変更はバイト数を減らすためのものではなく、HTML を順序どおりに 1 パスで走査しなければならなかった構造を解体するためのもの、と読むのが正確そうです。

ではブラウザは最初に何を読むのか。headless Chromium でトップページを開き、load イベントまでに取得した JS、その後 3 秒の idle 中に先回りで取得した JS、Counter の「+」を押したときに追加で取得した JS を数えました。

フェーズ v1 1.20.1 v2 2.0.0-rc.0
load までに取得した JS 3 本、59,435 B(core を含む) 2 本、9,048 B(Qwikloader と preloader のみ)
idle 中の先回り取得 9 本、8,938 B 29 本、161,612 B
「+」クリック後の追加取得 0 本 2 本、940 B
Counter の値 70 → 71 70 → 71

ブラウザが取得した JavaScript の量をフェーズ別に v1 と v2 で比べた棒グラフ
headless Chromium が取得した JS の量。v2 は load までに 9 KB しか読まないが、idle 中の先回り取得は 161.6 KB と v1 より多い。クリック後は v2 だけ 0.9 KB を追加で取る。

v2 は load までに core を読まず、9 KB で画面を出しています。そのあと idle 中に bundle graph に従って 161 KB を先回りで取得するので、取得量で見ると v2 のほうが多くなります。先回りは Qwik が v1.0 の告知から掲げている「speculative code fetching」そのもので、取得はしても実行はしないという立場です。クリック時に v2 が 940 B のチャンク 2 本を取ってから 71 に更新しているのは、ハンドラのコードがその瞬間まで読まれていなかったことの証拠で、こちらは resumability の挙動がそのまま見えています。

速度とストリーミングとキャッシュが同じ設計変更から手に入った

コメントマーカーを捨てた結果として、告知は 3 つの収穫を挙げています。

反応性は v1 の 2 倍以上になった

Qwik チームの手元計測による js-framework-benchmark の結果です。

操作(ms) Vanilla JS Qwik v2 2.0.0-rc.0 Vue 3.5.39 Angular 22.0.0 React 19.0.0 Qwik v1 1.20.1
Create 1,000 rows 20.9 26.8 24.7 33.7 25.5 80.5
Select a row 3.0 3.8 4.3 6.6 7.9 10.0
Swap two rows 14.1 17.7 16.7 16.5 96.6 27.0
Create 10,000 rows 208.5 275.7 247.8 291.0 414.8 757.6
加重幾何平均 1.00× 1.23× 1.25× 1.42× 1.53× 2.59×

js-framework-benchmark の加重幾何平均を 6 つのフレームワークで比べた棒グラフ
RC 告知の js-framework-benchmark の加重幾何平均。Vanilla JS を 1.00 としたとき Qwik v2 は 1.23、Qwik v1 は 2.59。Qwik チームの計測で、RC のため正式版までに変わり得る。

告知には「Since this is RC, the results are not published yet and are subject to change until v2 comes out.」と留保があり、ブラウザや測定機の記載もありません。それでも v1 の 2.59× から v2 の 1.23× への変化は、HTML を再パースせずメモリ上の vnode を直接更新するようになった効果として読めます。v1 の反応性が遅かった原因を「partial vDOM got in the way」と自分たちで書いているのが Qwik らしいところです。

順序外ストリーミングを手元で見てみた

v1 で不可能だった順序外ストリーミングは、v2 で experimental として入りました。vite.config.ts の qwikVite() に experimental: ["pendingBoundary", "catchBoundary", "blockSSR"] を渡し、遅いコンポーネントを <Pending fallback$={...}> で包みます。サーバーはその部分を待たずにページの残りを送り、本体は同じレスポンスの後ろで届いて、小さな inline script が差し替えます。

Pending の順序外ストリーミングでサーバーとブラウザの間を流れるメッセージのシーケンス図
手元で観測した順序外ストリーミングの流れ。5 ms までに本文と fallback と footer が届き、2 秒後に本体が template と qO(1) で届いて、ブラウザが fallback を本体に差し替える。

2 秒待つ async な useComputed$ を <Pending> で包んだ route を作り、HTTP のチャンクが届いた時刻を記録しました。

チャンクの到着時刻(抜粋)
      3 ms  chunk 1    +223 B  <!DOCTYPE html><html lang="en-us" q:render="ssr" ...
      4 ms  chunk 2  +8,186 B  <head :><link rel="modulepreload" ...
      5 ms  chunk 4  +5,007 B  ... id="immediate" ... id="fallback">Loading items... ... id="footer" ...
      5 ms  chunk 5  +4,116 B  <script type="qwik/state" ...
   2011 ms  chunk 6  +1,117 B  <template q:r="1"><ul :="Ws_0" id="slow-list"><li :="alpha">alpha</li> ... <script>qO(1)</script>
   2012 ms  chunk 7     +14 B  </body></html>

5 ms までに fallback の「Loading items...」とページ下部のフッターまで届き、本体の <ul> は 2 秒後に <template> で届いて qO(1) が表示を入れ替えます。差し替え用の inline script は 1,769 B でした。fallback は display:contents の div として DOM に残り、本体の置き場は display:none の div で待っています。

Pending デモの画面。左は到着直後の fallback、右は 2.5 秒後に本体が差し替わった状態
(A) 到着から 0.1 秒後は「Loading items...」の fallback とフッターが見えている。(B) 2.5 秒後に本体のリストが差し替わる。どちらも同じ 1 本のレスポンスで届いた。

一方、routeLoader$ に blockSSR: false を付けた版は、手元の vite preview では <html> の開始タグ以外が全部 2 秒後に届き、順序外にはなりませんでした。ドキュメントには「rendering begins immediately」とあるので、RC 段階の挙動か自分の構成の問題かは切り分けられていません。experimental の API はこういうところがまだあります。

useComputed$ が async 関数を受け付けるようになったのもこの流れで、<Resource> の後継という位置づけです。結果は普通の signal として読め、.pending と .error で状態が取れ、古いリクエストは abortSignal で打ち切られます。

ローダーはキャッシュできる単位に分かれた

v1 は route ごとに 1 本の q-data.json でデータを運んでいましたが、v2 では routeLoader$ ごとに JSON エンドポイントが分かれます。cacheControl: { maxAge: 60 } や 'immutable' を指定するとそのローダーの応答だけがブラウザキャッシュされ、eTag を設定すればローダーを実行せずに 304 を返せます。<Link> はホバー時に route のデータを先回りで取るので、クリック前にデータが届いていることも多くなります。ページ遷移でもローダーの完了を待たずに描画が始まり、<Pending> で包んだ領域だけが fallback を出す動きになります。

こだわりはフレームワーク自身の作り方にも及んでいた

告知の後半を読むと、こだわりの対象はアプリの出力だけではありません。

Optimizer は Rust 製で、WASM でも配布されていました。v2 では TypeScript 版の Optimizer が tsOptimizer: true の opt-in で入り、「every snapshot test」で Rust 版と一致し、速度もほぼ同じだと書かれています。貢献者が Rust を用意しなくてよくなるのが目的で、正式版では TypeScript 版を既定にする予定です。テストも、レンダラのテストを SSR からの再開とクライアント描画の 2 経路で回し、Playwright の E2E を Chromium、Firefox、WebKit の 3 ブラウザと 2 つの Optimizer の組み合わせで流し、CI で速度とメモリのベンチマークを取っています。2 つの実装を互いの検算に使う発想は、resumability の正しさがシリアライズの正しさに依存するフレームワークならではだと思います。

細かいところでは、*.server.ts という名前のファイルや server/ ディレクトリのコードをクライアント側から import するとビルドが失敗するようになりました。注意書きではなくビルドエラーで止めるのが、このチームの好みです。ストリーミング済みの要素の属性をあとから変える必要が出た場合は、小さな inline script でブラウザ側を書き換える backpatching も入っています。

移行については、告知が「only a handful of small breaking changes」と書き、bunx qwik migrate-v2 がパッケージ名(@builder.io/qwik → @qwik.dev/core、@builder.io/qwik-city → @qwik.dev/router)と識別子(QwikCityProvider → QwikRouterProvider など)を書き換えてくれます。手で直すのは、QwikCityProvider のラッパーを useQwikRouter() フックに変える、useResource$ を async の useComputed$ に置き換える、HTML 側の listener 属性が on:click から q-e:click に変わるのでそれを読んでいるテストやツールを直す、あたりです。2024 年の設計記事は破壊的変更を入れない計画だと書いていたので、ここは方針が少し動いています。

開発体験では Vite 8 が必須になり、ビルドは Rolldown で走ります。HMR は signal や store の値を保ったままコンポーネントを編集でき、Qwik DevTools は開発時のオーバーレイと本番ビルド向けのブラウザ拡張で props、signal、描画、route、バンドルを見せます。告知はこれを「so your agent can inspect」と書いていて、starter にも .vscode/mcp.json が同梱されています。新しい API としては Web Worker で関数を動かす worker$、React のツリーの中で Qwik コンポーネントを描く reactify$、ライブラリのクラスインスタンスをシリアライズする useSerializer$、layout の中で描かれる 404.tsx と error.tsx などが後方互換で追加されています。

告知の最後には、貢献者と他のフレームワークへの謝辞と並んで、「Thank you to Claude and Codex for their generous open-source programs」という一文がありました。OSS 向けのプログラムでコーディングエージェントを使い、開発と保守が速くなったと明記しています。長く止まって見えた v2 が RC まで来た背景の一つとして、書いているのが印象に残りました。

まとめ

Qwik 2.0 RC は、v1 の仕組みの運び手だった HTML コメントのマーカーを「重い」と判断して捨て、状態と DOM の写しを HTML 末尾の qwik/state と qwik/vnode に詰め直したリリースでした。手元のビルドでも、同じ starter で HTML コメントが 60 個から 0 個、q:id が 20 個から 0 個になり、load までに読む JS は 59 KB から 9 KB に減っていました。反応性の改善、順序外ストリーミング、ローダー単位のキャッシュは、この一つの設計変更から同時に出てきたものです。

一方で HTML の総バイトもクライアントバンドルも増えていて、idle 中の先回り取得は v2 のほうが積極的になっています。routeLoader$ の blockSSR: false は手元では効かず、<Pending> 系の API は正式版までに安定化すると告知にあります。ドキュメントも書き直し中のようなので、今から本番に入れる段階ではなく、RC で試して issue を返す段階となりそうです。

それでも、ユーザーが触るまで JavaScript を動かさないという一点のために、属性名を 1 文字まで削り、2 つの Optimizer を互いの検算に使うところまでやるフレームワークは他に思い当たりません。正式版が出たら、この starter で同じ計測をやり直して、RC からの変化を確かめてみたいところです。

参考リンク

この記事をシェアする

関連記事