はじめに
- フロントエンドは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>
$: let
は let = $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
<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 によると、
<script>
import Nested from './Nested.svelte';
</script>
<Nested answer={42} />
<Nested />
<script lang="ts">
let { answer = 'a mystery' } = $props();
</script>
と記述すれば良いことになっていますが、これは実際には次の(lint?)エラーを出します。(実行可能)
Type 'number' is not assignable to type 'string'.
代わりに次のように実装すれば
<script lang="ts">
let { answer = 'a mystery' }: { answer?: number | string } = $props();
</script>
エラーはなくなりますがこんな記述をしたくはなさそう。
そもそもこの程度でunion typeになるような実装を回避するべきな気はしています。
<script lang="ts">
let { answer }: { answer?: number } = $props();
</script>
<p>The answer is {answer || 'not known'}</p>
このやり方でも型定義が必要になりますが、デフォルトで表示したいもののためだけにunionになることは回避できます。
もちろん、より複雑な場合においては複数の型を入力したいこともあるでしょうし、その場合はより複雑な型定義をpropsに対して設定するのも良い気がしています。
例えばコールバック関数を受けるようにすることもできそうです。
<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を設定できます。
<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
<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}
<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のお題をそのままやるとこうなります
<script>
import Outer from './Outer.svelte';
function handle(message) {
alert(message);
}
</script>
<Outer eventHandler={handle} />
<script>
import Inner from './Inner.svelte';
let { callback } = $props();
</script>
<Inner {callback} />
<script>
let { callback } = $props();
function onclick() {
callback("Hello")
}
</script>
<button {onclick}>
Click to say hello
</button>
Typeをちゃんと伝播させてみる
↑でも良かったんですが、
- 孫コンポーネントで利用するコールバックの形がわからなくなる
-
callback
みたいに孫ではシンプルなattributeをShorthand attributesで上まで伝播するのはあとから混乱を招きそう
なので多少冗長に記述したほうが良いのではと思いました。
<script>
import Outer from './Outer.svelte';
function handle(message) {
alert(message);
}
</script>
<Outer innerProps={{ callback: handle }} />
<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} />
<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
effect
s in some places where you previously usedonMount
andafterUpdate
(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 usinguntrack
).
ということで、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>
この様に記述すると以下のような出力になりますが、
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 });
を用いて変化を追ってみると、
と、typing
をfilterできていないことがわかります。
一方 $state.frozen
を利用した場合は
となり、期待通りに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
を用いて解決可能でした 。
<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 />
<script>
import { store } from './stores.svelte';
function increment() {
store.counter++;
}
</script>
<button on:click={increment}>
+
</button>
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しても意図したようには利用できません。
export let counter = $state(0);
<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のものでやってみると
<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>
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 とかかしら