※記事内容をSvelte5対応に更新しています。
以前、バックエンドエンジニアのためのVue.js、React、Angular入門という記事を作成していた間に、Svelte(スヴェルト)というフレームワークが開発されました。現在では三大フレームワークに次ぐ人気を誇っており、発祥国のイギリスを初めヨーロッパやアメリカでは多数のコミュニティが作られ、2023年からはAppleなどの大手企業も協賛しているようです。
Svelte5からrune(ルーン)という技術が導入され、明確に、リアクティブなデータとそうでないデータが明確に分別されるようになりました。ほかにもonイベント(ディバインディング、バブリング含め)の廃止、export letの廃止、リアクティブステートメント記号 $:
の廃止と機能刷新、更にディスパッチャも非推奨と、大きく様変わりしました。
ちなみにルーンとは詳述していきますが、$hoge
によって表記されるコンパイラ(いわばVueのコンパイラマクロやReactのフックみたいなもので、公式サイトではシンボルと定義しています)のことで、今までより堅牢性や拡張性は大幅に上昇した一方で、やや初心者には難しい部分が増えた(分割代入が必須の処理も多くなっている)気もします。またルーンについてですが、言ってしまえば従来の仕様を根本から刷新したものではなく、より見やすくするために導入され、将来的な機能拡張のために整備された技術です。
その前に…Svelteとはなにか?
Svelteとはイギリス人のRich Harrisという人物が、VueとReactに影響を受け、それらの欠点を補い、更に進化させたJSフレームワークで、今までのJSフレームワークやライブラリは、VueディレクティブやJSXなどDOMとは独立した部品を使ってリアクティブなデータを操作していましたが、Svelteの場合はDOMを生成する際に、そのような機能も自動で盛り込んでしまうことで、より高速化を図ったものです。では、これも上記入門に記述した、例の魔法を用いたたとえで説明すると
Svelteという魔力を持ったマテリアルを用いてDOMを生成する。リアクティブな操作はルーンで操る。
こういうものです。つまり、ReactのJSXのようにリアクティブな操作をさせるだけの部品をhtmlに埋め込んだり、Vueディレクティブのようにhtmlの一部分だけをリアクティブにさせるようなものではなく、SvelteというJSXに似た性質を持つ言語だけで一つのアプリケーションを作成してしまうわけです。また、Angularとも似ている部分はありますが、Angularと違って、データの制御部分もSvelteに埋め込んでしまえるので、非常に記述がスリムになります。
ですが、自由度が高すぎた反面、大掛かりなアプリケーションやプログラム開発には不適な部分も多数ありました。そこで、ルーンを導入したことで、よりシステマティックに開発、動作を可能にし、今後のさらなる機能拡張を睨んでいます。
Svelteの動作環境を作成
Svelte5では公式がVite使用を推奨しているので、この際Viteのコマンドから作成すると手っ取り早いです。
#npm create vite@latest
あとはプロジェクト名、使用言語(Svelteを選択)、スクリプトの種類などを質問してくるので、とりあえずTypeScriptを選択します。
引き続き、依存関係を持つライブラリのインストールや更新、さらに適宜、演習に必要な関連ライブラリをインストールします。
#npm i
#npm i svelte-routing svelte-store
#npm run dev
※ローカルサーバで接続したい場合は以下のように追記します。
export default defineConfig({
plugins: [svelte()],
server:{
host: true,
port: 3000, //ポートを変更したい場合
}
})
演習1:フォーム操作:テキスト文字を表示させる
バインディングの基礎の動きで、入力された文字を表示させる仕組みです。これをSvelteで記述してみます。
<script>
let mes = $state('');
</script>
<main>
<input type="text" bind:value={mes}>
<p>入力された文字{mes}</p>
</main>
イベントの記述はVueと似ていますが、変数定義やリテラルの部分はReactと似ています。動きを見るとbind:value={hoge}
が入力フォームの同期を取っており、mesに格納され、それをリテラルで返すという形です。
$stateルーン
$stateルーンは変数を同期対象(リアクティブ)にする、ルーンの基本です。また、Svelteはconstではなくletを使用します。なぜかというと、他のJSフレームワークのようにvalueプロパティにデータ枠を用意したり、セッタ指定によって値を更新したりするものではなく、ダイレクトに代入して値を更新していく手段を採っているからです。
let 変数 = $state('初期値')
演習2:プルダウンメニュー(ループ処理)
では今度はSvelteを用いてプルダウンメニューを作成してみます。同じようにループ処理が必要になるはずです。ちなみに、リアクティブが不要な変数はそのまま利用可能です。
<script>
let ary_data = [
{key:0,name: "React"},
{key:1,name: "Angular"},
{key:2,name: "Vue"},
{key:3,name: "Svelte"},
{key:4,name: "Solid"},
{key:5,name: "Lit"},
];
let sel = $state(''); //リアクティブにする場合は$state必須
</script>
<main>
<select bind:value={sel}>
<option value="">選択</option>
{#each ary_data as data}
<option value={data.name} >{data.name}</option>
{/each}
</select>
<p>選択された値:<span id="txt" >{ sel }</span></p>
</main>
これでプルダウン処理が実現しました。ではループ処理はどこかというと、以下に挙げる部分で、ここもいろいろなフレームワークのビュー部分に非常に似た書き方になっており(expressのejsっぽいです)、each文を埋め込む形になっています。
{#each array as val}
//中にループしたいDOM要素を記述
{/each}
これで囲んだ部分にループが実現されることになります。ちなみに、インデックスを付与したい場合は次のような記述になります。
<select bind:value={sel}>
<option value="">選択</option>
{#each ary_data as data,i}
<option value={data.name} >{i+1:data.name}</option>
{/each}
</select>
<p>選択された値:<span id="txt" >{ sel }</span></p>
※一意性制御のキー指定は不要です。
演習3:検索フォーム(値の連動)
では、検索フォームの連動もSvelteで実践してみます。
<script>
let word = $state(''); //検索ワード
let ary_data =[
{key: 1,name: "モスクワ",},
{key: 2,name: "サンクトペテルブルク"},
{key: 3,name: "エカテリンブルク"},
{key: 4,name: "ムンバイ"},
{key: 5,name: "ベンガルール"},
{key: 6,name: "コルカタ"},
{key: 7,name: "サンパウロ"},
{key: 8,name: "リオデジャネイロ"},
{key: 9,name: "ブラジリア"},
];
const filterData = $derived(ary_data.filter(item =>
item.name.indexOf(word) !== -1 && word != ''
));
const bindWord = (event)=>{
word = event.target.value;
word = word.replace(/[\u3041-\u3096]/g, (match)=>{
let chr = match.charCodeAt(0) + 0x60;
return String.fromCharCode(chr);
});
}
</script>
<main>
<input type="text" bind:value={word} oninput={bindWord} >
<p>検索結果{filterData.length}件</p>
{#if filterData.length > 0}
<ul>
{#each filterData as data}
<li>{data.name}</li>
{/each}
</ul>
{:else if word != ''}
<p>検索結果なし</p>
{/if}
</main>
これで同じように動きます。では、これから説明を加えていきますが、Svelteと他のフレームワークと働きが異なる点は、DOM生成時から既にデータの連動が始まっているということです。なので、変数wordは最初から未入力の状態で変数ary_dataを検知してフィルタリングをかけてしまうため、初期状態で検索結果を表示しないようにするには、変数wordが未入力であるという条件も付さないといけません。
リアクティビティとデータバインディング
Svelteでデータの同期を取りたい際には$: オブジェクト名というかなり固有の記述法を用いていましたが、Svelte5からは$derived
ルーンもしくは$effect
ルーンによって処理されます。今回はリアクティブな変数に代入処理を施すので、$derived
ルーンが妥当です($effect
ルーンはリアクティブな変数に対し連動処理を施す場合に用います)。
$derivedルーン
変数をリアクティブに宣言する$state
ルーンに対し、その変数のデータ更新をキャッチし、連動的に値を返すルーンです。$state
ルーンの次に使用頻度が高いルーンなのでぜひ仕組みを覚えておきましょう。
let word = $state(''); //検索ワード
//検索ワードの変化をキャッチし、連動して処理が実行される
const filterData = $derived(ary_data.filter(item =>
item.name.indexOf(word) !== -1 && word != ''
));
※計算式を代入できる$derived.by
ルーンもあります。
イベントハンドラ
フォームに対してイベントを実行するにはon:hogeと記述し、中にメソッドを記述していましたが、これもSvelte5からはonhogeとセミコロンを取っ払った形となります。今回は入力したひらがなをカタカナに変換するメソッドbindWordが実行され、引数はReactのようにevent.target.valueで取得しています。また、引数はbind:valueで指定した同期を取りたい値となるので、ひらがなで打ち込んでもカタカナで表示されるようになり、検索を円滑にこなすことができます。
if else
Svelteのif文はけっこう独特の記述法なので、法則を覚えてしまいましょう。
{#if filterData.length > 0 } //始まりは#、判定文は括弧で囲まない
//肯定時の処理
{:else if word != ''} //中間は:、またelse ifと必ず間を開ける
//否定時の処理
{/if} //終わりは/で締める。endなどではない
逆に覚えてしまえば、テンプレートファイルに埋め込むような感じなので他のフレームワークより処理が楽だと思います。
ただし、注意点として、変数はいわばグローバルスコープのような状態になっています。そのため、一時的に値を格納する際に、メソッド内に同名の変数を用いたりすると、その値が干渉するので注意が必要です。
演習4: データリストの追加、削除、修正(DOMの再生成)
では、CRUDの基本となるデータリストの追加、削除、修正についても記述したいと思います。
<script>
let ins_item = $state('');
let upd_item = $state('');
let i = $state(0);
let ary_data = $state([
{key: 1,name: "モスクワ"},
{key: 2,name: "サンクトペテルブルク"},
{key: 3,name: "エカテリンブルク"},
]);
//クリックイベント(新規作成)
const onclick = ()=>{
ary_data = ary_data.concat({ "key":i,"name": ins_item});
i = ary_data.length - 1;
}
//文字変更イベント
const oninput = (i)=>{
upd_item = i.target.value
}
const upd = (i,upd_item) =>{
ary_data[i] = { "key":i,"name": upd_item};
}
const del = i =>{
ary_data = [...ary_data.slice(0, i), ...ary_data.slice(i +1)];
i = Math.min(i, ary_data.length - 1);
}
</script>
<main>
<input type="text" bind:value={ins_item}>
<button type="button" {onclick} >新規</button>
<ul>
{#each ary_data as data,i }
<li>
<dl>
<dt>
<input type="text" value={data.name} {oninput}>
</dt>
<dd ><button type="button" onclick={()=>upd(i,upd_item)} >修正</button></dd>
<dd ><button type="button" onclick={()=>del(i)} >削除</button></dd>
</dl>
</li>
{/each}
</ul>
</main>
では、これを説明していきます。
データの登録
データを登録する場合は、テキストエリアの値をbind:valueで同期させて、それをonclickイベントでメソッドを実行し、あとはconcat関数を使って、データを新規挿入するだけです。
データの削除
データを削除する場合はイベントに引数が必要となりますが、ここで注意点がいくつかあります。まずはonclickメソッドの内部の関数をReactのように無名関数にして呼び出す必要があります。ダイレクトに記述するとシステム起動時に1回だけ勝手に起動されてしまいます(Reactのように無限ループには陥りません)。そして、メソッド部分はletで定義しておく必要があります。
あとは削除のプロセスですが、これもReactの関数コンポーネントのように分割代入を用います。
<script>
//前略
let del = i =>{
ary_data = [...ary_data.slice(0, i), ...ary_data.slice(i +1)]; //対象のインデックスだけ除いて分割代入(対象のインデックスまでのオブジェクト+対象のインデックスより後のオブジェクト)
i = Math.min(i, ary_data.length - 1); //インデックス番号の繰り下げ
}
//後略
</script>
<main>
<!-- 略 -->
<dd ><button type="button" onclick={()=>del(i)} >削除</button></dd>
<!-- 略 -->
</main>
データの修正
後はデータの修正で、これも削除と似た課程を経ることにはなるのですが、施さないといけない処理がいくつかあります。
//文字変更イベント
const oninput = (i)=>{
upd_item = i.target.value
}
</script>
<dt>
<input type="text" value={data.name} {oninput}>
</dt>
<dd ><button type="button" onclick={()=>upd(i,upd_item)} >修正</button></dd>
テキストボックスの値は一旦valueで出力されます(bind:valueにしないこと)が、その値が修正された場合に反映させないといけません。そのイベントハンドラがoninput
で、この中には**{i=>upd_item = i.target.value}**となっています。これが、対象のテキストボックスに再入力された値(i.target.value
)を変数upd_itemに代入する処理となっており、そこで代入された値が修正ボタンのイベントハンドラonclick内の引数upd_itemの引数に反映されることになります。
あとはメソッドで修正処理を施すだけですが、配列の値を差し替えるだけで対応できます。
let upd = (i,upd_item) =>{
ary_data[i] = { "key":i,"name": upd_item}; //対象のインデックスに対して、ダイレクトに代入するだけ
}
また、Svelte5からは新規挿入のように、引数のないイベントはダイレクトに記述できるようになります(リテラルで囲えるのはイベントのみなので、あまり使い所がないですがoninputの部分はすっきりします)
演習5:コンポーネント制御(電卓)
では、Svelteでもコンポーネントの親子階層化とそれぞれのデータの受け渡しをやってみます。これもSvelte5からは外部出力のexport let構文が廃止され、$props
ルーンで制御するようになっています。それと並行してイベントバインディングの構文(on:hoge)も廃止され、ダイレクトに、リテラルを指定することができるようになっています。
ちなみにSvelteにおいて、コンポーネントの命名指定はパスカルケース(先頭を大文字にする)のみ受け付けているようです。
<script>
import Child from "./child.svete"; //先頭は大文字にすること
</script>
<main>
<Child />
</main>
親コンポーネント
まずは親コンポーネントです(コールバック関数のonBindは任意の変数)。
<script>
import Setkey from './setkey.svelte'; //子コンポーネントのインポート
let pushkeys = [
[['7','7'],['8','8'],['9','9'],['div','÷']],
[['4','4'],['5','5'],['6','6'],['mul','×']],
[['1','1'],['2','2'],['3','3'],['sub','-']],
[['0','0'],['eq','='],['c','C'],['add','+']],
];
let str = $state('')
let sum = $state(0)
function onBind(typed,total){
str = str + typed
sum = total
}
</script>
<main>
<Setkey {pushkeys} {onBind} />
<div>
<p>打ち込んだ文字:{str}</p>
<p>合計:{sum}</p>
</div>
</main>
だいたい、今までのJSフレームワークと同じ書き方になっているのがわかると思います。ただ、後述しますがSvelteは子コンポーネントから親コンポーネントにデータを受け渡す場合でもエミット処理が不要なので、今回のようにお互いバインドさせる値が一致している場合は、データバインド用のコールバック関数一つを定義するだけでやりとりが可能になります。
子コンポーネント
続いて、子コンポーネントの記述となります。子コンポーネントから逆送する場合は$bindable
ルーンを使用して操作します。ちなみに$props
は基本、上から下へ流す決まりがあるので、bind:valueを逆送する場合は$bindable
ルーン必須です。
<script>
let {pushkeys,onBind } = $props();
const status = $state({
lnum: null, //被序数
cnum: 0, //序数
sign: "",
});
const getChar = (chr,str)=>{
let lnum = status.lnum
let cnum = status.cnum
let sign = status.sign
let sum = 0 //合計
if(chr.match(/[0-9]/g)!== null){
let num = parseInt(chr)
cnum = cnum * 10 + num //数値が打ち込まれるごとに桁をずらしていく
}else if(chr.match(/(c|eq)/g) == null){
if(lnum != null){
lnum = calc(sign,lnum,cnum)
}else{
if(chr == "sub"){
lnum = 0
}
lnum = cnum
}
sign = chr
cnum = 0
}else if( chr == "eq"){
console.log(sign,lnum,cnum)
lnum = calc(sign,lnum,cnum)
sum = lnum
}else{
lnum = null
cnum = 0
str = ''
}
status.lnum = lnum;
status.cnum = cnum;
status.sign = sign;
onBind(str,sum) //親コンポーネントへ値を送出する
}
function calc(mode,lnum,cnum){
switch(mode){
case "add": lnum = cnum + lnum
break;
case "sub": lnum = lnum - cnum
break;
case "mul": lnum = lnum * cnum
break;
case "div": lnum = lnum / cnum
break;
}
return lnum
}
</script>
<main>
{#each pushkeys as val}
<div>
{#each val as v}
<button class="square" onclick={()=>{getChar(v[0],v[1])}}>
{v[1]}
</button>
{/each}
</div>
{/each}
</main>
Svelte5からは$props
ルーンで他コンポーネントのすべてをやりとり可能となっています。
子コンポーネント
<script>
let {pushkeys,onBind } = $props();
</script>
※ルーティングのディスパッチャもこの$props
を使用します。
$bindable
$bindable
ルーンは子要素のイベントを伝播させる場合に必要となります。以下のように、結果の部分も子コンポーネントに移動してみます。その際、リアクティブ対象の変数はbind:valueとする必要があります。
<Setkeys {pushkeys} bind:str={str} {sum} />
<script>
let {str=$bindable(""),pushkeys,sum,...props } = $props();
</script>
<main>
<p>打ち込んだ文字 { str } </p>
<p>合計:{sum}</p>
</main>
このように記述すれば親コンポーネントの変数に対し、再代入処理を円滑に行えます(なくても一応動きますが、文字列などを代入した場合、一旦undefinedとなります)。
また、$bindable
で引き出した変数ですが、今回はvalueに限り子コンポーネントから親コンポーネントにイベントと変数の変化を伝播、更新することができるようになります。他の変数を同期対象にしたい場合は、親コンポーネントでbindディレクティブのキーを任意に指定(bind:hoge)し、子コンポーネントでhoge=$bindable()とキーと同値の変数を設定してから再代入処理を行うと、親コンポーネントにもしっかり値が返るようになります)。
演習6:ルーティング(買い物かご)
ではSvelteでもルーティングを用いてSPAを作っていきます。演習用アプリケーションの構成は以下のようになっています。
■src
- ■static
- ■css(デザイン制御用、表示は割愛)
- ■pages
- Products.vue //商品一覧(ここに商品を入れるボタンがある)
- Detail.vue //商品詳細
- Cart.vue //買い物かご(ここに商品差し戻し、購入のボタンがある)
- ShopContext.vue //オブジェクト情報の格納
- GlobalState.vue //親コンポーネント(ここからデータを受け渡す)
- Reducers.vue //共通の処理制御用
- router.js //ルーティング制御用
svelte-routingによるルーティングの仕組み
svelteでルーティングを行う場合、svelte-routingというライブラリが必須となります。
#npm i svelte-routing
そして、このライブラリを用いた上で、以下のようにタグを記述していきます。svelteでルーティングを記述する際の注意点はまずSPA全体をRouterというタグで囲む必要があるということ、それからリンクボタンはLinkタグを用い、対してビュー部分はRouteタグで表示する必要があります。また、それぞれsvelte-routingのメソッドになっているので、ライブラリ使用の宣言は必須となります。
<script>
import {Router, Link , Route } from "svelte-routing" //ここからRouter、Link、Routeメソッド使用宣言
import Products from "./pages/Products.svelte" //コンポーネントのパス
import Cart from "./pages/Cart.svelte" //コンポーネントのパス
import {context} from "./ShopContext.js";
</script>
<Router>
<ul class="main-navigation">
<nav>
<li><Link to="/products" class="nav">Articles({cnt_articles})</Link></li>
<li><Link to="/cart" class="nav">Cart({cnt_cart})</Link></li>
</nav>
</ul>
<main>
<Route path="detail/:id" let:params ><Detail {context} selid={params.id} /></Route>
<Route path=""><Products {context} {bindvalue} /></Route>
<Route path="cart"><Cart {context} {bindvalue} /></Route>
</main>
</Router>
リンクの記述
リンクの記述は以下のようになっています。Linkタグ内のtoプロパティが遷移先の名称となります。
<Link to="cart">ほげほげ</Link>
ビューアの記述
対する、ビューアの記述は以下のようになっています。pathはリンク先のパス名となります。また、同期データをコンポーネントに渡したい場合は以下のように記述します。そして、この同期を取っているオブジェクトに値を返すことで、データ同期をとることができます。
<Route path="cart"><Cart {context} {bindvalue} /></Route>
データと処理のやり取り(svelte-store)
Svelteでデータをやりとりする場合はsvelte-storeというライブラリ使用推奨です。データからストアオブジェクト作成、あとは更新メソッドを実行するだけで同期が取れます。
※別途インストールが必要なので、まだの場合はインストールしておいてください。
npm i svelte-store
データ格納用のcontextファイル
svelteでVueのようにデータ格納用のjsファイルを使用することも可能で、その場合は以下のように記述します。ここではデータ確保用のファイル、ShopContext.jsを作成しています。ポイントはwritableメソッドを用いて参照渡ししておくことです(vue3のreactiveメソッドっぽいです)。
import { writable } from "svelte/store"
let values = {
products:[
{ id: "p1", title: "花王 バブ ゆず", price: 60, stock: 10 },
{ id: "p2", title: "バスクリン きき湯", price: 798 , stock: 3 },
{ id: "p3", title: "アース 温素 琥珀の湯", price: 980, stock: 2 },
{ id: "p4", title: "白元アース いい湯旅立ちボトル", price: 398, stock: 6 },
{ id: "p5", title: "クラシエ 旅の宿", price: 598, stock: 7 }
],
cart: [],
articles: [],
money: 10000,
total: 0, //残額
}
export const context = writable(values) //writableメソッドでcontextを作成しておく
このスクリプトに対し、値をインポートしているのが以下の部分です。Svelte5からは$props
ルーンでやりとりするようになった反面、事前にプレフィックスを用いて渡すことができなくなりました。
<script>
import {Router, Link , Route } from "svelte-routing";
import {derived} from 'svelte/store';
import Products from "./Products.svelte";
import Cart from "./Cart.svelte";
import Detail from "./Detail.svelte";
import {context} from "./ShopContext.js";
import {shopReducer} from "./bindvalues.svelte";
let cnt_cart = $state(0)
let cnt_articles = $state(0)
function bindvalue(ev){
const mode = ev.mode //機能分岐
const dif = ev.dif //更新差分
let store = [];
context.subscribe((x)=> store = x) //ストアオブジェクトを監視対象とする
shopReducer(mode,dif,store)
context.update(() => store) //分割代入で更新する
}
function itemCounter(items){
return items.reduce((count,curItem)=>{
return count + curItem.quantity //買い物かごの個数
},0)
}
$effect(()=>{
cnt_cart = itemCounter($context.cart);
cnt_articles = itemCounter($context.articles);
})
</script>
<Router>
<ul class="main-navigation">
<nav>
<li><Link to="/products" class="nav">Articles({cnt_articles})</Link></li>
<li><Link to="/cart" class="nav">Cart({cnt_cart})</Link></li>
</nav>
</ul>
<main>
<Route path="detail/:id" let:params ><Detail {context} selid={params.id} /></Route>
<Route path=""><Products {context} {bindvalue} /></Route>
<Route path="cart"><Cart {context} {bindvalue} /></Route>
</main>
</Router>
ストアとイベントバブリング
他コンポーネントへイベント(bindVal)を伝達する手段、すなわちイベントバブリングはSvelte4以前、createDispatcherを用いていましたが、Svelte5からは非推奨となり、同じく$props
ルーンで制御可能となっています。
<script>
import {Router, Link , Route } from "svelte-routing"
import Products from "./pages/Products.svelte"
import Cart from "./pages/Cart.svelte"
import Detail from "./pages/Detail.svelte"
import {context} from "./pages/ShopContext.js"
import {shopReducer} from "./reducers.svelte"
function bindvalue(ev){
const mode = ev.mode //機能分岐
const dif = ev.dif //更新差分
let store = [];
context.subscribe((x)=> store = x) //ストアオブジェクトを監視対象とする
shopReducer(mode,dif,store)
context.update(() => store) //分割代入で更新する
}
</script>
<Router>
<ul class="main-navigation">
<nav>
<li><Link to="/products" class="nav">Articles({cnt_articles})</Link></li>
<li><Link to="/cart" class="nav">Cart({cnt_cart})</Link></li>
</nav>
</ul>
<main>
<Route path="detail/:id" let:params ><Detail {context} selid={params.id} /></Route>
<Route path=""><Products {context} {bindvalue} /></Route>
<Route path="cart"><Cart {context} {bindvalue} /></Route>
</main>
</Router>
$effectルーン
$effectルーン
は、ReactのuseEffectフックやVueの監視プロパティのようなものです。ここではストアオブジェクトが更新を検知し、個数をカウントするメソッドが実行されます。またSvelteの$effect
ルーンは読み込み直後の作動が起きないように制御されています。
$effect(()=>{
cnt_cart = itemCounter($context.cart);
cnt_articles = itemCounter($context.articles);
})
ストアオブジェクトを展開する
ストアオブジェクトを展開する際にはプレフィックス($)が必須です。このプレフィックスによってリアクティビティの監視対象から外れる代わりに、値を自在に展開できるようになります。
カート画面
<script>
const {bindvalue,context } = $props() //bindvalueはイベントバブリング用のコールバック関数
</script>
<main class="cart" >
{#if $context.cart.length <= 0}<p>No Item in the Cart!</p>{/if}
<ul>
{#each $context.cart as cartItem,i}
<li>
<div>
<strong>{ cartItem.title }</strong> - { cartItem.price }円
({ cartItem.quantity })
</div>
<div>
<button onclick={()=>bindvalue({mode:"remove",dif:cartItem.id})}>買い物かごから戻す(1個ずつ)</button>
</div>
</li>
{/each}
</ul>
<h3>合計: {$context.total}円</h3>
<h3>残高: {$context.money}円</h3>
{#if $context.money >0 && $context.total >= 0}
<button onclick={()=>bindvalue({mode:"buy",dif:null})}>購入</button>
{/if}
</main>
<style>
.cart {
width: 50rem;
max-width: 90%;
margin: 2rem auto;
}
.cart p {
text-align: center;
}
.cart ul {
list-style: none;
margin: 0;
padding: 0;
}
.cart li {
padding: 1rem;
margin: 1rem 0;
border: 1px solid #00179b;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
商品一覧
<script>
import { Link } from "svelte-routing"
const {bindvalue,context } = $props()
</script>
<main class="products">
<ul>
{#each $context.products as product,i}
<li>
<Link to="detail?id={product.id}" class="li_product">
<div >
<strong>{ product.title }</strong> - { product.price }円
{#if product.stock > 0} 【残り{ product.stock }個】 {/if}
</div>
</Link>
<div>
<button onclick={()=>bindvalue({mode:"add",dif:product.id})}>かごに入れる</button>
</div>
</li>
{/each}
</ul>
</main>
<style>
.products {
width: 50rem;
max-width: 90%;
margin: 2rem auto;
}
.products ul {
list-style: none;
margin: 0;
padding: 0;
}
.products li {
padding: 1rem;
margin: 1rem 0;
border: 1px solid #00179b;
display: flex;
justify-content: space-between;
align-items: center;
}
.li_product{
color: black;
text-decoration: none;
}
</style>
ストアオブジェクトの処理
ストアオブジェクトが再代入された段階で、ストアオブジェクトの更新準備が完了します。ただし、これはProxy上に予約しているだけなので、この時点ではまだ更新処理が完了していません。前述したようにupdateの実行が必要です。
<script context="module">
let dataBind = []
let storage = [] //更新前
let stat = [] //更新差分
let state = [] //更新後
const addProductToCart = (selid,storage)=>{
let cartIndex = ''
const stat = storage
//買い物かごの調整
const updatedCart = stat.cart;
const updatedItemIndex = updatedCart.findIndex(
item => item.id === selid
);
if (updatedItemIndex < 0) {
let product = storage.products.find((item)=> item.id === selid)
updatedCart.push({ ...product, quantity: 1,stock: 0 });
cartIndex = updatedCart.length -1 //カートの最後尾
} else {
const updatedItem = { ...updatedCart[updatedItemIndex] }
updatedItem.quantity++;
updatedCart[updatedItemIndex] = updatedItem;
cartIndex = updatedItemIndex //加算対象のインデックス
}
stat.cart = updatedCart
//商品在庫の調整
const updatedProducts = stat.products //商品情報
const productid = updatedCart[cartIndex].id //在庫減算対象の商品
const productIndex = updatedProducts.findIndex(
p => productid === p.id
)
const tmpProduct = { ...updatedProducts[productIndex] }
tmpProduct.stock-- //在庫の減算
updatedProducts[productIndex] = tmpProduct
stat.products = updatedProducts
//合計金額の調整
const total = stat.total
const sum = getSummary(updatedCart,total)
stat.total = sum
}
//カートから商品の返却
const removeProductFromCart = (selid,storage)=>{
const stat = storage
const updatedCart = stat.cart;
const updatedItemIndex = updatedCart.findIndex(item => item.id === selid);
const updatedItem = updatedCart[updatedItemIndex]
updatedItem.quantity--
if (updatedItem.quantity <= 0) {
updatedCart.splice(updatedItemIndex, 1);
} else {
updatedCart[updatedItemIndex] = updatedItem;
}
stat.cart = updatedCart
//商品在庫の調整
const updatedProducts = stat.products //商品情報
const productIndex = updatedProducts.findIndex(
p => p.id === selid
)
const tmpProduct = { ...updatedProducts[productIndex] }
tmpProduct.stock++ //在庫の加算
updatedProducts[productIndex] = tmpProduct
stat.products = updatedProducts
//合計金額の調整
let sum = getSummary(updatedCart,stat.total)
stat.total = sum
}
//購入手続き
const buyIt = (storage)=>{
const stat = storage
const articles = storage.articles
let updatedArticles = articles //所持品
let tmp_cart = stat.cart
for( let cart of tmp_cart){
let articlesIndex = articles.findIndex(
a => a.id === cart.id
)
if (articlesIndex < 0) {
updatedArticles.push(cart);
} else {
const tmpArticles = articles[articlesIndex]
tmpArticles.quantity++;
updatedArticles[articlesIndex] = tmpArticles;
}
}
stat.articles = updatedArticles
let summary = getSummary(tmp_cart,stat.total)
let rest = stat.money - summary
stat.money = rest
tmp_cart.splice(0)
summary = 0
stat.cart = tmp_cart
stat.total = summary
}
//合計金額の算出
const getSummary = (cart,total)=>{
const sum = cart.reduce((total,{price = 0,quantity})=> total + price * quantity,0)
return sum
}
function shopReducer(mode,dif,store){
switch(mode){
case "add": addProductToCart(dif,store)
break
case "remove": return removeProductFromCart(dif,store)
break
case "buy": return buyIt(dif,store)
break
}
}
export {shopReducer}
</script>
詳細ページを作成(パラメータのやりとり)
ここがかなり曲者でした。というのもsvelte-routingだけを用いたパラメータのやりとりに関しては、ほとんど情報が見つからなかったからで、試行錯誤の末、公式マニュアルに記載されている通りに記述して以下の方法でうまくいきました。
というのも、Vue、React、Angularは遷移先のコンポーネントからparamやurlといった情報を取得するというやり方でしたが、Svelteはというと、ルーティングファイルから取得したいパラメータを予め設定するというかなり特殊な方法を用いるからです。
大事なポイントはlet:paramsというプロパティを付与しておくことと、それを用いて値を受け取りたいコンポーネントにパラメータを送っておくことです。具体的にはselid="{params.id}"が今回、詳細ページに渡したい商品idとなります。逆にいえば、pathプロパティはあくまでパスの記述にとどまっており、svelte-routingの場合、そこからパスパラメータを取得できません(svelte-kitを用いれば可能らしい)。
<Router>
<main>
<Route path="detail/:id" let:params ><Detail {context} selid={params.id} /></Route>
<Route path=""><Products {context} {bindvalue} /></Route>
<Route path="cart"><Cart {context} {bindvalue} /></Route>
</main>
</Router>
パスパラメータの埋め込みもコンポーネントタグ内のlet:paramsは必要ですが、export letが使用不可になっているので、これも$props
ルーンで代用します。また$props
ルーンは前述したように、事前にリアクティビティを解除したストアクラスを送出することができなくなっています。
<script>
const { context,selid } = $props()
let sel = $context.products.find((item)=>selid == item.id)
</script>
<main>
<ul>
<li>{sel.title}</li>
</ul>
<button on:click={()=>history.back()}>戻る</button>
</main>
ちなみに、Linkタグは以下のようになっており、変数をはめこんでいるだけです。
<Link to="detail/{product.id}" class="li_product">
<div >
<strong>{ product.title }</strong> - { product.price }円
{#if product.stock > 0} 【残り{ product.stock }個】 {/if}
</div>
</Link>
クエリパラメータから受け渡しする
クエリパラメータから受け渡しする場合はあまり難しく考える必要ありません。JavaScriptのURLSeachParamsを活用すれば、簡単に取得できます。
<script>
export let Detail = undefined
const urlParams = new URLSearchParams(window.location.search)
const selid = urlParams.get('id')//getパラメータの取得
export let dataBind = []
let context = dataBind //データの受け取り
console.log(selid)
let sel = context.products.find((item)=>selid === item.id)
</script>
<main>
<ul>
<li>{sel.title}</li>
</ul>
<button on:click={()=>history.back()}>戻る</button>
</main>
また、戻るボタンはJavaScriptのhistory.backをSvelteのリテラル内で記述すれば、問題ないみたいです。リンク先の方も、ただ変数を埋め込むだけです。
<Link to="detail?id={product.id}" class="li_product">
演習7:スタイル制御・外部IO(写真検索システム)
では、Svelteでもスタイル制御、外部IOの演習として、演習3の検索システムをアレンジし、結果に対していいねボタンを実装してみます。
まずは、他と同じように、事前にFont Awesomeをインストールしておきましょう。それに際しても色々な情報が錯綜していますが、有効だったのはsvelte-faを利用する方法でした。
※事前にインストールが必要です。
npm i @fontawesome/free-solid-svg-icons
npm i svelte-fa
<script>
import intersection from 'lodash/intersection'
import { onMount } from 'svelte';
import Fa from 'svelte-fa'
import { faHeart } from '@fortawesome/free-solid-svg-icons'
let cities = $state([])
let states = $state([])
onMount( async ()=> {
$: fetch('./json/city.json').then(res=>res.json()).then(data =>{cities = data})
$: fetch('./json/state.json').then(res=>res.json()).then(data =>{states = data})
})
let countries = [
{ab:"US",name:"United States"},
{ab:"JP",name:"Japan"},
{ab:"CN",name:"China"},
];
let word = $state('');
let sel_country = $state('')
let sel_area = $state('')
let hit_cities_by_area = $state([])
const bindWord = (event)=>{
word = event.target.value;
word = word.replace(/[\u3041-\u3096]/g, function(match) {
let chr = match.charCodeAt(0) + 0x60;
return String.fromCharCode(chr);
});
}
const clear = ()=>{
word = ''
sel_area = ''
hits = []
}
/*バインド処理*/
//国から該当するエリアを絞り込み
const opt_areas = $derived(states.filter((item,key)=>{
return item.country === sel_country
}))
//エリアから該当する都市を絞り込み
const hit_cities_by_state = $derived(cities.filter((item,key)=>{
return item.state === sel_area
}))
//文字検索一致
const hit_cities_by_word = $derived(cities.filter((elem,idx)=>{
return elem.name.indexOf(word) !== -1 && word != ''
}))
//処理を伴う連動制御
let hits = $derived.by(()=>{
const len_state = hit_cities_by_state.length
const len_word = hit_cities_by_word.length
let results = []
if(len_state > 0 && len_word > 0 ){
results = intersection(hit_cities_by_state, hit_cities_by_word)
}else if(len_state > 0){
results = hit_cities_by_state
}else if(len_word > 0){
results = hit_cities_by_word
}else{
results = []
}
return results
});
const colorSet = (id)=>{
let selecteditem = cities.find(function(item,idx){
return item.id === id
})
cities.filter((item,idx)=>{
if(item.id === id){
if(selecteditem.act !== 'red'){
selecteditem.act = 'red'
}else{
selecteditem.act = ''
}
cities[idx] = selecteditem //値の代入(同期がとれる)
}
})
}
</script>
<main>
<label for="sel1"> 国の選択 </label>
<select id="sel1" bind:value={sel_country} onchange={()=> sel_area = ''}>
<option value="">-- 国名を選択 --</option>
{#each countries as country}
<option value={country.ab}>{country.name}</option>
{/each}
</select>
<label for="sel2"> エリアの選択</label>
<select id="sel2" bind:value={sel_area} >
<option value=''>-- エリアを選択 --</option>
{#each opt_areas as area}
<option value={area.code}>{area.name}</option>
{/each}
</select>
<br/>
<h4>検索文字を入力してください</h4>
<input type="text" bind:value={word} oninput={ bindWord} />
<button type="button" onclick={ clear }>clear</button>
<div>ヒット数:{hits.length}件</div>
<ul class="ul_datas">
{#each hits as city}
<li class="li_data">
<label class="lb_hit" >
{city.name}
<label class="fa_list" onclick={()=> colorSet(city.id)}>
<Fa style="color: {city.act}" icon={faHeart} />
</label>
</label>
<br>
<img src="./img/{city.state}_{city.name.toLowerCase()}.jpg" />
</li>
{/each}
</ul>
</main>
<style>
*{
margin: 0;
padding: 0;
}
.ul_datas{
width: 800px;
display: block;
margin: 0px;
}
.li_data{
width: 198px;
height: 198px;
display: inline-block;
vertical-align: top;
border: 1px brown solid;
border-radius: 0 0 10px 0;
margin: 5px;
}
li img{
max-width: 90%;
margin-left: 5%;
margin-right: 5%;
}
.lb_hit{
background-color: black;
color: white;
font-weight: bold;
padding: 1px 0 1px 2px;
width: 196px;
height: 22px;
display: block;
}
</style>
画像パスを読み込む
画像を読み込むのは全く難しくなく、publicフォルダに任意の画像を用意しておき、そこにパスを通すだけです。またSvelteは変数を埋め込む場合も、他のjsのように${hoge}
とする必要がなく、{hoge}
とリテラルをそのまま埋め込むことができます。
<img src="img/{city.src}" />
スタイルを動的に制御する方法
Svelteで動的にCSSのスタイルを制御する方法は色々ありますが、一番単純な方法はstyleタグの変数を埋め込み、その変数をバインディングさせるやり方です。このcity.actには"red"というカラー指定の値が出力されたり、初期化されたりするので、それに合わせ、アイコンの色もリアクティブに変化します。
<label class="fa_list" on:click={()=> colorSet(city.id)}>
<Fa style="color: {city.act}" icon={faHeart} />
</label>
注意点
メソッドの中で制御するオブジェクトは、CRUDの修正処理での説明にあったように、値を代入して処理するようにしてください。この方法を用いないとリアルタイムなスタイル変更を実行できません。
※ストアオブジェクトを作成する方法もあります。
const colorSet = (id)=>{
let selecteditem = cities.find(function(item,idx){
return item.id === id
})
cities.filter(function(item,idx){
if(item.id === id){
if(selecteditem.act !== 'red'){
selecteditem.act = 'red'
}else{
selecteditem.act = ''
}
cities[idx] = selecteditem //値の代入(同期がとれる)
}
})
}
逆に言えば、変数citiesの同期をとるだけで、検索結果hitsの同期も取れ、いいねアイコンのステータスを維持することができます。これはSvelteの仕様によるもので、オブジェクトhitsのループで取得しているのは変数citiesから参照しているオブジェクトの値なので、リアクティビティを維持しているからです。
$derived.by
連動処理において計算処理を施すために$derived.by
ルーンを用います($effect
ルーンだと無限ループの警告が出ます)。こちらは監視された変数を検知し、そこから計算結果を返すもので、ReactのuseMemoやuseCallbackに近い働きをします。また$derived
や$derived.by
は再代入できないので、変数指定は各種ルーンの出力結果に対して指定します。
//処理を伴う連動制御
let hits = $derived.by(()=>{
const len_state = hit_cities_by_state.length
const len_word = hit_cities_by_word.length
let results = []
if(len_state > 0 && len_word > 0 ){
results = intersection(hit_cities_by_state, hit_cities_by_word)
}else if(len_state > 0){
results = hit_cities_by_state
}else if(len_word > 0){
results = hit_cities_by_word
}else{
results = []
}
return results
});
lodashを使用する場合
今回のプログラムでは論理積を求めるためにlodashを使用していますが、Svelteでlodashを使用する場合は、予めimport文で呼び出しておいて、スクリプト内ではメソッドだけを記述しないといけません。
import intersection from 'lodash.intersection'
//中略
hits = intersection(hit_cities_by_state, hit_cities_by_word) //intersectionはlodashで用意された論理積を求める関数
連動プルダウンを作成する際の注意
連動プルダウンを作成する場合、連動したプルダウンの値を逐一、初期化するための無名関数呼び出しが必要になります。これを行わないと、United States→Califolniaと検索した後、初期化した後は、一旦エリア名も初期化されるのですが、もう一度United Statesを選択すると、検索に用いたエリア名が元に再生(sel_areaの値が元通りになる)されてしまいます(これはSvelteの仕様となっており、それを解決させる方法が公式チュートリアルに記載されています)。
<label> 国の選択 </label>
<select id="sel1" bind:value={sel_country} on:change={()=> sel_area = ''}>
<option value="">-- 国名を選択 --</option>
{#each countries as country}
<option value={country.ab}>{country.name}</option>
{/each}
</select>
<label> エリアの選択</label>
<select id="sel2" bind:value={sel_area} >
<option value=''>-- エリアを選択 --</option>
{#each opt_areas as area}
<option value={area.code}>{area.name}</option>
{/each}
</select>
この部分のonchange={()=> sel_area = ''}
が鍵となっており、親のプルダウンにonchange
イベントを付与し無名関数で、連動プルダウンの選択値を初期化する必要があります。
jsonファイルをロードする
jsonファイルを起動直後にロードするには、次のようにfetch
を用いた記述方法が有効のようです(asyncとawaitを敢えて使う必要はなさそうです)。また、jsonファイルはpublicフォルダ配下に置いておきましょう。注意点は事前にオブジェクトを変数定義しておく必要があります。
大きく解決のヒントになったQiita内の記事
let cities = []
let states = []
$: fetch('./json/city.json').then(res=>res.json()).then(data =>{cities = data})
$: fetch('./json/state.json').then(res=>res.json()).then(data =>{states = data})
ライフサイクルでonMountを用いる
スクリプトタグ直下だとVueのsetupメソッドのようにDOM生成前に処理が実行されるため、まれにjsonがロードされない場合があります。その場合はDOM生成後に処理を実行させるonMountを用いると確実です。svelteのライフサイクルは一度外部から使用宣言してから用います。なお、ライフサイクルはいわばVueのライフサイクルフック(onMounted)とほぼ同一のものだと認識すればいいでしょう。
import { onMount } from 'svelte' //onMountライフサイクルの使用
let cities = []
let states = []
onMount( async ()=> {
$: await fetch('./json/city.json').then(res=>res.json()).then(data =>{cities = data})
$: await fetch('./json/state.json').then(res=>res.json()).then(data =>{states = data})
})
演習8:TypeScript(TODOアプリ)
では、SvelteでもTypeScriptの演習に入っていきたいと思います。その前にSvelteでTypeScriptを使う準備が必要です。既存のプロジェクトを対応させるよりは、新規にプロジェクト用のディレクトリを作成した方がいいでしょう。
ちなみにTypeScriptに際して、Svelteならではの癖があり、他のJSフレームワークならばtsファイルを使用していくのですが、Svelteに限っては以下のように利用した方が無理なく構築できます。
<script lang="ts">
/*これで中身はTypeScriptとなる*/
</script>
なぜ、こうするかというとsvelteはデータの堅牢性や制御機能を守るために独自の制限がかなり仕込まれているからで、下手にtsファイルを利用しようとすると、エラーに執拗なぐらいに引っかかるからです。
※そもそもSvelteの方向性が脱TypeScriptを標榜しており(同時期に登場したSolidJSと理念が真逆)、ストアオブジェクトによるデータ管理を推奨しています。
ファイル構成
TODOアプリのファイル構成(srcディレクトリ配下)は以下のようになっています。また、アプリは以下のページを元にしています(元記事はVueで作成)。
■components
- Todos.svelte //todoリストの親コンポーネント
- Todoitem.svelete //各todoリスト制御の子コンポーネント
■modules
- todo_module.svelte //データの操作
■stores
- todo_store.svelte //ストアオブジェクト
■types
- types_todo.svelte //パラメータ・インターフェースの格納
■views
- Todo.svelte //todoのトップコンポーネント
- AddTodo.svelte //todoの新規作成
- EditTodo.svelte //todoの修正
- DetailTodo.svelte //todoの詳細(元サイトにはない)
App.svelte //親コンポーネント
main.ts
ルートコンポーネントの制御
Svelteにとって一番重要な役割を担うコンポーネントです。前述したように、Svelteではトップコンポーネントにルーティング情報を記述するだけでなく、ストアオブジェクトの制御もルートコンポーネントで実行することも多いからで、かなり盛りだくさんの記述となっています。
<script lang="ts">
import {setContext} from "svelte";
import {Router, Route,navigate } from "svelte-routing";
import TodoList from "./TodoList.svelte";
import AddTodo from "./AddTodo.svelte";
import EditTodo from "./EditTodo.svelte";
import DetailTodo from "./DetailTodo.svelte";
import {storage} from "./TodoStore.svelte";
import {todoReducer} from "./TodoReducer.svelte";
//ストアオブジェクトの検知と更新
const bind = (ev)=>{
let store = [];
storage.subscribe((x)=> store = x );//監視開始
todoReducer(ev.mode,store.todos,ev.dif); //アクション別に処理を分岐
storage.update(()=> store ); //ストアオブジェクトを更新
}
setContext('todos',storage); contextを伝播
</script>
<Router>
<Route path="" ><TodoList {bind} /></Route>
<Route path="add"><AddTodo {bind} /></Route>
<Route path="edit/:id" let:params><EditTodo id={params.id} {bind} /></Route>
<Route path="detail/:id" let:params><DetailTodo {storage} id={params.id} /></Route>
</Router>
ストアオブジェクト
ストアオブジェクトは以下のようになっています。軽くおさらいですが、writableメソッドを通すことで、データはストアオブジェクトとなり、それぞれ監視、更新、展開などができます。ここも敢えてsvelteファイルにしていますが、データをエクスポートできるのはモジュールにした場合のみです。
<script context="module">
import {writable} from "svelte/store"
let Storage = {
todos:[]
}
export const storage = writable(Storage) //書き込み可能のストアオブジェクトにする
</script>
context
演習6ではコンポーネントも少なかったのですが、今回はコンポーネントが多いので、その場合はcontextを作成して、データ処理を行っていきます。
setContext
ここでsetContextという見慣れないメソッドが登場します。これはReactのuseContextやVue、Angularのinjectによく似た働きをするメソッドで、任意のコンポーネントにデータを送出するためのものです。記述の式は以下のようになります。
setContext('任意のキー',任意のデータ)
ここで注意しなければいけないのは、更新対象のストアオブジェクトを送りたい場合は、このタイミングでプレフィックスを用いて送出しないようにしてください。プレフィックスを用いて送出すると同一画面で更新処理が行われても、更新を検知できなくなります。プレフィックスを利用するのは、受け取り先からです。
setContext('hoge',$fuga) //このようにすると遷移先でリアクティビティを喪失する。
TODO一覧制御
TODO一覧画面では、先程送出したストアオブジェクトをループ処理にかけて、リストを作成します。ここでgetContextによってストアオブジェクトを受け取り、プレフィックスを付与してループしています(プレフィックスを付与しないとストアクラスのままなのでデータを利用できません)
<script lang="ts">
import { getContext } from "svelte"
import { Link } from "svelte-routing"
import Todoitem from "./TodoItem.svelte"
const {bind} = $props();
const storage = getContext('todos')
</script>
<main>
<h2>TODO一覧</h2>
<Link to="add"><button>新規作成</button></Link>
{#each $storage.todos as todo,i}
<Todoitem { todo } {bind}/>
{/each}
</main>
各TODOの制御
個別のTODOの制御はこのようにしています。イベント部分は次に説明します。
<script lang="ts">
import { Link } from "svelte-routing"
const { todo,bind} = $props(); //トップコンポーネントから
</script>
<main>
<div class="card">
<div>
<Link to="detail/{todo.id}">{todo.title}</Link>
</div>
<div class="action">
<Link to="edit/{todo.id}"><button>修正</button></Link>
<button onclick={()=>bind({mode:"del",dif:todo.id})} >削除</button>
</div>
</div>
</main>
contextとイベントの流れ
ディスパッチャ非推奨の流れを受けてContext操作系のメソッドと$props
ルーンとの使い分けが重要となってきます。SvelteのSetContextはReactのようにイベントを受け渡すことができないので、イベントバブリングには$props
ルーンが必要となってきます。したがって、前者はcontextデータを、後者はイベントを伝達しています。
<script lang="ts">
import { Link } from "svelte-routing"
const { todo,bind} = $props();
</script>
<main>
<div class="card">
<div>
<Link to="detail/{todo.id}">{todo.title}</Link>
</div>
<div class="action">
<Link to="edit/{todo.id}"><button>修正</button></Link>
<button onclick={()=>bind({mode:"del",dif:todo.id})} >削除</button>
</div>
</div>
</main>
登録処理
登録処理の説明に入っていきます。
また、ここからTypeScriptについても説明を入れていきますが、前述したようにSvelteのTypeScriptはかなり癖があり、たとえばtypeをインポートする場合はこのように記述しないとエラーになります。
import type { 使用したいtypeやinterface } from '任意の型定義ファイル'
新規作成の場合は事前に型を定義することで、記述が非常にシンプルになります。ここではDataという型を事前に定義しているお陰で、入力後の制御に対してはData型で定義した変数dataを回収するだけで差分データを回収できます。このフォーム処理のシンプルさは他のJSフレームワークよりずっと優れていると感じます。
ルーン適用の注意点として、差分を代入する際にはオブジェクトを分割代入
する点です。これを施さないとイベントバブリングで変更した値を転送できません。
<script lang="ts">
import { navigate } from "svelte-routing"
import type { Data } from "../types/types_todo"
const {bind } = $props();
const date = new Date()
const id = Number(date.getTime())
//フォームデータを定義する
let data:Data = $state({
id: id,
title: '',
description: '',
str_status: 'waiting',
});
function onclick(){
const dif = {...data}; //必ず分割代入すること
bind({mode:"add",dif:dif});
navigate('/');
}
</script>
<main>
<h2>TODOの作成</h2>
<div>
<label for="id"> id</label>
<input type="text" id="title" name="title" value={data.id} readonly />
</div>
<div>
<label for="title"> タイトル</label>
<input type="text" id="title" name="title" bind:value={data.title} />
</div>
<div>
<label form="desicription">内容</label>
<textarea id="description" name="description" bind:value={data.description} />
</div>
<div>
<label form="str_status">状態</label>
<select id="status" name="str_status" bind:value={data.str_status} >
<option value="waiting">waiting</option>
<option value="working">working</option>
<option value="completed">completed</option>
<option value="pending">pending</option>
</select>
</div>
<button {onclick}>作成する</button>
<button onclick={()=> navigate("/")} >戻る</button>
</main>
型定義ファイルについて
型定義のファイルは以下のようにしています。他のJSフレームワークと違い、あくまでsvelteファイルにしておくのがポイントです。
<script lang="ts">
const type Status = 'waiting'|'working'|'completed'|'pending'
export type Data = {
id: number,
title: string,
description: string,
str_status: Status,
}
export type Params = Pick<Data,'title'|'description'|'str_status'>
export interface Todos{
todos: Data[]
}
</script>
修正処理
修正の場合は以下のような記述となります。そして、この修正処理に対してもSvelteの恩恵を受けることができます。なぜかというとSvelteは前述した通り、同一コンポーネント内ならば変数は全てグローバルスコープとなっているためで、以下のような記述でも、変更をしっかりと把握してくれるからです。
具体的には差分を表す変数difにはdataオブジェクト内の値がフォームのvalueに紐づいているため、そこが変更されたタイミングでdifの値も自動更新されていきます。したがって更新の際には差分difと元のidだけを回収し以下のように分割代入しておけば問題ありません。
ただし、データ修正の場合は、差分を格納するオブジェクトに対しても、事前に$state
ルーンによるリアクティブ化が必要になります。また、変更後の値は同様に分割代入必須です。
<script lang="ts">
import { getContext } from "svelte"
import { navigate } from "svelte-routing"
import type { Data,Params } from "../types/types_todo"
const {bind,id} = $props();
const storage = getContext('todos');
let data = $state();
data = $storage.todos.find((item)=> item.id === Number(id))
let dif:Params = $derived({
title: data.title,
description: data.description,
str_status: data.str_status,
});
function onclick(){
const updated = {...dif,id} //同様に分割代入
bind({mode:"upd",dif:updated});
navigate('/');
}
</script>
<main>
<h2>TODOの修正</h2>
<div>
<label for="title"> タイトル</label>
<input type="text" id="title" name="title" bind:value={data.title} />
</div>
<div>
<label for="description">内容</label>
<textarea id="description" name="description" bind:value={data.description} />
</div>
<div>
<label for="str_status">状態</label>
<select id="status" name="str_status" bind:value={data.str_status} >
<option value="waiting">waiting</option>
<option value="working">working</option>
<option value="completed">completed</option>
<option value="pending">pending</option>
</select>
</div>
<button {onclick}>更新する</button>
<button onclick={()=> navigate("/")} >戻る</button>
</main>
削除処理
削除処理はイベントバブリングを経由して遡ります(Todoitem→Todolist→GlobalComponent)。
<div class="action">
<Link to="edit/{todo.id}"><button>修正</button></Link>
<button onclick={()=>bind({mode:"del",dif:todo.id})} >削除</button>
</div>
このbindがイベントバブリングの経由先となっていますが、もっとスマートに書けないか情報を回収中です。
<main>
<h2>TODO一覧</h2>
<Link to="add"><button>新規作成</button></Link>
{#each $storage.todos as todo,i}
<Todoitem {todo} {bind}/>
{/each}
</main>
差分処理
では差分データを取得したところで、具体的な処理部分を見ていきます。処理はモジュールによって制御されており、dispatchによって処理の分岐と差分データを取得しているので、それを制御していきます。
ちなみにモジュール上ではストアオブジェクトの検知、更新はできないので、あくまで差分データの処理だけです。
<script context="module" lang="ts">
import type {Data, Todos } from '../types/types_todo.svelte'
//データ登録
const addTodo = (todos:Todos,dif:Data)=>{
todos.push(dif)
}
//データ更新
const updTodo = (todos:Todos,dif:Data)=>{
const idx = todos.findIndex((item)=>item.id === dif.id)
todos[idx] = {...todos[idx],...dif}
}
//データ削除
const delTodo = (todos:Todos,id:number)=>{
const idx = todos.findIndex((item)=>item.id === id)
todos.splice(idx,1)
}
export {addTodo,updTodo,delTodo}
export const todoReducer =(mode:string,store,dif)=>{
let todos = store.todos
switch(mode){
case "add": addTodo(todos,dif)
break
case "upd": updTodo(todos,dif)
break
case "del": delTodo(todos,dif)
break
}
}
</script>
ここで更新された値が以下の部分で処理されるようになっています。また、別ページでは1つのコンポーネントに複数のイベントが混じっていましたのでデータに処理分岐用の名称を入力していましたが、今回のように1つのコンポーネントに単一のイベントの場合は第一引数に分岐情報を記述しておけば、event.typeプロパティからadd、upd、delといった分岐処理の名称を取得することができます。
<script lang="ts">
//中略
const bind = (ev)=>{
let store = [] //監視対象オブジェクト
let state = [] //更新対象オブジェクト
const mode = ev.type //処理分岐の名称を取得
const dif = ev.detail //更新の差分
storage.subscribe((x)=> store = x ) //監視開始
todoReducer(mode,store,dif) //各種処理
storage.update(()=> store)
}
</script>
<Router>
<Route path="" ><Todo {bind} /></Route>
<Route path="add"><AddTodo {bind} /></Route>
<Route path="edit/:id" let:params><EditTodo id="{params.id}" {bind} /></Route>
<Route path="detail/:id" let:params><DetailTodo {storage} id="{params.id}" /></Route>
</Router>
詳細画面
詳細画面はそこまで難しくないでしょう。ただし、$props
ルーンに機能変更になった影響で、事前のリテラルにおいてプレフィックス付与ができなくなったようです(検証中ですが、リアクティブが解除された変数は転送できません)。したがって、展開時にプレフィックスを付与しています。
<script lang="ts">
import { getContext } from "svelte"
import { navigate } from "svelte-routing"
const {storage,id} = $props();
let data = $state();
data = $storage.todos.find((item)=> Number(item.id) === Number(id))
</script>
<main>
{#if Number(id) !== Number(data.id)}
<div>ID:{data.id}のTODOが見つかりませんでした</div>
{:else}
<table>
<thead>
<tr>
<th>タイトル</th>
<th>説明</th>
<th>ステータス</th>
</tr>
</thead>
<tbody>
<tr>
<td>{data.title}</td>
<td>{data.description}</td>
<td>{data.str_status}</td>
</tr>
</tbody>
</table>
{/if}
<button onclick={()=> navigate("/")} >戻る</button>
</main>