😽

「理論上は最強」の Qwik/QwikCity を、フロントエンドの共通基盤にできないか

2023/08/14に公開

Qwik をマイクロフロントエンド基盤として使えないか検討していて思いついた色々。副産物で色々作った。

https://github.com/mizchi/qwik-svelte

https://github.com/mizchi/qwik-vue

tl;dr

  • Qwik は理論上は最強。だが難しい
  • qwik-react を使えば選択的に Qwik/React を切り替えられるので、 Astro と同じメタフレームワークとして使えそう
  • React 以外もその気になれば対応できるはず => qwik-svelte と qwik-vue を実装した
  • 最終的な問題は Qwik が流行るかどうか

Qwik/QwikCity とは何か

Qwik は SSR First なUIライブラリで、 .tsx の React 方言からコンポーネントを生成する。

import { component$, useSignal } from '@builder.io/qwik';
 
export default component$(() => {
  return (
    <>
      <p>Parent Text</p>
      <Child />
      <Counter />
    </>
  );
});
 
const Child = component$(() => {
  return <p>Child Text</p>;
});

const Couter = component$(() => {
  const counter = useSignal(0);
  return <button type="button" onClick$={() => { counter.value++ }}>{counter.value}</button>;
});

QwikCity は Qwik のフレームワークであり、 React における Next.js や Remix に相当する。フレームワークとして提供する機能として、流行りの File based routing やルーティング時サーバーアクションがある。

File based Routing の例

src/
└── routes/
    ├── contact/
    │   └── index.mdx         # https://example.com/contact
    ├── about/
    │   └── index.md          # https://example.com/about
    ├── docs/
    │   └── [id]/
    │       └── index.ts      # https://example.com/docs/1234
    │                         # https://example.com/docs/anything
    ├── [...catchall]/
    │   └── index.tsx         # https://example.com/anything/else/that/didnt/match
    │
    └── layout.tsx            # This layout is used for all pages

サーバーアクションの例

import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
 
export const useProductDetails = routeLoader$(async (requestEvent) => {
  // This code runs only on the server, after every navigation
  const res = await fetch(`https://.../products/${requestEvent.params.productId}`);
  const product = await res.json();
  return product as Product;
});
 
export default component$(() => {
  // In order to access the `routeLoader$` data within a Qwik Component, you need to call the hook.
  const signal = useProductDetails(); // Readonly<Signal<Product>>
  return <p>Product name: {signal.value.product.name}</p>;
});

@vitejs/app の標準テンプレートの一つになっているので、 npm create @vitejs/app my-project-dir で Qwik / QwikCity を選ぶだけで始められる。

Qwik の「理論上は最強」感と、その難しさ

React が生んだ宣言的UIというパラダイムが行き着くところまで行き着いた結果として、このような発想があると思う。

「宣言的UIが実現できるなら、つまりコンポーネントの宣言とその入力に対して同じ出力を取れるなら、その実装はなんでもいい」

結果として、内部アルゴリズムが仮想DOM である必要もなく、利用者からみてそう見えるようなAPIを提供しさえすればいい。

Qwik は現代のフロントエンド最適化技術を全マシマシの二郎系みたいなフレームワークだ。 Svelte のコンパイル時最適化と、Astro のアイランドアーキテクチャをさらに推し進めて関数単位でハイドレーションすることを前提に API 体系が整理されている。 SSR を前提にすることでコンパイル時にほとんどを処理してしまい、SSR 時以外で不要なものをクライアントから取り除いて、その上でブラウザイベントに応じて必要なものだけ選択的にロードする。その結果発生する細かく区切られたチャンクは ServiceWorker で積極的に先読みする。

おそらく現代のウェブ技術で、Qwik 以上の最適化は難しいだろう。調査する過程で内部実装を読んでいたのだが 「やりたいことはわかるが、本気で実現したのか?これを?」という狂気すら感じている。

https://qwik.builder.io/docs/concepts/think-qwik/

だが、その理想の実現のために強めの制約下でコードを書く必要があり、これに人間が対応するのが難しい。例えば React だと思って書くと、次のように失敗する。

export const MyApp = component$(()=>{
  const signal = useSignal(0);
  const onClick = () => {
    signal.value++;
    console.log('clicked')
  };
  return <button type="button" onClick$={onClick}>ClickMe</button>
});

(一応ビルドエラーが出る)

これは次のように書くと動く。

export const MyApp = component$(()=>{
  const signal = useSignal(0);
  return <button type="button" onClick$={() => {
    signal.value++;
    console.log('clicked')
  }}>ClickMe</button>
});

表面上は些細な変更で済んでいるようにみえるが、このように変更するにはコンパイラの気持ちになる必要がある。

  • 末尾が $ の API の呼び出しは、引数が QRL という Qwik 内 URL 形式に変換され、 onClick$on:click 属性として QRL のパスが埋め込まれる。
  • コア部分の qwikloader によって、on:click 要素の click イベント発火時に QRL が解決され、対応するJS チャンクを読み込んで実行される。そのためにコールバックがビルド時に chunk として払い出されている
  • 関数スコープは React と違って一回しか呼ばれず、また QRL で呼ばれるコールバック関数は QRL を経由しないと親スコープにアクセスできない
  • コンポーネントへの props は JSONシリアライズ可能なオブジェクトまたは QRL として渡す必要がある

...みたいな諸々を Qwik を書きながら学んだのだが、流石に React だけの知識で使えるものではない。Qwik Optimizer の気持ちに寄り添って、というか自分自身がコンパイラそのものになってコードを書く必要がある。

https://qwik.builder.io/docs/advanced/qrl/

具体的に何を学ぶ必要があるかだが、公式ドキュメントの Advanced と書いてある章が、実際には内部ロジックをイメージするためにほぼ必修だと思われた。少なくとも自分は(ライブラリを作っていたせいもあるが)ほとんどのページを読む羽目になった。

https://qwik.builder.io/docs/

実用するには QRL の受け渡しと qwikloader による参照解決をイメージできる必要がある。

Qwik はいつ使えるか?

自分の最初は懸念は「クリックするまでロードしないんだったら、モバイルのネットワークが細い環境だと反応が悪くなるのでは?」と考えていた。

が、実際に挙動を確かめると QwikCity は組み込みの ServiceWorker で細切れのチャンクをプレロードしまくるので、ほぼ問題なかった。そもそもの総量も大きくないので他のリクエストをブロッキングすることもなかった。ただし、HTTP/1.1 や iframe 下でホストすると、リクエストが多重化されないのでオーバーヘッドが重なってパフォーマンスは悪化するだろう。

他に懸念することとして、 Qwik は SSR した HTML に大量のメタデータを埋め込みまくっているので、JS が少ないといっても HTML の出力サイズが問題になる可能性がある。ベンチを取るとわかるが、 HTMLのパースコストは意外と高価。

また、これはユーザーの使い方の問題だが、結局ハイドレーションで読み込まれる chunk の中で重量ライブラリを読んでいたら Qwik を使う意味は薄れる。問題を隔離しやすい程度のメリットはあるかもしれない。

...ということを色々考えた結果、パフォーマンス面の要求があってアイランドアーキテクチャを採用したいなら Qwik(-City) が使えるのでないか、という結論に至った。ただし、最初は薄く使いつつ、ほとんどを React に委譲するところからはじめたほうがいいと思う。

という話をここからしていく。

(アイランドアーキテクチャについては https://jasonformat.com/islands-architecture/)

メタフレームワークとしての QwikCity

ここから本題。qwik-react を使うことで、Qwik 上で React コンポーネントを扱うことができる。

https://github.com/BuilderIO/qwik/tree/main/packages/qwik-react

// This pragma is required so that React JSX is used instead of Qwik JSX
/** @jsxImportSource react */
import { qwikify$ } from '@builder.io/qwik-react';
 
// An existing React component
function Greetings() {
  return <div>Hello from React</div>;
}
 
// Qwik component wrapping the React component
export const QGreetings = qwikify$(Greetings);

Qwik 側から React コンポーネントを Render/Hydration する例

import { component$ } from '@builder.io/qwik';
import { QCounter } from './react';
 
export default component$(() => {
  return (
    <main>
      <QCounter client:load />
    </main>
  );
});

この時、 React コンポーネントとして render するだけではなく、SSR しつつ qwik でハイドレーションするタイミングを制御できる。

これの何が嬉しいのか。マイクロフロントエンドではエンドポイントごとに異なるUIフレームワークを混ぜることが多いので、クリティカルパスとなる共通部分に特定のフレームワークを使うことが難しかった。例えばマイクロフロントエンドのライブラリである https://single-spa.js.org/ は本当に最低限のテンプレートロジックしか持ってないし、そもそも SSR は対象としていない。

しかし、qwik ならコアの軽量さ(qwikloader: 1kb)を維持したまま、さらに他のライブラリを対象にとしてハイドレーション制御で最小限の JS のロード/評価に抑制することができる。しかも qwik 自体の表現力があるので、その気になればフル機能の SPA 相当の処理ができるし、qwik-react のハイドレーションは本物の ReactDOM を呼ぶだけなので、ハイドレーションしさえすれば React のフル機能が使える。

つまりはパフォーマンス上重要なクリティカルパスとルーティング処理だけ qwik にやらせて、SSR でロードさせたように見せつつ、 qwik に選択的ハイドレーションさせて段階的にフル機能の React も使える。(一応 client:only で SSR しない選択肢も取れる)。

https://github.com/BuilderIO/qwik/blob/main/packages/qwik-react/src/react/qwikify.tsx#L58-L73

そして qwik-city を基盤としておけば File based routing とサーバーアクションが使えるし、さらに vercel edge や cloudflare-workers なんかの edge-cache を使えば動的なキャッシュロジックも書ける。やりたければ SSG もできる。

正直なところ、選択的ハイドレーションだけなら Astro でもできるのだが、 Astro は MPA が基本なのでビルトインのトリガーで書けない場合は普通にJSをロードするしかないのに対して、 qwik なら(頑張り次第で)フル機能の Next.js と同等の表現力がある。これが大きい。

QwikCity から React にルーティングを委譲する例

ルーターライブラリを使っている場合に問題になるのが、ページ遷移を Qwik City 側へ任せないといけない。

Qwik を全力で使った場合、そもそも軽いのでおそらく MPA でも問題ないのだが、せっかくなので SPA 遷移用の Link 要素を自作した。

/** @jsxImportSource react */
import { createContext, useCallback, useContext, useState } from "react";

const QwikContext = createContext<{
  navigate: (href: string) => void;
}>(null as any);

export function AppRoot(props: {
  onNavigate: (href: string) => void;
}) {
  const navigate = useCallback((href: string) => {
    props.onNavigate(href);
  }, []);
  return (
    <QwikContext.Provider value={{ navigate }}>
      <div>
        hello react
      </div>
      <Link href="/">Top</Link>
    </QwikContext.Provider>
  );
}
export function Link(props: {
  href: string,
  children?: React.ReactNode;
}) {
  const { navigate } = useContext(QwikContext);
  const onClick = useCallback((ev: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
    ev.preventDefault();
    navigate(props.href);
  }, [navigate, props.href]);
  return <a href={props.href} onClick={onClick}>
    {props.children}
  </a>
}

Qwik 側から onNavigate$ を渡す。

import { component$, $ } from "@builder.io/qwik";
import { useNavigate } from "@builder.io/qwik-city";
import { qwikify$ } from "@builder.io/qwik-react";
import { AppRoot } from "./react";

const QAppRoot = qwikfy(AppRoot, {
  eagerness: "hover"
});
export default component$(() => {
  const navigate = useNavigate();
  const onNavigate = $((href: string) => {
    console.log("react:navigate", href);
    navigate(href);
  })
  return (
    <>
      <QAppRoot onNavigate$={onNavigate}/>
    </>
  );
});

(React 側へ Qwik の props を渡す際は $ の QRL が剥がされて実体が渡される)

あとは既存の Link 要素を差し替えていくだけ。

React 以外も Qwik に管理させたい

とはいっても qwik-react は Qwik + React だけじゃんと思った人、いると思う。Astro の強みは複数UIライブラリの対応で、現状 Qwik にはそれがない。

というわけで svelte 用の qwikify を作ってみた。

https://github.com/mizchi/qwik-svelte

これが動く。

import App from "./components/App.svelte";
import { qwikifySvelte$ } from "@mizchi/qwik-svelte";
import { component$ } from "@builder.io/qwik";

const QApp = qwikifySvelte$<{name: string}>(App, {
  eagerness: 'load',
});

export default component$() => {
  return <QApp name="svelte"/>;
};
<!-- src/components/App.svelte -->
<script lang="ts">
  export let name: string;
  let count = 0;
</script>

<div class="app">
  <h1>Hello {name}!</h1>
  <button on:click={() => count++}>{count}</button>
</div>

<style>
  h1 {
    color: blue;
  }
</style>

作り方がわかったので、 qwik-vue も作った。

https://github.com/mizchi/qwik-vue

<!-- src/components/App.vue -->
<script setup>
import { ref, defineProps } from 'vue';
const props = defineProps(["counter"]);
const count = ref(0);
</script>

<template>
  <div>{{props.counter}}</div>
  <button @click="count++">You clicked me {{ count }} times.</button>
</template>

同様に qwikifyVue$ でラップして使う。

import { component$ } from "@builder.io/qwik";
import App from "./components/App.vue";
import { qwikifyVue$ } from "@mizchi/qwik-vue";
const QApp = qwikifyVue$<{counter: number}>(App, {
  eagerness: 'load',
});
export default component$(() => {
  return <QApp counter={0} />;
});

その気になれば solid や preact あたりも作れそうだが、 Qwik 自体と用途が重複しているので後回しにした。

他にも、逆の発想で Qwik の静的ビルドで他の静的ページに埋め込むのを試していた。

https://zenn.dev/mizchi/articles/static-build-qwik

Qwik は SSR 前提のフレームワークといえども、静的アセットとしてSSR済みのHTMLを埋め込んでもパフォーマンスメリットはあるし、そっちのほうが運用コストも低く喜ばれるはず...。

余談: 最近の SSR/RSC は Resume 技術

言葉の響きから誤解している人が多いのだが、サーバーサイドレンダリングは単なるサーバーサイドへの先祖返りではない。

フロントエンドにとって、SSR とそれに対してJSロジックを注入するハイドレーションはセットの処理で、そのためクライアント-サーバー共通で動くテンプレートを多段階で計算する。この冪等性を実現するために宣言的UI が必要になっていた。冪等性を担保できれば JS である必要はないが、イベントハンドラのロジックを書く以上、JS 以外での実装が困難になっている。

SSR は最初は確かに SEO 目的で生まれた技術な気はするのだが、今の SSR はサーバーで途中まで計算した結果をクライアントで Resume する技術になっている。RSC も基本は同じ発想で、サーバーのみで展開するコンポーネントをツリーの一部に折り込んだり、逆に静的なルート要素にしたりする。

https://zenn.dev/uhyo/articles/react-server-components-multi-stage

Qwik は resumable をコンセプトの一つにしている。

https://qwik.builder.io/docs/concepts/resumable/

結論

Qwik City をメタフレームワークとして扱うことでマイクロフロントエンド基盤を実現できそうな気がしている。また、 Qwik を通すことで単に SSR するだけではなく、選択的ハイドレーションが実現できるので、パフォーマンス面のメリットを受けることができる。ただし、あまりに細かくアイランドを切ると react-dom, vue, svelte/internal 等のランタイムがそれぞれに重複するのでパフォーマンスが悪化する可能性はある。

React 以外のアダプタは頑張って作るしかない。自分は qwik-react の実装を参考にしたら簡単に書けたが、Slot API は難しくて後回しにしている。実は、Qwik の前段階として、自前のアイランドアーキテクチャを実装していたのだが、途中で Astor と Qwik を調べて「Qwik でよくね?」になって、それまでに作った SSR/Hydration を qwik に転用しただけだった。

https://github.com/mizchi/polyglot

このアイデアの一番の問題は負債にならないように Qwik が流行るかどうかで、これに関しては頑張って布教記事を書くしかない。で、実際布教したいかと言うと、もうちょっとリサーチしてから決めたい。あと builder.io 社のやる気次第かな...

Discussion