はじめに
はじめまして、WEB フロントエンドエンジニアの nuintee です。
この度かねてより関心があった SolidJS に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。
SolidJS に少しでも興味のある方は、ぜひご覧ください。
フロントエンドの世界 2024 について
「フロントエンドの世界 2024」は普段 Next.js
を書いている筆者が、同じフロントエンドライブラリである Svelte(Kit)
, Remix
,SolidJS
, Qwik(City)
の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。
もくじ
- はじめに
- フロントエンドの世界 2024 について
- もくじ
- 今回作るモノ
- ディレクトリ構成
- 機能の型定義
- モックページの実装
- 共通コンポーネントの追加
- その他のコンポーネント
- 共通コンポーネント呼び出し
- 最終的なコード
- おわりに
今回作るモノ
今回は PokeAPI を用いたポケモン当てクイズアプリをSvelte
で開発します。
機能的には以下の通りです。
- ランダム出題機能
- 回答判定機能
- ライフ管理
- 制限時間管理
- リザルト機能
ディレクトリ構成
.
├── README.md
├── index.html
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── src
│ ├── App.tsx
│ ├── features
│ │ ├── form
│ │ │ ├── hooks
│ │ │ │ └── index.ts
│ │ │ └── types
│ │ │ └── index.ts
│ │ ├── pokemon
│ │ │ ├── data
│ │ │ │ └── index.ts
│ │ │ ├── types
│ │ │ │ └── index.ts
│ │ │ └── ui
│ │ │ └── Card
│ │ │ └── index.tsx
│ │ ├── time
│ │ │ └── hooks
│ │ │ └── index.ts
│ │ ├── ui
│ │ │ ├── CharButton
│ │ │ │ ├── index.tsx
│ │ │ │ └── type.ts
│ │ │ ├── Debugger
│ │ │ │ └── index.tsx
│ │ │ ├── LifeCounter
│ │ │ │ └── index.tsx
│ │ │ ├── Loader
│ │ │ │ └── index.tsx
│ │ │ ├── PrimaryButton
│ │ │ │ ├── index.tsx
│ │ │ │ └── type.ts
│ │ │ └── ProgressBar
│ │ │ ├── index.tsx
│ │ │ └── type.ts
│ │ └── utils
│ │ └── kana
│ │ └── index.ts
│ ├── index.css
│ ├── index.tsx
│ └── views
│ ├── GamePlayView
│ │ ├── index.tsx
│ │ └── type.ts
│ └── GameResultView
│ ├── index.tsx
│ └── type.ts
├── tailwind.config.ts
├── tsconfig.json
└── vite.config.ts
機能の型定義
PokeAPI のレスポンス
features 配下にポケモン API の取得レスポンス型を定義します。
(参考: PokeAPI)
export type SpecieResponse = {
names: {
language: {
name: string;
};
name: string;
}[];
};
export type PokemonResponse = {
id: number;
name: string;
sprites: {
other: {
home: {
front_default: string;
};
};
};
};
export type PokemonType = SpecieResponse & PokemonResponse;
回答入力フォーム
回答欄と選択肢で共通して使う型を定義します。
export type FormType = {
char: string;
index: number;
};
export type FormValues = {
options: FormType[];
displayTexts: FormType[];
};
モックページの実装
まずは固定のデータで UI を実装します。
リザルト画面
React で慣れ親しんだ JSX 構文でリザルト画面を実装します。
// propsの型定義
export type GameResultViewProps = {
score: number;
onRetry: () => void;
};
import { GameResultViewProps } from "./type";
export const GameResultView = ({ score, onRetry }: GameResultViewProps) => {
return (
<section class="h-full w-full flex items-center justify-center">
<div class="flex flex-col gap-y-4 border p-4 rounded-[10px] border-solid border-[gainsboro]">
<h2 class="text-center text-slate-500">スコア</h2>
<p class="text-center text-2xl">
{score}
<span class="text-sm">点</span>
</p>
{/* PrimaryButton */}
<button
onClick={onRetry}
class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
>
もう一度プレイする
</button>
</div>
</section>
);
};
プレイ画面
同じ要領でプレイ画面も実装します。
// propsの型定義
import { FormType } from "../../features/form/types";
import { PokemonType } from "../../features/pokemon/types";
export type GamePlayViewProps = {
displayTexts: FormType[];
options: FormType[];
lifeCounts: number;
pokemon: PokemonType;
onAddInput: (input: FormType) => void;
onRemoveInput: (input: FormType) => void;
};
import { For, Show } from "solid-js";
import { GamePlayViewProps } from "./type";
export const GamePlayView = (props: GamePlayViewProps) => {
const displayTexts = () => props.displayTexts;
const options = () => props.options;
const lifeCounts = () => props.lifeCounts;
const pokemon = () => props.pokemon;
const life = Array.from({ length: lifeCounts() }).map((_, i) => i);
return (
<section class="flex flex-col items-center justify-center">
{/* ProgressBar */}
<div class="w-full h-3 bg-[#cbff7e]">
<div
class={`h-full rounded-r-md bg-[yellowgreen] ease-linear`}
style={{ width: `${70}%` }}
></div>
</div>
{/* LifeCounter */}
<div class="flex items-center gap-x-2 px-4 m-4 w-full">
<For each={life}>{(_) => <div class="text-xl">❤️</div>}</For>
</div>
<h2 class="flex items-center justify-center text-2xl mt-8">
このポケモンは誰?
</h2>
{/* Card */}
<img
src={pokemon()?.sprites.other.home.front_default ?? ""}
alt="ポケモン"
draggable={false}
class="w-[350px] max-w-full h-fit mx-auto"
/>
<div class="flex items-center gap-x-4 h-[70px]">
<Show
when={!!displayTexts().length}
fallback={
<span class="flex items-center justify-center text-gray-400">
👇 クリックで名前を入力して下さい
</span>
}
>
<span>名前は</span>
<For each={displayTexts()}>
{(displayText) => (
<button
class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
onClick={() => props.onRemoveInput(displayText)}
>
{displayText.char}
</button>
)}
</For>
<span>です</span>
</Show>
</div>
<hr class="w-full" />
<div class="flex items-center gap-x-4 h-[70px] m-2">
<For each={options()}>
{(option) => (
<button
class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
onClick={() => props.onAddInput(option)}
>
{option.char}
</button>
)}
</For>
</div>
{/* PrimaryButton */}
<button
onClick={() => {}}
class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
>
回答する
</button>
</section>
);
};
ルート画面
条件によって画面を変える制御処理を追加します。
Match コンポーネント
when 条件が true
の場合にのみ要素を描画してくれるコンポーネントです。
Switch コンポーネント
複数の Match
コンポーネントを Switch
コンポーネントで囲む事で、最初に 条件 がtrue
になったものだけ描画されるようになります。
(参考: <Switch> / <Match>)
ErrorBoundary
クライアントでエラーが throw された際にフォールバック画面を表示してくれるコンポーネントです。
(参考: ErrorBoundary)
import { ErrorBoundary, Match, Switch, type Component } from "solid-js";
import { GameResultView } from "./views/GameResultView";
import { GamePlayView } from "./views/GamePlayView";
import { Loader } from "./features/ui/Loader";
import { PokemonType } from "./features/pokemon/types";
const App: Component = () => {
const displayTexts = [
{ char: "フ", index: 1 },
{ char: "シ", index: 2 },
{ char: "ギ", index: 3 },
{ char: "バ", index: 4 },
{ char: "ナ", index: 4 },
];
// NOTE: ダミー入りの選択肢
const options = [
{ char: "マ", index: 2 },
{ char: "バ", index: 3 },
{ char: "ジ", index: 5 },
{ char: "バ", index: 3 },
{ char: "フ", index: 1 },
{ char: "リ", index: 6 },
{ char: "ナ", index: 4 },
{ char: "ン", index: 7 },
];
const pokemon: PokemonType = {
sprites: {
other: {
home: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/3.png",
},
},
},
id: 1,
name: "xxx",
names: [{ language: { name: "xxx" }, name: "yyy" }],
};
// NOTE: API呼び出し時に置き換え
const isLoading = false;
const gameState = "START" as "START" | "END";
const lifeCounts = 3;
return (
// エラー発生時はフォールバックを表示フォールバックを表示
<ErrorBoundary fallback={<p>Error</p>}>
<Switch>
// NOTE: ロード中はLoaderを表示
<Match when={isLoading}>
<div class="h-[10rem] w-[10rem] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<Loader />
</div>
</Match>
// NOTE: ゲーム終了状態の場合はリザルト画面を表示
<Match when={gameState === "END"}>
<GameResultView score={3} onRetry={() => {}} />
</Match>
// NOTE: ゲームが開始状態の場合はプレイ画面を表示
<Match when={gameState === "START"}>
<GamePlayView
pokemon={pokemon}
lifeCounts={lifeCounts}
displayTexts={displayTexts}
options={options}
onAddInput={() => {}}
onRemoveInput={() => {}}
/>
</Match>
</Switch>
</ErrorBoundary>
);
};
export default App;
共通コンポーネントの追加
ui/
配下にアプリケーション全体で使用する共通 UI パーツを追加していきます。
(参考: React - コンポーネントの定義)
CharButton
回答の入力・表示を行うボタンをコンポーネント化します。
// 型定義
import { ReactNode } from "react";
export type CharButtonProps = {
onClick: () => void;
children: ReactNode;
};
// コンポーネント実態
import { CharButtonProps } from "./type";
export const CharButton = ({ onClick, children }: CharButtonProps) => {
return (
<button
class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
onClick={onClick}
>
{children}
</button>
);
};
PrimaryButton
回答やリトライを担うプライマリーボタンをコンポーネント化します。
import { ReactNode } from "react";
export type PrimaryButtonProps = {
onClick: () => void;
children: ReactNode;
};
import { PrimaryButtonProps } from "./type";
export const PrimaryButton = ({ onClick, children }: PrimaryButtonProps) => {
return (
<button
onClick={onClick}
class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
>
{children}
</button>
);
};
LifeCounter
残りのライフ表示をコンポーネント化します。
export const LifeCounter = (props: { lifeCount: number }) => {
const lifeCount = () => props.lifeCount;
return (
<div class="flex items-center gap-x-2 px-4 m-4 w-full">
{Array.from({ length: lifeCount() }).map((_) => (
<div class="text-xl">❤️</div>
))}
</div>
);
};
Loader
ローディング時に表示するアニメーション SVG のコンポーネント化します。
今回はSVG Backgroundsというサイトで生成したRipples
ローダーの色だけ調整してそのまま使用します。
export const Loader = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<circle
fill="none"
stroke-opacity="1"
stroke="#9E9E9E"
stroke-width=".5"
cx="100"
cy="100"
r="0"
>
<animate
attributeName="r"
calcMode="spline"
dur="2"
values="1;80"
keyTimes="0;1"
keySplines="0 .2 .5 1"
repeatCount="indefinite"
></animate>
<animate
attributeName="stroke-width"
calcMode="spline"
dur="2"
values="0;25"
keyTimes="0;1"
keySplines="0 .2 .5 1"
repeatCount="indefinite"
></animate>
<animate
attributeName="stroke-opacity"
calcMode="spline"
dur="2"
values="1;0"
keyTimes="0;1"
keySplines="0 .2 .5 1"
repeatCount="indefinite"
></animate>
</circle>
</svg>
);
};
ProgressBar
ゲームの制限時間表示をコンポーネント化します。
classList
を用いると条件に一致したクラス定義を動的に行えます。
(参考: classList)
export type ProgressBarProps = {
value: number;
maxValue: number;
};
import { ProgressBarProps } from "./type";
export const ProgressBar = ({ value, maxValue }: ProgressBarProps) => {
const progress = (value / maxValue) * 100;
const isBellowMax = value < maxValue;
// NOTE: 1000ms分のdelayがあるので-1を終了時間にする
const isAboveMin = value > -1;
const isBetweenRange = isAboveMin && isBellowMax;
return (
<div class="w-full h-3 bg-[#cbff7e]">
<div
classList={{
["h-full rounded-r-md bg-[yellowgreen] ease-linear"]: true,
["duration-1000"]: isBetweenRange,
}}
style={{ width: `${progress}%` }}
></div>
</div>
);
};
Debugger
開発時の値確認用コンポーネントを追加します。
export const Debugger = ({
data,
}: {
data: Record<string, string | number | undefined>;
}) => {
if (process.env.NODE_ENV !== "development") return null;
return (
<div class="absolute top-0 right-0 bg-black bg-opacity-50 text-white">
{Object.keys(data).map((key) => (
<dl class="flex items-center gap-x-2">
<dt>{key} :</dt>
<dd>{data[key]}</dd>
</dl>
))}
</div>
);
};
その他のコンポーネント
Card
取得したポケモンの画像表示用 img
タグをコンポーネント化します。
alt
が主にポケモンに依存しそうなのでpokemon
というfeature
配下に追加します。
今回のテーマでは無いので雑な設計はご容赦いただけると幸いです 🙇♂️
export const Card = ({ src }: { src: string }) => {
return (
<img
src={src}
alt="ポケモン"
draggable={false}
class="w-[350px] max-w-full h-fit mx-auto"
/>
);
};
共通コンポーネント呼び出し
リザルト画面
PrimaryButton をインポートします。
+ import { PrimaryButton } from "../../features/ui/PrimaryButton";
import { GameResultViewProps } from "./type";
export const GameResultView = ({ score, onRetry }: GameResultViewProps) => {
return (
<section class="h-full w-full flex items-center justify-center">
<div class="flex flex-col gap-y-4 border p-4 rounded-[10px] border-solid border-[gainsboro]">
<h2 class="text-center text-slate-500">スコア</h2>
<p class="text-center text-2xl">
{score}
<span class="text-sm">点</span>
</p>
- {/* PrimaryButton */}
- <button
- onClick={onRetry}
- class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
- >
- もう一度プレイする
- </button>
+ <PrimaryButton onClick={onRetry}>もう一度プレイする</PrimaryButton>
</div>
</section>
);
};
プレイ画面
上で作成した共通コンポーネントを GamePlayView
でインポートします。
import { For, Show } from "solid-js";
import { GamePlayViewProps } from "./type";
+ import { ProgressBar } from "../../features/ui/ProgressBar";
+ import { LifeCounter } from "../../features/ui/LifeCounter";
+ import { Card } from "../../features/pokemon/ui/Card";
+ import { CharButton } from "../../features/ui/CharButton";
+ import { PrimaryButton } from "../../features/ui/PrimaryButton";
export const GamePlayView = (props: GamePlayViewProps) => {
const displayTexts = () => props.displayTexts;
const options = () => props.options;
const lifeCounts = () => props.lifeCounts;
const pokemon = () => props.pokemon;
const life = Array.from({ length: lifeCounts() }).map((_, i) => i);
return (
<section class="flex flex-col items-center justify-center">
- {/* ProgressBar */}
- <div class="w-full h-3 bg-[#cbff7e]">
- <div
- class={`h-full rounded-r-md bg-[yellowgreen] ease-linear`}
- style={{ width: `${70}%` }}
- ></div>
- </div>
+ <ProgressBar value={70} maxValue={100} />
- {/* LifeCounter */}
- <div class="flex items-center gap-x-2 px-4 m-4 w-full">
- <For each={life}>{(_) => <div class="text-xl">❤️</div>}</For>
- </div>
+ <LifeCounter lifeCount={lifeCounts()}/>
<h2 class="flex items-center justify-center text-2xl mt-8">
このポケモンは誰?
</h2>
- {/* Card */}
- <img
- src={pokemon()?.sprites.other.home.front_default ?? ""}
- alt="ポケモン"
- draggable={false}
- class="w-[350px] max-w-full h-fit mx-auto"
- />
+ <Card src={pokemon()?.sprites.other.home.front_default ?? ""} />
<div class="flex items-center gap-x-4 h-[70px]">
<Show
when={!!displayTexts().length}
fallback={
<span class="flex items-center justify-center text-gray-400">
👇 クリックで名前を入力して下さい
</span>
}
>
<span>名前は</span>
<For each={displayTexts()}>
{(displayText) => (
- <button
- class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
- onClick={() => props.onRemoveInput(displayText)}
- >
- {displayText.char}
- </button>
+ <CharButton onClick={() => props.onRemoveInput(displayText)}>
+ {displayText.char}
+ </CharButton>
)}
</For>
<span>です</span>
</Show>
</div>
<hr class="w-full" />
<div class="flex items-center gap-x-4 h-[70px] m-2">
<For each={options()}>
{(option) => (
- <button
- class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
- onClick={() => props.onAddInput(option)}
- >
- {option.char}
- </button>
+ <CharButton onClick={() => props.onRemoveInput(displayText)}>
+ {displayText.char}
+ </CharButton>
)}
</For>
</div>
- {/* PrimaryButton */}
- <button
- onClick={() => {}}
- class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
- >
- 回答する
- </button>
+ <PrimaryButton onClick={() => {}}>
+ 回答する
+ </PrimaryButton>
</section>
);
};
最終的なコード
import { ErrorBoundary, Match, Switch, type Component } from "solid-js";
import { GameResultView } from "./views/GameResultView";
import { GamePlayView } from "./views/GamePlayView";
import { Loader } from "./features/ui/Loader";
import { PokemonType } from "./features/pokemon/types";
const App: Component = () => {
const displayTexts = [
{ char: "フ", index: 1 },
{ char: "シ", index: 2 },
{ char: "ギ", index: 3 },
{ char: "バ", index: 4 },
{ char: "ナ", index: 4 },
];
// NOTE: ダミー入りの選択肢
const options = [
{ char: "マ", index: 2 },
{ char: "バ", index: 3 },
{ char: "ジ", index: 5 },
{ char: "バ", index: 3 },
{ char: "フ", index: 1 },
{ char: "リ", index: 6 },
{ char: "ナ", index: 4 },
{ char: "ン", index: 7 },
];
const pokemon: PokemonType = {
sprites: {
other: {
home: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/3.png",
},
},
},
id: 1,
name: "xxx",
names: [{ language: { name: "xxx" }, name: "yyy" }],
};
const isLoading = false;
const gameState = "START" as "START" | "END";
const lifeCounts = 3;
return (
<ErrorBoundary fallback={<p>Error</p>}>
<Switch>
<Match when={isLoading}>
<div class="h-[10rem] w-[10rem] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<Loader />
</div>
</Match>
<Match when={gameState === "END"}>
<GameResultView score={3} onRetry={() => {}} />
</Match>
<Match when={gameState === "START"}>
<GamePlayView
pokemon={pokemon}
lifeCounts={lifeCounts}
displayTexts={displayTexts}
options={options}
onAddInput={() => {}}
onRemoveInput={() => {}}
/>
</Match>
</Switch>
</ErrorBoundary>
);
};
export default App;
おわりに
For や Match コンポーネントを使う事で、制御処理を非常に見やすく書く事が出来て感動しました。
また標準で ErrorBoundary が使えるのも便利だと感じました。
また本シリーズを通してお気軽にコメントお待ちしております。
また完走賞も目指しているので是非応援お願いします!
この記事は フロントエンドの世界 Advent Calendar 2024の 15 記事目です。
次の記事はこちら SolidJS の世界: データ取得と状態管理 #4