20
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSフレームワークSvelteでデータ処理、まとめ版(Svelte5対応α版)

Last updated at Posted at 2021-07-02

※演習6以降、特にルーティングにおける記事を修正しました(ストアとディスパッチャ※を用いたものに変更しました。リンク先とプログラムは刷新しています)。また、演習9にてSvelte5(rune)適用で書き換えています

以前、バックエンドエンジニアのためのVue.js、React、Angular入門という記事を作成Svelte(スヴェルト)というフレームワークも三大フレームワークに次ぐ人気を、発祥国のイギリスを初めヨーロッパやアメリカでは多数のコミュニティが作られ、2023年からはAppleなど大手企業も協賛しているようです。

そこで、ここでは同じテーマのデータ操作に対してSvelteではどう記述するのかを記していきます。8章ではTypeScriptの説明をしています。また、6章8章ではストアとディスパッチャも使用しています。

Svelte5から大きく記述ルールが変わりましたが、当記事は演習9以外、Svelte4時代のもので紹介しています。

ちなみにSvelte5からruneという概念が導入され、明確に、リアクティブなデータとそうでないデータの分別がなされるようになりました。ほかにもonイベント(ディバインディング、バブリング含め)の廃止、export letの廃止、リアクティブステートメント記号 $:の廃止と機能刷新、更にディスパッチャも非推奨になったので、記事を刷新予定です(Svelte5を知りたい人は演習9から閲覧して下さい)。

その前に…Svelteとはなにか?

Svelteとはイギリス人のRich Harrisという人物が、VueとReactに影響を受け、それらの欠点を補い、更に進化させたJSフレームワークで、今までのJSフレームワークやライブラリは、VueディレクティブやJSXなどDOMとは独立した部品を使ってリアクティブなデータを操作していましたが、Svelteの場合はDOMを生成する際に、そのような機能も自動で盛り込んでしまうことで、より高速化を図ったものです。では、これも上記入門に記述した、例の魔法を用いたたとえで説明すると

Svelteという魔力を持ったマテリアルを用いてDOMを生成する。リアクティブな操作はSvelteが既に持っている

こういうものです。つまり、ReactのJSXのようにリアクティブな操作をさせるだけの部品をhtmlに埋め込んだり、Vueディレクティブのようにhtmlの一部分だけをリアクティブにさせるようなものではなく、SvelteというJSXに似た性質を持つ言語だけで一つのアプリケーションを作成してしまうわけです。また、Angularとも似ている部分はありますが、Angularと違って、データの制御部分もSvelteに埋め込んでしまえるので、非常に記述がスリムになります。

Svelteの動作環境を作成

Svelteの操作環境作成は公式サイトのトップページにあります。今回は仮想環境内のcentOS7.4にてテスト用プロジェクトを作成してみました。

※npm i はnpm install の省略形です

#npx degit sveltejs/template 任意のプロジェクト
#cd 任意のプロジェクト
#npm i

これでnpmをインストールしてから、ローカルサーバに接続させるためpackage.jsonから以下のように追記します。

package.json
 "scripts": {
    "build": "rollup -c",
    "dev": "HOST=0.0.0.0 rollup -c -w",
    "start": "sirv public"
  }
}

これで、Webサーバを再起動してから

#npm run dev

で起動することができます。なお、Svelteのプロジェクトは以下の構造となっています。ちなみにポートは5000番がデフォルトです。

■任意のプロジェクト
    -■ node_modules
    -■ public
    -■ scripts
    -■ src
        - App.svelte
        - main.js
    - package.json

主なフォルダとファイルを抜粋してみました。そして、main.jsは以下のようになっています。

main.js
import App from './App.svelte';

const app = new App({
	target: document.body,
	props: {
		name: ''
	}
});

export default app;

演習1:フォーム操作:テキスト文字を表示させる

バインディングの基礎の動きで、入力された文字を表示させる仕組みです。これをSvelteで記述してみます。

Svelte-lesson1.svelte
<script>
	let mes = '';
</script>

<main>
	<input type="text" bind:value={mes}>
	<p>入力された文字{mes}</p>
</main>

驚くことにこれで終わりです。イベントの記述の仕方はVue.jsと似ていますが、変数定義やリテラルの部分はReactと似ています。動きを見るとbind:value={hoge}が入力フォームの同期を取っており、mesに格納され、それをリテラルで返すという形です。

演習2:プルダウンメニュー(ループ処理)

では今度はSvelteを用いてプルダウンメニューを作成してみます。同じようにループ処理が必要になるはずです。

Svelte-lesson2.svelte
<script>
        let ary_data = [
            {key:0,name: "cakePHP"},
            {key:1,name: "Laravel"},
            {key:2,name: "CodeIgniter"},
            {key:3,name: "Symfony"},
            {key:4,name: "Zend Framework"},
            {key:5,name: "Yii"},
        ];
	let sel; //これを記述しないとselがundefinedになる
</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文を埋め込む形になっています。

svelte
	  {#each array as val}
            //中にループしたいDOM要素を記述
	  {/each}

これで囲んだ部分にループが実現されることになります。ちなみに、インデックスを付与したい場合は次のような記述になります。

svelte
        <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で実践してみます。

Svelte-lesson3.svelte
<script>
	let word = ''; //検索ワード
        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: "ブラジリア"},
        ];
	$: filterData = ary_data.filter(item =>
		item.name.indexOf(word) !== -1 && word != ''
	);
				
	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);
		});
	}
</script>

<main>
	<input type="text" bind:value={word} on:input={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でデータの同期を取りたい際には $: オブジェクト名というかなり固有の記述法を用います。$:hogeはSvelteではreactive statementといい、Vueでいう算出プロパティのような働きをさせる命令であり、この$:の後に代入した変数がすぐデータ連動の対象となります。なので、逆に注意しなければいけないのは変数wordもコンパイル直後に反応してしまうので、上述の判定文を入れておかないと初期状態に変数ary_dataの値が全部反応することになります。

イベントハンドラ

フォームに対してイベントを実行するにはon:xxxxxと記述し、中にメソッドを記述します(引数は不要)。今回は入力したひらがなをカタカナに変換するメソッドbindWordが実行され、引数はReactのようにevent.target.valueで取得しています。また、引数はbind:valueで指定した同期を取りたい値となるので、ひらがなで打ち込んでもカタカナで表示されるようになり、検索を円滑にこなすことができます。

※Svelte5からはonXxxxxと変更になりました(後述するディスパッチャのデータ送出プロパティと同じ記述のためでしょう)。

if else

Svelteのif文はけっこう独特の記述法なので、法則を覚えてしまいましょう。

html
{#if filterData.length > 0 } //始まりは#、判定文は括弧で囲まない
//肯定時の処理
{:else if word != ''} //中間は:、またelse ifと必ず間を開ける
//否定時の処理
{/if} //終わりは/で締める。endなどではない

逆に覚えてしまえば、テンプレートファイルに埋め込むような感じなので他のフレームワークより処理が楽だと思います。

データの連動

データの連動は以下の部分です。テキストフォームの検索文字に応じて、一覧から検索結果を返します。

	$: filterData = ary_data.filter(item =>
		item.name.indexOf(word) !== -1 && word != ''
	);

注意点として、変数はいわばグローバルスコープのような状態になっています。そのため、一時的に値を格納する際に、メソッド内に同名の変数を用いたりすると、その値が干渉するので注意が必要です。

演習4: データリストの追加、削除、修正(DOMの再生成)

では、CRUDの基本となるデータリストの追加、削除、修正についても記述したいと思います。

Svelte-lesson4.svelte
<script>
	let ins_item = '';
	let upd_item = '';
	let i = 0;
	let ary_data =[
            {key: 1,name: "モスクワ"},
            {key: 2,name: "サンクトペテルブルク"},
            {key: 3,name: "エカテリンブルク"},
   ];

	function ins() {
		ary_data = ary_data.concat({ "key":i,"name": ins_item});
		i = ary_data.length - 1;
	}

	let upd = (i,upd_item) =>{
		ary_data[i] = { "key":i,"name": upd_item};
	}

	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>
  <input type="text" bind:value={ins_item}>
        <button type="button" on:click={ins}>新規</button>
        <ul>
            {#each ary_data as data,i }
	    <li>
		<dl>
		    <dt>
		        <input type="text" value={data.name} on:input={i=>upd_item = i.target.value}>
		    </dt>
		    <dd ><button type="button" on:click={()=>upd(i,upd_item)} >修正</button></dd>
		    <dd ><button type="button" on:click={()=>del(i)} >削除</button></dd>
	        </dl>
            </li>
	    {/each}
       </ul>
</main>

では、これを説明していきます。

データの登録

データを登録する場合は、テキストエリアの値をbind:valueで同期させて、それをon:clickイベントでメソッドを実行し、あとはconcat関数を使って、データを新規挿入するだけです。

データの削除

データを削除する場合はイベントに引数が必要となりますが、ここで注意点がいくつかあります。まずはon:clickメソッドの内部の関数をReactのように無名関数にして呼び出す必要があります。ダイレクトに記述するとシステム起動時に1回だけ勝手に起動されてしまいます(Reactのように無限ループには陥りませんが)。そして、メソッド部分はletで定義しておく必要があります。

あとは削除のプロセスですが、これもReactの関数コンポーネントのように分割代入を用います。

html
<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" on:click={()=>del(i)} >削除</button></dd>
    <!-- 略 -->
</main>

データの修正

後はデータの修正で、これも削除と似た課程を経ることにはなるのですが、施さないといけない処理がいくつかあります。

html
<dt>
    <input type="text" value={data.name} on:input={i=>upd_item = i.target.value}>
</dt>
<dd ><button type="button" on:click={()=>upd(i,upd_item)} >修正</button></dd>

テキストボックスの値は一旦valueで出力されます(bind:valueにしないこと)が、その値が修正された場合に反映させないといけません。そのイベントハンドラがon:inputで、この中には**{i=>upd_item = i.target.value}**となっています。これが、対象のテキストボックスに再入力された値(i.target.value)を変数upd_itemに代入する処理となっており、そこで代入された値が修正ボタンのイベントハンドラon:click内の引数upd_itemの引数に反映されることになります。

あとはメソッドで修正処理を施すだけですが、配列の値を差し替えるだけで対応できます。

html
    let upd = (i,upd_item) =>{
        ary_data[i] = { "key":i,"name": upd_item}; //対象のインデックスに対して、ダイレクトに代入するだけ
    }

演習5:コンポーネント制御(電卓)

では、Svelteでもコンポーネントの親子階層化とそれぞれのデータの受け渡しをやってみます。

まずは親コンポーネントです。

Svelte-lesson6.svelte
<script>
   import  Setkey from './Setkey.svelte'; //子コンポーネントのインポート
  let vals = [
        [['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 data = {
        lnum: null,
        cnum: 0 ,
        sum: 0 ,
        str: '',
        sign: '',           
  }
</script>

<div>
		{#each vals as val,i}
		<div>
			{#each val as v,i}
			<Setkey bind:v={v} bind:dataBind={data} />
			{/each}
		</div>
		{/each}
		<p>打ち込んだ文字:{data.str}</p>
		<p>合計:{data.sum}</p>
</div>

だいたい、今までのJSフレームワークと同じ書き方になっているのがわかると思います。ただ、後述しますがSvelteは子コンポーネントから親コンポーネントにデータを受け渡す場合でもエミット処理が不要なので、今回のようにお互いバインドさせる値が一致している場合は、データバインド用のコールバック関数一つを定義するだけでやりとりが可能になります。

bind:dataBind={data} //これ一つで親から子、子から親の双方向のデータのやりとりを実現してくれる

続いて、子コンポーネントの記述となります。

Setkey.svelte
<script>
	export let v = [];
	export let dataBind = [];
	let data = [];
	let getChar = (chr,str)=>{
        let data = dataBind //親から子へデータを渡す
        let lnum = data.lnum
        let cnum = data.cnum
        let sum = data.sum
        let sign = data.sign
        let strtmp = data.str
        str = strtmp + str
        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"){
          lnum = calc(sign,lnum,cnum)
          sum = lnum
        }else{
            lnum = null
            cnum = 0
            sum = 0
            str = ''
        }
	    data.str = str
	    data.lnum = lnum
	    data.cnum = cnum
	    data.sign = sign
	    data.sum = sum
	    dataBind = data; //親コンポーネントにデータを受け渡す
	}
		
	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>
<button type="button" value={v[0]} on:click={()=> getChar(v[0],v[1])} >
	{v[1]}
</button>

他のJSフレームワークだと親コンポーネントから子コンポーネントへ値を継承する場合は、propsといったオブジェクトを用いましたが、Svelteの場合は不要で、スクリプト部分にexport文で変数を定義するだけで対応できます(オブジェクトをやりとりする場合はexport let object = []とする)。

また、子コンポーネントから親コンポーネントに値をやりとりする、VueやAngularにあったエミット処理は普通に共通のコールバック関数dataBindを一つ記述するだけです。

これを厳密に、親から子、子から親へデータを受け渡したい場合はこのような記述になります。

Svelte-lesson6.svelte
	<Setkey bind:v={v} bind:dataFromParent={data} bind:dataFromChild={data} />

子コンポーネント

Setkey.svelte
<script>
	export let dataFromParent = []; //親から受け取る
	export let dataFromChild = []; //子から親に返す
//略
	let getChar = (chr,str)=>{
        let data = dataFromParent; //親コンポーネントから受け取る
        //略
	   dataFromChild = data; //親コンポーネントに受け渡す
	}
</script>

演習6:ルーティング制御(買い物かご)

では、Svelteでルーティングを制御してみます。Svelteのルーティングで大事なのは同期を取ったコンポーネントに値を返して代入するという部分で、これを把握しないと同期がうまくいきません。

システムの構造は以下のようになっています。

■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のメソッドになっているので、ライブラリ使用の宣言は必須となります。

GlobalState.svelte
<script>
import {Router, Link , Route } from "svelte-routing" //ここからRouter、Link、Routeメソッド使用宣言
import Products from "./pages/Products.svelte" //コンポーネントのパス
import Cart from "./pages/Cart.svelte" //コンポーネントのパス
import {Storage} from "./pages/ShopContext.js" //スクリプトのパス
let storage = Storage //スクリプト使用
</script>

<Router>
  <ul class="main-navigation">
    <nav>
        <li><Link to="/">Products({storage.products.length})</Link></li>
        <li><Link to="cart">Cart({storage.cart.length})</Link></li>
    </nav>
  </ul>
  <main>
    <Route path=""><Products bind:dataBind={storage}/></Route>
    <Route path="cart"><Cart bind:dataBind={storage}/></Route>
  </main>
</Router>

リンクの記述

リンクの記述は以下のようになっています。Linkタグ内のtoプロパティが遷移先の名称となります。

<Link to="cart">ほげほげ</Link>

ビューアの記述

対する、ビューアの記述は以下のようになっています。pathはリンク先のパス名となります。また、同期データをコンポーネントに渡したい場合は以下のように記述します。そして、この同期を取っているオブジェクトに値を返すことで、データ同期をとることができます。

bind:dataBind={ hogehoge } //hogehogeは同期をとっていきたい任意のオブジェクト

<Route path="cart"><Cart bind:dataBind={storage}/></Route>

※ ルーティングの別の書き方
svelteにはルーティング先にはコンポーネントを記述する方法もあります。ただ、この場合で別ファイルを用いる場合、子コンポーネントでexport処理が必要です。

GlobalState.svelte
  <main>
    <Route path="" component="Products" />
  </main>
Products.svelte
<script>
export let Products //このように使用コンポーネントをexportしておくこと
</script>

データと処理のやり取り(svelte-store)

ここがsvelteで一番引っかかった部分です。svelteのデータ同期は非常にシンプルで、bind:dataBind="hoge"で同期されたコンポーネントに対して行われ、そこに値さえ返してしまえば簡単に同期を取れます。ですが、外部処理用のメソッドreducers.svelteはdataBindで同期されていない(ただ、変数を渡しているだけなので)ため、その値をもう一度同期制御を行っているコンポーネントまで送り返さないといけません。つまり、見た目こそ中間処理メソッドを記述したAngularっぽい制御になっているのですが、処理用のserviceファイルで同期を取れるAngularに対し、Svelteの場合は一度、同期が取れるコンポーネントまで返す必要があります。

そんな面倒な制御を解決してくれるのがsvelte-storeというライブラリで、データからストアオブジェクト作成、あとは更新メソッドを実行するだけで同期が取れます。

※別途インストールが必要です。

npm i svelte-store

データ格納用のcontextファイル

svelteでVueのようにデータ格納用のjsファイルを使用することも可能で、その場合は以下のように記述します。ここではデータ確保用のファイル、ShopContext.jsを作成しています。ポイントはwritableメソッドを用いて参照渡ししておくことです(vue3のreactiveメソッドっぽいです)。

ShopContext.js
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を作成しておく

このスクリプトに対し、値をインポートしているのが以下の部分です。プレフィックス($hoge)を用いることで、データオブジェクトを受け渡すことができます。

但し、プレフィックスを用いるとリアクティビティが解除されてしまいます。

GlobalState.svelte
<script>
import {Router, Link , Route } from "svelte-routing"
import Products from "./pages/Products.svelte" 
import Cart from "./pages/Cart.svelte"
import {context} from "./pages/ShopContext.js" //データ格納ファイルのインポート
<Router>
  <ul class="main-navigation">
    <nav>
        <li><Link to="/products" class="nav">Products</Link></li>
        <li><Link to="/cart" class="nav">Cart({cnt_cart})</Link></li>
    </nav>
  </ul>
  <main>
    <Route path="detail" ><Detail bind:dataBind={$context}/></Route>
    <Route path=""><Products {context} on:reducer={reducer} /></Route>
    <Route path="cart"><Cart {context} on:reducer={reducer} /></Route>
  </main>
</Router>

ストアとディスパッチャの実例

ストアとディスパッチャを活用することで、非常にすっきりした記述となります。ただしupdateメソッドを実行しないと差分処理を予約しているだけなので、更新が反映されません。

注意点を書き出したらかなり長くなったので、詳細記事を独立させています。

トップコンポーネント

トップコンポーネントでは主にストアオブジェクトの更新制御とルーティング制御が置かれています。先ほどコンテクストファイルで作成したcontextがストアクラスとなっているので、ここから監視対象のストアオブジェクトstoreを抽出します。

そして、ディスパッチャによって差分をトップコンポーネントに回収し、各種処理を実行、最後にupdateメソッドの実行によって、ストアオブジェクトの更新が初めて行われます。

※setContextを用いた手法は演習8で実践します。

GlobalState.svelte
<script>
import {Router, Link , Route } from "svelte-routing"
import {derived} from 'svelte/store';
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"
let cnt_cart = 0
let cnt_articles = 0
function reducer(ev){
  const mode = ev.detail.mode //機能分岐
  const item = ev.detail.dif //更新差分
  let store = []
  context.subscribe((x)=> store = x) //監視対象とする
  shopReducer(mode,item,store)
  context.update(() => store) //分割代入で更新する
}
function itemCounter(items){
	return items.reduce((count,curItem)=>{
			return count + curItem.quantity //買い物かごの個数
		},0)
}
$:{
	cnt_cart = itemCounter($context.cart);
	cnt_articles = itemCounter($context.articles);
}
</script>

<Router>
  <ul class="main-navigation">
    <nav>
        <li><Link to="/products" class="nav">Products({cnt_articles})</Link></li>
        <li><Link to="/cart" class="nav">Cart({cnt_cart})</Link></li>
    </nav>
  </ul>
  <main>
    <Route path="detail" ><Detail bind:dataBind={$context}/></Route>
    <Route path=""><Products {context} on:reducer={reducer} /></Route>
    <Route path="cart"><Cart {context} on:reducer={reducer} /></Route>
  </main>
</Router>
<style>
.main-navigation {
  width: 100%;
  height: 4.5rem;
  background: #00179b;
}

.main-navigation nav {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
}

.main-navigation ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  height: 100%;
  justify-content: center;
  align-items: center;
}

.main-navigation li {
  margin: 0 1rem;
}

.main-navigation a {
  display: block;
  padding: 0.5rem 1rem;
  text-decoration: none;
  color: white;
  border-radius: 5px;
}
.main-navigation a:hover,
.main-navigation a:active,
.main-navigation a:visited{
  background: white;
  color: #00179b;
}
</style>

ルーティング先のコンポーネントファイル

各弟コンポーネントにはディスパッチャが用意されており、それを用いてイベントをトップコンポーネントに送出しています。

import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()

dispatch("reducer",{mode:"remove",dif:cartItem.id}) //トップコンポーネントに送出する

このreducerがトップコンポーネントと機能を紐づけるキーにもなっています(演習8のように、この部分に、任意の機能名を記述することもできます)。

※データの受取がトップコンポーネントにおける以下の部分で、on:reducerによってイベントを継承しているので、関数reducerから制御を分岐できます。また、引数evから、v.detailとすることで、弟コンポーネントのディスパッチャから送出したのプロパティ一式を抽出できるようになります。

svelte
//ディスパッチャによって紐付いた同期制御用メソッド
function reducer(ev){
  const mode = ev.detail.mode //機能分岐
  const item = ev.detail.dif //更新差分
  let store = []
  storage.subscribe((x)=> store = x) //監視対象とする
  shopReducer(mode,item,store)
  storage.update(() => store) //分割代入で更新する
}
/*中略*/
<Cart {storage} on:reducer={reducer} />

※ただし、Svelte5からは$props()で受け渡しするようになっており、createEventDispatcherer自体が非推奨の記述になっています。

ストアオブジェクトを展開する

また、ストアオブジェクトを展開する際にはプレフィックス($)が必須です。このプレフィックスによってリアクティビティの監視対象から外れる代わりに、値を自在に展開できるようになります。

カート画面

Cart.svelte
<script>
<script>
import { writable,readable } from "svelte/store"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let context = []
</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 on:click={()=>dispatch("reducer",{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 on:click={()=>dispatch("reducer",{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>

商品一覧

Products.svelte
<script>
import { Link } from "svelte-routing"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let context = []
</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 on:click={()=>dispatch("reducer",{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の実行が必要です。

svelte.reducer.svelte
<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を用いれば可能らしい)。

GlobalState.svelte
  <main>
    <Route path="detail/:id" let:params><Detail bind:dataBind={$storage} selid="{params.id}" /></Route>
    <Route path=""><Products bind:dataBind={$storage}/></Route>
    <Route path="cart"><Cart bind:dataBind={$storage}/></Route>
  </main>

では詳細ページがどうなっているかを見ていきますが、export let selidとすることでコンポーネントからのパラメータを変数として受け取っています。あとはそれをfindメソッドにかけて、該当するidのタイトルを引き出しています。

Detail.svelte
<script>
export let Detail = undefined
export let selid //これが詳細情報の商品id
export let dataBind = []
let context = dataBind //データの受け取り
let sel = context.products.find((item)=>selid === item.id)
</script>
<main>
	<ul>
		<li>{sel.title}</li>
	</ul>
</main>

ちなみに、Linkタグは以下のようになっており、変数をはめこんでいるだけです。

Products.svelte
        <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を活用すれば、簡単に取得できます。

Detail.svelte
<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のリテラル内で記述すれば、問題ないみたいです。リンク先の方も、ただ変数を埋め込むだけです。

Products.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
svelte
<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 = []
let states = []
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 = '';
let opt_areas = []
let sel_country = ''
let sel_area = ''
let hit_cities_by_area = []
let hit_cities_by_word = []
let hits = []
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 = []
}
/*バインド処理*/
//国から該当するエリアを絞り込み
$: opt_areas = states.filter(function(item,key){
    return item.country === sel_country
})
//エリアから該当する都市を絞り込み
$: hit_cities_by_state = cities.filter(function(item,key){
  return item.state === sel_area
})
//文字検索一致
$: hit_cities_by_word = cities.filter(function(elem,idx){
    return elem.name.indexOf(word) !== -1 && word != ''
})
//連動制御
$:{
    let hit_state = hit_cities_by_state
    let hit_word = hit_cities_by_word 
    hits = logicIntersect(opt_areas,hit_cities_by_state,hit_cities_by_word);
}

function logicIntersect(opt_areas,hits_cities_by_area,hits_cities_by_word){
  const len_state = hit_cities_by_state.length
  const len_word = hit_cities_by_word.length
  if(len_state > 0 && len_word > 0 ){
    hits = intersection(hit_cities_by_state, hit_cities_by_word)
  }else if(len_state > 0){
    hits = hit_cities_by_state
  }else if(len_word > 0){
    hits = hit_cities_by_word
  }else{
    hits = []
  }
  return hits		
}

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 //値の代入(同期がとれる)
    }
  })
}
</script>
<main>
            <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>
            <br/>
            <h4>検索文字を入力してください</h4>
            <input type="text" bind:value={word} on:input={ bindWord} />
            <button type="button" on:click={ 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" on:click={()=> colorSet(city.id)}>
										<Fa style="color: {city.act}" icon={faHeart} />
										</label>
									</label>
									<br>
									<img src="img/{city.src}" />
								</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}とリテラルをそのまま埋め込むことができます。

js
    <img src="img/{city.src}" />

スタイルを動的に制御する方法

Svelteで動的にCSSのスタイルを制御する方法は色々ありますが、一番単純な方法はstyleタグの変数を埋め込み、その変数をバインディングさせるやり方です。このcity.actには"red"というカラー指定の値が出力されたり、初期化されたりするので、それに合わせ、アイコンの色もリアクティブに変化します。

svelte
 <label class="fa_list" on:click={()=> colorSet(city.id)}>
      <Fa style="color: {city.act}" icon={faHeart} />
 </label>

注意点

メソッドの中で制御するオブジェクトは、CRUDの修正処理での説明にあったように、値を代入して処理するようにしてください。この方法を用いないとリアルタイムなスタイル変更を実行できません。

※ストアオブジェクトを作成する方法もあります。

svelte
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から参照しているオブジェクトの値なので、リアクティビティを維持しているからです。

lodashを使用する場合

今回のプログラムでは論理積を求めるためにlodashを使用していますが、Svelteでlodashを使用する場合は、予めimport文で呼び出しておいて、スクリプト内ではメソッドだけを記述しないといけません。

sj
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の仕様となっており、それを解決させる方法が公式チュートリアルに記載されています)。

js
<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>

この部分のon:change={()=> sel_area = ''}が鍵となっており、親のプルダウンにon:changeイベントを付与し無名関数で、連動プルダウンの選択値を初期化する必要があります。

jsonファイルをロードする

jsonファイルを起動直後にロードするには、次のようにfetchを用いた記述方法が有効のようです(asyncとawaitを敢えて使う必要はなさそうです)。また、jsonファイルはpublicフォルダ配下に置いておきましょう。注意点は事前にオブジェクトを変数定義しておく必要があります。

大きく解決のヒントになったQiita内の記事

js
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)とほぼ同一のものだと認識すればいいでしょう。

js
		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})
		})

Vueの監視プロパティのようなデータ監視を行う

Svelteでデータを監視するのは簡単で、次のように記述すれば、対象の変数が監視対象となるようです(今回は別に監視しなくても動きますが、テストのため)。ただ、これだと、あらゆる値でも反応してしまうため、厳密に行いたい場合はストア(第6章の別記事参照)を用いればいけるようです。

$:{
     hits = logicIntersect(opt_areas,hit_cities_by_state,hit_cities_by_word)
}

演習8:TypeScript(TODOアプリ)

では、SvelteでもTypeScriptの演習に入っていきたいと思います。その前にSvelteでTypeScriptを使う準備が必要です。既存のプロジェクトを対応させるよりは、新規にプロジェクト用のディレクトリを作成した方がいいでしょう。方法は以下の通りです。

#npx degit sveltejs/template 任意のプロジェクト
#cd 任意のプロジェクト
node scripts/setupTypeScript.js
npm i

viteを導入した方法

viteを導入した方が簡潔です。

#npm create vite@latest 

あとはプロジェクト名、使用言語(Svelteを選択)、スクリプトの種類などを質問してくるので、TypeScriptを選択します。

引き続き、依存関係を持つライブラリのインストールや更新、さらに適宜、必要な関連ライブラリをインストールします

#npm i
#npm i svelte-routing svelte-store

また、ローカルサーバで接続したい場合は以下のように追記するだけです。

vite-config.ts
export default defineConfig({
  plugins: [svelte()],
  server:{
	  host: true,
	  port: 3000, //ポートを変更したい場合
  }
})

これだけで準備完了です。ですが、ここからSvelteならではの癖があり、他のJSフレームワークならばtsファイルを使用していくのですが、Svelteに限っては以下のように利用した方が無理なく構築できます。

test.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ではトップコンポーネントにルーティング情報を記述するだけでなく、ストアオブジェクトの制御もルートコンポーネントで実行することも多いからで、かなり盛りだくさんの記述となっています。

App.svelte
<script lang="ts">
import {setContext} from "svelte"
import {Router, Route,navigate } from "svelte-routing"
import Todo from "./views/Todo.svelte"
import AddTodo from "./views/AddTodo.svelte"
import EditTodo from "./views/EditTodo.svelte"
import DetailTodo from "./views/DetailTodo.svelte"
import {storage} from "./stores/todo_store.svelte"
import {todoReducer} from "./modules/todo_module.svelte"
let store = []
let ev = []
//ストアオブジェクトの検知と更新
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 ) //ストアオブジェクトを更新
}
setContext('todos',storage)
</script>
<Router>
  <Route path="" ><Todo on:del={bind} /></Route>
  <Route path="add"><AddTodo on:add={bind} /></Route>
  <Route path="edit/:id" let:params><EditTodo id="{params.id}" on:upd={bind} /></Route>
  <Route path="detail/:id" let:params><DetailTodo bind:dataBind={$storage} id="{params.id}" /></Route>
</Router>

ストアオブジェクト

ストアオブジェクトは以下のようになっています。軽くおさらいですが、writableメソッドを通すことで、データはストアオブジェクトとなり、それぞれ監視、更新、展開などができます。ここも敢えてsvelteファイルにしていますが、データをエクスポートできるのはモジュールにした場合のみです。

todo_store.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によってストアオブジェクトを受け取り、プレフィックスを付与してループしています(プレフィックスを付与しないとストアクラスのままなのでデータを利用できません)

todos.svelte
<script lang="ts">
import { getContext } from "svelte"
import { Link } from "svelte-routing"
import Todoitem from "./Todoitem.svelte"
const storage = getContext('todos')
</script>
<main>
<h2>TODO一覧</h2>
/*プレフィックスを付与することで、データを展開できる*/
{#each $storage.todos as todo,i}
  <Todoitem bind:todo={ todo } on:del />
{/each}
</main>

各TODOの制御

個別のTODOの制御はこのようにしています。イベント部分は次に説明します。

Todoitem.svelte
<script lang="ts">
import { Link } from "svelte-routing"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let todo = [] //親コンポーネントから
</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 on:click={()=>dispatch("del",todo.id)} >削除</button>
  </div>
</div>
</main>

CRUDについて

ではここからCRUDを制御していきます。その制御に利用しているのはディスパッチャですが、このディスパッチャはReactのuseContextと違って、イベントを転送できません。そこで各種イベントに対しては面倒ですが、以下のようにリレーしていく必要があります。

具体的に見ていきます。このbindというデータ同期用のメソッドまでイベントを継承するのが狙いです。これに紐づいているのはTodo、AddTodo、EditTodoというコンポーネントであり、それぞれ削除、登録、修正処理に紐づいています。そして、その記述は以下のようになっています(ここが最終受け取りの部分であり、中間地点は後述します)。

function piyo(){ … }
<Hoge on:fuga={piyo}>
App.svelte
//データの検知と更新
const bind = (ev)=>{
  //省略
}
setContext('todos',storage)
</script>
<Router>
  <Route path="" ><Todo on:del={bind} /></Route>
  <Route path="add"><AddTodo on:add={bind} /></Route>
  <Route path="edit/:id" let:params><EditTodo id="{params.id}" on:upd={bind} /></Route>
  <Route path="detail/:id" let:params><DetailTodo bind:dataBind={$storage} id="{params.id}" /></Route>
</Router>

ディスパッチを伝達する

いきなり削除制御の説明から入るのは理由があります。dispatchは最終的にApp.svelte内のbindメソッドに伝達する必要があるのですが、現時点では順番に受け渡すしか方法がありません。つまり削除処理の分岐を行っているTodoitem.svelteからデータ更新制御を行うApp.svelteにメソッドを送るにはtodos.svelteとtodo.svelteを中継する必要があるわけです。

具体的にはこのように記述します。

Todoitem.svelte
function onSubmit(id){
  dispatch("del",id) //
  navigate("/")
}
</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 on:click={()=>dispatch("del",todo.id)} >削除</button><!-- このイベントを送りたい -->
  </div>
</div>
</main>

一つ上の中継コンポーネントでは先程のディスパッチの名称だけを保持しておきます。

<Fuga on:piyo >
Todos.svelte
<script lang="ts">
import { getContext } from "svelte"
import { Link } from "svelte-routing"
import Todoitem from "./Todoitem.svelte"
const storage = getContext('todos')
</script>
<main>
<h2>TODO一覧</h2>
{#each $storage.todos as todo,i}
  <Todoitem bind:todo={ todo } on:del />
{/each}
</main>

更に上の階層もこのようになります。これは明らかに他のJSフレームワークより不便なので、もっと楽な方法がないか調査しているところです。

Todo.svelte
<script lang="ts">
import { Link } from "svelte-routing"
import Todos from "../components/Todos.svelte"
</script>
<main>
  <Todos on:del />
  <Link to="add"><button>新規作成</button></Link>
</main>

※子コンポーネントから各種処理メソッドを実行する、Vueのコンポーザブル制御のような処理方法もあります。

登録処理

登録処理の説明に入っていきます。この登録処理はApp.svelteに直結しているので、削除のようなリレー制御(逐一イベントを受け渡す)はないです。

また、ここからTypeScriptについても説明を入れていきますが、前述したようにSvelteのTypeScriptはかなり癖があり、たとえばtypeをインポートする場合はこのように記述しないとエラーになります。

import type { 使用したいtypeやinterface } from '任意の型定義ファイル'

新規作成の場合は事前に型を定義することで、記述が非常にシンプルになります。ここではDataという型を事前に定義しているお陰で、入力後の制御に対してはData型で定義した変数dataを回収するだけで差分データを回収できます。このフォーム処理のシンプルさは他のJSフレームワークよりずっと優れていると感じます。

AddTodo.svelte
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { navigate } from "svelte-routing"
import type { Data } from "../types/types_todo"
const dispatch = createEventDispatcher()
const date = new Date()
const id =  Number(date.getTime())
//フォームデータを定義する
let data:Data = {
  id: id,
  title: '',
  description: '',
  str_status: 'waiting',
}
function onSubmit(){
  dispatch("add",data)
  navigate('/')
}
</script>
<main>
  <h2>TODOの作成</h2>
    <div>
      <label> id</label>
      <input type="text" id="title" name="title" value={data.id} readonly />
    </div>
    <div>
      <label> タイトル</label>
      <input type="text" id="title" name="title" bind:value={data.title} />
    </div>
    <div>
      <label></label>
      <textarea id="description" name="description" bind:value={data.description} />
    </div>
    <div>
      <label>ステータス</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 on:click={()=>onSubmit()}>作成する</button>
    <button on:click={()=> navigate("/")} >戻る</button>
</main>

型定義ファイルについて

型定義のファイルは以下のようにしています。他のJSフレームワークと違い、あくまでsvelteファイルにしておくのがポイントです。

types_todo.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だけを回収し以下のように分割代入しておけば問題ありません。

EditTodo.svelte
<script lang="ts">
import { getContext,createEventDispatcher } from "svelte"
import { navigate } from "svelte-routing"
import type { Data,Params } from "../types/types_todo"
export let id //取得ID
const selid:number = Number(id) 
const storage = getContext('todos')
const dispatch = createEventDispatcher()
let data = $storage.todos.find((item)=> item.id === selid)
let dif:Params = {
  title: data.title,
  description: data.description,
  str_status: data.str_status,
}
function onSubmit(){
  dispatch("upd", {...dif,selid}) //difはグローバルスコープとなっている
  navigate('/')
}
</script>
<main>
  <h2>TODOの修正</h2>
  <div>
    <label> タイトル</label>
    <input type="text" id="title" name="title" bind:value={data.title} />
  </div>
  <div>
    <label></label>
    <textarea id="description" name="description" bind:value={data.description} />
  </div>
  <div>
    <label>ステータス</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 on:click={()=>onSubmit()}>更新する</button>
  <button on:click={()=> navigate("/")} >戻る</button>
</main>

各種処理

では差分データを取得したところで、具体的な処理部分を見ていきます。処理はモジュールによって制御されており、dispatchによって処理の分岐と差分データを取得しているので、それを制御していきます。

ちなみにモジュール上ではストアオブジェクトの検知、更新はできないので、あくまで差分データの処理だけです。

todo_module.svelte
<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といった分岐処理の名称を取得することができます。

App.svelte
<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 on:del={bind} /></Route>
  <Route path="add"><AddTodo on:add={bind} /></Route>
  <Route path="edit/:id" let:params><EditTodo id="{params.id}" on:upd={bind} /></Route>
  <Route path="detail/:id" let:params><DetailTodo bind:storage={$storage} id="{params.id}" /></Route>
</Router>

詳細画面

詳細画面はそこまで難しくないでしょう。注意点としてこの詳細画面はApp.svelteの時点でプレフィックスを付与してデータを送っているので、値を受け取って展開する際には通常の変数と同じように扱う必要があります。

DetailTodo.svelte
<script lang="ts">
import { getContext,createEventDispatcher } from "svelte"
import { navigate } from "svelte-routing"
export let id //取得ID
const selid = Number(id) 
export let storage = [] //親コンポーネントから受け取ったstorage(送られてきた時点でストアオブジェクトではない)
const dispatch = createEventDispatcher()
let data = storage.todos.find((item)=> item.id === selid) //こっちはプレフィックス不要
</script>
<main>
  {#if selid !== 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 on:click={()=> navigate("/")} >戻る</button>
</main>

演習9: Svelte5(rune導入など仕様変更への対応)

svelte5から試験的にrune(ルーン)が導入され、2024年12月に正規リリースされました。そしてこのruneですが、言ってしまえば従来の仕様を根本から刷新したものではなく、より見やすくするために導入され、将来的な機能拡張のために整備された技術です。

ちなみにルーンとは詳述していきますが、$hogeによって表記されるコンパイラ(いわばVueのコンパイラマクロやReactのフックみたいなもので、公式サイトではシンボルと定義しています)のことで、今までより堅牢性や拡張性は大幅に上昇した一方で、やや初心者には難しい部分が増えた(分割代入が必須の処理も多くなっている)気もします。

例:以下は、ルーンを実体験するために公式ページのplaygroundで、以下の記事を参考に、試験的に作ってみた軽い仕掛けです。

sample_svelte.svete
<script>
let counter = $state({cnt:0});
let ar_items = ["React","Angular","Vue","Svelte"];
//$deriveは$stateによって監視されたcounterを検知する
let item = $derive(ar_items[counter.cnt]);
//こっちはitemを検知している
let mes = $derived.by(()=>{
    if(item == "Svelte"){
        mes = "Svelte is Good!"
    }
});
const turn = (flg)=>{
    if(flg == "I"){
        counter.cnt += 1;
        if(counter.cnt == ar_items.length){
            counter.cnt = 0;
        }
    }else if(flg == "D"){
        counter.cnt -= 1;
        if(counter.cnt < 0){
            counter.cnt = ar_items.length - 1;
        }
    }
}
</script>
<main>
<h1>Hello,{ item }</h1>
<p>{ mes }</p>
<button onclick={()=>{turn("I")}}></button>
<button onclick={()=>{turn("D")}}></button>
</main>

これでボタンを押せば、$stateによってリアクティブ制御された値(counter.cnt)を読み取り、それに従い、検知用の$deriveによって配列ar_itemsの値がitemに返されます。また、その値が「Svelte」であった場合のみ、$derived.byによって処理が実行され、処理結果がmesに返される仕組みとなっています。

ちなみに$derive系のルーンは監視対象の変数を代入できない仕様となっています。

それからSvelte5からon:hogeのイベントがonhoge(キャメルケースでは動きません。:を除去しただけの形となります)に変更となりました。また、コンポーネント出力のexport letは廃止、ディスパッチャも非推奨になり、いずれも$propsでやり取りするようになっています(ディスパッチャにおいてはしばらく後方互換は持つようですが、他のディバインディングイベントは廃止されています)。

では、以下の公式ページを追いながら、これまでの演習プログラムを書き換えてみます。

各演習のbefore after

演習1($state)

変数を$stateルーンで囲むだけでリアクティブ宣言できます。

lesson1rune.svelte
<script>
	let mes = $state('');
</script>

<main>
	<input type="text" bind:value={mes}>
	<p>入力された文字{mes}</p>
</main>

演習2

リアクティブ制御が不要な変数は敢えて$stateでラップする必要ありません。

lesson2rune.sv
<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: "qwik"},
        ];
	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>

演習3($derived)

これまでの$:というリアクティブステイトメントは$derivedルーンもしくは$effectルーンによって処理されます。今回はリアクティブな変数に代入処理を施すので、$derivedルーンが妥当です($effectルーンはリアクティブな変数に対し連動処理を施す場合に用います)。

lesson3.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>

演習4(onclick、oninput)

イベントの記述方法も変更になり、たとえばon:click→onclickとなりました(小文字なので注意)。また新規挿入のように引数のないイベントはダイレクトに記述できるようになります(リテラルで囲えるのはイベントのみなので、あまり使い所がないですが、oninputの部分はすっきりします)

lesson4rune.svelte
<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>

演習5( export let → $props())

コンポーネント制御に際して、exportが廃止され$propsルーンで制御するようになりました。また、コンポーネント遷移のイベントバインディング(on:hoge)も廃止され、ダイレクトに、リテラルに指定することができます。

※コールバック関数の変数名は任意

//子コンポーネントからのコールバック関数onBindと連動している
function onBind(typed,total){
	str = str + typed
	sum = total
}
</script>
```
calc.svelte
<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>

setkey.svelte
<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>

演習6 ( createEventDispatcher → $props() )

createEventDispatcherが非推奨となり、ディスパッチャを$propsルーンで代用できるようになっています。またイベントバブリング用のon:hogeが廃止になり、単純にリテラル一つを記述するだけです。また、ディスパッチャにあったアクション指定用の第一引数が必須でなくなり、代わりにダイレクトに引数を用意してトップコンポーネントで受け取れるので、こちらの方が記述もずっと簡潔になっています(ただし、引数が適用されるのは1つだけです)。

dispatchという名称が紛らわしくなったので、今回は関数をbindvalueとしていますが、働きはそれまでのディスパッチャとそこまで変わっていません。

Globalcomponent.svelte
<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>

イベントバブリング

対するカート、商品一覧は$propsから展開することができます。ただし、$propsで受け取ったストアオブジェクトは伝播先で再代入できません。したがって、差分をbindvalueで回収するという流れとなります。

<Products {context} {bindvalue} />

$effect

$effectルーンは、ReactのuseEffectフックやVueの監視プロパティのようなものです。ここではストアオブジェクトが更新を検知し、個数をカウントするメソッドが実行されます。またSvelteの$effectルーンは読み込み直後の作動が起きないように設定されています。

$effect(()=>{
	cnt_cart = itemCounter($context.cart);
	cnt_articles = itemCounter($context.articles);
})

イベントの受取

cart.svelte
<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>
Products.svelte
<script>
import { Link } from "svelte-routing"
import { createEventDispatcher } from "svelte"
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>

パラメータの埋め込みと送出、受取

パスパラメータの埋め込みもコンポーネントタグ内のlet:paramsは必要ですが、export letが使用不可なので、これも$propsルーンで代用します。また$propsルーンは、事前にリアクティビティを解除したストアクラスを送出することができなくなっています。

Detail.svelte
<script>
const { context,selid } = $props()
console.log(context,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>

演習7($derived.by)

今度は連動処理において計算処理を施すために$derived.byルーンを用います($effectルーンだと無限ループの警告が出ます)。こちらは監視された変数を検知し、そこから計算結果を返すもので、ReactのuseMemoやuseCallbackに近い働きをします。また$derived$derived.byは再代入できないので、変数指定は各種ルーンの出力結果に対して指定しておきます。

lesson7.svelte
<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> 国の選択 </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> エリアの選択</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>

演習8(contextに対する対応)

演習8は細部で変更が必要となります。リアクティブな変数制御には$stateルーン必須となったため、それらの代入に$derivedルーンを使用しなければいけない点は要注意ポイントでしょう。

GlobalState.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>

リストコンポーネントですが、この$propsのリレー制御はどうにかしたいものです($hostルーンで実験中)。

Todos.svelte
<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>

アイテムコンポーネント
ディスパッチャ非推奨化に伴い、関数bindをダイレクトに記述しています。引数は一つだけなので注意。

TodoItem.svelte
<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>

新規登録画面
注意点として、差分を代入する際には$derivedルーン上でオブジェクトを分割代入する点です。これを施さないとイベントバブリングで変更した値を転送できません。

AddTodo.svelte
<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 = $derived({...data}); //必ず分割代入すること 
	bind({mode:"add",dif:dif});
	navigate('/');
}
</script>
<main>
  <h2>TODOの作成</h2>
    <div>
      <label> id</label>
      <input type="text" id="title" name="title" value={data.id} readonly />
    </div>
    <div>
      <label> タイトル</label>
      <input type="text" id="title" name="title" bind:value={data.title} />
    </div>
    <div>
      <label></label>
      <textarea id="description" name="description" bind:value={data.description} />
    </div>
    <div>
      <label>ステータス</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>

修正画面
データ修正の場合は、差分を格納するオブジェクトに対しても、事前に$stateルーンによるリアクティブ化が必要になります。また、変更後の値は同様に分割代入必須です。

EditTodo.svelte
<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 = $derived({...dif,id})
	bind({mode:"upd",dif:updated});
	navigate('/');
}
</script>
<main>
  <h2>TODOの修正</h2>
  <div>
    <label> タイトル</label>
    <input type="text" id="title" name="title" bind:value={data.title} />
  </div>
  <div>
    <label></label>
    <textarea id="description" name="description" bind:value={data.description} />
  </div>
  <div>
    <label>ステータス</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>

詳細画面
$propsルーンに機能変更になった影響で、事前のリテラルにおいてプレフィックス付与ができなくなったようです(検証中ですが、リアクティブが解除された変数は転送できません)。したがって、展開時にプレフィックスを付与しています。

DetailTodo.svelte
<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>
20
14
0

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
20
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?