はじめに
前々々々回、前々々回 、前々回 、前回 と、順調に自己満足してきましたが、そろそろ終焉が近づいてきました。安心してください。
さて、ただのバッチ処理的なスクリプトファイル程度しか作成しないエンジニアならいざ知らず、WEB アプリを作成するエンジニアたるもの Async(非同期)の理解が欠かせません。Async を理解していないと給料は絶対に上がりません。断言できます。
React の Async はよくできていますので、本シリーズ的には、Async は React 側で処理をして、Svelte では Sync のみ、という手も考えられるところですが、せっかくなので Svelte で、React や SolidJS のように Async できないか実験してみましょう!
(本手)Svelte での Async
Svelte5(現時点では v5.31.1) での Async は、正直イマイチです。
ただ、PR が既に上がっておりまして、かなり期待できそうな気がしております。
- Discussion - Asynchronous Svelte
- PR - feat: allow await in components
-
Svelte Play Ground ⇦ このように、Play Ground で
?version=pr-15844
とすればお試しができます。
(疑問手)React, SolidJS みたいな Async
上記 Discussion 内で、React や SolidJS のようなやり方は否定されていますので何ですが、本手がマージされるのはまだ先でしょうし、お勉強のために、React や SolidJS のような Async を自作してみました。
なお、useResource
, useTransition
, Suspense
の細かい仕様の解説については、今後要望が多ければ(かつ私が絶好調であれば)やりたいと思います。
サンプルコード1 - Suspense 的なもの
まずは、Suspense 的なものを利用したサンプルです。
サンプルコード1 の解説
さっそく App.svelte
にダイブしましょう。
import { useResource } from './signals.svelte';
import Suspense from './Suspense.svelte';
import {wait} from './utils.js';
useResource
は、 SolidJS の createResource
的な Hook? です。Hook と呼んでよいのかどうかはよくわかっておりませんが、ナウいのかなと思いとりあえずこの名前にしました。
Suspense
は、React や SolidJS の Suspense 的なものです。
wait
はよくある、デバッグ用のウェイト処理です。AbortController を使って中断できるようになっています。
wait
内では、AbortController 経由で中断された場合に (new DOMException('Wait aborted', 'AbortError')
として例外を投げています。どうもこれで fetch を中断した場合と同じ処理になるようなのでそうしています。
もし今後、Promise の中断処理を実装するときは、これを参考にしてください。そうすることで Promise の中断を統一して判断できるようになります。なお、./utils.js
内の説明はこれ以上しません。
let inputValue = '', queryValue = '';
inputValue
が input
の現在の入力値を保持するための変数です。React 脳なので果たしてこういうやり方でよいのか自信ありません。識者の方のコメントをお待ちしております。queryValue
は現在の検索文字列を保持するための変数です。
const [searchResult, { refetch, abort: abortSearch }] = useResource(
async (src, { signal }) => {
await wait(3000, signal);
const response = await fetch(`https://dummyjson.com/products/search?q=${src}`, { signal });
if (!response.ok) throw new Error(`dummyjson API error: ${response.status}`);
const data = await response.json();
return data.products;
},
{ initialStart: false }
);
useResource
を使用して、リアクティブな検索結果を作成します。
引数は SolidJS の createResource
と同じ感じにしましたので、1番目の引数に fetcher を渡すパターンと、1番目の引数に source、2 番目の引数に fetcher を渡すパターンと2種類あります。
options に initialStart: false を指定すると fetcher を自動実行せずに待機します。
fetcher の部分は JavaScript で Async の経験がある方であれば特に難しいところはないかと思います。
function handleSearch() {
queryValue = inputValue;
refetch(queryValue);
}
検索を実行するハンドラです。useResource
が返してきた関数を呼んでいるだけです。
なお、queryValue は表示専用ですので必須ではありません。
function handleAbort() {
abortSearch();
}
検索を中止するハンドラです。useResource
が返してきた関数を呼んでいるだけです。
<Suspense>
<!-- children -->
<input bind:value={inputValue} placeholder="Enter product keyword" />
<button onclick={handleSearch} disabled={!inputValue}>
Search
</button>
{#if queryValue}
{#if searchResult.error()}
<p style="color: red;">Error: {searchResult.error()?.message}</p>
{:else if searchResult()?.length}
<p>=== Search results for "{queryValue}" ===</p>
<ul>
{#each searchResult() as product}
<li>
{product.title} ({product.price} $)
</li>
{/each}
</ul>
{:else}
<p>No products found for "{queryValue}".</p>
{/if}
{/if}
<!-- fallback -->
{#snippet fallback()}
<button onclick={handleAbort}>
Abort Search
</button>
<p>Loading search results...</p>
{/snippet}
</Suspense>
React と同じように Suspense
コンポーネントの、children 部分に検索結果を表示します。検索結果の使い方についてはまあこんな感じかくらいに見ておいてください。エラー時の表示は <svelte:boundary>
に任せたほうが良いかもしれません。
fallback
prop には Snippet を渡します。fallback={ほにゃらら}
という通常の prop の書き方もできますが、このソースのように分けても書けるらしいです。分けて書いた方が見やすい気がします。
useResource の解説
signals.svelte.js
内の useResource
の解説をします。
export const resourceStates = [];
export const globalGroup = Symbol();
Promise の完了を待機するための変数です。この辺は今回はあまり詳しく説明しません。
export function useResource(arg1, arg2, arg3) {
useResource
にダイブしましょう。
const bag = {};
if (typeof arg2 === 'function') {
// source, fetcher, (options)
bag.source = arg1;
bag.fetcher = arg2;
bag.options = arg3 || {};
} else {
// fetcher, (options)
bag.fetcher = arg1;
bag.options = arg2 || {};
bag.source = undefined;
}
引数を bag
に詰め込んでいます。
引数の順序で処理を変えるためやや複雑になっていますが、その辺は本筋ではありませんの理解できなくて OK です。
if (bag.options.initialStart === undefined) {
bag.options.initialStart = true;
}
if (!bag.options.group) {
bag.options.group = globalGroup;
}
options.initialStrat が未指定の場合は true にして fetcher を自動実行します。
group
は、resource をグループ化して処理すためのものです。React や Svelte のように、Suspens の子供をグループ化できればよいのですが私の力量ではできそうにもないので、group
prop でグループ化できるようにしてみました。ただ、あまりテストしていませんで注意です。
この辺も今回はこれ以上解説しません。
const [value, setValue] = useSignal(undefined);
const [state, setState] = useSignal('unresolved');
const [error, setError] = useSignal(undefined);
value, state, error をリアクティブにして、コンポーネントの更新を起動するようにします。
なお、useSignal
は、すでにやった createSignal
と同じものです。useXXX
の方がナウいのかなと思いましたので変更しました。
async function load(signal) {
Promise を実行する処理です。今回は立ち入りません。
function abort() {
if (abortController) {
abortController.abort();
abortController = null;
}
}
中止処理です。
async function refetch(src) {
abort();
if (resourceStates[bag.options.group]?.amount <= 0 && state() !== 'pending') {
return;
}
if (bag.options.initialStart !== true) {
bag.options.initialStart = true;
return;
}
if (src !== undefined) {
bag.source = src;
}
const resolvedSource = typeof bag.source === 'function' ? bag.source() : bag.source;
abortController = new AbortController();
await load(resolvedSource, abortController.signal);
}
Promise の実行を手動で起動する処理です。
if (resourceStates[bag.options.group]?.amount <= 0 && state() !== 'pending') {
は必要かどうか疑わしいですが、Gemini 上級SE氏が付けろというので付けました。
initialStart が false の場合は何もしません。
src
が指定された場合はそれが新しい source
になります。
$effect(() => {
if (typeof bag.source === 'function') {
// Allow opt-in explicit dependency tracking for $effect
bag.source();
}
untrack(() => refetch());
});
初回起動時及び source
がリアクティブな変数だった場合に自動的に refetch
します。
source()
とすることで source
に依存する effect であることを伝えていますが、もっといい方法はないものでしょうか。
$effect(() => {
return () => {
if (abortController) {
abortController.abort();
}
resourceStates[bag.options.group].amount--;
};
});
後始末をしています。React
の useEffect
に近いですが、useEffect
で手動で指定する依存関係(2番目の引数)を自動的に感知してくれます。そのことを理解していないと Svelte5 でも onMount
を使いたくなってしまいますので注意してください。今回の例でいえば、この $effect
内ではリアクティブな変数を使用していませんので(というか何も処理がない)、 onMount
と同じくマウント後に一度呼ばれるだけで、再描画時には呼ばれません(といっても何も処理がないので仮に呼ばれても問題ないですが)。そして戻り値の関数が useEffect
と同じくクリーンアップ処理になります。
const rss = value;
rss.error = error;
rss.state = state;
rss
に useSignal の getter 関数である value を代入していますので rss は関数になります。
したがって value
の中身を得る場合は rss()
と関数呼び出しを行うことになります。
error, state は関数のメンバ変数ということになります。JavaScript では、関数も Object の一種ですのでこのようにメンバ変数を加えることができます。そして、これらにも useSignal の getter 関数を代入していますので、利用する場合はやはり rss.error()
, rss.state()
のようにします。このように、関数呼び出しを多用することで Svelte はリアクティブな機能を実現しています(多分)。その点は、SolidJS も同様であり(多分)、仮想 DOM を使わない手法では一般的なやり方なのかもしれません。
Suspense の解説
Suspense.svelte
を見てみましょう。
<script>
import { pendingPromisesSignal, resourceStates, globalGroup } from './signals.svelte';
let { fallback, children, group = globalGroup } = $props();
const pending = resourceStates[group].signal[0];
const isPending = $derived(pending() > 0);
</script>
{#if isPending}
{@render fallback?.()}
{:else}
{@render children?.()}
{/if}
これだけです。難しい部分は group 関係だけだと思いますので解説はしません。
以上で、Suspense を使用したサンプル1 の解説は終わりです。
続けて Suspense を利用しないサンプル0も見て、Suspense の良さを伝えたいと思います。
サンプル0の解説
useResource
は、Suspense がなくても使えます。
サンプル1 とサンプル0 の違いはテンプレートでの条件分岐だけです。
{#if searchResult.state() === 'pending'}
<button onclick={handleAbort}>
Abort Search
</button>
<p>Loading search results...</p>
{:else}
<input bind:value={inputValue} placeholder="Enter product keyword" />
<button onclick={handleSearch} disabled={!inputValue}>
Search
</button>
{#if queryValue}
{#if searchResult.error()}
<p style="color: red;">Error: {searchResult.error().message}</p>
{:else if searchResult()?.length}
<p>=== Search results for "{queryValue}" ===</p>
<ul>
{#each searchResult() as product}
<li>
{product.title} ({product.price} $)
</li>
{/each}
</ul>
{:else}
<p>No products found for "{queryValue}".</p>
{/if}
{/if}
{/if}
state を直接参照して分岐するため、みにくくなりがちです。
最後に
今回はここまでにします。次回は useTransition
の解説です。楽しみにせずにお待ちください。
こんなものを使う人はいないとは思いますが、気に入った方はご利用ください。ライセンスはいわゆる MIT ライセンスです。
Let’s have fun with React and Svelte!