🧊

Qwikというフレームワークについて

GitHub - BuilderIO/qwik: An Open-Source framework designed for best possible time to interactive, by focusing on resumability of server-side-rendering of HTML, and fine-grained lazy-loading of code.

去年から気になってて、調べたいなーと思ってたやつ。

昨今の覇権を握ってる系のJavaScript-firstなフレームワークたちとは違い、HTML-firstを謳うユニークなアプローチをしてるのが一番の特徴。

中の人による一連のシリーズもあって、そこも読んでまとめてみた記事です。

Qwik Series' Articles - DEV Community

Qwikの特徴

  • 遅いモバイル環境だとしても、TTIを爆速にすることを目的としたフレームワーク
    • TTI: Time To Interactive
    • ページが読み込まれて、UIが表示され、イベントハンドラがアタッチされ、操作可能になるまでの時間
  • Resumableをテーマに作られている
    • 詳細は後述
    • 現状のフレームワークたちはどれもResumableではなく、強いて言うならReplayable
  • JSではなく、HTML(DOM)を中心とした設計になってる
    • 内部状態の管理などに、JSのヒープではなくDOM属性の文字列を使う
    • JSが不要というわけではない

という具合で、これまでのものとは一線を画する感じ。

現状のフレームワークとTTIの問題

TTIまでに必要なステップは、ざっくり次のとおり。

  • クライアントがページをリクエス
  • サーバーがそれを受けてSSR
    • CDNなどから返すでもいいけど、それだとCSRが必須になってしまう
    • 遅いモバイル環境でも最速を目指すなら、SSRしかない
  • HTMLとJSのダウンロード
    • HTMLはたいていすぐ取得できるけど、問題はJS
  • ダウンロードしたJSの実行

Nextなど現状のフレームワークを使う場合、この後半のステップが重くなりがちで、どうしてもTTIが遅くなる傾向にある、と。

ほとんど静的なページだったとしても、デカいランタイムが必要(Svelteみたいにそこが小さいのもあるけど)になるし、イベントハンドラをアタッチするためにVDOMを全走査したりしないといけない。

そうやってJSをロードしてすべてのUIにハンドラをアタッチしても、ユーザーがそれらを必ず使う保証はないという別の問題もある。もしそれらが使われなかったら?その部分のイベントハンドラ(およびその裏側のクロージャに連なる多数の依存コード)や、テンプレですらも無駄になる。

ただこれはそういうデザインになってるからであって、それが必ずしも悪いというわけではない。そのおかげで以降のランタイムのパフォーマンスは良くなるので、何事もトレードオフ

Qwikのデザイン

TTIを速くするには、そういう初期ロードでのJSのダウンロードや実行をやめる、もしくは遅延させる必要がある。

Qwikでも初期ロードでJSを利用するけど、それは1KB足らずで実行も1ms以内に終わる。あとの全て(UIもイベントハンドラも何もかも)は、非同期で遅延ロードされる。

もちろん他のフレームワークでも、遅延ロードするように手書きすることはできる。けど、それは付け足しであって、コードは確実に煩雑になるはず。

Qwikの場合、遅延ロードするのがデフォルトなので、そのための特別なコードは必要ない。単にQwik-wayにコードを書けば、それだけで遅延ロードされる。そんなすべてを遅延ロードするデザインを可能にしてるのが、最初に言ってたResumableという概念。

Resumableなフレームワーク

qwik/RESUMABLE.md at main · BuilderIO/qwik · GitHub

現状のフレームワークたちはResumableではなく、Replayableと言ってたけどそれは、

  • フレームワークの内部状態が、JSのヒープで管理されてる
  • その解釈や評価がロード時に行われる
    • そのために、大量のJSが必要になるわけではある
  • SSR時にやったはずのことを、"リプレイ"する必要がある
  • 途中でやめることも、一部だけをハイドレーションすることもできない
    • いわゆるパーシャルハイドレーションができない

という特徴があるから。

このデザインだと、どうしてもTTIに不利であり、アプリのサイズが大きくなればなるほど、もっと不利になる。

それに対してQwikでは、

  • SSRでやったことを、クライアントで引き継げる
    • つまり"レジューム"できる
  • そのためには、引き継ぎ情報をサーバーからクライアントに渡す必要がある
  • それに使われるのが、DOMの属性(= Attributes)
  • レジュームできるということは、途中でやめることも容易
    • スクロールしないと表示されないようなUIは、後回しにできる
    • いわゆるパーシャルハイドレーションができる

という風になってる。

たとえば、これはいわゆる`Counter`コンポーネントSSRした結果のコード。(手動でこれを書くわけではない)

<div
  on:q-mount="./q-fbbfca2f#Counter_onMount"
  on:q-render="./q-35fc9755#Counter_onRender"
  count-step="5"
  q:obj="#2 !:22l6t4ye9i5"
>
  <div>
    <button on:click="./q-3f6b5546#Counter_onRender_on_click">-5</button>
    1
    <button on:click="./q-4e593eee#Counter_onRender_on_click5">+5</button>
  </div>
</div>

というように、内部状態がすべて属性で書かれてる。クリックのハンドラですら文字列。

属性で書かれてるがゆえに、特定の状態が更新されたときに再描画が必要なコンポーネントも、`querySelectorAll()`で一発で取ってきてマーキングできる。`requestAnimationFrame()`で間引きながら再描画していくときにも、Dirtyとマーキングがされてるものだけを逐次処理できる。変わってないかもしれない部分をわざわざレンダリングする必要もない。

すべてを遅延ロードする

qwik/LAZY_LOADING.md at main · BuilderIO/qwik · GitHub

さっきのコードにもあったけど、なんとイベントハンドラですら遅延ロードされる。実際にクリックされるまで、ハンドラで実行するコードをダウンロードすらしない!インクリメントのハンドラと、デクリメントのハンドラも別ファイルになる。クリックされたら、その当該のハンドラと、テンプレのためのコードがダウンロードされて実行される。

イベントハンドラは、もちろんそのイベントが実行されない限りは必要にならない。そして必要にならないかもしれないのに、実行コードとその依存を必ずダウンロードして、待ち受けるというコストを支払う必要がある。

これを回避するために、QwikではイベントハンドラすらDOM属性にシリアライズしてしまうことで、遅延ロードを可能にしてる、と。

ただ原初のクリックイベントを拾う必要はもちろんあって、それをやってるのが最初にロードする1KBに満たない`qwikloader.js`ってやつ。

https://github.com/BuilderIO/qwik/blob/main/src/bootloader-shared.ts

各イベントタイプごとに1つだけ、グローバルなハンドラをアタッチする。そして`ev.target.getAttribute()`で何をすべきか判断して、サーバーにリクエストする。サーバーとのやりとりには、QRLっいうプロトコルを使ってるけど、これはただの独自URL。

あとは、遅延ロードされたコンポーネントだとしても既存のグローバルな状態や親の状態などとやり取りできるように、`Entity`っていう仕組みがあったりする。

このあたりは、コードを書く我々としても考え方をQwik-wayにする必要があって、そこは少しとっつきにくいなーとは思った。最速のTTIを実現するために必要な代価ではあるけど。

qwik/MENTAL_MODEL.md at main · BuilderIO/qwik · GitHub

ともあれ、デザインからしてすべてを遅延ロードするので、TTIは必然的に速くなるというわけ。遅いモバイル環境であっても、それがどれだけデカいアプリになったとしても、初期ロードが常に速いってのが、Qwikの特徴。

コードを動かす、試す

実際に動いてるものを見るなら、

確かに、初期ロードは軽いしめっちゃ速い気がする・・!

さっきの`Counter`コンポーネントはこんな感じで書いてた。

import { h } from "@builder.io/qwik";
import { qComponent, qHook } from "@builder.io/qwik";

export const Counter = qComponent<{ countStep: number }, { count: number }>({
  onMount: qHook(() => ({ count: 1 })),
  onRender: qHook((props, state) => (
    <div>
      <button
        on:click={qHook<typeof Counter>((props, state) => {
          state.count -= props.countStep;
        })}
      >
        -{props.countStep}
      </button>
      {state.count}
      <button
        on:click={qHook<typeof Counter>((props, state) => {
          state.count += props.countStep;
        })}
      >
        +{props.countStep}
      </button>
    </div>
  )),
});

という感じで、

  • `class`ではなく、`qComponent()`にオブジェクトを渡してコンポーネントを作る
    • `onMount()`で、コンポーネントに閉じた状態を返す
    • `onRender()`で、おなじみのJSXを使ってテンプレを書く
  • `qHook()`でラップすると、コンポーネントのPropsやStateにアクセスできる
  • CSSはクラスを振って書くしかなさそう
    • ただしオブジェクトを渡せば`true`のときに付加するみたいな仕組みはある

これはできる限り現状のフレームワークっぽい書き方でComponentにすべてを詰め込んでるけど、Qwikの真髄的には、ViewとStateとHandlerはそれぞれバラして書くべきらしい。そのほうが、本当に必要なものだけを遅延ロードできるから。

Your bundler is doing it wrong - DEV Community

ただこのへんはコンパイラがいい感じにやってくれる?っぽい感もあって、まだよくわかってない。

Qwik compiler playground

Rollupのプラグインとして動いてるであろうOptimizerでもその具合を調整してる風だった。推論できないときのためのマーキングなんだろうか・・?

ローカルで試したい場合は、スターターのコマンドを使う。

npm init qwik

ってやると、いくつかテンプレを選ぶだけでコードを書き始められる。

現時点のデプロイ先の選択肢には、Node.js用の`express`のバージョンと、Cloudflare Workersへデプロイできるバージョンの2つがあった。たしかにコレはWorkerでやれると良さそうって思ってたんよね。

スターターを使って、

  • もっともベーシックな`Starter`プロジェクトを
  • Node.jsでデプロイする

とした場合に、生成されたコードはこのような感じ。

├── README.md
├── package.json
├── rollup.config.js
├── server
│   └── index.js
├── src
│   ├── index.server.tsx
│   └── my-app.tsx
└── tsconfig.json

`src`配下がアプリのコードで、この状態でビルドすると、こうなる。

├── README.md
├── package.json
├── public
│   └── build
│       ├── index.server.js
│       ├── my-app.js
│       ├── q-1612aa57.js
│       ├── q-5404689f.js
│       ├── q-6c8e2d16.js
│       └── q-8b22cd88.js
├── rollup.config.js
├── server
│   ├── build
│   │   ├── core-3865dc2e.js
│   │   ├── h_my-app_myapp_onmount-4f2b0222.js
│   │   ├── h_my-app_myapp_onrender-e999a192.js
│   │   ├── h_my-app_myapp_onrender_on_keyup-69b97bcc.js
│   │   ├── index.server.js
│   │   ├── my-app.js
│   │   └── q-symbols.json
│   └── index.js
├── src
│   ├── index.server.tsx
│   └── my-app.tsx
└── tsconfig.json

`server/build`配下にSSRのために必要なアプリのコードが出力されて、クライアントに返して使うための同様のものが、`public/build`配下にできるという感じ。とにかく細かいファイルが作られて、必要なときに必要なものだけが利用されるってのがよくわかるはず。

Cloudflare Workersにデプロイする方式を選んだ場合は、Workers Sitesを使ってホスティングするようになってた。

おわりに

というわけで、TTIが速いのは納得のデザインって感じでかなり好印象だった。TTIをKPIに据えてるなら、選択肢として考えられるようにしていきたい感じ。

ただ、UIイベントが発生してからハンドラのコードをリクエストするので、UIとしてのレスポンスにはいくらか制限があるんでは?とも思った。キャッシュしたりWorker使ったり、ある程度はカバーできると思うけど。

まだ生まれて間もないし(というかドキュメントすらないし)、実用段階ではないにしても、これからも要チェックでいきたいところ。

JSの用法と用量を最適化しようとする昨今のトレンドは割といいなと思っていて、そういう意味ではAstroとかと出発点は同じなのかもなーと。SSRのQwikと、SSGのAstroみたいな。

ちなみにQwikの中の人、あのAngularの中の人でもあるそうな。ここでまたその名を聞くとはな・・どうりでそれらしいコードやらコメントやらがあるわけやって感じ。

ほかにもBuilder.io社のOSSは興味深いのが多くて、`partytown`とか`mitosis`とか、一度は話題になってたことがあるはず。