Svelte 5 的 runes:把響應式攤在桌上
Svelte 5 的 runes:把響應式攤在桌上
Svelte 5 含 runes 在去年十月正式發布,到現在算是穩了。runes 這東西直說,就是把以前「靠編譯器猜哪些變數是響應式」這件事,改成你自己在程式碼裡寫清楚。$state、$derived、$props、$effect 這幾個帶 $ 的符號不是 function,是編譯器層級的 keyword,不用 import,而且在 .svelte 跟 .svelte.ts 裡都能用。對一個寫過 Svelte 3/4 的人來說,這是個值得適應的轉變。
從「編譯器幫你猜」到「你自己講清楚」
Svelte 4 的響應式是靠 let 宣告加上 $: label 在編譯期分析出來的。好處是程式碼簡潔,壞處是那層魔法藏在編譯器裡,元件一大、邏輯一繞,你就開始猜到底哪個變數會觸發更新、$: 的執行順序又是怎麼排的。能 build 不代表你真的掌握了資料怎麼流。
runes 把這層列出來。let count = $state(0) 就是宣告一個響應式的值,count 本身還是個普通數字,你照常 count++ 就好,沒有額外的 set API。要算衍生值就 $derived:
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>{doubled}</button>
$derived 裡讀到的東西自動變成它的依賴,源頭一變就標記為 dirty,下次被讀到才重算。這跟 Svelte 4 的 $: 差在哪?差在「響應式」這件事從變數命名的副作用,變成你明確套上去的 primitive。少了一層猜測,多了一點可預測。
我喜歡這個方向,但不打算假裝它沒有成本。顯式換來的是幾條要記的規則:$state 套在物件或陣列上會回傳一個深層響應的 proxy,push、改屬性都能觸發更新,但你 destructure 出來的值就不再是響應式的了,因為那是 JS 在解構當下取的快照。class 實例不會被 proxy 化,要在欄位上各自寫 $state。這些都不難,但都得知道。重點是用「狀態怎麼流動」去理解,而不是把這幾條當 API 背。背 API 遲早會在某個邊界踩坑。
跟 React/Vue 比,少一層抽象
對有 DOM 直覺的人來說,Svelte 的心智模型一直比較流暢:你寫的是標記加狀態,編譯器幫你生出直接操作 DOM 的程式碼,中間沒有 virtual DOM 那層。React 的 re-render 模型要你時時記得「整個 function component 會重跑」,所以才有 useMemo、useCallback、依賴陣列這一整套為了壓制重算而存在的工具。那些東西多半不是在解業務問題,是在跟框架的執行模型角力。
Svelte 5 的 push-pull 響應式走的是另一條路:狀態一變,依賴它的東西立刻被通知(push),但 $derived 不會馬上重算,要等真的被讀到才算(pull)。而且新值如果跟舊值 referentially 相同,下游更新直接跳過。這不是什麼黑魔法,就是把「誰依賴誰、什麼時候該重算」這件事講清楚而已。對照 React 那套要靠人工標記依賴的做法,Svelte 把記帳的工作收回編譯器,但記帳的「規則」是你看得到的。
Vue 的 ref/reactive/computed 其實跟 runes 概念上很近,都是顯式的響應式 primitive。差別在 Svelte 沒有 .value 的包裝,也沒有 virtual DOM 的 diff。你要的就是少一層抽象,Svelte 給的就是少一層。
真實取捨:module state 退場 stores
runes 最實際的好處,是把跨元件共享狀態這件事變簡潔了。以前要嘛用 Svelte stores,寫 writable、訂閱、$store 自動訂閱那套;要嘛自己想辦法。現在直接在 .svelte.ts 裡寫響應式 module state 就好。
拿購物車舉例。需求很單純:跨頁面共享、刷新後還在。以前我會開個 store,搭 subscribe 寫進 localStorage,再處理初始化讀回來。現在一個 .svelte.ts module 就整合掉:
// cart.svelte.ts
function createCart() {
let items = $state<Item[]>(load());
$effect.root(() => {
$effect(() => localStorage.setItem('cart', JSON.stringify(items)));
});
return {
get items() { return items; },
add: (i: Item) => { items.push(i); },
clear: () => { items = []; },
};
}
export const cart = createCart();
這裡有個會踩雷的地方:你不能直接 export let count = $state(0) 再到處重新賦值,因為編譯器會把每個 count 的參照都改寫掉,跨 module 的賦值會接不上。所以對外要嘛包成物件用 getter 暴露,要嘛給 add() / clear() 這種操作 function。知道這條規則,設計起來就順了。
舊的 stores 寫法不是不能用,svelte/store 還在,但對新的程式碼,module state 多半更直接,也少一層 $ 自動訂閱的語法糖要解釋給下一個人聽。能退場就讓它退場。
對不追最大生態、只想要工具輔助思考而不是給安全感的人,Svelte 5 在這一票現代框架裡,算是相對俐落的一支參考實作。它沒有把所有東西都包進框架抽象裡,HTML 還是主角,響應式也擺在你看得到的地方。這就夠了。
重點整理
- runes 把 Svelte 4 靠編譯器猜的響應式,換成
$state/$derived/$props/$effect這種顯式 primitive,顯式換可預測。 - 顯式的代價是幾條要記的規則(proxy 深層響應、destructure 失去響應、class 欄位要各自標),用狀態流動去理解,別當 API 背。
- 相對 React 的 re-render 模型,Svelte 少一層 virtual DOM,對有 DOM 直覺的人心智負擔更低。
- 購物車這類共享狀態用
.svelte.ts的 module state 加localStorage很清爽,記住不能直接 export 會被重新賦值的$state。 - 對不追生態規模的人,Svelte 5 是現代框架裡相對俐落的參考實作,HTML 還是主角。
觀點 Sheng,內文 Claude 協助 · 列入 20260613 blog 翻新計劃,新漆未乾。