Svelte のルーン (Runes)
ここでは、ut.code(); Learn のプロジェクトであるストップウォッチを Svelte で実装することを考えます。
Step 0: 環境構築
CLI ツール sv
を使って、環境構築していきます。
ここでは、パッケージマネージャーとして bun を用いますが、npm でも問題ありません。
bunx sv create
今回は、
- テンプレート: SvelteKit minimal
- 型チェック: TypeScript
- アドオン: None
を選択します。
bun installbun dev
これで http://localhost:5173 を開いて “Welcome to SvelteKit” と表示されれば成功です。
このリポジトリのサンプルでは、 Pico CSS でスタイルを当てています。
1. $state
ルーン - 簡単なストップウォッチ
まずは、ページを開いたと同時に時間が経過していく、簡単なストップウォッチを作ってみましょう。src/routes/+page.svelte
を編集していきます。
$state
ルーンは、 Svelte でリアクティブな状態を宣言できるルーンです。
リアクティブとは、値を変更するとその変更が UI に反映されることをいいます。
もちろん、参照が変わらなくても内部状態が変われば反映されます。
<script lang="ts"> let timer = $state(0); setInterval(() => { timer += 1; }, 1000);</script>
<span> time: {timer}</span>
2. $derived
ルーン - 分と時間の表示
1. で作ったタイマーに、分と時間を表示してみましょう。
$derived
ルーンを使うと、リアクティブな変数に依存する変数を作成することができます。
マークアップ部分では、 $derived
ルーンを使わなくても自動でリアクティブになります。
<script lang="ts"> let timer = $state(0); setInterval(() => { timer += 1; }, 100); // 本来は 1000 にするべきだが、長過ぎるため 1/10 にしている
const seconds = $derived(timer % 60); const minutes = $derived(Math.floor((timer / 60) % 60)); const hours = $derived(Math.floor(minutes / 60));
function show(n: number) { return n.toString().padStart(2, "0"); }</script>
<span> {show(hours)}:{show(minutes)}:{show(seconds)}</span>
3. $effect
ルーン - スタート/ストップボタンの実装
次に、 Start / Stop ボタンを作ってみましょう。
$effect
ルーンは、コールバックで使用したリアクティブな変数を自動でトラックし、その変数が変更されるたびにコールバックを呼び出すルーンです。
$effect
ルーンを使って、タイマーが ON のときのみ setInterval
を動かしてみましょう。
$effect
のコールバックの返り値はクリーンアップ関数といい、コールバックが再実行される直前やコンポネントがアンマウントされたときに実行されます。
<script lang="ts"> let timer = $state(0); let enabled = $state(false); $effect(() => { if (enabled) { const timerId = setInterval(() => { timer += 1; }, 1000); return () => clearTimeout(timerId); } });</script>
<div> timer: {timer}</div><button onclick={() => (enabled = true)} disabled={enabled}> Start </button><button onclick={() => (enabled = false)} disabled={!enabled}> Stop </button>
AbortController
入力された値に対して、JSON Placeholder の todos から検索するアプリケーションを考えてみましょう。
<script lang="ts"> type Todo = { userId: number; id: number; title: string; completed: boolean; };
let length = $state<number | null>(null); let search = $state("");
$effect(() => { fetchTodos(search).then((todos) => { length = todos.length; }); });
async function fetchTodos(search: string) { const resp = await fetch("https://jsonplaceholder.typicode.com/todos"); const todos: Todo[] = await resp.json(); return todos.filter((todo) => todo.title.includes(search)); }</script>
<div>found results: {length}</div><input bind:value={search} />
何が問題でしょうか?
リクエストの解決にかかる時間にはばらつきがあるため、最後にリクエストを発行したものがそれ以前に発行したリクエストよりも早く終わってしまうことがあります。
そのため、前に入力した状態のレスポンスが最終的に残ってしまう可能性があります。
これを解決するため、search
を更新したときにリクエストをキャンセルしましょう。
AbortController
という組み込みオブジェクトを使います。
<script lang="ts"> type Todo = { userId: number; id: number; title: string; completed: boolean; };
let length = $state<number | null>(null); let search = $state("");
$effect(() => { const ctl = new AbortController(); fetchTodos(search, { signal: ctl.signal }) .then((todos) => { length = todos.length; }); return () => ctl.abort(); });
async function fetchTodos( search: string, options?: { signal?: AbortSignal }, ) { const resp = await fetch("https://jsonplaceholder.typicode.com/todos", { signal: options?.signal, }); const todos: Todo[] = await resp.json(); return todos.filter((todo) => todo.title.includes(search)); }</script>
<div>found results: {length}</div><input bind:value={search} />