Svelte Store 狀態管理
tags: Svelte
category: Front-End
description: Svelte Store 狀態管理
created_at: 2023/04/16 22:00:00

前言
終於繼續更新了,上禮拜期中考週
進入到進階篇,第一篇先來寫 Store,也就是狀態管理的部分,狀態管理的概念可以參考我在 Vue 3 的文章,核心概念都是一樣的。
簡單來說就是當你組件開始多了,要傳遞資料的時候可能會需要傳遞多個組件,而中間組件可能根本不需要這些東西,如果有個地方專門存放這些狀態,要的時候直接拿,就會方便許多。
基本使用
在 Svelte 當中定義 Store 非常簡單(相比於其他框架)
不過在建立 Store 之前,先做一個簡單的 Counter:
/src/routes/+page.svelte
<script lang="ts">
    let count = 0;
    function increment() {
        count += 1;
    }
    function decrement() {
        count -= 1;
    }
    function reset() {
        count = 0;
    }
</script>
<div>
    <h1>Counter</h1>
    <p>Count: {count}</p>
    <button on:click={increment}>+</button>
    <button on:click={decrement}>-</button>
    <button on:click={reset}>Reset</button>
</div>
假設這時我希望建立一個 Store 去管理 count 的值,非常簡單
建立檔案: /src/stores/count.ts
import { writable } from 'svelte/store';
export const count = writable(0);
就這麼簡單,你沒看錯
使用他的方式比較像 Angular,是使用訂閱(subscribe)的方式(應該沒改吧,我好幾年沒碰了(如果他還是 )based on rxjs 的話
這時先建立一個新的組件,取代掉呈現的部分:
/src/components/ShowCount.svelte
<script lang="ts">
    import { count } from '../stores/count';
    let value = 0;
    count.subscribe((val) => {
        console.log(val);
        value = val;
    });
</script>
<div>
    Count: {value}
</div>
console.log 是給你檢查用的,會看到 console 印出 0。
這時要把 +page.svelte 的內容也換掉:
<script lang="ts">
    import ShowCount from '../components/ShowCount.svelte';
    import { count } from '../stores/count';
    function increment() {
        count.update((n) => n + 1);
    }
    function decrement() {
        count.update((n) => n - 1);
    }
    function reset() {
        count.set(0);
    }
</script>
<div>
    <h1>Counter</h1>
    <ShowCount />
    <button on:click={increment}>+</button>
    <button on:click={decrement}>-</button>
    <button on:click={reset}>Reset</button>
</div>
這時你會發現一切運作正常,然後當 count 的值改變的時候,console.log 也會一直觸發。
這個簡單的範例就剛好展示出了他提供的三個方法:
set: 直接設定值update: 基於上一個值來更新subscribe: 訂閱他,當改變會執行
我要退訂
實際上訂閱是需要取消訂閱(unsubscribe)(退訂)的,如果你沒退訂久了可能會發生災難,所以還是養成好習慣。
unsubscribe 的取得方式很簡單,subscribe 函數的回傳值就是 unsubscribe
這時先弄一個很髒的東西來看看沒退訂會怎樣:
+page.svelte
<script lang="ts">
    // 略
    let value = 0;
    count.subscribe((val) => {
        value = val;
    });
</script>
<div>
    <h1>Counter</h1>
    <!-- 偷偷交換順序,比較好操作 -->
    <button on:click={increment}>+</button>
    <button on:click={decrement}>-</button>
    <button on:click={reset}>Reset</button>
    {#if value % 2 == 0}
        <ShowCount />
    {/if}
</div>
這時你去多戳幾次增減,會發現你的 console.log 觸發越來越多次,這不是我們要的,所以我們要退訂,像是下面這樣:
ShowCount.svelte
const unsubscribe = count.subscribe((val) => {
    console.log(val);
    value = val;
});
onDestroy(unsubscribe);
這時在回去戳就正常了。(不會重複 console.log)
自動訂閱 & 退訂
上面訂閱&退訂,多了之後會變得很 routine 很重複,所以有這個 $ 語法糖,跟前面提到的響應式資料不一樣。
可以先像下面這樣確認他有幫我們退訂:
<script lang="ts">
    import { count } from '../stores/count';
    let value = 0;
    $: {
        console.log($count);
        value = $count;
    }
</script>
<div>
    Count: {value}
</div>
玩夠了之後再把他簡寫:
<script lang="ts">
    import { count } from '../stores/count';
</script>
<div>
    Count: {$count}
</div>
超精簡
然後這時 +page.svelte 也可以改寫
<script lang="ts">
    import ShowCount from '../components/ShowCount.svelte';
    import { count } from '../stores/count';
    function increment() {
        $count += 1;
    }
    function decrement() {
        $count -= 1;
    }
    function reset() {
        $count = 0;
    }
</script>
<div>
    <h1>Counter</h1>
    <button on:click={increment}>+</button>
    <button on:click={decrement}>-</button>
    <button on:click={reset}>Reset</button>
    {#if $count % 2 == 0}
        <ShowCount />
    {/if}
</div>
$ 不只可以自動訂閱 & 退訂,還可以直接對他做修改。
這樣改會不會有性能問題?
Svelte 是一個編譯器,直接從他產出的 code 來看,我私心覺得用 $ 可能效能更好?(撇開退訂)
來觀察一下使用 update、set 所產出的如下:
function increment() {
    count.update(n => n + 1);
}
function decrement() {
    count.update(n => n - 1);
}
function reset() {
    count.set(0);
}
而 update 實際上底層也是去呼叫 set;而使用 $ 編譯出來的是:
function increment() {
    set_store_value(count, $count += 1, $count);
}
function decrement() {
    set_store_value(count, $count -= 1, $count);
}
function reset() {
    set_store_value(count, $count = 0, $count);
}
而 set_store_value 函數非常簡單:
function set_store_value(store, ret, value) {
  store.set(value);
  return ret;
}
也是直接去呼叫 set,但少了像 update 還要先把函數執行完才知道結果的這個步驟。
(也許我不是很理解 svelte,如果有錯可以跟我說XD)
唯讀的 Store
有些時候可能想要定義一個狀態,他是唯讀的,只能給其他地方用但不能改。(只能透過自己改)
所以我這邊想了一個使用情境,假設你在前端做遊戲,你可能需要一個每 1 fps 去增加的 frame,在遊戲過程中可以根據這個 frame 去做一些處理,這時你就不會希望外部可以直接修改這個 frame 值。
先寫下下面的 code,待會再來解釋
/src/stores/frame.ts
import { readable } from 'svelte/store';
const fps = 30;
let value = 0;
export const frame = readable(0, function start(set) {
    console.log('start');
    const interval = setInterval(() => {
        set(++value);
    }, 1000 / fps);
    return function stop() {
        console.log('stop');
        value = 0;
        set(value);
        clearInterval(interval);
    };
});
改成使用 readable 宣告,就會只剩下 subscribe 可以使用,外部沒有辦法再去修改他的值,就算你使用前面的語法糖也無法。
而第二個參數帶入一個函數,當有人去訂閱的時候會觸發,可以在 start 函數內回傳一個函數,被回傳的函數(stop)會觸發在最後一個退訂的時候。
所以上面這段就是當有第一個訂閱來的時候會去開始計時器去增加 frame,而當所有地方都退訂,沒有人訂閱的時候就把 frame 歸零。
要測試的話可以把 +page.svelte 變成
<script lang="ts">
    import ShowFrame from '../components/ShowFrame.svelte';
    let loop = 1;
</script>
<div>
    <h1>Game</h1>
    <button on:click={() => loop++}>Add</button>
    <button on:click={() => loop--}>Delete</button>
    {#each { length: loop } as _}
        <ShowFrame />
    {/each}
</div>
然後建立一個: /src/components/ShowFrame.svelte
<script lang="ts">
    import { frame } from '../stores/frame';
</script>
<div>
    Frame: {$frame}
</div>
這時你去戳 Add,下方會多渲染一個訂閱 frame 的組件
點擊 Delete 會退訂一個訂閱 frame 的組件
可以觀察 console 的變化,應該很好理解。
衍生自其他 Store
一個 store 也能使用其他 store 的值來做衍生,有點像 Vue 3 的 computed 或 Svelte 的響應式資料的 $。
假設我要一個 time 他是從 frame 去計算的:
/src/stores/time.ts
import { derived } from 'svelte/store';
import { fps, frame } from './frame';
export const time = derived(frame, ($frame) => {
    return ($frame / fps).toFixed(2);
});
因為用到了 fps,所以記得把 frame.ts 的 fps 導出。
然後可以建立一個 ShowTime 去使用他
<script lang="ts">
    import { time } from '../stores/time';
</script>
<div>
    Time: {$time}
</div>
然後在 +page.svelte 就可以使用 ShowTime
<script lang="ts">
    import ShowTime from '../components/ShowTime.svelte';
    // 略...
    let showTime = true;
</script>
<!-- 略... -->
<button on:click={() => (showTime = !showTime)}>Toggle time</button>
<!-- 略... -->
{#if showTime}
    <ShowTime />
{/if}
然後也可以戳一戳去觀察他 start 和 stop 的情形。
衍生自多個 Store
import { derived, writable } from 'svelte/store';
export const a = writable(0);
export const b = writable(0);
export const ab = derived([a, b], ([$a, $b]) => {
    return $a + $b;
});
可以像這樣使用他:
<script lang="ts">
    import { a, b, ab } from '../stores/ab';
</script>
<div>
    <h1>A + B</h1>
    <div>A: <input type="number" bind:value={$a} /></div>
    <div>B: <input type="number" bind:value={$b} /></div>
    <div>A + B: {$ab}</div>
</div>
自訂 Store
上面宣告 writable 的時候會暴露出三個函數,假設你不希望外部直接使用,可以把它包裝起來。
import { writable } from 'svelte/store';
export function createCount() {
    const { subscribe, set, update } = writable(0);
    return {
        subscribe,
        increment: () => update((n) => n + 1),
        decrement: () => update((n) => n - 1),
        reset: () => set(0)
    };
}
然後可以像這樣使用他
<script lang="ts">
    import { createCount } from '../stores/counter';
    const count = createCount();
</script>
<div>
    <h1>Counter</h1>
    <button on:click={() => count.increment()}>+</button>
    <button on:click={() => count.decrement()}>-</button>
    <button on:click={() => count.reset()}>Reset</button>
    <span>{$count}</span>
</div>
總結
這一篇介紹了 Store 的使用方式,真的相比於其他框架簡單太多了XD
用了就回不去了