之前一直沒特別說明 Svelte 中 reactivity 的細節以及如何實作的,今天就來「稍微」探討一下 Svelte 的編譯器到底做了什麼事情。
來看一個簡單的計數器例子
<script>
let count = $state(0);
function increment() {
count += 1;
}
</script>
<button onclick={increment}>
clicks: {count}
</button>
上面這個 Svelte 程式經過編譯器後會變成下面 js code
這邊是用 Svelte 5 的 REPL 的輸出,實際上專案中的 output 會經過壓縮及混淆等等程序。
import * as $ from "svelte/internal/client";
function increment(_, count) {
$.set(count, $.get(count) + 1);
}
var root = $.template(`<button> </button>`);
export default function App($$anchor) {
let count = $.state(0);
var button = root();
button.__click = [increment, count];
var text = $.child(button);
$.reset(button);
$.template_effect(() => $.set_text(text, `clicks: ${$.get(count) ?? ""}`));
$.append($$anchor, button);
}
$.delegate(["click"]);
可以看出來 Svelte 的編譯器主要就是幫我們把狀態與畫面之間的互動邏輯給完成了,像是我們寫完的 markup 怎麼被畫在 DOM 上、事件與狀態更新的邏輯、事件的掛載以及狀態更新時 DOM 如何被更新。
如果只看這兩個片段
function increment(_, count) {
$.set(count, $.get(count) + 1);
}
$.template_effect(() => $.set_text(text, `clicks: ${$.get(count) ?? ""}`));
會看到幾個比較有意思的 Svelte runtime 的 function set
、 get
、 template_effect
,大概可以推論說 Svelte 底層自動地幫我把狀態的變更以及狀態變更後的畫面重新渲染給實作了,然後這邊也可以看出一個小細節
$.template_effect(() => $.set_text(text, `clicks: ${$.get(count) ?? ""}`))
這行程式碼的另外一層意義就是這個地方是單獨 re-render 的,也就是說它不像 React 一樣是「以 component 作為 re-render 的最小單位」,至於什麼情況下會觸發 template_effect
呢?基本上可以認為只有它所依賴的狀態有變更時才會 re-render ,以這個例子來說就是 count
有變動時才會觸發 re-render ,而且也只會讓這裡 re-render 而不是整個 Svelte component。
上面的例子或許有的讀者已經能夠聯想到 「 Signal」這個大概這一兩年在前端圈裡常常提到的概念,簡單來說就是「Fine-grained reactivity」直翻的話大概就是細顆粒度響應性,至於為什麼說是「細顆粒度」因為相比「以 component 作為 re-render 的最小單位」來說, Signal 只會在狀態更新時,有用到該狀態的地方會自動重新計算。
雖然聽起來有點像 observable ,但不管在實作還是概念上還是有相當大的差距,對於這個主題有興趣的讀者可以參閱這篇文章,這裡就不展開討論了。
這邊「簡單實現」一個 Signal 來理解看看這個概念
let subscriber
export const signal = (value) => {
let subscriptions = [];
return{
get value(){
if(subscriber){
subscriptions.push(subscriber)
}
return value;
},
set value(updated){
value = updated;
subscriptions.forEach(fn=>fn())
}
}
}
export const effect = (fn)=>{
subscriber = fn;
fn()
subscriber = null;
}
export const derived = (fn)=>{
const dep = signal()
effect(()=>{
dep.value = fn()
})
return dep
}
import { effect, signal, derived } from "./signal.js";
document.querySelector("#app").innerHTML = `
<div>
<button id='counter' onclick={increment}>
</button>
</div>
`;
const $btn = document.querySelector("#counter");
const count = signal(0);
$btn.addEventListener("click", () => {
count.value++;
});
effect(() => {
$btn.textContent = `clicks: ${count.value}`;
});
這樣看應該就大概能理解是在使用 effect
時如果有使用到 signal
便會順便把 subscriber
記起來(或者可以理解就是訂閱),然後在 signal
更新時會再次執行所有的 subscriptions
。
當然這跟 Svelte 編譯器實際上做的事情還有一段差距,像是自動產生類似 template_effect
的行為來 re-render 以及某些效能優化的實作,但大致上應該可以為什麼 Svelte 的 $effect
及 derived
是怎麼做到像是自動收集依賴的行為,就是因為這些狀態更新後會自動重新計算這些 subscriptions
。