Svelte 響應式資料

tags: Svelte
category: Front-End
description: Svelte 響應式資料
created_at: 2023/04/02 15:00:00

cover image


回到 手把手開始寫 Svelte


前言

老樣子,還是假設你已經做好開新專案與環境設定裡面所做的事

這邊也和 VueReact 差很多,但也比較方便一些(?)

Vue 當中是使用 ref()reactive() 去做到。

React 當中就是 useState() 這個 hook 去做。

而在 Svelte 中,你只要有 assign(=),就可以了。

前面都沒有提過 Svelte 核心跟其他像是 VueReact 到底哪裡不一樣,這邊剛好比較適合帶出來提。


與其他框架的差異

也不見得是框架(framework),因為你可能會說是函式庫(library),總之就是到底 Svelte 與其他像是 VueReact 到底哪裡不一樣?

Svelte 出現之前,大多都是採用虛擬DOM(Virtual DOM)去做即時的更新 UI,而 Virtual DOM 雖然主打會比原生(native)效能快,實際上是因為基於 diff 演算法先去找更新前後的 DOM 的差異,重複利用沒變化的部分,舉例簡單的例子來說:

假設有一個資料 data 的值是 Hello,且假設下面的語法合法。

<div>{data}</div>

會輸出

<div>Hello</div>

然而 data 改成 Hello World,理所當然的會輸出

<div>Hello World</div>

但他是用同一個 div,所以實際上他是改了 innerText 之類的東西。

所以為什麼會比原生操作還快? 有些人可能會覺得不可思議,他跑的都是 JavaScript,為什麼可以比自己還快?

更精確地說一些應該說操作 DOM 的速度? 以上面的例子來說,如果你每次都刪除所有 DOM,重新 append 所有節點,就很慢。

但如果透過一個演算法去找出差異的部分,不要每次都去刪除又插入,只插入跟刪除必要做的,重複利用可以直接用的,當然就會比較快了。

但這時 diff 演算法就很重要,但也不是你我可以控制的就是了,但他也足夠快了


Svelte 怎麼做?

Svelte 主打沒有 Virtual DOM,上面提到 Virtual DOM 他是在 runtime 的時候做運算,而 Svelte 像是一個編譯器(compiler),他把 runtime 要做的事情移動到編譯的時候做,所以對於瀏覽器來說他只是執行 JavaScript 而已,不用再去跑 diff 演算法。

舉例來說在做更新的時候:

  1. 檢查前後 DOM 有無差異,假設都是 div,就可以沿用
  2. 去檢查所有屬性是否需要修改
  3. 發現有東西改變,所以去更新真實的 DOM

可以看到除了第三點是真的去動 DOM,而其他步驟都只是在檢查,而且檢查也是跑在 runtime,如果可以把檢查移動到編譯的時候做掉,那 runtime 效能就會提高許多。

套用官方原文:Unlike traditional UI frameworks, Svelte is a compiler that knows at build time how things could change in your app, rather than waiting to do the work at run time.

簡單說就是: Svelte 跟傳統的框架不同,Svelte 是一個編譯器,他知道你的應用程式什麼時候需要更新,而不是在 runtime 才跑那些額外的檢查。

舉例來說,當你寫下面簡單的語法:

<script>
    let num = 5;
</script>

{#if num % 2 == 0}
    {num} is even
{:else}
    {num} is odd
{/if}

<button on:click={() => (num += 1)}>num++</button>

雖然還沒講到事件處理,不過反正就是點擊按鈕,num 會被 +1

這時 Svelte 會編譯出像下面的東西(節錄):

function create_else_block(ctx) {
    // 略...
}

function create_if_block(ctx) {
    // 略...
}

function create_fragment(ctx) {
    // 略...

    function select_block_type(ctx, dirty) {
        if (/*num*/ ctx[0] % 2 == 0) return create_if_block;
        return create_else_block;
    }

    let current_block_type = select_block_type(ctx, -1);
    let if_block = current_block_type(ctx);

    const block = {
        // 略...
        m: function mount(target, anchor) {
            if_block.m(target, anchor);
            // 略...

            if (!mounted) {
                dispose = listen_dev(button, "click", /*click_handler*/ ctx[1], false, false, false, false);
                mounted = true;
            }
        },
        p: function update(ctx, [dirty]) {
            if (current_block_type === (current_block_type = select_block_type(ctx, dirty)) && if_block) {
                if_block.p(ctx, dirty);
            } else {
                if_block.d(1);
                if_block = current_block_type(ctx);

                if (if_block) {
                    if_block.c();
                    if_block.m(t0.parentNode, t0);
                }
            }
        },
        // 略...
    };

    // 略...
}

function instance($$self, $$props, $$invalidate) {
    // 略...
    let num = 6;

    // 略...

    const click_handler = () => $$invalidate(0, num += 1);

    // 略...
}

主要的秘密在 $$invalidate 當中,他會設定新的值進去,然後把變數變髒(標記dirty),並告訴 Svelte 要更新。

你可能會問,設定新的值,設給誰,0??

Svelte 會在底層為每個變數分配一個索引,在底層驗證變數是否 dirty 也是使用位元運算。


所以 Svelte 怎麼樣讓他知道要更新?

就如同最前面說的,你只要有 assign(=),就可以了。

這邊只要注意,一樣會有類似 Vue 碰到的問題,監聽不到 array.push 之類的。

而在 Svelte 當中,則是把變數設回給自己,去解決這個問題。

先嘗試寫一個正確會更新的

<script>
    let nums = [1, 2, 3, 4, 5];
    const pushNum = () => {
        nums.push(nums.length + 1);
        nums = nums;
    };
</script>

<button on:click={pushNum}>num++</button>

{#each nums as num}
    <p>{num}</p>
{/each}

會看到他編譯出這個:

const pushNum = () => {
    nums.push(nums.length + 1);
    $$invalidate(0, nums);
};

而假設你把 nums=nums 拿掉,他就不會編譯出 $$invalidate(0, nums);,自然就不會更新。不信你自己試試


Svelte 如何更新?

上面有提到說 $$invalidate 當中,他會設定新的值進去,然後把變數變髒(標記dirty),並告訴 Svelte 要更新。

而更新的函數原始碼如下,

const resolved_promise = /* @__PURE__ */ Promise.resolve();
let update_scheduled = false;

export function schedule_update() {
    if (!update_scheduled) {
        update_scheduled = true;
        resolved_promise.then(flush);
    }
}

看到這個,大概可以猜測他會批次更新,且因為 promise 是微任務(microtask),所以他會在最後才一起處理。(microtask相關的內容之後有機會再來跟 Event loop 一起寫一篇文章填坑)。

總之就先以一個範例帶過,假設你寫下面這段 js

setTimeout(() => console.log('timeout'), 0);

Promise.resolve().then(() => console.log('promise'));

console.log('end');

上面的輸出會是

end
promise
timeout

end 先出現,這個有一點經驗的應該都能輕易判斷出來,但 promisesetTimeout 的順序可能就會猶豫了一下。

總之可以先簡單想成下面這樣更新:

const resolvedPromise = Promise.resolve();
let isUpdating = false;

function onUpdated() {
    console.log('onUpdated');
    isUpdating = false;
}

function update() {
    console.log('update');
    if (!isUpdating) {
        isUpdating = true;
        resolvedPromise.then(onUpdated);
    }
}

update();
update();

他會輸出

update
update
onUpdated

也就是說不管你觸發幾次 $$invalidate,他都只會真的去執行 flush 一次。


懶人包總結

只要你有 =(assign) 出現,他編譯出 $$invalidate,就可以了。


補充: 一個要注意的陷阱?

雖然說得很簡單,有 = 出現就可以,不過還是有些地方要注意。

雖然你應該不會寫出這麼髒的程式,但還是做個紀錄

<script>
    let obj = {
        foo: 'bar',
        deep: {
            foo: 'bar'
        }
    };

    let x = {
        foo: 'bar x'
    };
    const changeFoo = () => {
        x = obj;
        x.foo = 'baz x';
    };

    const changeDeepFoo = () => {
        obj.deep.foo = 'baz';
    };
</script>

<div>{obj.foo}</div>
<div>{obj.deep.foo}</div>

<hr />

<div>x: {x.foo}</div>

<hr />

<button on:click={changeFoo}>Change foo</button>
<br />
<button on:click={changeDeepFoo}>Change deep foo</button>

他會編譯出

let obj = { foo: 'bar', deep: { foo: 'bar' } };
let x = { foo: 'bar x' };

const changeFoo = () => {
    $$invalidate(1, x = obj);
    $$invalidate(1, x.foo = 'baz x', x);
};

const changeDeepFoo = () => {
    $$invalidate(0, obj.deep.foo = 'baz', obj);
};

會發生什麼事大概可以想像,當你點 changeFoo,他只會更新用到 x 的依賴,所以雖然 obj.foo 被更新了,但畫面上不會更新,直到你點下 changeDeepFoo 他觸發 obj 的更新,畫面上用到 obj.foo 才會更新。




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