7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発エンジニア応援 - 個人開発の成果や知見を共有しよう!-

Qwikが魅せるコンテンツスケーラブルで爆速なウェブページの最終形

Last updated at Posted at 2023-10-08

爆速でコンテンツスケーラブルなウェブページを目指して

NextJSなどに代表するフレームワークはHydrationという仕組みでストリーミングでコンテンツをダウンロードするというアプローチを取っています。
対してQwikはResumableという全く違ったアプローチで高速化を図っています。
QwikはMisko Hevery(Builder.ioのCTOでAngularJSの開発者)が作成した新しいフレームワークです。

Resumable vs. Hydration

極端な図ですが、ファーストビューのレンダリング完了まで(要はCore Web Vitalsの計測対象まで)の比較は以下のイメージです。(実際にはQwikの方も1ms程度の最低限のJSコードはダウンロードが発生する)

assets_YJIGb4i01jvw0SRdL5Bt_04681212764f4025b2b5f5c6a258ad6e.png

主にReactJSなどに代表されるSSR/SSGアプリケーションがクライアントで起動すると、クライアントのフレームワークが次の3つの情報を復元する必要があります。

  1. イベントリスナーを見つけてDOMノードにインストールし、アプリケーションを対話型にします。
  2. アプリケーションコンポーネントツリーを表す内部データ構造(いわゆる仮想DOM)を構築します。
  3. サーバー上のストアにフェッチまたは保存されたデータを復元してアプリケーションの状態を構築します。

Hydration(いわゆる水戻し)はJSフレームワークゆえ、これらの作業が発生するため、そもそも非常に高価な処理です。特にファーストビューで見えていないbundle JS(chunk)のダウンロードやイベントリスナーの構築、DOMツリーの構築は無駄が多いです。
特にページが肥大化するにつれてHydrationという仕組みそのものがパフォーマンスのボトルネックになることがわかってきました。
これらはReactJS(VueJS等も同様)というJSフレームワークをクライアントサイドで再構築しようとすると必然的にすべてのJS実装をダウンロードしないといけないという自体が発生していたわけです。(Replayable)

例えば、NextJSは次のようなstream形式でhydrationを行っています。
NextJS自体はホスティングサービスであるVercelのISRの仕組みなど優れたキャッシュ戦略をインフラレベルで提供しているのは優れているとは思っていますが、ページ内のコンテンツが肥大化していくにつれて遅くなるという限界があります。

FRxl9YcVkAAE-4G.png

ちなみに理論上はコンテンツスケーラブルな点で最強ですが、Qwik独自の書き方を学ばないといけないため習得難易度は若干高めです。
「理論上は最強」の Qwik/QwikCity を、フロントエンドの共通基盤にできないか

なぜ速度が大事なのか

Core Web VitalsはGoogleが出している指標です。
事実上ウェブページのパフォーマンス計測のデファクトスタンダード指標です。

Web Vitals

主な指標としては

  • 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(SSG+disabled CSR)
GCzeBZSbkAA-shE.jpeg

Qwik(infinite scroll)
GCzeCiobUAAZm58.jpeg

※補足:NextJSでもReact v18、App Router前提で
React.lazy+Suspenseとファーストビュー以外をclient componentにして
InfiniteListを実装すれば早いのでimportするコンポーネント数が増えてくると変わってくるかもしれないです。
スクリーンショット 2024-01-20 15.54.37.png

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サイズもミニマムでページ内コンテンツサイズに依存しません。

XyqHIwK0xDMOGdIf5iTNW4CrLjb2-zzg33l7.png

XyqHIwK0xDMOGdIf5iTNW4CrLjb2-7pe33gz.jpeg

対してNextJSの場合はSSR Streamingをしながらレンダリングとなるためページ内コンテンツが肥大化するほど遅くなっていきます。

XyqHIwK0xDMOGdIf5iTNW4CrLjb2-lmk33h8.png

XyqHIwK0xDMOGdIf5iTNW4CrLjb2-jhf33g7.jpeg

QwikのLifeCycle

以下のようなライフサイクルとなっています。
スクリーンショット 2023-10-08 18.10.39.png

ブラウザ上では次の順番で呼ばれます。
スクリーンショット 2023-10-08 18.10.57.png

  • 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$: 該当コンポーネントのレンダリング完了後(表示時)に一度実行される。
test.tsx
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無しで子コンポーネントに参照させることができます。

component.tsx
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で書くことも可能です。

styles.css
import { style } from 'styled-vanilla-extract/qwik';
 
export const blueClass = style({
  display: 'block',
  width: '100%',
  height: '500px',
  background: 'blue',
});

使う側はCSSファイルをimportします。

component.tsx
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]はマッチする任意の文字列部分となります。

スクリーンショット 2023-10-08 16.57.20.png

  • 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$でデータを受け渡しします。
index.tsx
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_の接頭詞の環境変数はどこでも参照できます。(アクセストークンなど公開してはいけないものは登録しないように注意しましょう)

.env
PUBLIC_API_URL=https://api.example.com

例えば、サーバーURLが環境変数で設定されている場合、以下のように参照できます。

example.tsx
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_でない環境変数を定義します。

.env.local
DB_PRIVATE_KEY=123456789

middlewareやrouteLoader$routeAction$server$などで以下のように参照できます。

src/route/index.tsx
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に追加します。

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の導入

Qwik React ⚛️

package.jsonにReact関連のライブラリを追加します。

package.json
{
  "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にプラグインを追加します。

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のイベントハンドリングを有効にします。

Greetings.tsx
/** @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アプリになります。
完全に孤立しています。

slider.tsx
export const MUISlider = qwikify$(Slider);
 
<MUISlider></MUISlider>
<MUISlider></MUISlider>
  • 各MUISliderは完全に分離されたReactアプリケーションであり、独自の状態、ライフサイクルなどを持ちます。
    • stateは独立です。
    • styleは重複します。
    • Qwikのcontextは使えません。
    • それぞれのインスタンスごとにhydrateされます。
7
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?