🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

開發者從不掩飾對某些 React API 的不滿。他們覺得這些 API 用起來彆扭、限制性強,或乾脆就是反直覺。但事實上,React 中最常被詬病的兩個設計選擇並非隨意之舉——它們預示著更深層的限制,而這些限制最終都會被所有 UI 模型所採用。

正如你們許多人所知,過去幾年我一直在開發Solid 2.0 。這真是一段漫長的旅程。我使用 Signals 已經超過十年了,自認為我已經完全了解了整個設計領域。但隨著研究的深入,我發現自己進入了許多意想不到的領域。

在探索的過程中,我意識到了一些令人不安的事情。 React 在某些設計決策上確實做對了,而這些決策正是人們絕對無法接受的。我指的不是 React 的模型——我並非要為它辯護。但 React 的確正確地指出了生態系中其他部分(包括 Solid 1.x)忽略的兩個不變因素。

我指的是延遲狀態提交:

const [state, setState] = useState(1);

// later
setState(2);
state === 1; //not committed yet

以及 Effects 的依賴陣列:

useEffect(() => console.log(state), [state]);

Signals 旨在「解決」這兩個問題。從某種意義上說,它的確做到了。但並非以人們通常理解的方式。今天,我們將探討為什麼這並非事情的全貌。


生活在非同步世界中

圖片描述

我們在網路上所做的一切都建立在非同步性之上。整個平台由客戶端和伺服器構成,它們之間透過網路邊界分隔開來。串流、資料取得、分散式更新、事務性變更、樂觀使用者介面——所有這些都源自於這簡單的真理。

非同步編程讓我們跳脫了命令式編程的舒適圈。命令式程式碼關注的是寫入:「設定這個值,然後再讀取它。」非同步程式的重點是讀取:「這個值是否可用、是否已過期,或是否仍在處理中?」這是每個使用者介面在渲染任何內容之前都必須回答的問題:我可以顯示這個值嗎?還是會暴露一些不一致的內容?

對大多數框架而言,非同步就像是短暫存在的、在同步聲明式世界中忽隱忽現的狀態。它給人一種不可預測的感覺,因為我們只能看到非同步與計算交會的瞬間。但非同步並非混亂——它只是時間。如果我們想要理解它,就需要語言能夠直接表示它。

一切都始於我們如何表示狀態。如果某個值尚不可用,就沒有可以安全替代的佔位符。傳回nullundefined或包裝器會破壞確定性。繼續執行會產生一個永遠無法對應任何實際時間點的結果。保持一致性的唯一方法就是停止。

這也需要遵循聲明式模型。響應式系統(包括 React)的魅力在於它們能夠將 UI 表示為特定時刻的狀態。所有架構的清晰度和執行保證都源自於此。確定性是其目標:相同的輸入產生相同的輸出,時間不會改變狀態,UI 始終保持一致。

當非同步操作透過條件分支或替代值形狀洩漏到使用者空間時,我們就迫使使用者手動管理一致性,聲明式模型也就崩潰了。

// Derived computation forced to branch on async state
const firstInitial = user.loading ? "" : user.name[0];

非同步操作的 UI 功能——載入指示器、框架、回退方案——本身不是問題所在。這些只是呈現層面的問題。真正的問題在於,當非同步操作成為狀態圖中值流的一部分時,它會強制每個使用者進行分支操作。 UI 可以顯示任何內容,但狀態圖只能看到實際值。


  1. 非同步操作必須與提交操作隔離

圖片描述

與其他響應式系統不同,React 的狀態與渲染緊密耦合,迫使它很早就面對這個問題。當每次狀態改變都會觸發重新渲染時,你無法透過同步派生來掩蓋不一致之處。訊號機制避免了這個問題,因為當你讀取訊號時,所有資料始終是最新的——無需重新渲染,無需編排,也無需浪費時間。

但這些特性只是掩蓋了一個根本事實:你不能讓非同步操作與同步提交交錯進行。如果一個計算仍在等待非同步操作完成,那麼它執行的任何寫入操作都是推測性的。你不能基於尚未取得的狀態來顯示使用者介面,因為使用者如果與之交互,他們期望的是與他們所看到的內容進行交互,而不是框架所持有的某種中間狀態。

考慮:

let count = 0;
let doubleCount = count * 2;
function increment() {
  count++;
  console.log(`${count} * 2 = ${doubleCount}`);
}

<button onClick={increment}>{count} * 2 = {doubleCount}</div>

我過去曾多次使用過這個例子,但它確實抓住了問題的本質。參見:

{% link https://dev.to/playfulprogramming/the-cost-of-consistency-in-ui-frameworks-4agi

在純 JavaScript 中, countdoubleCount值會逐漸不一致。訊號機制透過在讀取資料時更新doubleCount來解決這個問題。但這仍然留下了一個問題:這個更新何時才能到達 DOM?如果立即刷新(例如 Solid 1.x),連續更新的開銷會很大。如果不刷新,則不會出現這種情況,這表示系統本身就存在一定的調度機制。

React 是唯一一個不會立即更新count的系統,這讓用戶非常不滿。但背後的動機是合理的。 React 希望事件處理程序能夠看到一致的狀態,但它沒有辦法在元件重新執行時更新派生值。

現在假設處理程序是:

function onClick(event) {
  setBooks([]);
  // derived value
  if (booksLength) {
    books[booksLength - 1]
  }
}

如果books更新但booksLength沒有更新,表示你讀取的內容超出了範圍。

訊號機制能夠完美地保持狀態和派生狀態的同步,這給開發者帶來了極大的安全感。只需編寫一次程式碼,一切即可正常運作。然而,一旦派生值變為非同步,這種安全感就會變成一種隱患,因為無法保證它始終保持同步。

恢復countdoubleCount ,但將doubleCount設定為非同步函數。如果您希望 UI 保持一致——即在非同步的 `doubleCount函數執行完畢之前一直顯示1 * 2 = 2那麼您也必須延遲更新 `count 。否則,您將會遇到奇怪的情況:UI 仍然顯示1 * 2 = 2 ,但控制台已經開始記錄2 * 2 = 2因為底層資料已經更新為count = 2

一旦你發現這種不匹配——用戶介面還在等待資料一致,而資料已經更新——結論就無可避免了。同步世界讓你感到安全,因為所有資料都會同時更新,但這種安全感只是建立在所有派生值都能立即生效的假設之上的錯覺。一旦其中任何一個值變成非同步,這個假設就不存在了。如果你想要使用者介面保持一致,就必須延遲提交。而一旦你延遲了使用者介面的提交,你也必須延遲資料的提交,否則兩者就會出現偏差,從而破壞你所依賴的保證。非同步不僅會增加延遲;它還會強制採用不同的執行模型。


  1. 效應之間的依賴關係必須在計算時已知

圖片描述

React 的重新渲染模型迫使它比任何人都更早地面對另一個事實:派生和副作用遵循不同的規則。

當元件每次變更都需要重新執行時,每次都重新計算所有內容會造成資源浪費。因此,在引入 Hooks 時,也引入了依賴陣列——一種粗糙但有效的記憶化形式。

與 Signals 相比,Signals 能夠動態發現依賴關係,並且只重新執行必要的計算,這種方式看起來確實有些限制。但它卻帶來了一個重要的結果:React 在執行任何渲染或副作用之前,就已經知道了程式碼樹的所有依賴關係。

當非同步操作引入時,這個細節就變得至關重要。如果渲染隨時可能被中斷——暫停、重播或中止——那麼副作用就無法執行。在所有依賴項都確定先前觸發的副作用,可能會以不完整或推測性的狀態運作。 React 的架構立即暴露了這一點。渲染無法保證完成,因此副作用無法與渲染綁定。

訊號憑藉其精準性,多年來避免了這個問題。變更傳播是同步且隔離的,因此推導和副作用似乎都按照單一且可預測的流程運作。但一旦非同步操作進入處理流程,這種可預測性就會消失。

因為如果非同步操作只在產生副作用時才被發現,那就為時已晚。而且,如果非同步操作是可中斷的——例如透過拋出一個 Promise 並在 Promise 解析後重新執行——那麼執行過程就會變得完全不可預測。

考慮:

const a = asyncSignal(fetchA());
const b = asyncSignal(fetchB());
const c = asyncSignal(fetchC());

effect(() => {
  console.log(a());
  console.log(b());
  console.log(c());
});

效果會記錄什麼?它會執行幾次?在純同步環境中,這些問題幾乎無關緊要——派生是穩定的,每次提交效果只執行一次。但對於非同步環境,這些問題就變得無法回答。每個非同步來源的解析時間可能不同。每次解析都可能重新觸發效果。如果其中任何一個暫停或重試,整個執行順序就會變得不確定。

而這只是初始負載。如果這些非同步資料來源可以隨時間獨立更新,那麼這種不可預測性就會加劇。如果你無法推斷副作用何時發生或它接收到哪些值,就無法推斷出副作用。

解決方案很簡單,而且無可避免。 Effect 必須在所有依賴的非同步來源都已完成處理後才能運作。為此,你必須在執行任何 effect 之前了解所有依賴項。你必須將收集依賴項和執行 effect 分開。


這對基於訊號的解決方案意味著什麼

此時,架構迫使我們做出選擇:要麼正面應對非同步,要麼繼續假裝同步的保證在非同步世界中仍然有效。非同步是真實存在的,它終將出現在架構圖中。一旦出現,除非系統明確承認,否則你在同步情況下所依賴的保證將不再成立。

編譯器能解決這個問題嗎?

不。編譯器無法透過重排語法來解決語意問題。提前提交並非機制上的限制,而是正確性上的限制。一旦非同步操作進入程式碼圖,系統就必須知道哪些值是實際值,哪些值是推測值。任何靜態分析都無法改變這一點。

編譯器能否從單一 effect 函數中提取依賴關係?從表面上看,可以-React 的編譯器正是這樣做的。但基於編譯器的提取只能看到作用域內的內容,無法看到整個依賴關係圖。如果你的來源函數是呼叫訊號而不是訊號本身,編譯器就無法判斷這些函數是純函數還是隱藏了副作用。

這正是 Svelte 5 遷移到 Runes(訊號)的原因。編譯時依賴捕獲遇到了瓶頸,它無法追蹤語法上不可見的原始碼。

let count = 0;

function getDoubleCount() {
  return count * 2;
}

// never updates because count is not
// visible in this scope
$: doubled = getDoubleCount();

一旦觸及這些邊界,就必須捫心自問:增加的複雜性、隱藏規則和不完整的程式碼覆蓋是否值得?編譯器推斷可以掩蓋問題,但無法從根本解決問題。非同步是一種執行時現象,其保證必須在執行時強制執行。

這是否意味著我們注定要模仿 React?

完全不是。這並非抄襲 React,而是承認 React 最初遇到的同一個基本事實:非同步操作強制執行提交隔離,非同步操作強制執行副作用分離。 Vue 多年來一直在其監聽器(副作用)中實現這種分離。這些並非 React 特有的做法,而是任何希望在非同步環境下保持一致性的系統都必須遵循的不變原則。

至關重要的是,採用這些不變式並不會消除 Signals 的優勢:

  • 更新仍然非常精細。

  • 元件永遠不會重新渲染

  • 依賴關係仍然易於發現且動態變化。

  • 只有效應才需要分離──純粹的計算不需要。

  • 響應式圖保持精確、簡潔和同步。

事實上,接受這些不變性恰恰凸顯了模型的優點。它將 Signals 的表達能力與函數式程式設計的正確性原則完美結合。它正視現實,而非與之對抗。並且,它賦予非同步計算與 Signals 賦予同步計算的相同的確定性和清晰度。


結論

圖片描述

Solid 一直以來都在不斷拓展前端架構的邊界,並非一味追求新奇,而是致力於挖掘那些讓 UI 可預測、一致且快速的底層規則。 React 之所以率先遇到這些規則,是因為其架構本身就迫使它這樣做。它並非主動選擇這些限制,而是被迫接受的。稱其為「設計決策」幾乎誇大了其中的主動性。它們是發現。

從優勢地位出發選擇接受這些不變的規則,則完全是另一回事。我們並非因為受到限製而採用這些約束,而是因為它們是真理。非同步操作強制執行提交隔離。非同步操作強制執行效果拆分。非同步操作強制執行一致的快照。這些並非 React 特有的規則,而是 UI 的基本物理特性。

接受這一點並非模仿,而是成熟。這是睜大雙眼選擇必然之路,並建構一個將非同步視為架構核心而非邊緣情況的系統。這是讓 Solid 不僅速度快,而且從根本上正確邁出的下一步。

清晰並不會簡化世界,但它確實能讓方向變得明確無誤。


原文出處:https://dev.to/playfulprogramming/two-react-design-choices-developers-dont-like-but-cant-avoid-d6g


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝23   💬8   ❤️1
581
🥈
我愛JS
📝1   💬6  
36
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付