はじめに
はじめまして、WEB フロントエンドエンジニアの nuintee です。
この度かねてより関心があった Qwik に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。
Qwik に少しでも興味のある方は、ぜひご覧ください。
フロントエンドの世界 2024 について
「フロントエンドの世界 2024」は普段 Next.js
を書いている筆者が、同じフロントエンドライブラリである Svelte(Kit)
, Remix
,SolidJS
, Qwik(City)
の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。
もくじ
非同期データ取得
今回はPokeAPIを利用してポケモンのデータを取得します。
features
配下に pokemon
という機能単位でディレクトリを作成し、そこに諸々追加していきます。
ポケモン詳細取得
ポケモンの詳細情報を取得するために、fetchPokemonDetail
関数をfeatures/pokemon/data/index.ts
配下に作成します。
import type { PokemonResponse, SpecieResponse } from "../types";
const BASE_ENDPOINT = "https://pokeapi.co/api/v2";
const fetchPokemonDetail = async (
pokemonId: number
): Promise<PokemonResponse | null> => {
try {
const pokemon = await fetch(`${BASE_ENDPOINT}/pokemon/${pokemonId}`);
const pokemonResponse = await pokemon.json();
return pokemonResponse;
} catch (error) {
return null;
}
};
日本語版ポケモン名取得
ポケモンの日本語名を取得するために、fetchPokemonSpecie
関数をfeatures/pokemon/data/index.ts
配下に作成します。
...
const fetchPokemonSpecie = async (pokemonId: number) => {
try {
const specie = await fetch(`${BASE_ENDPOINT}/pokemon-species/${pokemonId}`);
const specieResponse: Awaited<SpecieResponse> = await specie.json();
// レスポンスのnamesから日本語名を取得
const specieNameJP = specieResponse.names.find(
(specie) => specie.language.name === "ja-Hrkt"
);
return {
...specieResponse,
name: specieNameJP?.name ?? "",
};
} catch (error) {
return null;
}
};
ポケモン総数の取得
ポケモン総数を取得するために、fetchTotalPokemonCount
関数をfeatures/pokemon/data/index.ts
配下に作成します。
...
const fetchTotalPokemonCount = async () => {
try {
const allSpecies = await fetch(`${BASE_ENDPOINT}/pokemon-species?limit=0`);
const allSpeciesResponse = await allSpecies.json();
const totalPokemonCount: number = allSpeciesResponse.count;
return totalPokemonCount;
} catch (error) {
return null;
}
};
ランダムポケモン取得
ランダムなポケモンを取得するために、fetchPokemon
関数をfeatures/pokemon/data/index.ts
配下に作成します。
...
export const fetchPokemon = async () => {
try {
// 総数取得
const totalPokemonCount = await fetchTotalPokemonCount();
if (!totalPokemonCount) return null;
// ポケモンIDが1から始まるので最小値に設定
const MIN_POKEMON_ID = 1;
// 1と総数の間でランダムな整数を返す
const randomId = Math.floor(
Math.random() * (totalPokemonCount - MIN_POKEMON_ID) + MIN_POKEMON_ID
);
// ポケモン詳細データの取得
const pokemonDetailResponse = await fetchPokemonDetail(randomId);
if (!pokemonDetailResponse) return null;
// 日本語名が含まれているポケモン種族データの取得
const pokemonSpecieResponse = await fetchPokemonSpecie(
pokemonDetailResponse.id
);
if (!pokemonSpecieResponse) return null;
return {
...pokemonDetailResponse,
...pokemonSpecieResponse,
};
} catch (error) {
return null;
}
};
ストアによる状態管理
今回は以下の 3 つの状態を管理します:
- ゲーム状態(スコア、ライフ、ラウンド数など)
- タイマー状態(制限時間)
- フォーム状態(入力された文字)
ゲーム状態
Qwik では状態管理のためにcreateContextId
とuseContextProvider
を使用してコンテキストを作成し、useStore
で状態を管理します。
import { GameContextType, GameStoreType } from "../types";
import { INIT_GAME_STATE } from "../constants";
import {
createContextId,
useStore,
component$,
Slot,
useContextProvider,
$,
} from "@builder.io/qwik";
export const GameContext = createContextId<GameContextType>("game.context");
export const GameProvider = component$(() => {
const gameStore = useStore<GameStoreType>(INIT_GAME_STATE);
const decrementLife = $(() => {
const MIN_LIFE = 0;
const updatedLife = gameStore.life - 1;
const isGreaterThanMin = updatedLife >= MIN_LIFE;
const life = isGreaterThanMin ? updatedLife : MIN_LIFE;
gameStore.life = life;
gameStore.round++;
});
const incrementScore = $(() => {
gameStore.round++;
gameStore.score++;
});
const startGame = $(() => {
gameStore.state = "START";
});
const finishGame = $(() => {
gameStore.state = "END";
});
const initGame = $(() => {
gameStore.life = INIT_GAME_STATE.life;
gameStore.round = INIT_GAME_STATE.round;
gameStore.score = INIT_GAME_STATE.score;
gameStore.state = INIT_GAME_STATE.state;
});
const handleAnswer = $((isCorrect: boolean) => {
if (isCorrect) return incrementScore();
if (gameStore.life - 1 === 0) {
return finishGame();
}
return decrementLife();
});
useContextProvider(GameContext, {
gameStore,
initGame,
finishGame,
startGame,
handleAnswer,
});
return <Slot />;
});
タイマー状態
タイマー機能ではuseSignal
とuseVisibleTask$
を組み合わせて実装します。
(参考: useSignal / [useVisibleTask$][https://qwik.dev/docs/components/tasks/#usevisibletask])
import {
$,
component$,
createContextId,
Slot,
useContextProvider,
useSignal,
useVisibleTask$,
} from "@builder.io/qwik";
const MAX_TIME = 15;
export const TimeProvider = component$(() => {
const timer = useSignal<number>(MAX_TIME);
const isRunning = useSignal(false);
useVisibleTask$(({ cleanup }) => {
let intervalId: NodeJS.Timeout | undefined;
if (isRunning.value) {
intervalId = setInterval(() => {
if (timer.value > -1) {
timer.value--;
} else {
isRunning.value = false;
}
}, 1000);
}
cleanup(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
});
// タイマー制御関数
const startTimer = $(() => {
timer.value = MAX_TIME;
isRunning.value = true;
});
useContextProvider(TimeContext, {
timer,
startTimer,
clearTimer,
resetTimer,
MAX_TIME,
});
return <Slot />;
});
フォーム状態
import { getMergedKanas } from "../../../utils/kana";
import { FormType, FormValues } from "../types";
import { $, useStore } from "@builder.io/qwik";
export const useForm = () => {
const formStore = useStore<FormValues>({
options: [],
displayTexts: [],
});
const initOptions = $((answer: string) => {
const splittedAnswer = answer.split("");
const kanasWithDummy = getMergedKanas(splittedAnswer);
const shuffledAnswerChars = kanasWithDummy.sort(() => 0.5 - Math.random());
const options = shuffledAnswerChars?.map((char, index) => ({
char,
index,
}));
formStore.options = options;
formStore.displayTexts = [];
});
const addDisplayText = $((displayText: FormType) => {
const options = formStore.options.filter(
(option) => JSON.stringify(option) !== JSON.stringify(displayText)
);
const displayTexts = [...formStore.displayTexts, displayText];
formStore.options = options;
formStore.displayTexts = displayTexts;
});
const removeDisplayText = $((displayText: FormType) => {
const options = [...formStore.options, displayText];
const displayTexts = formStore.displayTexts.filter(
(display) => JSON.stringify(display) !== JSON.stringify(displayText)
);
formStore.options = options;
formStore.displayTexts = displayTexts;
});
return {
formStore,
initOptions,
addDisplayText,
removeDisplayText,
};
};
ストアの呼び出し
ルート画面
アプリケーションのメインコンポーネントを実装します。ゲームの状態に応じて表示する画面を切り替えます。
データ取得
useResource$
を用いて非同期データを取得します。
fetchPokemon
は本記事の上部で定義したフェッチャー関数です
(参考: useResource$)
副作用
useTask$
を用いた依存値の変更を監視による副作用を追加します。
(参考: useTask$)
Resource コンポーネント
非同期データの読み込み状態に応じて適切な UI を表示する Resource コンポーネントを使用します。
参考: Resource
import { $, component$, Resource, useResource$, useTask$ } from "@builder.io/qwik";
export default component$(() => {
const { gameStore, handleAnswer, initGame, finishGame } = useGame();
const { timer, startTimer, resetTimer } = useTime();
const { formStore, initOptions, addDisplayText, removeDisplayText } =
useForm();
const pokemonResource = useResource$(({ cleanup, track }) => {
track(() => gameStore.round);
track(() => gameStore.state);
const controller = new AbortController();
cleanup(() => controller.abort());
return fetchPokemon();
});
const onAnswer = $(async () => {
const pokemon = await pokemonResource.value;
const joinedDisplayTexts = formStore.displayTexts
.map(({ char }) => char)
.join("");
const isCorrect = joinedDisplayTexts === pokemon?.name;
const correctType = isCorrect ? "✅ 正解" : "❌ 不正解";
const message = `${correctType} \n 答えは「${pokemon?.name}」です`;
alert(message);
handleAnswer(isCorrect);
});
const onRetry = $(() => {
initGame();
resetTimer();
});
useTask$(async ({ track }) => {
const pokemon = track(() => pokemonResource.value);
const name = (await pokemon)?.name;
initOptions(name || "");
startTimer();
});
useTask$(({ track }) => {
const time = track(() => timer.value);
if (time === -1) {
finishGame();
}
});
if (gameStore.state === "END") {
return <GameResultView onRetry$={onRetry} />;
}
return (
<Resource
value={pokemonResource}
onPending={() => (
<div class={styles.loader__container}>
<Loader />
</div>
)}
onResolved={(pokemon) =>
pokemon && (
<GamePlayView
pokemon={pokemon}
displayTexts={formStore.displayTexts}
options={formStore.options}
onAddInput$={addDisplayText}
onAnswer$={onAnswer}
onRemoveInput$={removeDisplayText}
/>
)
}
/>
);
});
最終的なコード
※ 記事執筆前に実装したので所々記事と異なるコードが存在します。ご了承ください。
おわりに
Qwik の状態管理とデータ取得の基本的な実装を行いました。
useStore
やuseResource$
などの API は直感的で使いやすく、
コンテキストを使った状態管理も比較的シンプルに実装できました。
また本シリーズを通してお気軽にコメントお待ちしております。
また完走賞も目指しているので是非応援お願いします!
この記事は フロントエンドの世界 Advent Calendar 2024の 21 記事目です。
次の記事はこちら Qwik(City)の世界: ビルドとデプロイ #5