Svelte (Sveltekit) に入門してクイズの点数管理アプリを作った
これは何?
フロントエンドの技術やFWにがっつりというわけではない筆者が、独学でSvelteKitのSPAを作ってみた一連のまとめ。
JavaScript FWは身内のOSSでReactを少し触っているのと、業務でVueコンポーネントを一つ作ったことがあるくらい。
自分がやったことのまとめでしかないが、Svelteに興味がある誰かの参考になれば。
モチベーション
エンジニアとして
- 最近勢いのあるSvelteで遊んでみたい
- 勢いという意味ではSolidJSも候補だが、実益を兼ねているのである程度育ったFWを採用したかった
クイズプレイヤーとして
- 個人の点数管理ができるアプリはWebにもモバイルにもあるけど、フリバで使えるような複数人の管理ができるものがない
- Blazorを触ってみるのに過去一度作ったが、色々粗があったので作り直したい
成果物
リリース物:
ソースコード:
全体像
ルートの+layout.svelte
にはヘッダー部分のみを定義していて、それ以外はすべてルートの+page.svelte
に乗っています。
各個人の得点管理はカードコンポーネントでフレームを作り共通部分はフレーム内に作成、
ルールによって表示が変わる部分はコンポーネントを分け、選んだルールによって切り替わるようにしています。
components/counters/
下のcounterFrame.svelte
が赤の部分、
scoreCounter.svelte
とsimpleCounter.svelte
が黄色の部分です。
ルールを決める部分はruleSetting.svelte
としてコンポーネントを作り、モーダルで表示させるようにしました。
プロジェクトの準備
SvelteKitの手順に乗っ取り進めていきます。
pnpmが好みなのでpnpmを使いました。
pnpm create svelte@latest quiz-counter
cd quiz-counter
pnpm i
UIコンポーネントとしてはFlowbite Svelteを採用しました。
デザイン性よりツールとしてのシンプルさを重視したことと、コンポーネントの種類が多かったことが理由です。
pnpm dlx svelte-add@latest tailwindcss
pnpm i
pnpm dev
Flowbiteのインストール前にTailwind-CSSを入れた状態で一度devを実行する手順になっていたので倣いました。
pnpm i -D flowbite-svelte flowbite
pnpm i -D flowbite-svelte-icons
Flowbite iconsも利用しています。
実装
リポジトリは上に貼ったので、ポイントになりそうな箇所だけコメントします。
シンタックスハイライトにsvetleが対応していないのでTypeScriptとhtmlで表示しています。
+page.svelte
カウンターの表示と追加、全体リセットの機能と、ルール設定の呼び出しがあります。
カウンターの追加
let keys: Array<number> = [1];
const addCard = () => {
keys = [...keys, Math.max(...keys) + 1];
};
<div class="grid gap-5 grid-cols-1 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each keys as key, index (key)}
<CounterFrame bind:this={counters[index]} order={index} on:delete={deleteCard} />
{/each}
<Button color="dark" outline={true} on:click={addCard}><PlusSolid /></Button>
</div>
ボタンを押したときにkeyとなる整数を配列に追加し、key配列を#each
にわたすことでカウンターコンポーネントを複数配置しています。
このとき、カウンター側で削除ボタンが押されたとき、「何番目のカウンターを削除するのか」がページ側に伝わるようにインデックスをカウンターのorder
プロパティとして渡しています。
チュートリアルやドキュメントにもある通りSvelteは代入がリアクティブのトリガーなので、keys
への追加はkeys.push()
ではなく、上記のようにして代入する必要があります。
Svelteのリアクティビティは代入に基づいているため、.push() や .splice() のような配列のメソッドを使用しても自動的に更新をトリガーしません。更新をトリガーするにはそれに続いて代入する必要があります。
元のソースのコメントにある通り、全体リセットについては動作していません。
counters
とallReset
の見直しが必要ですが、一旦置いています。
#each
で動的に増えるコンポーネント中の関数を呼び出す方法の知見がある方がいればお教えください。
ルール設定
RuleSettingコンポーネントで各ルールの設定を行います。
<Accordion class="my-5">
<AccordionItem>
<span slot="header">{ruleName(RuleType.simple)}</span>
<p>正答数と誤答数をカウントするシンプルなルールです。</p>
<div class="flex justify-end">
<Button on:click={() => ($rule = RuleType.simple)}>submit</Button>
</div>
</AccordionItem>
<AccordionItem>
<span slot="header">{ruleName(RuleType.mn)}</span>
<p>正解でmポイント、誤答でnポイント点数が変動します。</p>
<div class="flex items-center gap-3 m-3">
<p>正解ポイント</p>
<input type="number" style="width:4rem" bind:value={$whenCorrect} />
<p>誤答ポイント</p>
<input type="number" style="width:4rem" bind:value={$whenIncorrect} />
</div>
<div class="flex justify-end">
<Button
on:click={() => {
$rule = RuleType.mn;
}}
>
submit
</Button>
</div>
</AccordionItem>
...
ルールはページ全体で参照されるのでstoreで管理してどのコンポーネントからでもアクセスできるようにしています。
import { RuleType } from '$lib/definitions/rules';
import { derived, writable } from 'svelte/store';
export const rule = writable<RuleType>(RuleType.simple);
export const whenCorrect = writable(1);
export const whenIncorrect = writable(-1);
export const inicialPoint = writable(10);
export const nByMParameters = writable<{
n: number;
m: number;
}>({
n: 10,
m: 10
});
export const nByMGoalScore = derived(
nByMParameters,
($nByMParameters) => $nByMParameters.n * $nByMParameters.m
);
n by mというルールだけ取り扱いが特殊なので専用の項目を用意しています。
ルール種別はenum代わりのオブジェクトリテラルで管理しています。
export const RuleType = {
undefined: 0,
simple: 1,
mn: 2,
by: 3,
updown: 4,
swedish: 5,
divide: 6,
backstream: 7
} as const;
export type RuleType = (typeof RuleType)[keyof typeof RuleType];
export const ruleName = (rule: RuleType): string => {
switch (rule) {
case 0:
return 'undefined';
case 1:
return 'Simple';
case 2:
return '+m/-n';
case 3:
return 'n by m';
case 4:
return 'UpDown';
case 5:
return 'Swedish';
case 6:
return 'Divide by n';
case 7:
return 'Backstream';
}
};
export const CounterType = {
undefined: 0,
simple: 1,
score: 2
} as const;
export type CounterType = (typeof CounterType)[keyof typeof CounterType];
export const counterType = (rule: RuleType): CounterType => {
switch (rule) {
case 2:
case 3:
case 6:
case 7:
return CounterType.score;
default:
return CounterType.simple;
}
};
ルールの名称と、ルール毎のカウンター種別もここで管理しています。
ボタンのラベルがsubmitになっているので、ボタンを押さないと数値設定の部分も反映されないように見えますが、
ルール種別以外のパラメーターはinputに直接バインドしているので実際にはsubmitボタンを押したルールのまま数値を変更する分にはボタンを押さなくても反映されています。
カウンター
CounterFrameコンポーネントと、その子コンポーネントとなるSimpleCounterコンポーネント/ScoreCounterコンポーネントとの組み合わせで構成されています。
ルール毎のレイアウト変更
$: counter = counterType($rule) === CounterType.score ? ScoreCounter : SimpleCounter;
<svelte:component this={counter} bind:counterParameter on:changed={pushUndoStack} />
rule.ts中のcouterType()
がカウンターの種別を判定してくれるので、これを元にカウンターコンポーネントを変数counter
に入れ、
テンプレートでは<svelte:component>
のthis
に指定することでSimpleCounterコンポーネント/ScoreCounterコンポーネントの切り替えをしています。
ルールが変更されるごとにコンポーネントを見直してほしいので、$:
でマークしてリアクティブにしています。
最初の段階ではテンプレートに直接#if
で分岐を書いていましたが、scriptにロジックを寄せられるので<svelte:component>
を使う方法に変更しました。
削除
カウンターの削除は親コンポーネントである+pageから要素を消す必要があるため、
カウンターでは削除ボタンのイベント(delete
)を発火し、前述のorder
(何番目のカウンターか?の情報)を持たせてcreateEventDispatcher
で伝播させています。
<CloseButton on:click={deleteClick} />
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function deleteClick() {
dispatch('delete', order);
}
<CounterFrame bind:this={counters[index]} order={index} on:delete={deleteCard} />
const deleteCard = (event: CustomEvent) => {
keys = keys.toSpliced(event.detail, 1);
};
受け取り側の+pageは受け取ったdeleteイベントからorder
を取り出し、カウンターを管理している配列keys
から要素を削除することでカウンターを削除しています。
keys
に追加したときと同じくkeys.splice()
ではなく、keys = keys.toSpliced()
で代入します。
アンドゥ
アンドゥとリセットはボタンを持っているCounterFrameコンポーネントにロジックを置いています。
アンドゥの処理ではまず、SimpleCounter/ScoreCounterコンポーネントで◯ボタンや✕ボタンが押されたときに変更イベントを発火し、親コンポーネントであるCounterFrameコンポーネントに伝播させます。
export let counterParameter: CounterParameters;
const dispatch = createEventDispatcher();
const changeScore = () => {
dispatch('changed');
};
// ◯ボタン押下時
const onCorrect = () => {
changeScore();
counterParameter.correct++;
};
// ✕ボタン押下時
const onIncorrect = () => {
changeScore();
//...
};
親コンポーネントであるCounterFrameはchangeScore
イベントを受け取る度にスタックundoStack
に現在の点数状況をpushします。
<svelte:component this={counter} bind:counterParameter on:changed={pushUndoStack} />
let counterParameter: CounterParameters = {
score: 0,
correct: 0,
incorrect: 0
};
let undoStack: Array<{ correct: number; incorrect: number; score: number }> = [];
const pushUndoStack = () => {
undoStack.push({
correct: counterParameter.correct,
incorrect: counterParameter.incorrect,
score: counterParameter.score
});
};
CounterParameters
はここのカウンターが取り扱う要素(マルの数、バツの数、点数)をまとめたTypeです。
これは作り始めた時点の設計の名残で、現在はこの3つのパラメーターはCounterFrameとSimpleCounter/ScoreCounter間でしかやり取りがないので、わざわざTypeにする意義はありません。
export type CounterParameters = {
correct: number;
incorrect: number;
score: number;
};
実際にアンドゥボタンが押されたときには、undoStack
からpopした値をcounterParameter
に設定することでアンドゥ動作を実現しています。
export const undo = () => {
const pop = undoStack.pop();
if (pop) {
counterParameter.correct = pop.correct;
counterParameter.incorrect = pop.incorrect;
counterParameter.score = pop.score;
}
};
リセット
export const reset = () => {
switch ($rule) {
case RuleType.by:
counterParameter.score = 0;
counterParameter.correct = 0;
counterParameter.incorrect = $nByMParameters.m;
break;
case RuleType.divide:
counterParameter.score = $inicialPoint;
counterParameter.correct = 0;
counterParameter.incorrect = 0;
break;
default:
counterParameter.score = 0;
counterParameter.correct = 0;
counterParameter.incorrect = 0;
}
undoStack = [];
};
リセットはcounterParameter
をルール毎の初期値に設定してundoStack
を空にしているだけです。
全体リセットのために関数をexportしています。
理想的にはアンドゥもリセットも、ロジックはSimpleCounter/ScoreCounterコンポーネントの中に持たせ、
CounterFrameコンポーネントから実行できるようにした方が良いと思います。
実際にそのように作ることも可能です。
現状そうしていない理由は、undoStack
が空の場合にアンドゥボタンをdisabledにしているのですが、
SimpleCounter/ScoreCounterコンポーネントにundoStack
を持たせた場合、undoStack
の変化を親コンポーネントに通知できないため、undoStack
のdisabledが解除できないためです。
アンドゥボタンをdisabledにする処理を諦めれば良いのですが、天秤にかけて現状はCounterFrameコンポーネントにロジックを持たせています。
リセットについては課題はありませんが、アンドゥボタンとの一貫性のためにCounterFrameコンポーネントに乗せています。
+layout.svelte
全体のレイアウトを決める+layout.svelteにはヘッダーに当たる部分のみを定義しています。
ここはルールによって表示が変わるように設定しています。
Simple
+1/-1
+2/-1
10 by 10
この表記はルールとパラメーターの設定によって変化します。
ルールもパラメーターもstoreなので、derived
として組み立てています。
ルール名自体はrule.ts
中のruleName()
から取得できるので、Simpleのようにパラメーターによって組み立てが必要ないルールはこの名称をそのまま採用します。
import { derived } from 'svelte/store';
const title = derived(
[rule, whenCorrect, whenIncorrect, nByMParameters, nByMGoalScore],
([$rule, $whenCorrect, $whenIncorrect, $nByMParameters, $nByMGoalScore]) => {
switch ($rule) {
case RuleType.mn:
return `+${$whenCorrect}/${$whenIncorrect}`;
case RuleType.by:
return `${$nByMParameters.n} by ${$nByMParameters.m} (${$nByMGoalScore})`;
case RuleType.backstream:
return $whenIncorrect == -1 ? 'Backstream -n' : `Backstream ${$whenIncorrect}n`;
default:
return ruleName($rule);
}
}
);
<div class="flex justify-center align-middle mb-4 p-1" style="background-color:lightsteelblue;">
<h1 class="text-3xl">{$title}</h1>
</div>
<slot />
デプロイ
デプロイ先としてCloudflare Pagesを採用しました。
Githubと連携して笑っちゃうくらい簡単にできたので詳細は割愛します。
本当はアダプターとして@sveltejs/adapter-cloudflare
を指定した方が良いみたいですが、
@sveltejs/adapter-auto
でも問題なく動きますし、リリースブランチ切るのも面倒くさかったので今のところしていません。
余談で、ちょっと古い記事だとCloudflare Pagesの自動デプロイは全ブランチで実施するか全ブランチで実施しないかしか設定できないという記述が見られますが、
現在は大きくプロダクションとプレビューで設定が分けられていて、プレビューの方はブランチ毎の設定もできるようになっていました。
所感
学習が容易
- 設計で紆余曲折ありながら、1週間程度で完成
- 公式のチュートリアルをやっていた期間を含めても2週間かかっていない
- チュートリアルとドキュメントが充実しているので、迷ってもチュートリアルとドキュメントの該当箇所を眺めれば大抵解決できた
- そもそも新しく覚えないといけないことが少ない
- 簡単な機能しか使っていないからかも。とはいえReactやVueに比べるととっつきやすさが段違い
storeの取り扱い
- 標準で状態管理が用意されているのは後発らしくて助かる
- writableなstoreの値を直接書き換えできるのはちょっと怖い
- 複数人でやっていたりある程度プロジェクトが大きくなってきた場合にはチュートリアルにあるようなカスタムストアとしてラップした方が良さそう
SvelteKitじゃなくSvelteで十分だった
- 最終的にシングルページでルーティング不要になったのでSvelteKitにする必要はなかった
- 構造を見た時点でおや?と思った人もいるかもしれない
- とはいえNextやNuxtと異なり、Svelte側が新規プロジェクトにはSvelteKitを推奨しているのでこれで良かったといえば良かったかも
- https://svelte.jp/docs/introduction#start-a-new-project
- 今後aboutとかアップデートノートとかでページ追加するかもしれないし
Svelteの勉強として作ったアプリですが、自分のほしいツールとして作った面も大きいので、
完成してない機能(全体リセット)や機能追加、Svelte自体のアップデートへの追従などして今後もメンテしていく予定です。
Discussion