Svelteに関しては今までJSフレームワークSvelteでデータ処理を色々と実践してみたという記事を書いてみたことがあり、この記事ではルーティングに際しては、基本のキということで、かなりゴリ押しで書いていました。
ですが、Svelteにはストアというデータ管理ライブラリやディスパッチャという処理分岐制御用のメソッドが実装されており、それらを活用すれば、もっとスムーズに記述でき、VuexやReactのuseReducerフック、AngularのRxJSのように状態管理もできることがわかりました。ところが、これらはかなり挙動に癖があり、ディスパッチャはコンポーネント間でイベントとデータを受け渡すことができるのはいいとして、従来の方法では伝達元のデータ同期が取れなかったり、またディスパッチャもモジュール内で使用できなかったりと、かなり面倒な制約があり、悪戦苦闘を繰り返しました(Svelteの欠点としてSPA周りが弱いといわれる所以です)。
そして、ストアにしてもディスパッチャにしても、記事やサンプルにあるのはプリミティブな変数でのケースばかりで、なかなか配列やオブジェクトといったデータ群の一括同期処理の方法が見つからなかった(GitHubのissueやStackoverflowの回答を見ても、有効な答えがほとんど見つからなかった)ので、元の記事に書き加えていくうちに相当のボリュームになってしまったので、一つの記事として独立させました。
なお、ストアとディスパッチャに関しては以下の記事がかなり役立ちました。
また、テストに役立ったのが他のJSフレームワークでの知識やノウハウでした。
※ルーティングの基礎となる記述方法は他のページを参考にしていただければと思いますが、SPA制御用のライブラリは色々あり、当記事ではsvelte-routingというAPIを使用しています(他に後発のsvelte-spa-routerなどがありますが、記述方法が大きく異なります)。
仕様
まず、前回記事のルーティングの部分に軽く目を通して頂けたらと思いますが、軽くおさらいすると簡単な買い物かご機能をSPAで作成しています。ですが、前述した通り、かなりゴリ押しで書いたものなので、ひたすらdataBindで同期をとるというどこまでも原始的な書き方をしています。
■src
- ■static
- ■css(デザイン制御用、表示は割愛)
- ■pages
- Products.svelte //商品一覧(商品一覧、ここに商品を買い物かごに入れるボタンがある)
- Detail.svelte //商品詳細(ここに商品詳細を表示。本記事では出て来ない)
- Cart.svelte //買い物かご(ここに買い物かごを表示。また商品差し戻し、購入のボタンがある)
- ShopContext.js //ストアデータの格納
- GlobalState.svelte //親コンポーネント(ナビゲーションリンクと処理の分岐。ここからストアデータを受け渡す)
- modules.svelte //分岐処理から呼び出された各種メソッド(モジュール化)
ですが、今回はこの買い物かごを改良して、ストアとディスパッチャを駆使して、データを一元管理しながら同期を取れるようにします。ちなみに、ストアとディスパッチャを用いたSPAですが、以下の点に気をつけないといけません。
ストアとディスパッチャの主な使用制約
ストアにしてもディスパッチャにしても、かなり複雑な使用制約がありました。今回のシステムにおいて、とりわけ押さえておきたい仕様は以下の通りです。
- コンポーネントの受け渡しにおいて更新データを同期する場合、dataBindを用いてはならない(dataBindだとストアクラスのデータ《以下、ストアデータと表記》を受け渡しても、データが同期されない)
- ストアのメソッド更新(subscribeとupdate)は同一コンポーネント内で行わないといけない。また、リアルタイムで更新可能なオブジェクトはストアデータ、あるいはsubscribeメソッドによって監視されたデータのみ。
- ディスパッチャはモジュール内で使用できない。また、ディスパッチャでストアデータを転送できない(転送しても同期が取れない)。
- 外部のスクリプトからメソッドを呼び出す場合は、モジュールでないと引数を転送できないため、ストアデータを転送できない(ちなみにスクリプトをモジュールに設定しない場合は、メソッド呼び出し時に発生する2つのエラーで堂々巡りにされます。海外では、これで困ったという質問がかなり見受けられました)。
これらを踏まえておいてください。また、ストアはデータを同期管理するためのライブラリで、監視と更新用のメソッドがあるなどAngularのRxJSに近いです。また、、ディスパッチャは処理を分岐するためのライブラリでこれはReactのuseReducerフックの働きに近いです。
データファイル
データファイルをShopContext.jsというファイルから一元管理するためのストアデータを作成します。ここで注意しないといけないのはwritableメソッドを用いて、オブジェクトを渡す必要がありますが、このwritableメソッドによって返される値は、ストア管理のクラスそのものなので、Vueのreactiveメソッドのように参照渡しするだけのものではありません。
import { writable } from "svelte/store" //writableは外部から呼び出す必要がある
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 storage = writable(values) //ここでwritableメソッドを通す
このwritableメソッドを通すと、任意のデータはクラス化され、以下のような働きを持ちます。
const storage = writable(storage)
storage.set([]) //更新前に初期設定を行う(この記述だとリセットとなるが、今回は使用しない)
storage.subscribe(x=> store = x) //更新対象のオブジェクトstoreを抽出
storage.update({...store,...state}) //元のオブジェクトに対し、差分stateを更新
$storage //プレフィックスをつけると、オブジェクトをそのまま利用できる(同期対象)
これを把握することが大事です。また、繰り返しますがデータの同期が可能なのはストアデータかsubscribeメソッドで抽出した監視対象オブジェクトだけです。
ナビゲーションファイル
次は親コンポーネントとなるナビゲーション用ファイルです。ここで大事なのは子コンポーネントにデータを受け渡すときの表記です。
<script>
import {Router, Link , Route } from "svelte-routing"
import Products from "./pages/Products.svelte"
import Cart from "./pages/Cart.svelte"
import Detail from "./pages/Detail.svelte"
import {storage} from "./pages/ShopContext.js"
import Modules,{addProductToCart,removeProductFromCart,buyIt,getter} from "./modules.svelte"
//処理分岐用のメソッド
function reducer(ev){
const mode = ev.detail.mode
const selid = ev.detail.id
let state = [] //更新差分
let store = [] //監視用オブジェクト
storage.subscribe((x)=> store = x) //監視対象とする
switch(mode){
case "add": addProductToCart(selid,store) //商品かごに追加
break
case "remove": removeProductFromCart(selid,store) //商品を戻す
break
case "buy": buyIt(store) //購入
break
}
state = getter() //戻り値を返す
storage.update(()=>({...store,...state})) //分割代入で更新する
}
$: cnt_cart = $storage.cart.reduce((count,curItem)=>{
return count + curItem.quantity //買い物かごの個数
},0)
</script>
<Router>
<ul class="main-navigation">
<nav>
<li><Link to="/" 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={$storage}/></Route>
<Route path=""><Products {storage} on:reducer={reducer} /></Route>
<Route path="cart"><Cart {storage} on:reducer={reducer} /></Route>
</main>
</Router>
ストアデータを受け渡す場合
同期を取りたいストアデータを子コンポーネントに送る場合は、次の記述が必要になります。普通にコンポーネントにデータを引き渡すだけならbind:dataBind={$storage}
として制御可能ですが、この処理だとストアデータの同期を行えません。したがって、{storage}
とダイレクトにストアを記述する必要があります。
<Route path="detail" ><Detail bind:dataBind={$storage}/></Route>
<Route path=""><Products {storage} on:reducer={reducer} /></Route>
<Route path="cart"><Cart {storage} on:reducer={reducer} /></Route>
受け取ったファイルをコンポーネントで処理する
次は商品リストを制御するProductsコンポーネントと買い物かごを制御するCartコンポーネントの中身を見ていきます。ここでディスパッチャが初めて登場します。
<script>
import { Link } from "svelte-routing"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let storage = []
</script>
<main class="products">
<ul>
{#each $storage.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("add",product.id)}>かごに入れる</button>
</div>
</li>
{/each}
</ul>
</main>
ディスパッチャについて
ディスパッチャは前述した通り処理の分岐を円滑にするためのメソッドで、買い物かごに追加するaddProductToCartメソッドがProductsコンポーネント内に、買い物かごから商品を返却するremoveProductFromCartメソッド、そして購入処理を行うbuyItメソッドがCartコンポーネント内に存在していますが、いずれも同一のストアデータを扱っていくことになるので、その処理を逐一重複して書くのは面倒です。そこでディスパッチャを用いれば、処理を一元管理できます。その記述が以下の部分です。
<script>
import { createEventDispatcher } from "svelte" //svelteライブラリから呼び出す
const dispatch = createEventDispatcher() //メソッドを使用できるようにする
</script>
/*中略*/
<button on:click={()=>dispatch("add",product.id)}>かごに入れる</button>
<script>
import { createEventDispatcher } from "svelte" //svelteライブラリから呼び出す
const dispatch = createEventDispatcher() //メソッドを使用できるようにする
</script>
<main class="cart" >
/*中略*/
<div>
<button on:click={()=>dispatch("remove",cartItem.id)}>買い物かごから戻す(1個ずつ)</button>
</div>
/*中略*/
<button on:click={()=>dispatch("buy",null)}>購入</button>
/*中略*/
</main>
また、dispatchメソッドは以下の記述をします。第二引数は無記入の場合でもイベントが代入されますが、処理に利用したい任意の変数を代入することも可能です(イベントオブジェクトのdetailプロパティ内に格納されます)。
dispatch(任意の処理用メソッド,イベント)
また、reducerはコンポーネントを経由できます。その記述が以下の部分で、on:hoge={fuga}によってディスパッチャによって制御されたメソッドを伝播させることができます。
on:子コンポーネントのdispatchメソッドの第一引数={親コンポーネント内のメソッド}
<Route path=""><Products {storage} on:reducer={reducer} /></Route>
分岐処理用のメソッド
GlobalState.svelteファイルにはreducerという任意の処理分岐用メソッドを用意しています。そして、ここが今回のデータ処理において最も要となる部分です。reducerはdispatchによって制御されたメソッドで、第二引数に処理分岐用の変数を代入していたことで、イベントオブジェクトを格納した変数より呼び出すことができます。
ディスパッチャのevent変数は色々なプロパティを格納しており、event.detailとすると代入データを抽出できます(任意の変数で問題ないので、今回はevと記述)
<script>
function reducer(ev){
const mode = ev.detail.mode //dispacthメソッドで代入した変数(modeプロパティ)
const selid = ev.detail.id //dispacthメソッドで代入した変数(idプロパティ)
let state = [] //更新差分
let store = [] //監視用オブジェクト
storage.subscribe((x)=> store = x) //監視対象とする
switch(mode){
case "add": addProductToCart(selid,store) //監視対象としてのデータを渡す
break
case "remove": removeProductFromCart(selid,$storage) //プレフィックスのまま渡す
break
case "buy": buyIt(store)
break
}
state = getter()
storage.update(()=>({...store,...state})) //分割代入で更新する
}
</script>
ストアデータの更新
今回の買い物かごにおいて、同期対象のデータはstorageという、データファイルからwritableメソッドを通して渡されたストアデータです。ここで更新処理を施すために処理用メソッドにデータを受け渡す際には、プレフィックスをつけてストアデータを渡すか、subscribeメソッドによって抽出された更新対象のオブジェクトを渡すか、いずれかの手段が必要です。上記の例でいえばaddProductToCartメソッドで渡しているのは監視対象のデータで、removeProductFromCartメソッドで渡しているのはストアデータです(今回はテスト用に敢えて区別していますが、今回のケースのようにストアデータを一元管理している場合は同一でも問題ありません)。
いずれにしても更新処理に欠かせないのは同一コンポーネントにおいてupdateというメソッドを実行することであり、ここでは分割代入で更新する必要があります。ここもプレフィックスを付与したストアデータか監視対象のデータを代入すれば大丈夫です。
これでデータが更新されたので、リアクティブ(いわばSvelte版算出プロパティのこと)の$:cnt_cartも更新を検知し、買い物かごの個数cnt_cartの値が再計算されるようになります。
storage.update(()=>{...更新前のデータオブジェクト,...更新後の差分データオブジェクト})
あるいは更新元がストアデータの場合、下記のように再代入することも可能なようです。
$storage = [...$storage,...store]
※注意
ディスパッチャでストアデータを転送しても同期できません。なぜならSvelteのディスパッチャはあくまで処理の分岐だけで、戻り値を返せない(変数としてテンプレートに参照させることは可能)からです。今回敢えて分岐処理のあとに差分を戻り値で返しているのはそのためです。
また、ストアデータが同期可能なのは、あくまで同一コンポーネント内で処理した場合です。以下のようにgetterメソッドの中で、updateメソッドを記述しても、別階層のコンポーネントで処理しようとしているため処理エラーとなります。
export function getter(storage){
storage.update(()=>({...storage,...stat})) //一旦、処理が分断されているのでstorageはストアクラスではなくなっている
}
子コンポーネントの同期データ
ちなみに、伝播先の子孫コンポーネントではストアデータでないと同期対象とならない仕様があります。同期をとりたくない、堅牢性を維持したいオブジェクトの場合は次のように対処するといいでしょう。
let _storage = $storage
<script>
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let storage = []
let _storage = $storage //_storageは同期をとれなくなる
</script>
<main class="cart" >
{#if $storage.cart.length <= 0}<p>No Item in the Cart!</p>{/if}
<ul>
{#each $storage.cart as cartItem,i}
<li>
<div>
<strong>{ cartItem.title }</strong> - { cartItem.price }円
({ cartItem.quantity })
</div>
<div>
<button on:click={()=>dispatch("reducer",{mode:"remove",id:cartItem.id})}>買い物かごから戻す(1個ずつ)</button>
</div>
</li>
{/each}
</ul>
<h3>合計: {$storage.total}円</h3>
<h3>所持金: {$storage.money}円</h3>
{#if $storage.money - $storage.total >= 0}
<button on:click={()=>dispatch("reducer",{mode:"buy",id:null})}>購入</button>
{/if}
</main>
ストアデータの初期値を不変のまま維持したい場合はストアデータからderivedメソッドを用いて、最初からwritableを通さずに、元データのまま受け渡すことができます。
処理用メソッドについて
実は処理用メソッドは自分が作ったVue、React、Angular用の記事の使いまわしで、このストアを用いれば、処理用メソッドを格納するモジュールはほとんどいじる必要ありません(どのフレームワークもメソッド最終行の処理を変更するだけ)。
ですが、繰り返し説明となりますが、Svelteのモジュール(scriptにcontext="module")内ではディスパッチャが使用できないようで、それでGlobalState.svelteファイル内に処理分岐用のreducerメソッドを移動させています。
また、storeという戻り値だけをモジュール上のgetterメソッドで返すこともできます。その場合、グローバルスコープに注意しないと、オブジェクトの同期がとれません(一旦、stateという変数をかましているのはそのためです)。
<script context="module">
let dataBind = []
let storage = []
let stat = []
let state = []
const addProductToCart = async(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
state = {...stat}
}
//カートから商品の返却
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
state = {...stat}
}
//購入手続き
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
state = {...stat}
}
//合計金額の算出
const getSummary = (cart,total)=>{
const sum = cart.reduce((total,{price = 0,quantity})=> total + price * quantity,0)
return sum
}
export {addProductToCart,removeProductFromCart,buyIt}
export function getter(){
storage = state
return storage
}
</script>