在閱讀關於 React 和 Vue 的文章時,我們經常會看到應該使用 useMemo和不應該使用 useMemo,以及事件處理器中應該使用 useEffect和[不應該在事件處理器中使用 useEffect](https://zenn.dev/begineer/articles/8a0696fda04c09#2.%E3%83%A6%E3%83%BC%E3%82%B8%E3%82%A2%E3%83%99%E3%83%B3%E3%83%88%E3%81%AE%E5%87%A6%E7%90%86%E3%81%AB%E3%82%A8%E3%83%95%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AF%E5%BF%85%E8%A6%81%E3%81%82%E3%82%8A%E3%81%BE%E3%81%9B%E3%82%93%E3%80%82),有時甚至會看到「為什麼會無限重渲染」或「為什麼某些計算會執行兩次」的疑惑文章,那麼該怎麼辦呢?
那麼,為什麼這些問題要我們來解決呢?
本來,反應式這個概念是你們自作主張引入的,為什麼解決問題的責任不在函式庫那邊自動解決呢?
為什麼使用者每次都必須面對這些問題?
這時登場的就是完全從獨特的角度解決重渲染問題的 JavaScript 框架Crank.js。
import { renderer } from "@b9g/crank/dom";
function Greeting({ name = "World" }) {
return <p>Hello {name}.</p>;
}
function RandomName() {
const names = ["Alice", "Bob", "Carol", "Dave"];
const randomName = names[Math.floor(Math.random() * names.length)];
return (
<div>
<Greeting name={randomName} />
{
<button onclick={() => this.refresh()}>
隨機名稱
</button>
}
</div>
);
}
renderer.render(<RandomName />, document.body);
這段源碼看似普通,但與其他框架最大的不同點在於,refresh() 是使得 Crank.js 特立獨行的最關鍵特徵。
因此接下來是介紹 Crank.js 設計理念的官方部落格,為什麼要使用反應式?
反應式框架雖然保證了 UI 可以自動更新,但卻成為了 bug 和性能問題的溫床。
Crank 明確的 refresh() 並不是限制,而是強大的工具。
本文將檢視反應式常見的陷阱,並解釋 Crank 為何選擇不採用反應式的哲學根據。
當我們首次發布 Crank.js 時,得到了熱烈的反響。
來自 React 核心團隊的支持推文、GitHub 的星數、在 Reddit 和 HackerNews 上的討論都引起了廣泛關注。
不幸的是,我在隨後浪費了許多時間,如今 Crank 的使用率已經不高。
儘管如此,我對於多年來參與 Crank 的過程感到驕傲。
從應對棘手的 bug、API 設計,到性能提升,我參與了從基礎到高級的各類維護工作。
但是,最困難的挑戰並不在技術層面,而是在社會層面上。
我需要說服開發者採納這個新框架,這是一項相當艱難的任務。
我最初的賣點之一便是 Crank 是一個「JavaScript 本身」的框架。
組件就是函數,而異步函數和生成器也都是函數,這意味著你可以直接在組件內部 awaiting Promise 或將狀態保存在局部變數。
這讓人從直覺上感受到,這實際上就是 JavaScript。
為了說服那些認為 JSX 不是 JavaScript 的人,我特意準備了模板標籤。
那麼,僅靠這些賣點就足夠了嗎?
多年使用 Crank 的過程中,我發現更好的賣點。
從一般的反應式定義來看,Crank 其實並不是真正的反應式,甚至可以說是非反應式的。
幾乎所有框架都宣稱自己是反應式的,框架間的比較也都是基於反應式抽象進行的,而 Crank 則是極為特立獨行。
當今的框架是以 Signals、Stores、Observables 等反應式原語為中心構建的。
組件通常會創建 state,而框架則會自動重新渲染。
因此,未能提供反應式抽象也許會被視為是一個不完整的框架。
那麼,為什麼 Crank 要特意宣稱自己是非反應式,甚至想將這一點作為賣點呢?
為了避免抽象的討論,讓我們舉出具體的定義和代碼範例。
在 Web 框架的上下文中,反應式抽象可以定義為「當 state 發生變化時,框架自動更新 view」的功能。
view 和 state 的定義是任意的,但反應式抽象保持兩者之間的狀態是同步的。
即使根據這個最基本的定義,Crank 在早期的版本中也並不反應式。
例如,Crank 的早期版本如下所示,定義一個計時器。
function* Timer() {
let seconds = 0;
const interval = setInterval(() => {
seconds++;
this.refresh();
}, 1000);
try {
for ({} of this) {
yield <div>{seconds}</div>;
}
} finally {
clearInterval(interval);
}
}
Timer 組件的唯一 state 是 seconds。
在改變此值後,必須另外調用 refresh() 方法來更新 view。
初次接觸 Crank 時,許多反應式派的開發者對這段代碼感到反感。
「在更新 state 之後,若忘記呼叫 refresh 就會導致問題,這難道不是一個設計缺陷嗎?」
確實如此。
的確,作為 Crank 的第一批使用者,我曾因忘記在更新 state 之後呼叫 refresh 而苦惱。
然而,我們選擇了不引入反應式抽象,而是努力避免忘記調用 refresh。
幸運的是,最近我意識到通過向 refresh 傳入回調可以解決這個問題。
import { renderer } from "@b9g/crank/dom";
function* Timer() {
let seconds = 0;
const interval = setInterval(() => this.refresh(() => {
seconds++;
}), 1000);
for ({} of this) {
yield <div>{seconds}</div>;
}
clearInterval(interval);
}
renderer.render(<Timer />, document.body);
這個 API 的實現相對簡單,因此在 Crank0.7 版本臨時添加了此功能。
另外,請注意不再需要將 for 迴圈包裹在 try 中。
這是在 Crank0.5 中引入的能夠提高使用便利性的功能。
將 state 的變更放在回調函數內,可以避免忘記 refresh,而回調內的代碼則會被明確識別為需要觸發重渲染的代碼。
這本來是一個應更早想到的想法。
其實這個想法最早是 Claude Code 提出的。
他在開發 Crank 組件的同時卻陷入了 React 的幻覺當中,並且同時對 API 也發生了幻覺。
我既對 Claude 想到 refresh 的回調感到感激,亦對自己未能更早想到這一點感到遺憾。
但是,為什麼要接受忘記 refresh 的情況呢?
反應式抽象是通過自動同步 state 和 view 來消除同步遺忘的 bug。然而,正如後面所述,這個解決方案會引發其他問題。
為了理解我為什麼認為非反應式不是結構上的缺陷,首先來考慮一下 bug 的嚴重性。
bug 的嚴重性可以從以下兩個方面進行評估。
這兩個問題將決定使用者多久會發現此 bug,並且應用程式能否保持運行。
對於 Crank 而言,這兩個問題的答案都是「是」。
因為忘記呼叫 refresh() 造成的 bug,會因為畫面不更新而立即被發現。
而修正也只需簡單地添加 refresh()。
接下來的部分耐人尋味。
反應式抽象宣稱自己可以消除傳統的重渲染問題,但反應式抽象卻有其自身的陷阱。
在下一節中,我將用 Solid、Vue、Svelte 等框架舉出具體範例來說明。
我們來看一下 Solid.js 的著名例子。
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
// ❌ 不反應
function BrokenDisplay1({ seconds }: { seconds: number }) {
return <div>{seconds}</div>;
}
// ❌ 不反應
function BrokenDisplay2(props: { seconds: number }) {
const minutes = props.seconds / 60;
return (
<div>
<span>{props.seconds}</span> 秒已過
<span>{props.seconds === 1 ? "" : "s"}</span>
<span>{minutes.toFixed(2)} 分鐘</span>
</div>
);
}
// ✅ 反應
function WorkingDisplay1(props: { seconds: number }) {
return <div>{props.seconds}</div>;
}
// ✅ 反應
function WorkingDisplay2(props: { seconds: number }) {
const minutes = () => props.seconds / 60;
return (
<div>
<span>{props.seconds}</span> 秒已過
<span>{props.seconds === 1 ? "" : "s"}</span>
<span>{minutes().toFixed(2)} 分鐘</span>
</div>
);
}
function Timer() {
const [seconds, setSeconds] = createSignal(0);
setInterval(() => setSeconds(seconds() + 1), 1000);
return (
<>
<BrokenDisplay1 seconds={seconds()} />
<BrokenDisplay2 seconds={seconds()} />
<WorkingDisplay1 seconds={seconds()} />
<WorkingDisplay2 seconds={seconds()} />
</>
);
}
render(() => <Timer />, document.getElementById("app")!);
Solid 使用兩種反應式抽象,即 Signals 和 Stores,進行 DOM 更新。
在 Solid 中,組件是函數,但傳遞給組件的 props 是反應式的 Store。
為了保持 DOM 的最新狀態,Solid 需要專門的 Babel 渲染器。
這個渲染器在 JSX 讀取狀態時觸發重新計算邏輯。
這種方法在從 props Store 當中提取值或在 JSX 之外更改值時會立即出錯。
我們應用剛剛的嚴重性判斷來思考這個 bug。
在簡單案例中,如果不從 props 中取出值,則使用回調進行計算的 Linter 規則能夠輕易發現這個問題。
然而,複雜的應用程式中可能存在那些不受 Linter 規則約束的邊緣情況。
有關此反應式框架的邊緣案例,建議搜尋losing reactivity,你會發現大量資料。
那麼,這個 bug 好修復嗎?
答案是否定的。
因為每個框架都有其各自的規則,必須完全掌握哪些上下文是反應式的。
操作 props 是複雜的,因此執行基本的拆分和合併任務時,還需要使用專用的工具。
在 Crank 中,props 僅僅是一個普通的物件。
在 Crank 明確的 refresh 模型中,不存在這類 bug。
props 只是普通的值。
你可以從 props 中提取值、進行計算或傳遞給其他函數,全部都是普通的 JavaScript。
如果要更新組件,只需呼叫 refresh()。
那種不易察覺的、脆弱的反應式上下文是不存在的。
接下來以 Vue.js 為例。
Vue 同樣使用控制讀取的反應式抽象。
它為嵌套的狀態和屬性提供了遞歸的反應式代理。
也就是說,即便深層嵌套的狀態被更改,DOM 依然會被更新。
import { ref } from "vue";
const state = ref({
todos: [
{ id: 1, text: "學習 Vue", completed: false, metadata: { priority: "high", tags: ["learning"] } },
// ... 假設有 1000 個嵌套的 todos
],
filters: { status: "all", search: "" },
ui: { selectedTodo: null, theme: "light" }
});
state.value.todos[0].completed = true; // ✅ UI 更新
state.value.todos[0].metadata.priority = 'low'; // ✅ UI 更新
const { ui } = state.value;
// ✅ UI 也會更新,與 Solid 不同的地方
ui.selectedTodo = state.value.todos[0];
私有成員不會起作用的事實我們暫且不提。
這是語言規範的問題。
還有,原始數據類型不能使用的事實我們也先不提。
這正是 Vue 的 reactive() 和 ref() 之所以複雜的原因。
此外,對於大型物件或陣列深層進行代理,會帶來性能瓶頸。
因此,在 Vue 中,對於大型物件建議僅對頂層進行淺層代理。
Vue 官方框架的基準代碼也是如此構建的。
所有能夠深層代理的框架,在基準測試中都盡量不使用該功能。
import { ref, shallowRef } from "vue";
// ❌ ref() 會使所有東西變成反應式
// const rows = ref([])
// ✅ 為了性能,應當這樣使用
const rows = shallowRef([]);
function setRows(update = rows.value.slice()) {
// 手動觸發
rows.value = update;
}
function update() {
const _rows = rows.value;
for (let i = 0; i < _rows.length; i += 10) {
_rows[i].label += ' !!!';
}
// 手動觸發
setRows();
}
為避免性能問題,Vue 提供了 shallowRef() 和 markRaw() 等解決方案。
但同時,開發者必須手動掌握到底哪些會重新渲染,哪些不會。
這使得開發者不得不依賴 isReactive() 等工具來判斷是否為反應式。
那麼來評估一下嚴重性。
由於反應式狀態不基於數據結構,並且為了提高性能而可能被移除,因此此 bug 很難被發現。
更進一步,要搞清楚該數據是否為反應式,無論壓根還是究竟如何,都是需要花時間去調查的工作。
最終實現者必須自己查明該數據是否為反應式。
現在我們來看 Crank。
Crank 不考慮嵌套層數,僅僅在一呼叫 refresh() 的時候執行更新。
一旦陷入反應式思維,類似於函數式程序員把所有東西都看作 Monad,程序員也會逐漸開始理解反應式。
程序的所有狀態都是反應式的,狀態本身也是反應式的。
而反應式的狀態是通過自動執行的 effect 來讀取的。
框架進行的 DOM 更新只是眾多 effect 之一。
你還可以隨意編寫其他操作,例如調用第三方庫、命令式畫布的繪製等 effect。
Svelte 的早期版本中有一種極其 Crank 的反應式 API。
Svelte 編譯器監視對組件內部狀態的賦值,並觸發相應的重渲染。
不僅不存在嵌套狀態更新,執行時的反應式也不再存在,賦值就等於更新。
<script>
let todos = [
{ id: 1, text: "學習 Svelte", completed: false, metadata: { priority: "high" }},
// 還有更多待辦事項...
];
function toggleTodo(id) {
// ❌ 不反應
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed;
// ✅ 反應
todos = todos; // 或 todos = [...todos]
}
function addTodo() {
// ❌ 不反應
todos.push({ id: Date.now(), text: '新待辦事項', completed: false });
// ✅ 反應
todos = todos;
}
</script>
{#each todos as todo}
<div>
<input type="checkbox" checked={todo.completed} on:change={() => toggleTodo(todo.id)} />
{todo.text}
</div>
{/each}
沒想到 Svelte 的維護者似乎認為這種反應式的缺乏是個問題,之後開發了名為 runes 的特別語法。
通過呼叫以 $ 開頭的 $state() 或 $derived() 函數,可以建立擁有反應性的變量。
另外,可以透過 $effect() 等函數來監聽更新。
<script>
let todos = $state([
{ id: 1, text: '學習 Svelte', completed: false, metadata: { priority: 'high' }},
// 還有更多待辦事項...
]);
function toggleTodo(id) {
// ✅ 反應
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed;
}
function addTodo() {
// ✅ 反應
todos.push({ id: Date.now(), text: '新待辦事項', completed: false });
}
</script>
{#each todos as todo}
<div>
<input type="checkbox" checked={todo.completed} on:change={() => toggleTodo(todo.id)} />
{todo.text}
</div>
{/each}
這些 runes 是編譯器特有的功能,提供高層次的反應式抽象,而非低層次的內存或組件訪問。
問題在於,使用 effect 時極容易陷入無限迴圈。
<script>
let password = $state('');
let attempts = $state(0);
let isSubmitting = $state(false);
// ❌ 無限迴圈
$effect(() => {
if (isSubmitting && password !== 'hunter2') {
attempts++; // effect 會再反應
setTimeout(() => {
isSubmitting = false;
password = '';
}, 2000);
}
});
function handleSubmit(e) {
e.preventDefault();
if (password) {
isSubmitting = true;
}
}
</script>
<form on:submit={handleSubmit}>
<input type="password" bind:value={password} disabled={isSubmitting} />
<button type="submit" disabled={isSubmitting || !password}>
{isSubmitting ? '檢查中...' : '登入'}
</button>
<p>失敗嘗試次數: {attempts}</p>
</form>
此組件內部同時讀取與寫入 $state(),導致 callback 重新發動並造成堆疊爆炸。
反應式的信奉者時常主張,透過反應式的方式,程序設計將變得如同電子表格,每一個單元都會隨著其他單元的變化而更新。
這意味著他們可能從未經歷過將電子表格從窗口扔出去的過程。
包含大量計算單元的電子表格極可能變得極其臃腫,有時甚至無法打開。
所有 effect 的 callback 都可能會因為寫入操作而觸發另外一次讀取,導致無限迴圈的情況。
與 Excel 一樣,Svelte 也提供了啟發式和解決方案,盡可能地避免無限迴圈,但仍然可能發生崩潰。
在 Svelte 中解決的方案包括不使用 $effect() runes、避免在 $effect() 內進行更新,或使用 untrack() 方法來禁止反應。
// ✅ 使用 untrack() 後不再反應
$effect(() => {
if (isSubmitting && password !== 'hunter2') {
untrack(() => {
attempts++;
});
// 以下省略
}
});
那麼來評估這個錯誤的嚴重性。
這個 bug 好發現嗎?
通常會立即由於堆疊溢出而驟然反應,但在複雜的組件中有可能存在較為少見的邊緣情況引發此錯誤。
再者 $effect() 會污染進入的所有代碼,因此不是只有這個 callback 不能寫入,任何嵌套的調用也必須避免寫入。
這種 effect 汙染不可被發現,使用者需要謹慎編寫代碼,或從一開始就使用 untrack() 來做好防範。
然而,這樣一來又可能導致無法在需要時觸發 effect。
這些無限迴圈的 bug 在進行效果內部調試時,可能因為反應式行為而改變,從而使得修復困難。
單單只輸出日誌的行為,毫無疑問會有意外的無限迴圈。
只要在日誌被註釋掉的情況下,就不會出現這個無限迴圈。
因此,進行調試而不改變函數的行為是相當困難的。
Crank 中不存在會導致無限迴圈的 effect。
相反,根本不存在 effect。
儘管仍有可能發生無限循環,但大多數情況是由於開發者的書寫不當所引起,錯誤也會在堆疊追蹤中明確顯示。
諷刺的是,儘管反應式抽象原本承諾要不再需要手動管理更新,但無論哪個框架都需要各自的更新管理。
Solid 需要使用 splitProps 和 mergeProps 來安全處理 props,Vue 不得不使用 shallowRef 和 markRaw 來避免性能問題,而 Svelte 則必需使用 untrack 以避免無限迴圈。
這類 API 的存在正是因為反應式仍未能完全解決更新問題。
為什麼 Crank 著重於明確的 refresh,甚至在最近才想到 refresh 回調來解決「忘記 refresh 的問題」?
若要思考這一點,就不得不涉及一個不常被提及的計算哲學概念——「執行的透明性」。
執行透明性可以被視為參考透明性的對應概念。
參考透明性就是無副作用,並使用不變的變量與數據結構的代碼形式。
這一約束的結果使得代碼內部的數據流動變得透明可見。
因為並不存在秘密改變數據的方式。
// 參考透明。同樣的輸入回饋同樣的輸出
const add = (a, b) => a + b;
// 不是參考透明。值會依據輸入不同而改變輸出
let counter = 0;
const increment = () => ++counter;
如果參考透明性關乎於如何掌握數據的變化,則執行透明性則在於掌握代碼何時執行。
框架被定義為控制由 API 呼叫,而非代碼進行呼叫的「控制反轉」概念。
這在提高代碼執行透明性方面發揮重要作用。
Crank 的代碼明確表達了控制,因此在父組件觸發 refresh 或本身 refresh 的情況下才會執行。
Crank 強調執行透明性背後,反映的是對於 React 的反思。
儘管 React 是我們提到的這些框架中最不反應式的,卻在某種程度上卻有著最低的執行透明性。
多年來,React 對於執行透明性一直有所忽視。
比如,為確認渲染過程中未包含副作用,React 會雙重渲染組件,回調可以回傳回調,排程算法不按規則隨意執行渲染,並實現像 useEffect()、useSyncExternalStore()、useTransition() 這樣混淆的 API。
隨著開發進程,React 不斷將組件拆解成多個回調,導致組件的執行不透明度愈發增加。
這意味著無論是意圖還是無意,React 的開發者似乎認為參考透明性比執行透明性要更重要。
然而事實上,代碼完全可以兼顧參考透明性和執行透明性。
儘管這兩者看似相互矛盾,卻並不必然相互排斥。
在 React 生態中,與代碼執行時機相關的部落格文章、錯誤、為避免過度渲染所提出的工具如 Why Did You Render 列表不計其數。
而在僅僅需要在組件內保存常量值的簡單任務中,依舊會引發關於最佳實踐的爭端。
甚至到各類文章依然在持續探討何時應該使用 useCallback。
老實說,仔細思考反應式抽象的缺陷和為了讓 Web 反應式所浪費的時間,幾乎讓我感到驚訝的是,幾乎沒有框架選擇不採用反應式。
而在深夜,我常常思考。
若框架的維護者大多是男性,那麼將更新的時機交給開發者或許是最簡單的解決方案,此事並不奇怪。
而若這些維護者多數受雇於 Facebook 或 Google 等廣告公司,可能會認為更需要推測而不是明確告訴開發者何時進行更新。
或許,他們看待 Web 問題的角度不同。
所有框架都在不斷追尋完美的反應式解決方案,而 React 的編譯器則幾乎會將所有變數緩存在快取中,使得逐步調試變得不可能,而 TodoMVC 也愈發精緻。
然而,Web 的最前沿並不只是 TodoMVC。
動畫、虛擬列表、滾動敘事、可編輯的代碼編輯器、WebGL 渲染器、遊戲、使用 WebSocket 的即時應用、大規模數據視覺化、視頻編輯器等都是更為先進的領域。
原文出處:https://qiita.com/rana_kualu/items/d16ad3a0b271924013ec