Svelte Store 狀態管理

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

cover image


回到 手把手開始寫 Svelte


前言

終於繼續更新了,上禮拜期中考週

進入到進階篇,第一篇先來寫 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 來看,我私心覺得用 $ 可能效能更好?(撇開退訂)

來觀察一下使用 updateset 所產出的如下:

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 3computedSvelte 的響應式資料的 $

假設我要一個 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.tsfps 導出。

然後可以建立一個 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}

然後也可以戳一戳去觀察他 startstop 的情形。


衍生自多個 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

用了就回不去了




最後更新時間: 2023年04月16日.