4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Svelte Tutorial を Svelte5 でやっていく

Last updated at Posted at 2024-04-29

はじめに

  • フロントエンドはAndroidとFlutterをひとかじりくらい
  • Svelte自体も初学者
  • そろそろSvelte5リリースされるっぽいしチュートリアルもそっちでやってみよう

まだSvelte5はプレビュー版。
参考: https://qiita.com/oekazuma/items/ab617096af10ad94356e

執筆現在では svelte@5.0.0-next.111 を利用しています。

書き換えが必要な章についてのみ言及します。

Part1: Basic Svelte

Reactivity

Declarations

<script>
	let count = $state(0);
	const doubled = $derived(count * 2);

	function increment() {
		count += 1;
	}
</script>

<button onclick={increment}>
	Clicked {count}
	{count === 1 ? 'time' : 'times'}
</button>

<span>Doubled: {doubled}</span>

$: letlet = $state let = $derived で置き換え。

ちなみに $state などSvelte5な機能を記述すると自動的に Runes mode となるので $: let の利用など非推奨のものはlintエラーが出るようになります。

Updating arrays and objects

<script>
	let numbers = $state([1, 2, 3, 4]);

	function addNumber() {
		numbers.push(numbers.length + 1);
	}

	let sum = $derived(
        numbers.reduce((total, currentNumber) => total + currentNumber, 0)
    );
</script>

<p>{numbers.join(' + ')} = {sum}</p>

<button on:click={addNumber}> Add a number </button>

ポイント: Svelte4の $: let と異なり、 $state はデフォルトで deeply reactive です。

Svelte 5 preview docsより:

Objects and arrays are made deeply reactive

Svelte 4 の LEARN.SVELTE.DEV より

Because Svelte's reactivity is triggered by assignments, using array methods like push and splice won't automatically cause updates. For example, clicking the 'Add a number' button doesn't currently do anything, even though we're calling numbers.push(...) inside addNumber.

そのため再代入なしでもpush/popなどをするだけでnumbersが置換されます。

個人的にはこれは大きな変更で、Svelte4までで「再代入されない限り更新されない」ことを利用したコードを書いていた場合、その代替を考える必要がありそうです。例えばasync/joinで多数の変更を待ち合わせ、最後に値を更新してユーザーに見せる、などの場合がありそうです。

そのような用途は $state.frozen を用いることで明確に宣言できそうです。更新もpushなどを利用せずに再代入のみによって行われます。
これはつまり、JavaScriptでは破壊的変更である push を用いても $: let 宣言した変数っぽいものは更新されないなぁという挙動の差異がなくなるということで、わかりやすくなったのではないでしょうか。

<script lang="ts">
	let numbers = $state.frozen([1, 2, 3, 4]); // 

	function addNumber() {
		numbers.push(numbers.length + 1);
	}
</script>

と記述すると次のエラーが見られます:

Property 'push' does not exist on type 'readonly number[]'.js(2339)

Declaring props

Nested.svelte
<script>
	let { answer } = $props();
</script>

<p>The answer is {answer}</p>

Svelte4 LEARN.SVELTE.DEV には次のように書かれています:

NOTE: Just like $:, this may feel a little weird at first. That's not how export normally works in JavaScript modules! Just roll with it for now — it'll soon become second nature.

この weird が解消されたということになりますね。
しかし export がないと(コード的に)どんなpropsが定義されているのかわかりにくくない?と思ったのですが、次のようにこれは杞憂でした。

<script>
	let { answer } = $props();
    let { answer2 } = $props();
</script>

Cannot use $props() more than once | svelte(duplicate-props-rune)

$props は1度しか利用できないのですね!
ならば常にファイルの先頭にpropsを記述するようにすれば見通しも良くなりそうです。

Default values

これはちょっと悩ましいです。
$props の説明 https://svelte-5-preview.vercel.app/docs/runes#$props によると、

Main.svelte
<script>
	import Nested from './Nested.svelte';
</script>

<Nested answer={42} />
<Nested />
Nested.svelte
<script lang="ts">
	let { answer = 'a mystery' } = $props();
</script>

と記述すれば良いことになっていますが、これは実際には次の(lint?)エラーを出します。(実行可能)

Type 'number' is not assignable to type 'string'.

代わりに次のように実装すれば

Nested.svelte
<script lang="ts">
	let { answer = 'a mystery' }: { answer?: number | string } = $props();
</script>

エラーはなくなりますがこんな記述をしたくはなさそう。
そもそもこの程度でunion typeになるような実装を回避するべきな気はしています。

Nested.svelte
<script lang="ts">
	let { answer }: { answer?: number } = $props();
</script>

<p>The answer is {answer || 'not known'}</p>

このやり方でも型定義が必要になりますが、デフォルトで表示したいもののためだけにunionになることは回避できます。

もちろん、より複雑な場合においては複数の型を入力したいこともあるでしょうし、その場合はより複雑な型定義をpropsに対して設定するのも良い気がしています。
例えばコールバック関数を受けるようにすることもできそうです。

Nested.svelte
<script lang="ts">
	type Props = {
		answer?: number;
		onClick?: () => void
	};
    // `onclick` はnullであってはならないので利用するコンポーネントでデフォルト値を導入する
	let { answer, onClick = () => {} }: Props = $props();
</script>

<p onclick={() => {onClick()}}>
	The answer is {answer || 'not known'}
</p>

これにより次のようにonClickを設定できます。

Main.svelte
<script>
	import Nested from './Nested.svelte';
</script>

<Nested answer={42} />
<Nested onClick={() => {alert("clicked!")}}/>

JS/TSの型定義は初心者なのでもっとイケてるやり方はあるかもしれません。

Events

Event modifiers

Event modifierはSvelte5からはなくなり、自前でやってねという形

<script>
	function once(fn) {
		return function (event) {
			if (fn) fn.call(this, event);
			fn = null;
		};
	}
</script>

<button onclick={once(() => alert('clicked'))}>
	Click me
</button>

とはいえ明瞭さはなくなった感はある よく使うものであればライブラリに純粋な関数の状態で良いから含めておけばよかったのではないだろうか…
おそらく外部ライブラリで代替になるものがあるor出てくるだろうと思った

Component events

Main.svelte
<script lang="ts">
	import Inner from './Inner.svelte';

	type Item = {
		id: string,
		message: string
	}
    
    let items: Array<Item> = $state([])

	function handleMessage(message: string) {
		console.log(message);
		items.push({id: message, message: message})
	}
</script>

<Inner sayHello={handleMessage} />

{#each items as item (item.id)}
	<p>{item.message}</p>
{/each}
Inner.svelte
<script lang="ts">
	let { sayHello }: { sayHello: (message: string) => void } = $props();
</script>

<button onclick={() => sayHello('Hello!')}> Click to say hello </button>

もとのサンプルのようにInner側でメッセージを設定するならこのようなコールバック関数になりそう。

FYI: $hostの利用
Deprecations の項にも書いてあるように、
https://svelte-5-preview.vercel.app/docs/deprecations#createeventdispatcher
custom elementであれば $host().dispatchEvent を利用することができそうです。

ざっと調べた感じ、もともとSvelteでWeb Componentsをやる場合イベント周りが独特だったようで(createEventDispatcherの都合で)、これが標準的な dispatchEvent を利用するようになることで改善したのではなかろうか?

Event forwarding

こちらもcreateEventDispatcherのdeprecationとともに糖衣構文もdeprecatedなので、単純にpropsにコールバックをふくめ、それをバケツリレーしていきます。

そのままやる場合

Svelteではpropsと変数が同名の場合は <Foo {bar} /> の形で(この場合は bar={bar} を)記述できるので、Tutorialのお題をそのままやるとこうなります

Main.svelte
<script>
	import Outer from './Outer.svelte';

	function handle(message) {
		alert(message);
	}
</script>

<Outer eventHandler={handle} />
Outer.svelte
<script>
	import Inner from './Inner.svelte';

    let { callback } = $props();
</script>

<Inner {callback} />
Inner.svelte
<script>
	let { callback } = $props();

    function onclick() {
        callback("Hello")
    }
</script>

<button {onclick}>
	Click to say hello
</button>
Typeをちゃんと伝播させてみる

↑でも良かったんですが、

  • 孫コンポーネントで利用するコールバックの形がわからなくなる
  • callback みたいに孫ではシンプルなattributeをShorthand attributesで上まで伝播するのはあとから混乱を招きそう

なので多少冗長に記述したほうが良いのではと思いました。

Main.svelte
<script>
	import Outer from './Outer.svelte';

	function handle(message) {
		alert(message);
	}
</script>

<Outer innerProps={{ callback: handle }} />
Outer.svelte
<script context="module" lang="ts">
	export type Props = {
		innerProps: InnerProps;
	};
</script>

<script lang="ts">
	import Inner, { type Props as InnerProps } from './Inner.svelte';

	let { innerProps }: Props = $props();
</script>

<Inner {...innerProps} />
Inner.svelte
<script context="module" lang="ts">
	export type Props = {
		callback: (message: string) => void;
	};
</script>

<script lang="ts">
	let { callback }: Props = $props();

	function onclick() {
		callback('Hello');
	}
</script>

<button {onclick}> Click to say hello </button>

ポイントは2点:

  • 下端のコンポーネントのProp型を伝播させることで、利用する箇所で困らない
  • <Inner {...innerProps} /> のようにPropsをspreadするので冗長性を抑え、コード変更にも耐えられる

Bindings

inputやtextareaのbindingについてはSvelte 4までと変わるところはなさそうですね。

Lifecycle

onMount

onMount() の代わりに $effect() を利用します。

<script lang="ts">
-	import { onMount } from 'svelte';
	import { paint } from './gradient.js';

-	onMount(() => {
+	$effect(() => {
		const canvas = document.querySelector('canvas');
		const context = canvas.getContext('2d');

		let frame = requestAnimationFrame(function loop(t) {
			frame = requestAnimationFrame(loop);
			paint(context, t);
		});

		return () => {
			cancelAnimationFrame(frame);
		};
	});
</script>

ちなみに同時に onDestroy() も deprecated っぽいんですが($effect() 内の return function で対応)、実際にdeprecatedと書かれているわけではないです。
onDestroy() はSSRでも機能するのですが $effect() はそうではないっぽいので。

追記:

Svelte 5 Preview documentによると

Additionally, you may prefer to use effects in some places where you previously used onMount and afterUpdate (the latter of which will be deprecated in Svelte 5). There are some differences between these APIs as $effect should not be used to compute reactive values and will be triggered each time a referenced reactive variable changes (unless using untrack).

ということで、onMount/onDestroy自体はdeprecatedではなさそうでした。
ただし特性は異なるものの $effect の利用が良さそうですね。

ついでのように untrack に付いて触れられてますが untrack も結構重要な関数ですね。 $effect$derived の中で別の $state をトリガーとして扱わないようにするための関数です。
https://svelte-5-preview.vercel.app/docs/imports#svelte-untrack

<script>
	import { untrack } from 'svelte';

	let a = $state(1);
	let b = $state(1);
	let c = $derived(a * untrack(() => b));
</script>

A: <input type="range" bind:value={a} min="1" max="10" />
B: <input type="range" bind:value={b} min="1" max="10" />
<p>{c}</p>

この様に記述すると以下のような出力になりますが、

image.png

Aを変更すると結果がすぐ更新されるものの、Bを変更しても結果は変更されません。
しかしBの値は実際には変わっているので再度Aを変更するとAとB両方の変更が適用されます。
言い換えると $derived のトリガーから b が外されたわけですね。

(しかし untrack(() => b) ってもうちょっとどうにかならんのかな)

beforeMount and afterMount

beforeMount afterMount は明確にdeprecatedです。

ちょっと長いのでscriptの部分だけ書きますと、

<script>
	import Eliza from 'elizabot';

	let div;
	let autoscroll = false;
	let comments = $state.frozen([]);

	const eliza = new Eliza();
	const pause = (ms) => new Promise((fulfil) => setTimeout(fulfil, ms));

	const typing = { author: 'eliza', text: '...' };

	$effect.pre(() => {
		comments;
		if (div) {
			const scrollableDistance = div.scrollHeight - div.offsetHeight;
			autoscroll = div.scrollTop > scrollableDistance - 20;
		}
	});

	$effect(() => {
		comments;
		if (autoscroll) {
			div.scrollTo(0, div.scrollHeight);
		}
	});

	async function handleKeydown(event) {
		if (event.key === 'Enter' && event.target.value) {
			const comment = {
				author: 'user',
				text: event.target.value
			};

			const reply = {
				author: 'eliza',
				text: eliza.transform(comment.text)
			};

			event.target.value = '';
			comments = [...comments, comment];

			await pause(200 * (1 + Math.random()));
			comments = [...comments, typing];

			await pause(500 * (1 + Math.random()));
			comments = [...comments, reply].filter((comment) => comment !== typing);
		}
	}
</script>

のようになるはずです。

今回触っていて意外だったのは $state の値への再代入では思ったように動かないということです。

let comments = $state([])

...

comments = [...comments, comment];
comments = [...comments, typing];
comments = [...comments, reply].filter((comment) => comment !== typing);

のようにして $inspect({ comments }); を用いて変化を追ってみると、
image.png
と、typing をfilterできていないことがわかります。

一方 $state.frozen を利用した場合は
image.png
となり、期待通りにfilterができています。

$state で値を保存する際にオブジェクトのコピーが行われていて参照先が変わってしまうため、単純な ===== での比較ではオブジェクトの同一性が false になってしまうのだと思われます。
例えばLodashで _.isEqual(obj1, obj2) を利用するなどすれば $sate 再代入とfilterでも行けそうですが、このような場合は素直に $state.frozen を利用するのが得策っぽいですね。

Stores

Writable stores

store はSvelte 5でも続投ですが、 $state で置換可能だという言及はブログや「試してみた」系ぶちらほら見られました。(redditとか
一方でSvelte 5 preview docsには

There are no plans to deprecate onMount or stores at the current time.

と記載があるだけで、いまいち要領がつかめませんでした。
最終的には以下のように $state を用いて解決可能でした 。

App.svelte
<script>
	import { store } from './stores.svelte.js';
	import Incrementer from './Incrementer.svelte';
	import Decrementer from './Decrementer.svelte';
	import Resetter from './Resetter.svelte';

	// (1)
	let count = $derived(store.counter);

	// (2)
	$effect(() => {
		console.log(store.counter);
	});
</script>

<h1>The count is {count}</h1>

<Incrementer />
<Decrementer />
<Resetter />
Incrementer.svelte
<script>
	import { store } from './stores.svelte';

	function increment() {
		store.counter++;
	}
</script>

<button on:click={increment}>
	+
</button>
stores.svelte.js
let counter = $state(0);

export const store = {
    get counter() { return counter },
    set counter(v) { counter = v },
};

(Decrementer, Resetterは省略)

App.svelte内にコメントを残した箇所について

もとのコードは次のようになっています

	count.subscribe((value) => {
		count_value = value;
	});

これはstoreのsubscribe機能を用いて通常ならば $: で行うような処理を行っています。が、 $: {} の利用はSvelte 5ではdeprecatedなのは前述のとおりで、同じようにsubscribeの利用も抑えられます。
新しい store.counter$state なので他の値を更新するときは(1)のように $derived、副作用を発生させたい場合は(2)のように $effect を利用できます。

ちなみにこの問題ではそもそも store.counter をそのまま利用できます。(store.counterがすでにreactive)

<h1>The count is {store.counter}</h1>

ちなみに stores.svelte.js で直接 $state な変数をexportしても意図したようには利用できません。

stores.svelte.js
export let counter = $state(0);
Decrementer.svelte
<script>
    import { counter } from './stores.svelte';

    function decrement() {
        counter--;  // Cannot assign to 'counter' because it is an import.  [js(2632)]
    }
</script>

これはimportしたものはconstantになるからですね。

ポイントをまとめると、

  • storeはrunes modeならstateで代用可能
  • runesは *.svelte *.svelte.js/ts ファイルでしか利用できない
    • ストア用のファイルを切り出すなら、 .js/ts の代わりに .svelte.js/ts にすれば良い
  • $state は直接exportできない
    • getter/setterを利用する

Readable stores

こちらも $state runeを用いて実装可能ですが、結構再発明っぽくなってしまいそうです。
将来的にはstoreもrunesで用意されるようになるのかもしれませんが、いくつか先のメジャーアップデートになると思われます。

公式Discordサーバーで紹介されていた $state による実装を紹介すると以下のようになります。
実装のplayground

function readable(initial, start) {
	let value = $state(initial);

	let listeners = 0;
	let stop;

	return {
		get current() {
			if ($effect.active()) {
				$effect(() => {
					if (listeners++ === 0) {
						stop = start((v) => {
							value = v;
						});
					}

					return () => {
						if (--listeners === 0) {
							stop?.();
						}
					}
				});
			}

			return value;
		}
	};
}

runes mode対応のライブラリなどが出回りそうな気がしますが、とりあえずSvelte 5の間は購読管理と初期化・終了時処理が必要になる複雑なstoreは従来通りstoreを用いると理解しておくと良さそう。

Derived stores

問題のベースがReadable storesなのでStore bindingsのものでやってみると

App.svelte
<script>
	import { states } from './stores.svelte.js';
</script>

<h1>{states.greeting}</h1>
<input bind:value={states.name} />

<button onclick={() => states.name += '!'}>
	Add exclamation mark!
</button>
stores.svelte.js
let name = $state('world');
const greeting = $derived(`Hello ${name}!`);

export const states = {
    get name() { return name },
    set name(value) { name = value },

    get greeting() { return greeting }
}

のように実現できます。単純なstoreは $state で代替可能で、 derived も他のSvelteファイルのように $derived で実現可能です。今回もgetter/setterを必要に応じてエクスポートする必要がある点だけ注意が必要です。


これで一通り Part1: Basic Svelte の章が終わりました… storeのあたりで結構手間取ってしまいましたが、Svelte5の新機能は概ね(SSR関連以外は)触れることができたと思います。

チュートリアルで触れられなくて大事そうなのは
svelte/reactivity とかかしら

4
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?