爆速でコンテンツスケーラブルなウェブページを目指して
NextJSなどに代表するフレームワークはHydrationという仕組みでストリーミングでコンテンツをダウンロードするというアプローチを取っています。
対してQwikはResumableという全く違ったアプローチで高速化を図っています。
QwikはMisko Hevery(Builder.ioのCTOでAngularJSの開発者)が作成した新しいフレームワークです。
極端な図ですが、ファーストビューのレンダリング完了まで(要はCore Web Vitalsの計測対象まで)の比較は以下のイメージです。(実際にはQwikの方も1ms程度の最低限のJSコードはダウンロードが発生する)
主にReactJSなどに代表されるSSR/SSGアプリケーションがクライアントで起動すると、クライアントのフレームワークが次の3つの情報を復元する必要があります。
- イベントリスナーを見つけてDOMノードにインストールし、アプリケーションを対話型にします。
- アプリケーションコンポーネントツリーを表す内部データ構造(いわゆる仮想DOM)を構築します。
- サーバー上のストアにフェッチまたは保存されたデータを復元してアプリケーションの状態を構築します。
Hydration(いわゆる水戻し)はJSフレームワークゆえ、これらの作業が発生するため、そもそも非常に高価な処理です。特にファーストビューで見えていないbundle JS(chunk)のダウンロードやイベントリスナーの構築、DOMツリーの構築は無駄が多いです。
特にページが肥大化するにつれてHydrationという仕組みそのものがパフォーマンスのボトルネックになることがわかってきました。
これらはReactJS(VueJS等も同様)というJSフレームワークをクライアントサイドで再構築しようとすると必然的にすべてのJS実装をダウンロードしないといけないという自体が発生していたわけです。(Replayable)
例えば、NextJSは次のようなstream形式でhydrationを行っています。
NextJS自体はホスティングサービスであるVercelのISRの仕組みなど優れたキャッシュ戦略をインフラレベルで提供しているのは優れているとは思っていますが、ページ内のコンテンツが肥大化していくにつれて遅くなるという限界があります。
ちなみに理論上はコンテンツスケーラブルな点で最強ですが、Qwik独自の書き方を学ばないといけないため習得難易度は若干高めです。
「理論上は最強」の Qwik/QwikCity を、フロントエンドの共通基盤にできないか
なぜ速度が大事なのか
Core Web VitalsはGoogleが出している指標です。
事実上ウェブページのパフォーマンス計測のデファクトスタンダード指標です。
主な指標としては
- LCP(Largest Content Paintful):ページ内の最大のコンテンツのレンダリングにかかる時間です。短いほど良く、 2.5秒以内にする必要があります。
- FID(First Input Delay):ユーザーの操作(インタラクション)が開始できるまでの時間です。将来的にはINPに重要指標が代わる予定です。短いほど良く、100ミリ秒以下にする必要があります。
- CLS(Cumulative Layout Shift):ページ表示時のレイアウトの位置ズレがどれくらい発生しているかの指標です。値が少ないほど良く、0.1以下に維持する必要があります。(理想は0=位置ズレなし)
上記の各指標について、ほぼすべてのユーザーにとって好ましい推奨目標値を確実に達成するために、モバイルデバイスとデスクトップデバイスに分けた上で、総ページロード数の75パーセンタイルをしきい値として設定しています。
Core Web Vitalsへの準拠を評価するツールは、上記3つの指標すべてにおいて75パーセンタイルという推奨目標値を達成している場合に、そのページを合格として判定するように設定されています。
Page Speed Insight
この指標がなぜ大事かというとUXの指標でもあり、Googleが検索順位にも考慮すると明言しているからです。
例えば、Rakuten 24はCore Web Vitalsを改善したことで以下の値が改善したそうです。
- 訪問者の利益率が53.37%増加
- コンバージョン率(購買率)が33.13%増加
- 平均購買額が15.20%増加
- 平均滞在時間が9.99%増加
- 離脱率が35.12%低下
この例だけでも速度は正義ということがはっきりわかります。
Reactに関する高速化の方法は過去以下のような記事を書きましたのでご参考ください。
お前らのReactは遅い
お前らのReactは遅すぎる(SSG編)
爆速Headless NextJSのすゝめ
本記事はその続編の位置づけです。
サンプル
DOMが多いページ(コンテンツが多いページ)の比較として、
FirstView以下に10万DOM追加で試してみました。
github
https://github.com/teradonburi/compare-nextjs-qwik
注1:FirstView以下はシンプルなdivのみ追加しているけど実際には様々なコンポーネントが埋め込まれることを想定している。
注2:NextJSは上記の場合、disabled CSRをやめればqwik同様にFE側でobserver apiでファーストビュー以下に最低限のDOMのみ挿入する仕組みは作れるが、FE側の初期化仮想DOM構築とhydrationの不要なロードで結局遅くなる。
※補足:NextJSでもReact v18、App Router前提で
React.lazy+Suspenseとファーストビュー以外をclient componentにして
InfiniteListを実装すれば早いのでimportするコンポーネント数が増えてくると変わってくるかもしれないです。
QwikがResumableをどうやって実現しているのか
Qwikが行っている手法は予めHTMLのカスタム属性値に埋め込んでおき、すべてを遅延ロードするという手法です。
HTML-first, JavaScript last: the secret to web speed!
例えば、以下のようにQRL形式でhtmlに展開されます。
<div ::app-state="./AppState"
app-state:1234="{count: 321}">
<div decl:template="./Counter_template"
on:q-render="./Counter_template"
::.="{countStep: 5}"
bind:app-state="state:1234">
<button on:click="./MyComponent_increment">+5</button>
321.
<button on:click="./MyComponent_decrrement">-5</button>
</div>
</div>
-
::app-state
(application state code): アプリケーション状態変更コードをダウンロードできる URL を指します。状態更新コードは、状態を変更する必要がある場合にのみダウンロードされます。 -
app-state:1234
(application state instance):特定のアプリケーション インスタンスへのポインタ。状態をシリアル化することにより、アプリケーションは状態の再構築を再実行するのではなく、中断したところから再開できます。 -
decl:template
(declare template): コンポーネント テンプレートをダウンロードできる URL を指します。コンポーネント テンプレートは、コンポーネントの状態が変化し、再レンダリングする必要があると Qwik が判断するまでダウンロードされません。 -
on:q-render
(component is scheduled for rendering): フレームワークは、どのコンポーネントを再レンダリングする必要があるかを追跡する必要があります。これは通常、無効化されたコンポーネントの内部リストを保存することによって行われます。Qwik では、無効化されたコンポーネントのリストが属性の形式で DOM に保存されます。その後、コンポーネントは q-render イベントがブロードキャストされるのを待ちます。 -
::.="{countStep: 5}"
(Internal state of component instance):コンポーネントは、再ハイドレーション後に内部状態を維持する必要がある場合があります。状態を DOM に保持できます。コンポーネントが再ハイドレーションされると、継続するために必要なすべての状態が得られます。状態を再構築する必要はありません。 -
bind:app-state="state:1234"
(a reference to shared application state):これにより、複数のコンポーネントが同じ共有アプリケーション状態を参照できるようになります。
Qwik vs Next.js: Speed Comparison
当然querySelectorAllなどで属性値を解釈するためのcoreなJSを最初にダウンロードする必要がありますが、JSサイズもミニマムでページ内コンテンツサイズに依存しません。
対してNextJSの場合はSSR Streamingをしながらレンダリングとなるためページ内コンテンツが肥大化するほど遅くなっていきます。
QwikのLifeCycle
- useSignal: primitiveなデータをstate管理するのに使う(React.useStateに近い)、React.useRefに該当するDOM参照もこちらで行う
- useStore: オブジェクトや配列はuseSignalの代わりに使う。
-
useComputed$
: 計算結果をメモライズすることで高速化が図れます。主に算出にコストが掛かるような処理結果を保存したい場合にwrapします。(ReactのuseMemoやuseCallbackと等価) -
useResource$
: useComputed$のasync版、関数内でAPIコールなどしたい場合に使う。Resourceとセットで使う。 -
useTask$
: SSR、CSR両方で実行される。onClickやonChangeなどのブラウザのイベントハンドリングをbindすることもできる -
useVisibleTask$
: 該当コンポーネントのレンダリング完了後(表示時)に一度実行される。
import { component$, useSignal, useStore, useComputed$, useTask$ } from '@builder.io/qwik';
export default component$(() => {
const text = useSignal('qwik');
const debounceText = useSignal('');
const state = useStore({ count: 0 });
const capitalizedText = useComputed$(() => {
// it will automatically reexecute when text.value changes
return text.value.toUpperCase();
});
useTask$(({ track, cleanup }) => {
const value = track(() => text.value);
const id = setTimeout(() => (debounceText.value = value), 500);
cleanup(() => clearTimeout(id));
});
const time = useSignal('paused');
useVisibleTask$(
({ cleanup }) => {
isRunning.value = true;
const update = () => (time.value = new Date().toLocaleTimeString());
const id = setInterval(update, 1000);
cleanup(() => clearInterval(id));
}
);
return (
<section>
<label>
Enter text: <input bind:value={text} />
</label>
<p>Capitalized text: {capitalizedText.value}</p>
<p>Debounced text: {debounceText}</p>
<button onClick$={() => state.count++}>Increment</button>
<p>Count: {state.count}</p>
<div>{time}</div>
</section>
);
});
Context
ReactのContextと同様にuseContextProviderとuseContext、context識別用のcreateContextIdで状態をprops無しで子コンポーネントに参照させることができます。
import { type Signal, component$, useSignal } from '@builder.io/qwik';
import {
useContext,
useContextProvider,
createContextId,
} from '@builder.io/qwik';
export const ThemeContext = createContextId<Signal<string>>(
'docs.theme-context'
);
export default component$(() => {
const theme = useSignal('dark');
useContextProvider(ThemeContext, theme);
return (
<>
<button
onClick$={() =>
(theme.value = theme.value == 'dark' ? 'light' : 'dark')
}
>
Flip
</button>
<Child />
</>
);
});
const Child = component$(() => {
const theme = useContext(ThemeContext);
return <div>Theme is {theme.value}</div>;
});
Styles
NextJSと同様にglobal CSSやCSS Modulesが使えます。
styled-vanilla-extractのライブラリでCSS in JSで書くことも可能です。
import { style } from 'styled-vanilla-extract/qwik';
export const blueClass = style({
display: 'block',
width: '100%',
height: '500px',
background: 'blue',
});
使う側はCSSファイルをimportします。
import { component$ } from '@builder.io/qwik';
import { blueClass } from './styles.css';
export const Cmp = component$(() => {
return <div class={blueClass} />;
});
Qwik City
ReactにおけるNextJSにおけるのと似た位置づけでフルスタックのフレームワークです。
- routing: ディレクトリベースのルーティングを使用してアプリケーションのルートを定義します。 (MPAとSPAの両方のルーティングモデルをサポートします。)
- pages: アプリケーションページのレンダリング。アプリケーションの主な機能です。
- layouts: ページ間で再利用される共通の共有ページレイアウトを定義します。
- loaders: コンポーネントが使用するデータをサーバー上で取得します。
- actions: コンポーネントがサーバーにアクションの実行を要求する方法を提供します。
- validators: アクションとローダーをバリデーションする方法を提供します。
- endpoints: RESTful API、GraphQL API、JSON、XML、またはリバースプロキシのデータエンドポイントを定義する方法。
- middleware: 認証、セキュリティ、キャッシュ、リダイレクト、ロギングなどの横断的な処理を一元的に実行する方法。
- server$: サーバー上でロジックを実行する簡単な方法。
- cache: コンテンツのキャッシュを制御します。
- env variables: 一般的にキーに使用される環境変数の読み取りをプラットフォームに依存しない方法で管理するためのAPI。
Routing, pages, layouts
routingディレクトリ以下にホスティングするページファイルを作成していきます。
この例だと/product/[id]
がエンドポイントとなります。(この辺はNextJSに似ています。)
[id]はマッチする任意の文字列部分となります。
- layouts.tsxは共通のレイアウトを定義します。なお、共通レイアウトはnestすることも可能です。
- product/[id]/index.tsxはホスティングするページの実装を書きます。
また、[...catchall]を使うことでいずれのルーティングに該当しない場合の404ページを定義することも可能です。
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
Pageの定義はcomponent$で定義したコンポーネントを表示します。
- onRequest, onGet, onPost, onPut, onDelete: それぞれのリクエストメソッドに応じて最初に呼ばれる関数でミドルウェアです。例えば、キャッシュコントロールや認証に使えます。不要時は省略可能です。
-
routeLoader$
: 外部APIコールなどでデータを取得することができます。component$やheadで呼び出しします。不要時は省略可能です。 -
routeAction$
: Formからの送信時にサーバーサイドでハンドリングすることができるhookを作成できます。validateに関してはzod validatorとほぼ同じzod$を使います。もしくは代わりにvalidator$では定義することができます。 -
server$
: Form以外からのcomponent$からの通信処理先を定義することができます。通信はRPC(Remote Procedure Call)により実装されています。 -
component$
: ページに表示する要素を返却します。Slotは子要素となります(ReactJSのchildrenに近い)。layout.tsxで使う際に埋め込む中のコンポーネントとなります。ページ内遷移はLinkもしくはuseNavigateで遷移することができます。 - head:ページのheadタグ部分に相当する部分を返却します。ページ毎に動的なメタタグを埋め込みたいケースもよくあるのでその場合は
routeLoader$
でデータを受け渡しします。
import { component$, useSignal, Slot } from '@builder.io/qwik';
import { Link, useNavigate, routeLoader$, routeAction$, zod$, z, Form } from "@builder.io/qwik-city";
import type { DocumentHead, RequestHandler } from '@builder.io/qwik-city';
import { PrismaClient } from '@prisma/client'
export const onGet: RequestHandler = async ({ cacheControl }) => {
// Control caching for this request for best performance and to reduce hosting costs:
// https://qwik.builder.io/docs/caching/
cacheControl({
// Always serve a cached response by default, up to a week stale
staleWhileRevalidate: 60 * 60 * 24 * 7,
// Max once every 5 seconds, revalidate on the server to get a fresh version of this page
maxAge: 5,
});
};
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 const useAddUser = routeAction$(
async (data, requestEvent) => {
// This will only run on the server when the user submits the form (or when the action is called programmatically)
const prisma = new PrismaClient();
const user = await prisma.user.create({
data,
});
return {
success: true,
user,
};
},
// Zod schema is used to validate that the FormData includes "firstName" and "lastName"
zod$({
firstName: z.string(),
lastName: z.string(),
})
);
// By wrapping a function with `server$()` we mark it to always
// execute on the server. This is a form of RPC mechanism.
const serverGreeter = server$((name: string) => {
const greeting = `Hello ${name}`;
console.log('Prints in the server', greeting);
return greeting;
});
export default component$(() => {
const name = useSignal('');
const nav = useNavigate();
const action = useAddUser();
// In order to access the `routeLoader$` data within a Qwik Component, you need to call the hook.
const signal = useProductDetails(); // Readonly<Signal<Product>>
return <>
<Form action={action}>
<input name="firstName" />
<input name="lastName" />
{action.value?.failed && <p>{action.value.fieldErrors?.firstName}</p>}
{action.value?.failed && <p>{action.value.fieldErrors?.lastName}</p>}
<button type="submit">Add user</button>
</Form>
{action.value?.success && <p>User added successfully</p>}
<p>Product name: {signal.value.product.name}</p>
<Link reload>Refresh (better accessibility)</Link>
<button onClick$={() => nav()}>Refresh</button>
<section>
<label>First name: <input bind:value={name} /></labe
<button
onClick$={async () => {
const greeting = await serverGreeter(name.value);
alert(greeting);
}}
>
greet
</button>
</section>
<Slot />
</>;
});
export const useJoke = routeLoader$(async (requestEvent) => {
// Fetch a joke from a public API
const jokeId = requestEvent.params.jokeId;
const response = await fetch(`https://api.chucknorris.io/jokes/${jokeId}`);
const joke = await response.json();
return joke;
});
// Now we can export a function that returns a DocumentHead object
export const head: DocumentHead = ({resolveValue, params}) => {
const joke = resolveValue(useJoke);
return {
title: `Joke "${joke.title}"`,
meta: [
{
name: 'description',
content: joke.text,
},
{
name: 'id',
content: params.jokeId,
},
],
};
};
環境変数
Qwikはviteでビルドされているため、viteにbuilt-inされている.env
ファイルに環境変数を追加することでQwik上で参照できます。
PUBLIC_
の接頭詞の環境変数はどこでも参照できます。(アクセストークンなど公開してはいけないものは登録しないように注意しましょう)
PUBLIC_API_URL=https://api.example.com
例えば、サーバーURLが環境変数で設定されている場合、以下のように参照できます。
import { component$ } from '@builder.io/qwik';
export default component$(() => {
// `({}).PUBLIC_*` variables can be read anywhere, including browser
return <div>PUBLIC_API_URL: {import.meta.env.PUBLIC_API_URL}</div>
})
サーバーサイドで参照する場合はPUBLIC_
でない環境変数を定義します。
DB_PRIVATE_KEY=123456789
middlewareやrouteLoader$
、routeAction$
、server$
などで以下のように参照できます。
import {
routeLoader$,
routeAction$,
server$,
type RequestEvent,
} from '@builder.io/qwik-city';
export const onGet = (requestEvent: RequestEvent) => {
console.log(requestEvent.env.get('DB_PRIVATE_KEY'));
};
export const onPost = (requestEvent: RequestEvent) => {
console.log(requestEvent.env.get('DB_PRIVATE_KEY'));
};
export const useAction = routeAction$(async (_, requestEvent) => {
console.log(requestEvent.env.get('DB_PRIVATE_KEY'));
});
export const useLoader = routeLoader$(async (requestEvent) => {
console.log(requestEvent.env.get('DB_PRIVATE_KEY'));
});
export const serverFunction = server$(function () {
// `this` is the `RequestEvent` object
console.log(this.env.get('DB_PRIVATE_KEY'));
});
Rewrite Routes
同じパスに別名を付けたい場合があります。例えば、/docsと/documentsの両方を同じページコンポーネントからレンダリングしたい場合や、/it/productsのときは/it/prodottiにリダイレクトしたい場合などです。
リバーシプロキシしたい場合はvite.config.tsにrewriteRoutesの定義をvite.config.tsに追加します。
import { defineConfig } from 'vite';
import { qwikCity } from '@builder.io/qwik-city/vite';
export default defineConfig(async () => {
return {
plugins: [
qwikCity({
rewriteRoutes: [
{
paths: {
'docs': 'documentation'
},
},
{
prefix: 'it',
paths: {
'docs': 'documentazione',
'getting-started': 'per-iniziare',
'products': 'prodotti',
},
},
],
}),
],
};
});
React、MUIの導入
package.jsonにReact関連のライブラリを追加します。
{
"dependencies": {
"@builder.io/qwik-react": "0.5.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
}
}
vite.config.tsにプラグインを追加します。
import { qwikReact } from '@builder.io/qwik-react/vite';
export default defineConfig(() => {
return {
plugins: [
// The important part
qwikReact()
],
};
});
Reactを使う際、ファイル先頭に/** @jsxImportSource react */
のマジックコメントが必須です。
qwikify$が重要で、hover時にreactのhydrateを行い、
onClickのイベントハンドリングを有効にします。
/** @jsxImportSource react */
import React from 'react';
import { qwikify$ } from '@builder.io/qwik-react';
import Button from '@mui/material/Button';
// Create React component standard way
function Greetings() {
const [count, setCount] = React.useState(0);
return (
<div>
<p>Hello from React</p>
<Button variant="contained" onClick={() => setCount(count + 1)}>Count {count}</Button>
</div>
);
}
// Specify eagerness to hydrate component on hover event.
export const QGreetings = qwikify$(Greetings, { eagerness: 'hover' });
Qwik化したReactコンポーネントの制限
Qwik化されたReactコンポーネントの各インスタンスは、独立したReactアプリになります。
完全に孤立しています。
export const MUISlider = qwikify$(Slider);
<MUISlider></MUISlider>
<MUISlider></MUISlider>
- 各MUISliderは完全に分離されたReactアプリケーションであり、独自の状態、ライフサイクルなどを持ちます。
- stateは独立です。
- styleは重複します。
- Qwikのcontextは使えません。
- それぞれのインスタンスごとにhydrateされます。