以前、バックエンドエンジニアのためのVue.js、React、Angular入門という記事を作成し、バックエンドエンジニアのためにjQueryで制御してきたことをそれぞれどう記述するかという説明をしてきましたが、それを記述しているうちに新勢力としてSvelte(スヴェルト)というフレームワークも三大フレームワークに迫る勢いで人気を得てきているようで、発祥国のイギリスを初めヨーロッパやアメリカでは多数のコミュニティが作られ、日本でも名の知れた大手企業が導入したりしているようです。
そこで、ここでは同じテーマのデータ操作に対してSvelteではどう記述するのかを記していきます。8章ではTypeScriptの説明をしています。また、8章ではストアとディスパッチャも使用しています。
その前に…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にてテスト用プロジェクトを作成してみました。
#npx degit sveltejs/template 任意のプロジェクト
#cd 任意のプロジェクト
#npm install
これでnpmをインストールしてから、ローカルサーバに接続させるため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は以下のようになっています。
import App from './App.svelte';
const app = new App({
target: document.body,
props: {
name: ''
}
});
export default app;
演習1:フォーム操作:テキスト文字を表示させる
バインディングの基礎の動きで、入力された文字を表示させる仕組みです。これを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を用いてプルダウンメニューを作成してみます。同じようにループ処理が必要になるはずです。
<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文を埋め込む形になっています。
{#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 = ''; //検索ワード
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で指定した同期を取りたい値となるので、ひらがなで打ち込んでもカタカナで表示されるようになり、検索を円滑にこなすことができます。
if else
Svelteのif文はけっこう独特の記述法なので、法則を覚えてしまいましょう。
{#if filterData.length > 0 } //始まりは#、判定文は括弧で囲まない
//肯定時の処理
{:else if word != ''} //中間は:、またelse ifと必ず間を開ける
//否定時の処理
{/if} //終わりは/で締める。endなどではない
逆に覚えてしまえば、テンプレートファイルに埋め込むような感じなので他のフレームワークより処理が楽だと思います。
演習4: データリストの追加、削除、修正(DOMの再生成)
では、CRUDの基本となるデータリストの追加、削除、修正についても記述したいと思います。
<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の関数コンポーネントのように分割代入を用います。
<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>
データの修正
後はデータの修正で、これも削除と似た課程を経ることにはなるのですが、施さないといけない処理がいくつかあります。
<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の引数に反映されることになります。
あとはメソッドで修正処理を施すだけですが、配列の値を差し替えるだけで対応できます。
let upd = (i,upd_item) =>{
ary_data[i] = { "key":i,"name": upd_item}; //対象のインデックスに対して、ダイレクトに代入するだけ
}
データの連動
では、データの連動ができるように、検索システムを実装してみようと思います。
<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>
これで今までの検索システムと同じように動きます。このシステムの鍵を握っているのは以下のバインド処理部分で、$: hoge
で値を連動させ、それぞれに紐付いている$: opt_areas
、$: hit_cities_by_state
、$: hit_cities_by_word
の値のいずれかに変化が起きると、このlogicIntersectメソッドが発火し、検索結果を格納する$: 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 != ''
})
//連動制御
$: 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
}
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の仕様となっており、それを解決させる方法が公式チュートリアルに記載されています)。
Bindings / Select bindings • Svelte Tutorial
<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内の記事
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 ()=> {
$: fetch('./json/city.json').then(res=>res.json()).then(data =>{cities = data})
$: 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)
}
画像パスを読み込む
画像を読み込むのは全く難しくなく、publicフォルダに任意の画像を用意しておき、そこにパスを通すだけです。またSvelteは変数を埋め込む場合も、他のjsのように${hoge}
とする必要がなく、{hoge}
とリテラルをそのまま埋め込むことができます。
<img src="img/{city.src}" />
演習5:コンポーネント制御(電卓)
では、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} //これ一つで親から子、子から親の双方向のデータのやりとりを実現してくれる
続いて、子コンポーネントの記述となります。
<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を一つ記述するだけです。
これを厳密に、親から子、子から親へデータを受け渡したい場合はこのような記述になります。
<Setkey bind:v={v} bind:dataFromParent={data} bind:dataFromChild={data} />
子コンポーネント
<script>
export let dataFromParent = []; //親から受け取る
export let dataFromChild = []; //子から親に返す
//略
let getChar = (chr,str)=>{
let data = dataFromParent; //親コンポーネントから受け取る
//略
dataFromChild = data; //親コンポーネントに受け渡す
}
</script>
補足:デバッグしたい場合
デバッグ用の関数はconsole.log
のほかconsole.dir
を用いることもできます。後者を用いると、型も表示してくれます。
演習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 install 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 {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処理が必要です。
<main>
<Route path="" component="Products" />
</main>
<script>
export let Products //このように使用コンポーネントをexportしておくこと
</script>
ルーティング先のテンプレートファイル
ルーティング先のテンプレートファイルは以下のようになっています。svelteはデータのやり取りがdataBindメソッドだけで実現できるので、非常にシンプルです。
<script>
import Reducers,{reducer} from "../reducers.svelte"
export let dataBind = []
let storage = dataBind //データの受け取り
//イベントのデータ同期用制御
function postcall(mode,product,store){
storage = reducer(mode,product,store) //更新情報を取得
dataBind = storage //同期を取るために親コンポーネントまで値を返す
}
</script>
<main class="products">
<ul>
{#each storage.products as product,i}
<li>
<div >
<strong>{ product.title }</strong> - { product.price }円
{#if product.stock > 0} 【残り{ product.stock }個】 {/if}
</div>
<div>
<button on:click={()=>postcall("add",product,storage)}>かごに入れる</button>
</div>
</li>
{/each}
</ul>
</main>
データと処理のやり取り
ここがsvelteで一番引っかかった部分です。svelteのデータ同期は非常にシンプルで、bind:dataBind="hoge"で同期されたコンポーネントに対して行われ、そこに値さえ返してしまえば簡単に同期を取れます。ですが、外部処理用のメソッドreducers.svelteはdataBindで同期されていない(ただ、変数を渡しているだけなので)ため、その値をもう一度同期制御を行っているコンポーネントまで送り返さないといけません。つまり、見た目こそ中間処理メソッドを記述したAngularっぽい制御になっているのですが、処理用のserviceファイルで同期を取れるAngularに対し、svelteの場合は一度、同期が取れるコンポーネントまで返してあげないといけないわけです。
具体的に言えば、以下の処理用ファイルreducers.svelteはあくまでProducts.svelteというテンプレートのメソッドから呼び出された処理用ファイルに過ぎないので、このファイルに送り込まれたデータstorageは同期の対象となっていません。したがって、同ファイル内のreducerメソッドでreturn storageとして更新された値を返しています。一方、ファイル内ではデータ用変数storageに対し、逐一戻り値を返さなくても、メソッド内の分割代入で更新をかければ、更新されたデータを継承していきます。これはsvelteファイルで同一ファイルで同名の変数は、通常だとグローバルスコープとなっているからです。
<script context="module">
export let dataBind = []
let stat = []
let storage = dataBind
const addProductToCart = (product,state)=>{
let cartIndex = ''
const stat = state
//買い物かごの調整
const updatedCart = stat.cart;
const updatedItemIndex = updatedCart.findIndex(
item => item.id === product.id
);
if (updatedItemIndex < 0) {
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
storage = {...stat} //データの差分を更新(逐一、変数を返す必要はない)
}
//合計金額の算出
const getSummary = (cart,total)=>{
const sum = cart.reduce((total,{price = 0,quantity})=> total + price * quantity,0)
return sum
}
export function reducer(mode,selected,store){
switch(mode){
case "add": addProductToCart(selected,store)
break
case "remove": removeProductFromCart(selected,store)
break
case "buy": buyIt(selected,store)
break
}
return storage //更新されたデータをコンポーネントに返す
}
</script>
データ格納用のスクリプトファイル
svelteでVueのようにデータ格納用のjsファイルを使用することも可能で、その場合は以下のように記述します。ここではデータ確保用のファイル、ShopContext.jsを作成しています。ポイントはwritableメソッドを用いて参照渡ししておくことです(vue3のreactiveメソッドっぽいです)。
import { writable } from "svelte/store"
const Storage = {
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 storage = writable(Storage) //writableメソッドを用いて値を渡しておく
このスクリプトに対し、値をインポートしているのが以下の部分です。プレフィックス($hoge)を用いることで、データオブジェクトを受け渡すことができます。
<script>
import {Router, Link , Route } from "svelte-routing"
import Products from "./pages/Products.svelte"
import Cart from "./pages/Cart.svelte"
import {storage} from "./pages/ShopContext.js" //データ格納ファイルのインポート
<Router url={url}>
<ul class="main-navigation">
<nav>
<li><Link to="/" class="nav">Products({$storage.products.length})</Link></li>
<li><Link to="cart" class="nav">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>
ストアとディスパッチャを用いてデータを制御する
ルーティングのデータ同期に際しては、かなりゴリ押しの方法で書いてきましたが、本来はストアやディスパッチャが実装されており、それを活用すれば、もっとスムーズに記述できます。ところが、ディスパッチャはコンポーネント間でイベントとデータを受け渡すことができるのはいいのですが、伝達元のデータ同期が取れなかったり、またディスパッチャもストアもモジュール内で使用できなかったりと、かなり面倒な制約がありました。そして、試行錯誤の末、気をつけなければいけない点がいくつもありました。
書き出したらかなり長くなったので記事を独立させています。
SvelteのSPAでストアとディスパッチャを用いてデータを一括制御する
詳細ページを作成(パラメータのやりとり)
ここがかなり曲者でした。というのもsvelte-routingだけを用いたパラメータのやりとりに関しては、ほとんど情報が見つからなかったからで、試行錯誤の末、公式マニュアルに記載されている通りに記述して以下の方法でうまくいきました。
というのも、Vue、React、Angularは遷移先のコンポーネントからparamやurlといった情報を取得するというやり方でしたが、Svelteはというと、ルーティングファイルから取得したいパラメータを予め設定するというかなり特殊な方法を用いるからです。
大事なポイントはlet:paramsというプロパティを付与しておくことと、それを用いて値を受け取りたいコンポーネントにパラメータを送っておくことです。具体的にはselid="{params.id}"が今回、詳細ページに渡したい商品idとなります。逆にいえば、pathプロパティはあくまでパスの記述にとどまっており、svelte-routingの場合、そこからパスパラメータを取得できません(svelte-kitを用いれば可能らしい)。
<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のタイトルを引き出しています。
<script>
export let Detail = undefined
export let selid //これが詳細情報の商品id
export let dataBind = []
let storage = dataBind //データの受け取り
let sel = storage.products.find((item)=>selid === item.id)
</script>
<main>
<ul>
<li>{sel.title}</li>
</ul>
</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 storage = dataBind //データの受け取り
console.log(selid)
let sel = storage.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:スタイル制御(写真検索システム)
では、Svelteでもスタイル制御の演習として、いいねボタンを実装してみます。
まずは、他と同じように、事前にFont Awesomeをインストールしておきましょう。それに際しても色々な情報が錯綜していますが、有効だったのはsvelte-faを利用する方法でした。
import Fa from 'svelte-fa'
import { faHeart } from '@fortawesome/free-solid-svg-icons'
/*中略*/
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 //値の代入(同期がとれる)
}
})
}
/*中略*/
<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>
/*中略*/
.fa_list{
display: inline-block;
}
スタイルを動的に制御する方法
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から参照しているオブジェクトの値だからです。
演習8:TypeScript(TODOアプリ)
では、SvelteでもTypeScriptの演習に入っていきたいと思います。その前にSvelteでTypeScriptを使う準備が必要です。既存のプロジェクトを対応させるよりは、新規にプロジェクト用のディレクトリを作成した方がいいでしょう。方法は以下の通りです。
#npx degit sveltejs/template 任意のプロジェクト
#cd 任意のプロジェクト
node scripts/setupTypeScript.js
npm install
これだけで準備完了です。ですが、ここからSvelteならではの癖があり、他のJSフレームワークならばtsファイルを使用していくのですが、Svelteに限っては以下のように利用した方が無理なく構築できます。
<script lang="ts">
/*これで中身はTypeScriptとなる*/
</script>
なぜ、こうするかというとsvelteはデータの堅牢性や制御機能を守るために独自の制限がかなり仕込まれているからで、下手にtsファイルを利用しようとすると、エラーに執拗なぐらいに引っかかるからです。
ファイル構成
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ではトップコンポーネントにルーティング情報を記述するだけでなく、ストアデータの制御もルートコンポーネントで実行することも多いからで、かなり盛りだくさんの記述となっています。
また、SPAでデータを制御する際にはストアとディスパッチャを活用しているので、別記事に目を通しておいてください。
<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 ) //監視開始
state = todoReducer(mode,store,dif) //ディスパッチャによる分岐処理と更新データの取得
storage.update(()=>({...store,...state})) //ストアデータを更新
}
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ファイルにしていますが、データをエクスポートできるのはモジュールにした場合のみです。
<script context="module">
import {writable} from "svelte/store"
let Storage = {
todos:[]
}
export const storage = writable(Storage) //書き込み可能のストアオブジェクトにする
</script>
setContext
ここでsetContextという見慣れないメソッドが登場します。これはReactのuseContextやVueの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 storage = getContext('todos')
</script>
<main>
<h2>TODO一覧</h2>
/*プレフィックスを付与することで、データを展開できる*/
{#each $storage.todos as todo,i}
<Todoitem bind:todo={ todo } on:del />
{/each}
</main>
各TODOの制御
個別のTODOの制御はこのようにしています。イベント部分は次に説明します。
<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}>
//データの検知と更新
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を中継する必要があるわけです。
具体的にはこのように記述します。
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 >
<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フレームワークより不便なので、もっと楽な方法がないか調査しているところです。
<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>
登録処理
ではここから具体的に登録処理の説明に入っていきます。この登録処理はApp.svelteに直結しているので、削除のようなリレー制御(逐一イベントを受け渡す)はないです。
また、ここからTypeScriptについても説明を入れていきますが、前述したようにSvelteのTypeScriptはかなり癖があり、たとえばtypeをインポートする場合はこのように記述しないとエラーになります。
import type { 使用したいtypeやinterface } from '任意の型定義ファイル'
新規作成の場合は事前に型を定義することで、記述が非常にシンプルになります。ここではDataという型を事前に定義しているお陰で、入力後の制御に対してはData型で定義した変数dataを回収するだけで差分データを回収できます。このフォーム処理のシンプルさは他のJSフレームワークよりずっと優れていると感じます。
<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ファイルにしておくのがポイントです。
<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だけを回収し以下のように分割代入しておけば問題ありません。
<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によって処理の分岐と差分データを取得しているので、それを制御していきます。
ちなみにモジュール上ではストアデータの検知、更新はできないので、あくまで差分データの処理だけです。
<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
}
return todos
}
</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 ) //監視開始
state = todoReducer(mode,store,dif) //差分を取得
storage.update(()=>({...store,...state}))
}
</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の時点でプレフィックスを付与してデータを送っているので、値を受け取って展開する際には通常の変数と同じように扱う必要があります。
<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>