📌

Getting Started Qwik

2024/06/29に公開

はじめに

Qwikのドキュメント Getting Started Qwiklyにある チュートリアルのQwik Joke Appを進めながらジョークアプリを作っていきます。

前提条件

QwikをDocker環境で構築する で構築した環境を使ってやります。

デモアプリがすでに入っている場合は、src/routes/layout.tsxを修正してスタイルを初期状態します

src/routes/layout.tsx
  import { component$, Slot, useStyles$ } from "@builder.io/qwik";
  import { routeLoader$ } from "@builder.io/qwik-city";
  import type { RequestHandler } from "@builder.io/qwik-city";

- import Header from "../components/starter/header/header";
- import Footer from "../components/starter/footer/footer";

- import styles from "./styles.css?inline";

  export const onGet: RequestHandler = async ({ cacheControl }) => {
    // Control caching for this request for best performance and to reduce hosting costs:
    // https://qwik.dev/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 useServerTimeLoader = routeLoader$(() => {
    return {
      date: new Date().toISOString(),
    };
  });

  export default component$(() => {
-   useStyles$(styles);
    return (
      <>
-       <Header />
        <main>
          <Slot />
        </main>
-       <Footer />
      </>
    );
  });

Qwik Joke App

Qwikのチュートリアルでは、Qwik でジョーク アプリを構築する手順を進めしながら、Qwik の最も重要な概念について理解していきます。このアプリは、https://icanhazdadjoke.comからランダムにジョークを取得し、ボタンをクリックすると新しいジョークが表示されるようにします。

1.ルートの作成

まず、初めに特定のルートでページを作成します。この基本的なアプリは、ルート/joke/でランダムなお父さんジョークのアプリケーションをつくります。このチュートリアルはディレクトリベースのルーティングを使うQwikのメタフレームワークであるQwikcityに依存しています。

1.プロジェクトのroutesディレクトリに、jokeディレクトリとindex.tsxファイルを作成します。
2.各ルートのindex.tsxファイルには、Qwikcity がどのコンテンツを出力するかわかるように export default component$(...)が必要です 。

src/routes/joke/index.tsx
import { component$ } from '@builder.io/qwik';

export default component$(() => {
  return <section class="section bright">A Joke!</section>
});

3.ブラウザでlocalhost:5173/joke/にアクセスして、新しいページが表示していることを確認します。

メモ

  • jokeの デフォルトコンポーネントは、既存のレイアウトに囲まれています。レイアウトについての詳細は、Layoutを参照してください。
  • index.tsxroutesフォルダのlayout.tsxroot.tsx、すべてのエントリーファイルにはexport defaultが必要です。その他のコンポーネントには、export constexport functionを使用できます。
  • コンポーネントの作成方法の詳細については、Component API を参照してください。

2.データ読込

https://icanhazdadjoke.comJSON APIを使ってランダムなジョークを読み込みます。ルート ローダーを使用してデータをサーバーにロードし、コンポーネントでレンダリングします。

1.src/routes/joke/index.tsxに次のコードを追加します

src/routes/joke/index.tsx
  import { component$ } from '@builder.io/qwik';
+ import { routeLoader$ } from '@builder.io/qwik-city';

+ export const useDadJoke = routeLoader$(async () => {
+   const response = await fetch('https://icanhazdadjoke.com/', {
+     headers: { Accept: 'application/json' },
+   });
+   return (await response.json()) as {
+     id: string;
+     status: number;
+     joke: string;
+   };
+ });

  export default component$(() => {
-   return <section class="section bright">A Joke!</section>
+   // `useDadJoke`フックを呼び出すと、ロードされたデータに対してリアクティブ・シグナルが返される。
+   const dadJokeSignal = useDadJoke();
+   return (
+     <section class="section bright">
+       <p>{dadJokeSignal.value.joke}</p>
+     </section>
+   );
  });

2.http://localhost:5173/joke/にアクセスする度にランダムなジョークが表示されます。

メモ

  • routeLoader$に渡された関数は、コンポーネントがレンダリングされる前にサーバー上で呼び出され、データロードをします。
  • routeLoader$は、useDadJoke()フックを返します。
  • routeLoader$は、どのコンポーネントでもuse-hookが起動されなくても、コンポーネントがレンダリングされる前にサーバー上で起動されます。
  • routeLoader$の戻り値の型は、追加の型情報を必要とせずに、コンポーネント内で推測されます。

3.サーバーへデータ送信

これまでは、サーバからクライアントにデータを送信するために、routeLoader$というコンポーネントが使われていました。クライアントからサーバにデータをポスト(送信)するには、routeAction$を使います。

注:routeAction$は、ブラウザのネイティブフォームAPIを使用するため、JavaScriptが無効になっていても動作するので、サーバにデータを送信する方法として推奨されます。

1.アクションを宣言するには、次のコードを追加します。

import { routeLoader$, Form, routeActions$ } from '@builder.io/qwik-city';

export const useJokeVoteAction = routeAction$((props) => {
  console.log('VOTE', props);
});

2.export default コンポーネントを更新し、<Form>useJokeVoteActionフックを使用するようにします。

export default component$(() => {
  const dadJokeSigbnal = useDadJoke();
  const favoriteJokeAction = useJokeVoteAction();
  return (
    <section class="section bright">
      <p>{dadJokeSignal.value.joke}</p>
      <Form action={favoriteJokeAction}>
        <input type="hidden" name="jokeID" value={dadJokeSignal.value.id} />
        <button name="vote" value="up">👍</button>
        <button name="vote" value="down">👎</button>
      </Form>
    </section>
  );
});

3.これで http://localhost:5173/joke/ にボタンが表示され、クリックすると値をコンソールに表示します。

メモ

  • routeAction$はデータを受け取ります。

    • routeAction$に渡された関数は、フォームが投稿されるたびにサーバー上で呼び出されます。
    • routeAction$useJokeVoteActionというuseフックを返し、フォームデータを投稿するコンポーネントで使うことができます。
  • Formは、ブラウザのネイティブ <form>要素をラップする便利なコンポーネントです。

  • バリデーションについては、zod validationを参照してください。

  • routeAction$はJavaScriptが無効でも動作します。

  • JavaScriptが有効な場合、Formコンポーネントはブラウザがフォームを投稿しないようにし、代わりにJavaScriptを使ってデータを投稿し、ブラウザのネイティブフォームの動作をエミュレートします。

このセクションのコードは次のとおりです。

src/routes/joke/index.tsx
  import { component$ } from '@builder.io/qwik';
- import { routeLoader$ } from '@builder.io/qwik-city';
+ import { routeLoader$, Form, routeActions$ } from '@builder.io/qwik-city';

  export const useDadJoke = routeLoader$(async () => {
    const response = await fetch('https://icanhazdadjoke.com/', {
      headers: { Accept: 'application/json' },
    });
    return (await response.json()) as {
      id: string;
      status: number;
      joke: string;
    };
  });

+ export const useJokeVoteAction = routeAction$((props) => {
+   console.log('VOTE', props);
+ });

  export default component$(() => {
    // `useDadJoke`フックを呼び出すと、ロードされたデータに対してリアクティブ・シグナルが返される。
    const dadJokeSigbnal = useDadJoke();
+   const favoriteJokeAction = useJokeVoteAction();
    return (
      <section class="section bright">
        <p>{dadJokeSignal.value.joke}</p>
+       <Form action={favoriteJokeAction}>
+         <input type="hidden" name="jokeID" value={dadJokeSignal.value.id} />
+         <button name="vote" value="up">
+           👍
+         </button>
+         <button name="vote" value="down">
+           👎
+         </button>
+       </Form>
      </section>
    );
  });

4.状態管理

状態を追跡し、UI を更新することは、アプリケーションの中心的な機能です。Qwik は、アプリケーションの状態を追跡するためのuseSignalフックを提供します。詳細については、「状態管理」を参照してください。

1.qwikからuseSignalをインポートします。

import { component$, useSignal } from "@builder.io/qwik";

2.useSignal()を使用してコンポーネントの状態を宣言します。

const isFavoriteSignal = useSignal(false);

3.Formタグの後に、状態を変更するためのボタンをコンポーネントに追加します。

<button
  onClick$={() => {
    isFavoriteSignal.value = !isFavoriteSignal.value;
  }}>
  {isFavoriteSignal.value ? '❤️' : '🤍'}
</button>

ボタンをクリックすると状態が更新され、UI も更新されます。

このセクションのコードは次のとおりです。

src/routes/joke/index.tsx
- import { component$ } from '@builder.io/qwik';
+ import { component$, useSignal } from '@builder.io/qwik';
  import { routeLoader$, Form, routeActions$ } from '@builder.io/qwik-city';

  export const useDadJoke = routeLoader$(async () => {
    const response = await fetch('https://icanhazdadjoke.com/', {
      headers: { Accept: 'application/json' },
    });
    return (await response.json()) as {
      id: string;
      status: number;
      joke: string;
    };
  });

  export const useJokeVoteAction = routeAction$((props) => {
    console.log('VOTE', props);
  });

  export default component$(() => {
+   const isFavoriteSignal = useSignal(false);
    // `useDadJoke`フックを呼び出すと、ロードされたデータに対してリアクティブ・シグナルが返される。
    const dadJokeSigbnal = useDadJoke();
    const favoriteJokeAction = useJokeVoteAction();
    return (
      <section class="section bright">
        <p>{dadJokeSignal.value.joke}</p>
        <Form action={favoriteJokeAction}>
          <input type="hidden" name="jokeID" value={dadJokeSignal.value.id} />
          <button name="vote" value="up">
            👍
          </button>
          <button name="vote" value="down">
            👎
          </button>
        </Form>
+       <button
+         onClick$ = {() => (isFavoriteSignal.value = !isFavoriteSignal.value)}
+       >
+         {isFavoriteSignal.value ? '❤️' : '🤍'}
+       </button>
      </section>
    );
  });

5.タスクとサーバーコードの呼び出し

taskは状態が変化したときに実行する処理です。(これは他のフレームワークのeffectに似ています。)
この例では、taskを使用してサーバー上のコードを呼び出します。

1.qwikからuseTask$, qwik-cityからserver$をインポートします。

import { component$, useSignak, useTask$ } from "@builder.io/qwik";
import {
  routeLoader$,
  From,
  routeAction$,
  server$
} from "@builder.io/qwik-city";

2.isFavoriteSignalの状態を追跡する新しいタスクを作成します。

useTask$(({ track }) => {});

3.isFavoriteSignalの状態の変化時にタスクを再実行するための呼び出しtrackを追加します。

useTask$(({ track }) => {
  track(() => isFavoriteSignal.value);
});

4.状態の変更時に実行する処理を追加します。

useTask$(({ track }) => {
  track(() => isFavoriteSignal.value);
  console.log('FAVORITE (isomorphic)', isFavoriteSingnal.value);
});

5.サーバー上のみ処理を実行したい場合は、server$()をラップします。

useTask$(({ track }) => {
  track(() => isFavoriteSignal.value);
  console.log('FAVORITE (isomorphic)', isFavoriteSingnal.value);
  server$(() => {
    console.log('FAVORITE (server)', isFavoriteSignal.value);
  })();
});


メモ

  • useTask$の本体は、サーバーとクライアントの両方で実行されます。
  • SSRでは、サーバーはFAVORITE (isomorphic) falseおよびFAVORITE (server) falseを出力します。
  • ユーザーがお気に入りを操作すると、クライアントは FAVORITE (isomorphic) trueを表示し、サーバーは FAVORITE (server) trueを表示します。

このセクションのコードは次のとおりです。

src/routes/joke/index.tsx
- import { component$, useSignal } from '@builder.io/qwik';
+ import { component$, useSignak, useTask$ } from "@builder.io/qwik";
- import { routeLoader$, Form, routeActions$ } from '@builder.io/qwik-city';
+ import {
+   routeLoader$,
+   From,
+   routeAction$,
+   server$
+ } from "@builder.io/qwik-city";

  export const useDadJoke = routeLoader$(async () => {
    const response = await fetch('https://icanhazdadjoke.com/', {
      headers: { Accept: 'application/json' },
    });
    return (await response.json()) as {
      id: string;
      status: number;
      joke: string;
    };
  });

  export const useJokeVoteAction = routeAction$((props) => {
    console.log('VOTE', props);
  });

  export default component$(() => {
    const isFavoriteSignal = useSignal(false);
    // `useDadJoke`フックを呼び出すと、ロードされたデータに対してリアクティブ・シグナルが返される。
    const dadJokeSigbnal = useDadJoke();
    const favoriteJokeAction = useJokeVoteAction();
+   useTask$(({ track }) => {
+     track(() => isFavoriteSignal.value);
+     console.log('FAVORITE (isomorphic)', isFavoriteSingnal.value);
+     server$(() => {
+       console.log('FAVORITE (server)', isFavoriteSignal.value);
+     })();
+   });
    return (
      <section class="section bright">
        <p>{dadJokeSignal.value.joke}</p>
        <Form action={favoriteJokeAction}>
          <input type="hidden" name="jokeID" value={dadJokeSignal.value.id} />
          <button name="vote" value="up">
            👍
          </button>
          <button name="vote" value="down">
            👎
          </button>
        </Form>
        <button
          onClick$ = {() => (isFavoriteSignal.value = !isFavoriteSignal.value)}
        >
          {isFavoriteSignal.value ? '❤️' : '🤍'}
        </button>
      </section>
    );
  });

6.スタイリング

スタイルは、アプリケーションにとって重要です。スタイルをコンポーネントに関連付けてスコープを設定する方法になります。

スタイルを追加するには:
1.新しいファイル src/routes/joke/index.css を作成します。

p {
  font-weight: bold;
}
from {
  float: right;
}

2.src/routes/joke/index.tsx にスタイルを追加します

import styles from "./index.css?inline";

3.qwikuseStylesScoped$ を追加します

import { component$, useSignal, useStylesScoped$, useTask$ } from "@builder.o/qwik";

4.コンポーネントにスタイルをロードします

useStylesScoped$(styles);

メモ

  • ?inline クエリパラメータは、Vite にスタイルをコンポーネントにインライン化するようにします。
  • useStylesScoped$ の呼び出しは、スタイルをコンポーネントのみ関連付けるようにします (スコープ設定)。
  • スタイルは、SSR の一部としてインライン化されていない場合にのみ、最初のコンポーネントに対してのみ読み込まれます。

このセクションのコードは次のとおりです。

src/routes/joke/index.tsx
- import { component$, useSignak, useTask$ } from "@builder.io/qwik";
+ import {
+   component$,
+   useSignal,
+   useStylesScoped$,
+   useTask$
+ } from "@builder.o/qwik";
  import {
    routeLoader$,
    From,
    routeAction$,
    server$
  } from "@builder.io/qwik-city";
+ import styles from "./index.css?inline";

  export const useDadJoke = routeLoader$(async () => {
    const response = await fetch('https://icanhazdadjoke.com/', {
      headers: { Accept: 'application/json' },
    });
    return (await response.json()) as {
      id: string;
      status: number;
      joke: string;
    };
  });

  export const useJokeVoteAction = routeAction$((props) => {
    console.log('VOTE', props);
  });

  export default component$(() => {
+   useStylesScoped$(styles);
    const isFavoriteSignal = useSignal(false);
    // `useDadJoke`フックを呼び出すと、ロードされたデータに対してリアクティブ・シグナルが返される。
    const dadJokeSigbnal = useDadJoke();
    const favoriteJokeAction = useJokeVoteAction();
    useTask$(({ track }) => {
      track(() => isFavoriteSignal.value);
      console.log('FAVORITE (isomorphic)', isFavoriteSingnal.value);
      server$(() => {
        console.log('FAVORITE (server)', isFavoriteSignal.value);
      })();
    });
    return (
      <section class="section bright">
        <p>{dadJokeSignal.value.joke}</p>
        <Form action={favoriteJokeAction}>
          <input type="hidden" name="jokeID" value={dadJokeSignal.value.id} />
          <button name="vote" value="up">
            👍
          </button>
          <button name="vote" value="down">
            👎
          </button>
        </Form>
        <button
          onClick$ = {() => (isFavoriteSignal.value = !isFavoriteSignal.value)}
        >
          {isFavoriteSignal.value ? '❤️' : '🤍'}
        </button>
      </section>
    );
  });

7.プレビュー

このチュートリアルには、主要な Qwik の概念と API の概要として、基本的なサンプル アプリケーションが含まれています。アプリケーションは開発モードで実行され、ホット モジュール リロード (HMR) を使用して、コードを変更しながらアプリケーションを継続的に更新します。

開発モードの場合:

  • 各ファイルは個別に読み込まれるため、ネットワーク タブにウォーターフォールが発生する可能性があります。
  • バンドルの推測的な読み込みは行われないため、最初のやり取りで遅延が発生する可能性があります。

これらの問題を排除した本番ビルドを作成しましょう。

プレビュー ビルドを作成するには:

  • npm run preview を実行して本番ビルドを作成します。

メモ

  • これで、アプリケーションは別のポートで本番ビルドを実行するようになります。
  • ここでアプリケーションを操作すると、開発ツールのネットワーク タブに、ServiceWorkerキャッシュからバンドルが即座に配信されていることが表示されるはずです。

Discussion