# Svelte 5 的 runes：把響應式攤在桌上

> Svelte 5 的 runes 把原本靠編譯器猜的響應式換成顯式的 reactive primitive，顯式換可預測，代價是多幾條要記的規則。

原文：https://sheng.page/posts/svelte-5-runes-explicit-reactivity/ · 發布：2025-10-18 · 作者：Sheng

Svelte 5 含 runes 在去年十月正式發布，到現在算是穩了。runes 這東西直說，就是把以前「靠編譯器猜哪些變數是響應式」這件事，改成你自己在程式碼裡寫清楚。`$state`、`$derived`、`$props`、`$effect` 這幾個帶 `$` 的符號是編譯器層級的 keyword，並非 function，不用 import，而且在 `.svelte` 跟 `.svelte.ts` 裡都能用。對一個寫過 Svelte 3/4 的人來說，這是個值得適應的轉變。

## 從「編譯器幫你猜」到「你自己講清楚」

Svelte 4 的響應式是靠 `let` 宣告加上 `$:` label 在編譯期分析出來的。好處是程式碼簡潔，壞處是那層魔法藏在編譯器裡，元件一大、邏輯一繞，你就開始猜到底哪個變數會觸發更新、`$:` 的執行順序又是怎麼排的。能 build 不代表你真的掌握了資料怎麼流。

runes 把這層列出來。`let count = $state(0)` 就是宣告一個響應式的值，`count` 本身還是個普通數字，你照常 `count++` 就好，沒有額外的 set API。要算衍生值就 `$derived`：

```svelte
<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 就整合掉：

```ts
// 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 翻新計劃，新漆未乾。