はじめに
はじめまして、WEB フロントエンドエンジニアの nuintee です。
この度かねてより関心があった Qwik に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。
Qwik に少しでも興味のある方は、ぜひご覧ください。
フロントエンドの世界 2024 について
「フロントエンドの世界 2024」は普段 Next.js
を書いている筆者が、同じフロントエンドライブラリである Svelte(Kit)
, Remix
,SolidJS
, Qwik(City)
の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。
もくじ
- はじめに
- フロントエンドの世界 2024 について
- もくじ
- ディレクトリ構成
- 機能の型定義
- モックページの実装
- 共通コンポーネントの追加
- その他のコンポーネント
- 共通コンポーネント呼び出し
- 最終的なコード
- おわりに
ディレクトリ構成
.
├── README.md
├── package.json
├── public
│ ├── favicon.svg
│ ├── manifest.json
│ └── robots.txt
├── qwik.env.d.ts
├── src
│ ├── components
│ │ └── router-head
│ │ └── router-head.tsx
│ ├── entry.dev.tsx
│ ├── entry.preview.tsx
│ ├── entry.ssr.tsx
│ ├── features
│ │ ├── form
│ │ │ └── types
│ │ │ └── index.ts
│ │ ├── game
│ │ │ ├── constants
│ │ │ │ └── index.ts
│ │ │ └── types
│ │ │ └── index.ts
│ │ ├── pokemon
│ │ │ ├── types
│ │ │ │ └── index.ts
│ │ │ └── ui
│ │ │ └── Card
│ │ │ └── index.tsx
│ │ └── time
│ │ └── types
│ │ └── index.ts
│ ├── global.css
│ ├── root.tsx
│ ├── routes
│ │ ├── index.tsx
│ │ ├── layout.tsx
│ │ └── service-worker.ts
│ ├── styles
│ │ └── index.module.css
│ ├── ui
│ │ ├── CharButton
│ │ │ ├── index.tsx
│ │ │ └── type.ts
│ │ ├── LifeCounter
│ │ │ └── index.tsx
│ │ ├── Loader
│ │ │ └── index.tsx
│ │ ├── PrimaryButton
│ │ │ ├── index.tsx
│ │ │ └── type.ts
│ │ └── ProgressBar
│ │ ├── index.tsx
│ │ └── type.ts
│ └── views
│ ├── GamePlayView
│ │ ├── index.module.css
│ │ ├── index.tsx
│ │ └── type.ts
│ └── GameResultView
│ ├── index.module.css
│ ├── index.tsx
│ └── type.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 を実装します。
リザルト画面
Qwik のcomponent$
を使用してリザルト画面を実装します。
イベントハンドラーは$
サフィックスを付けた命名にし、関数の型は QRL
にジェネリクスとして渡すことで Qwik 用のイベントハンドラー型を定義できます。
(参考: component$ / Custom Event Props)
// NOTE: QRLはQwik特有の型で、関数型のpropsに使用します
import { QRL } from "@builder.io/qwik";
export type GameResultViewProps = {
onRetry$: QRL<() => void>;
};
スタイリングは CSS Modules を使用します。
(参考: CSS Modules)
.section__container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.score__container {
display: flex;
padding: 1rem;
flex-direction: column;
row-gap: 1rem;
border: 1px solid gainsboro;
border-radius: 10px;
}
.score__label {
font-weight: 400;
text-align: center;
color: rgb(100 116 139);
}
.score__value {
font-size: 1.5rem;
line-height: 2rem;
text-align: center;
}
.score__unit {
font-size: 0.875rem;
line-height: 1.25rem;
}
.primary__button {
padding-top: 1rem;
padding-bottom: 1rem;
padding-left: 2rem;
padding-right: 2rem;
max-width: 100%;
font-weight: 500;
background: greenyellow;
width: 350px;
border: none;
cursor: pointer;
border-radius: 10px;
&:hover {
opacity: 0.75;
}
&:active {
opacity: 0.5;
}
}
import type { GameResultViewProps } from "./type";
import { component$ } from "@builder.io/qwik";
import styles from "./index.module.css";
// NOTE: component$でコンポーネントを定義します
export const GameResultView = component$(
({ onRetry$ }: GameResultViewProps) => {
const { score } = {
score: 3,
};
return (
<section class={styles.section__container}>
<div class={styles.score__container}>
<h2 class={styles.score__label}>スコア</h2>
<p class={styles.score__value}>
{score}
<span class={styles.score__unit}>点</span>
</p>
{/* NOTE: onClick$のように$サフィックスを付けてイベントハンドラを定義 */}
<button onClick$={onRetry$} class={styles.primary__button}>
もう一度プレイする
</button>
</div>
</section>
);
}
);
プレイ画面
同様にプレイ画面も実装します。
import { QRL } from "@builder.io/qwik";
import type { FormType } from "~/features/form/types";
import type { PokemonType } from "~/features/pokemon/types";
export type GamePlayViewProps = {
displayTexts: FormType[];
options: FormType[];
pokemon: PokemonType;
// NOTE: すべてのイベントハンドラpropsに$サフィックスを付ける
onAddInput$: QRL<(input: FormType) => void>;
onRemoveInput$: QRL<(input: FormType) => void>;
onAnswer$: QRL<() => void>;
};
.section__container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.progressBar__container {
width: 100%;
height: 0.75rem;
background: #cbff7e;
}
.progressBar__track {
border-top-right-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
height: 100%;
transition-timing-function: linear;
background: yellowgreen;
}
.lifeCounter__container {
display: flex;
padding-left: 1rem;
padding-right: 1rem;
margin: 1rem;
column-gap: 0.5rem;
align-items: center;
width: 100%;
box-sizing: border-box;
}
.lifeCounter__item {
font-size: 20px;
}
.title__text {
display: flex;
margin-top: 1rem;
justify-content: center;
align-items: center;
font-size: 1.5rem;
line-height: 2rem;
font-weight: 500;
}
.displayText__container {
display: flex;
column-gap: 1rem;
align-items: center;
height: 70px;
}
.options__container {
display: flex;
margin: 0.5rem;
column-gap: 1rem;
align-items: center;
height: 70px;
}
.fallback__text {
display: flex;
justify-content: center;
align-items: center;
color: #9ca3af;
}
.card__container {
max-width: 100%;
height: fit-content;
width: 350px;
}
.primary__button {
padding-top: 1rem;
padding-bottom: 1rem;
padding-left: 2rem;
padding-right: 2rem;
max-width: 100%;
font-weight: 500;
background: greenyellow;
width: 350px;
border: none;
cursor: pointer;
border-radius: 10px;
&:hover {
opacity: 0.75;
}
}
.char__button {
padding-top: 1rem;
padding-bottom: 1rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
background: ghostwhite;
border: none;
cursor: pointer;
border-radius: 10px;
&:hover {
opacity: 0.75;
}
&:active {
opacity: 0.5;
}
}
.separator {
width: 100%;
height: 1px;
background-color: #e5e7eb;
}
import { component$ } from "@builder.io/qwik";
import type { GamePlayViewProps } from "./type";
import styles from "./index.module.css";
export const GamePlayView = component$(
({
pokemon,
options,
displayTexts,
onAddInput$,
onRemoveInput$,
onAnswer$,
}: GamePlayViewProps) => {
const lifeCounts = Array.from({ length: 3 });
return (
<section class={styles.section__container}>
{/* ProgressBar */}
<div class={styles.progressBar__container}>
<div
class={styles.progressBar__track}
style={{ width: `${70}%` }}
></div>
</div>
{/* LifeCounter */}
<div class={styles.lifeCounter__container}>
{lifeCounts.map((_, index) => (
<div class={styles.lifeCounter__item} key={index}>
❤️
</div>
))}
</div>
<h2 class={styles.title__text}>このポケモンは誰?</h2>
<img
src={pokemon.sprites.other.home.front_default ?? ""}
alt="ポケモン"
draggable={false}
class={styles.card__container}
width={350}
height={350}
/>
<div class={styles.displayText__container}>
{displayTexts.length ? (
<>
<span>名前は</span>
{displayTexts.map((displayText) => (
<button
class={styles.char__button}
onClick$={() => onRemoveInput$(displayText)}
key={`${displayText.char}-${displayText.index}`}
>
{displayText.char}
</button>
))}
<span>です</span>
</>
) : (
<span class={styles.fallback__text}>
👇 クリックで名前を入力して下さい
</span>
)}
</div>
<div class={styles.separator} />
<div class={styles.options__container}>
{options.map((option) => (
<button
class={styles.char__button}
onClick$={() => onAddInput$(option)}
key={`${option.char}-${option.index}`}
>
{option.char}
</button>
))}
</div>
<button onClick$={onAnswer$} class={styles.primary__button}>
回答する
</button>
</section>
);
}
);
ルート画面
条件によって画面を切り替える制御処理を追加します。
ページローダーのスタイル
※ ページローダー用のスタイルを src 直下の styles 配下に追加します。
.loader__container {
position: absolute;
top: 50%;
left: 50%;
height: 10rem;
width: 10rem;
}
関数を定義するときは $()
で囲みます。(参考: The dollar $ sign)
import { $, component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { GameStoreType } from "~/features/game/types";
import { Loader } from "~/ui/Loader";
import { GamePlayView } from "~/views/GamePlayView";
import { GameResultView } from "~/views/GameResultView";
import styles from "../styles/index.module.css";
export default component$(() => {
// NOTE: モック用の固定データ
const displayTexts = [
{ char: "メ", index: 1 },
{ char: "タ", index: 2 },
{ char: "モ", index: 3 },
{ char: "ン", index: 4 },
];
const options = [
{ char: "ン", index: 4 },
{ char: "メ", index: 1 },
{ char: "タ", index: 2 },
{ char: "メ", index: 5 },
{ char: "モ", index: 3 },
{ char: "ル", index: 6 },
];
const gameState: GameStoreType = {
state: "START",
life: 3,
score: 0,
round: 1,
};
const isLoading = false;
// NOTE: $関数でイベントハンドラを定義
const onAddInput = $(() => {});
const onAnswer = $(() => {});
const onRemoveInput = $(() => {});
const onRetry = $(() => {});
// NOTE: 早期リターンによる条件分岐
if (isLoading)
return (
<div class={styles.loader__container}>
<Loader />
</div>
);
if (gameState.state === "END") {
return <GameResultView onRetry$={onRetry} />;
}
return (
<GamePlayView
pokemon={{
id: 1,
name: "yyy",
names: [{ language: { name: "ja" }, name: "xxx" }],
sprites: {
other: {
home: {
front_default: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/132.png",
},
},
},
}}
displayTexts={displayTexts}
options={options}
onAddInput$={onAddInput}
onAnswer$={onAnswer}
onRemoveInput$={onRemoveInput}
/>
);
});
共通コンポーネントの追加
ui/
配下にアプリケーション全体で使用する共通 UI パーツを追加していきます。
CharButton
回答の入力・表示を行うボタンをコンポーネント化します。
import { component$ } from "@builder.io/qwik";
import type { QRL } from "@builder.io/qwik";
type CharButtonProps = {
onClick$: QRL<() => void>;
children: any;
};
.button {
padding: 1rem 1.5rem;
background-color: ghostwhite;
border: none;
cursor: pointer;
border-radius: 10px;
}
.button:hover {
opacity: 0.75;
}
.button:active {
opacity: 0.5;
}
import { component$ } from "@builder.io/qwik";
import type { CharButtonProps } from "./type";
import styles from "./index.module.css";
export const CharButton = component$((props: CharButtonProps) => {
return (
<button onClick$={props.onClick$} class={styles.button}>
{props.children}
</button>
);
});
PrimaryButton
回答やリトライを担うプライマリーボタンをコンポーネント化します。
.button {
padding: 1rem 2rem;
max-width: 100%;
width: 350px;
font-weight: 500;
background-color: greenyellow;
border: none;
cursor: pointer;
border-radius: 10px;
}
.button:hover {
opacity: 0.75;
}
.button:active {
opacity: 0.5;
}
import { component$ } from "@builder.io/qwik";
import type { QRL } from "@builder.io/qwik";
import styles from "./index.module.css"
type PrimaryButtonProps = {
onClick$: QRL<() => void>;
children: any;
};
export const PrimaryButton = component$(
({ onClick$, children }: PrimaryButtonProps) => {
return (
<button
onClick$={onClick$}
class={styles.button}
>
{children}
</button>
);
}
);
LifeCounter
残りのライフ表示をコンポーネント化します。
.container {
display: flex;
padding: 0 1rem;
margin: 1rem;
column-gap: 0.5rem;
align-items: center;
width: 100%;
box-sizing: border-box;
}
.item {
font-size: 20px;
}
import { component$ } from "@builder.io/qwik";
import styles from "./index.module.css"
export const LifeCounter = component$(({ lifeCount }: { lifeCount: number }) => {
return (
<div class={styles.container}>
{Array.from({ length: lifeCount }).map((_, index) => (
<div class={styles.item} key={index}>
❤️
</div>
))}
</div>
);
});
Loader
ローディング時に表示するアニメーション SVG のコンポーネント化します。
今回はSVG Backgroundsというサイトで生成したRipples
ローダーの色だけ調整してそのまま使用します。
import { component$ } from "@builder.io/qwik";
export const Loader = component$(() => {
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
ゲームの制限時間表示をコンポーネント化します。
.container {
width: 100%;
height: 0.75rem;
background-color: #cbff7e;
}
.track {
height: 100%;
border-radius: 0 0.375rem 0.375rem 0;
background-color: yellowgreen;
transition: width 1s linear;
}
import { component$ } from "@builder.io/qwik";
import styles from "./index.module.css"
type ProgressBarProps = {
value: number;
maxValue: number;
};
export const ProgressBar = component$(({ value, maxValue }: ProgressBarProps) => {
const progress = (value / maxValue) * 100;
return (
<div class={styles.container}>
<div
class={styles.track}
style={{ width: `${progress}%` }}
></div>
</div>
);
});
その他のコンポーネント
Card
取得したポケモンの画像表示用コンポーネントを実装します。
.card {
width: 350px;
max-width: 100%;
height: fit-content;
margin: 0 auto;
}
import { component$ } from "@builder.io/qwik";
import styles from "./index.modules.css"
export const Card = component$(({ src }: { src: string }) => {
return (
<img
src={src}
alt="ポケモン"
draggable={false}
class={styles.card}
/>
);
});
共通コンポーネント呼び出し
リザルト画面
PrimaryButton をインポートして使用します。
+ import { PrimaryButton } from "~/ui/PrimaryButton";
import type { GameResultViewProps } from "./type";
export const GameResultView = component$(
({ onRetry$ }: GameResultViewProps) => {
const { score } = {
score: 3,
};
return (
<section class={styles.section__container}>
<div class={styles.score__container}>
<h2 class={styles.score__label}>スコア</h2>
<p class={styles.score__value}>
{score}
<span class={styles.score__unit}>点</span>
</p>
- <button onClick$={onRetry$} class={styles.primary__button}>
- もう一度プレイする
- </button>
+ <PrimaryButton onClick$={onRetry$}>
+ もう一度プレイする
+ </PrimaryButton>
</div>
</section>
);
}
);
プレイ画面
共通コンポーネントをインポートして使用します。
+ import { ProgressBar } from "~/ui/ProgressBar";
+ import { LifeCounter } from "~/ui/LifeCounter";
+ import { CharButton } from "~/ui/CharButton";
+ import { PrimaryButton } from "~/ui/PrimaryButton";
+ import { Card } from "~/features/pokemon/ui/Card";
export const GamePlayView = component$(
({
pokemon,
options,
displayTexts,
onAddInput$,
onRemoveInput$,
onAnswer$,
}: GamePlayViewProps) => {
return (
<section class={styles.section__container}>
- <div class={styles.progressBar__container}>
- <div class={styles.progressBar__track} style={{ width: `${70}%` }}></div>
- </div>
+ <ProgressBar value={70} maxValue={100} />
- <div class={styles.lifeCounter__container}>
- {lifeCounts.map((_, index) => (
- <div class={styles.lifeCounter__item} key={index}>❤️</div>
- ))}
- </div>
+ <LifeCounter lifeCount={3} />
<h2 class={styles.title__text}>このポケモンは誰?</h2>
- <img
- src={pokemon.sprites.other.home.front_default ?? ""}
- alt="ポケモン"
- draggable={false}
- class={styles.card__container}
- />
+ <Card src={pokemon.sprites.other.home.front_default ?? ""} />
{/* ... 他のコード ... */}
- <button onClick$={onAnswer$} class={styles.primary__button}>
- 回答する
- </button>
+ <PrimaryButton onClick$={onAnswer$}>
+ 回答する
+ </PrimaryButton>
</section>
);
}
);
最終的なコード
import { $, component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { GameStoreType } from "~/features/game/types";
import { Loader } from "~/ui/Loader";
import { GamePlayView } from "~/views/GamePlayView";
import { GameResultView } from "~/views/GameResultView";
export default component$(() => {
const displayTexts = [
{ char: "メ", index: 1 },
{ char: "タ", index: 2 },
{ char: "モ", index: 3 },
{ char: "ン", index: 4 },
];
const options = [
{ char: "ン", index: 4 },
{ char: "メ", index: 1 },
{ char: "タ", index: 2 },
{ char: "メ", index: 5 },
{ char: "モ", index: 3 },
{ char: "ル", index: 6 },
];
const gameState: GameStoreType = {
state: "START",
life: 3,
score: 0,
round: 1,
};
const isLoading = false;
const onAddInput = $(() => {});
const onAnswer = $(() => {});
const onRemoveInput = $(() => {});
const onRetry = $(() => {});
if (isLoading)
return (
<div class={styles.loader__container}>
<Loader />
</div>
);
if (gameState.state === "END") {
return <GameResultView onRetry$={onRetry} />;
}
return (
<GamePlayView
pokemon={{
id: 1,
name: "yyy",
names: [{ language: { name: "ja" }, name: "xxx" }],
sprites: {
other: {
home: {
front_default: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/132.png",
},
},
},
}}
displayTexts={displayTexts}
options={options}
onAddInput$={onAddInput}
onAnswer$={onAnswer}
onRemoveInput$={onRemoveInput}
/>
);
});
おわりに
今回共通コンポーネントやページコンポーネント作成を通して、$()
やQRL
をはじめとしたQwik
の基本概念が少し分かってきました。
また本シリーズを通してお気軽にコメントお待ちしております。
また完走賞も目指しているので是非応援お願いします!
この記事は フロントエンドの世界 Advent Calendar 2024の 20 記事目です。
次の記事はこちら Qwik(City)の世界: データ取得と状態管理 #4