首頁  >  文章  >  web前端  >  反應性的可變派生

反應性的可變派生

Linda Hamilton
Linda Hamilton原創
2024-10-24 08:22:02815瀏覽

所有這些對調度和非同步的探索讓我意識到我們對反應性仍然不了解。許多研究源自於電路和其他即時系統的建模。函數式程式設計範式也進行了大量的探索。我覺得這在很大程度上塑造了我們對反應的現代看法。

當我第一次看到 Svelte 3 和後來的 React 編譯器時,人們質疑這些框架的渲染是細粒度的。老實說,他們有很多相同的特徵。如果我們用訊號和到目前為止我們所看到的派生原語來結束這個故事,你可能會認為它們是等價的,除了這些系統不允許它們的反應性存在於其 UI 元件之外。

但是 Solid 從來不需要編譯器來完成這個任務,這是有原因的。為什麼直到今天它仍然是更優化的。這不是實作細節。這是建築性的。它與 UI 元件的反應獨立性有關,但不止於此。


可變與不可變

在定義中,改變的能力與不改變的能力。但這不是我們的意思。如果什麼都不改變的話,我們的軟體就會非常無聊。在程式設計中,它指的是一個值是否可以改變。如果一個值不能被改變,那麼改變變數值的唯一方法就是重新分配它。

從這個角度來看,訊號在設計上是不可變的。他們知道某些內容是否發生變化的唯一方法是在分配新值時進行攔截。如果有人獨立地改變他們的價值,就不會發生任何反應。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

我們的反應式系統是一個不可變節點的連通圖。當我們匯出資料時,我們會傳回下一個值。它甚至可以使用先前的值來計算。

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

但是當我們將訊號放入訊號中並將效果放入效果中時,會發生一些有趣的事情。

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

現在我們不僅可以更改用戶,還可以更改用戶名。更重要的是,當僅更改名稱時,我們可以跳過不必要的工作。我們不會重新執行外部 Effect。此行為不是聲明狀態的結果,而是使用狀態的結果。

這非常強大,但很難說我們的系統是不可變的。是的,單個原子是,但透過嵌套它們,我們創建了一個針對突變優化的結構。當我們更改任何內容時,只有需要執行的程式碼的確切部分才會運行。

我們可以在不嵌套的情況下獲得相同的結果,但我們可以透過運行額外的程式碼來比較:

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

這裡有一個相當明顯的權衡。我們的嵌套版本需要映射傳入的資料以產生嵌套訊號,然後再次映射它以分解資料在獨立效果中的存取方式。我們的 diff 版本可以使用純數據,但需要針對任何更改重新運行所有程式碼,並比較所有值以確定更改的內容。

考慮到比較的表現相當好,並且映射特別是深度嵌套的數據很麻煩,人們通常選擇後者。 React 基本上就是這樣。然而,隨著我們的數據和與該數據相關的工作的增加,差異只會變得更加昂貴。

一旦放棄 diff 就不可避免了。我們失去訊息。您可以在上面的範例中看到它。當我們在第一個範例中將名稱設為「Janet」時,我們告訴程式更新名稱 user().setName(“Janet”)。在第二次更新中,我們設定了一個全新的用戶,程式需要弄清楚用戶使用的所有地方發生了什麼變化。

雖然嵌套比較麻煩,但它永遠不會運行不必要的程式碼。啟發我創建 Solid 的是我意識到映射嵌套反應的最大問題可以透過代理來解決。反應式商店誕生了:

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

好多了。

這樣做的原因是我們仍然知道當我們 setUser(user => user.name = "Janet") 時名稱會被更新。 name 屬性的 setter 被命中。我們實現了這種精細的更新,無需映射我們的數據或比較。

為什麼這很重要?想像一下如果您有一個用戶列表。考慮一個不可變的變化:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

我們得到一個新的數組,其中包含除具有更新名稱的新使用者物件之外的所有現有使用者物件。此時框架所知道的只是清單已更改。它將需要迭代整個清單以確定是否有任何行需要移動、新增或刪除,或是否有任何行已更改。 如果它發生了變化,它將重新運行映射函數並產生將被替換/與 DOM 中當前內容不同的輸出。

考慮可變的改變:

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

我們不會退回任何東西。相反,作為該使用者名稱的一個訊號會更新並執行更新我們顯示名稱的位置的特定效果。沒有列表娛樂。沒有列表差異。沒有划船休閒。沒有 DOM 差異。

透過將可變反應性視為一等公民,我們獲得了類似於不可變狀態的創作體驗,但具有即使是最聰明的編譯器也無法實現的功能。但我們今天來這裡並不是要確切地討論反應式商店。這和推導有什麼關係?


重溫推導

我們所知的派生值是不可變的。我們有一個函數,每當它運行時它都會傳回下一個狀態。

狀態 = fn(狀態)

當輸入更改時,它們會重新運行,您將獲得下一個值。它們在我們的反應圖中扮演著幾個重要的角色。

首先,它們充當記憶點。如果我們認識到輸入沒有改變,我們就可以節省昂貴的或非同步計算的工作。我們可以多次使用一個值而無需重新計算它。

其次,它們充當匯聚節點。它們是我們圖中的「連結」。他們將多個不同的來源連結在一起定義他們的關係。這是事物一起更新的關鍵,但這也說明了隨著來源數量有限以及它們之間的依賴關係不斷增加,一切最終都會變得糾纏在一起。

這很有道理。對於衍生的不可變資料結構,您只有“連接”而不是“分叉”。隨著複雜性的增加,你注定要合併。有趣的是,反應性「商店」沒有這個屬性。各個部分獨立更新。那我們要如何將這種思考應用到推導中呢?

跟著形狀

Mutable Derivations in Reactivity

Andre Staltz 幾年前發表了一篇令人驚嘆的文章,他將所有類型的反應/可迭代原語連接到一個單一的連續體中。推/拉全部統一在一個模型下。

我長期以來一直受到安德烈在本文中應用的系統思維的啟發。長期以來,我一直在努力解決本系列中涵蓋的主題。有時,了解設計空間的存在就足以開啟正確的探索。有時,您首先需要了解解決方案的形狀。


例如,我很久以前就意識到,如果我們想避免某些狀態更新的同步,我們需要一種衍生可寫狀態的方法。它在我的腦海裡縈繞了好幾年,但最終我提出了一個可寫的推導。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"


這個想法是它總是可以從來源重置,但是可以在頂部應用短暫的更新,直到下一次來源更改為止。為什麼要使用它而不是效果器?

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"


因為,正如本系列第一部分中深入介紹的那樣,這裡的 Signal 永遠不會知道它依賴於 props.field。它破壞了圖的一致性,因為我們無法追蹤它的依賴關係。直覺上我知道將讀取放入同一個原語中可以解鎖該功能。事實上,createWritable 如今已完全可以在使用者空間中實現。

<script> // Detect dark theme var iframe = document.getElementById('tweet-1252839841630314497-983'); if (document.body.className.includes('dark-theme')) { iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=1252839841630314497&theme=dark" } </script>
const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

這只是一個高階訊號。訊號的訊號,或安德烈稱之為「Getter-getter」和「Getter-setter」的組合。當傳入的 fn 執行外推導 (createMemo) 時,會追蹤它並建立一個 Signal。每當這些依賴項發生變化時,就會建立一個新訊號。然而,在被替換之前,該 Signal 處於活動狀態,任何監聽傳回的 getter 函數的東西都會訂閱衍生並保持依賴鏈的 Signal。

我們降落在這裡是因為我們遵循了解決方案的形狀。隨著時間的推移,遵循這種形狀,我現在相信,正如上一篇文章末尾所示,這個可變的派生基元不是可寫派生,而是派生信號。

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

但是我們仍在研究不可變原語。是的,這是一個可寫的派生,但值的更改和通知仍然會大量發生。


差異化的問題

Mutable Derivations in Reactivity

直觀上我們可以看到有一個差距。我可以找到我想要解決的問題的範例,但無法找到一個單一的原語來處理該空間。我意識到部分問題在於形狀。

一方面,我們可以將派生值放入 Stores 中:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

但是如何動態改變形狀,也就是在不寫入 Store 的情況下產生不同的 getter?

另一方面,我們可以從 Store 匯出動態形狀,但它們的輸出不會是 Store。

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

如果所有衍生值都是透過傳入傳回下一個值的包裝函數產生的,那麼我們該如何隔離變更?最好的情況是,我們可以將新結果與先前的結果進行比較,並向外應用細粒度的更新。但這是假設我們一直想要差異。

我閱讀了 Signia 團隊的一篇文章,內容是關於增量計算以通用方式實現 Solid 的 For 組件之類的內容。然而,除了邏輯並不簡單之外,我注意到:

  • 它是一個單一的不可變訊號。嵌套更改無法獨立觸發。

  • 鏈上的每個節點都需要參與。每個節點都需要應用其來源差異來實現更新的值,並產生其差異以向下傳遞(最終節點除外)。

處理不可變資料時。參考文獻將會遺失。差異有助於取回這些訊息,但隨後您需要支付整個鏈條的成本。在某些情況下,例如來自伺服器的新鮮數據,沒有穩定的引用。需要一些東西來「鍵入」模型,而範例中使用的 Immer 中不存在這種東西。 React有這個能力。

就在那時我想到這個函式庫是為 React 建構的。假設已經存在,將會有更多的差異。一旦你屈服於差異,差異就會產生更多的差異。這是無法迴避的事實。他們創建了一個系統,透過降低整個系統的增量成本來避免繁重的工作。

Mutable Derivations in Reactivity

我覺得我太聰明了。 「壞」方法雖然不可持續,但無可否認效果更好。


(細粒度)反應性的大統一理論

Mutable Derivations in Reactivity

對事物進行一成不變的建模並沒有什麼問題。但還是有差距。

所以讓我們跟著形狀:

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

顯而易見的是,衍生函數與訊號設定器函數具有相同的形狀。在這兩種情況下,您都會傳入先前的值並傳回新值。

為什麼我們不在商店裡這樣做?

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

我們甚至可以引入衍生來源:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

這裡有一個對稱性。不可變的變化總是構造下一個狀態,可變的變化將當前狀態轉變為下一個狀態。差異也不行。如果不可變派生 (Memo) 的優先權發生變化,則整個參考將被替換,並且所有副作用都會運行。如果可變派生(投影)上的優先權發生變化,則只有專門偵聽優先權的內容才會更新。


探索預測

不可變的變化在其操作上是一致的,因為無論發生什麼變化,它只需要建構下一個狀態。 Mutable 根據變化可能有不同的操作。不可變變更始終具有未修改的先前狀態可供使用,而可變變更則不然。這會影響期望。

我們可以在上一節的範例中看到這一點,需要使用 reconcile。當一個全新的物件透過投影傳入時,您並不滿足於替換所有內容。您需要逐步套用變更。根據更新內容,它可能需要以不同的方式進行變異。您可以每次套用所有變更並利用商店的內部平等檢查:

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

但這很快就變得令人望而卻步,因為它只能淺層發揮作用。協調(差異)始終是一種選擇。但通常我們想做的只是應用我們需要的東西。這會導致程式碼更複雜,但效率更高。

修改 Solid 的 Trello 克隆的摘錄,我們可以使用投影來單獨應用每個樂觀更新,或使面板與伺服器的最新更新保持一致。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

這很強大,因為它不僅保留 UI 中的引用,因此只發生粒度更新,而且它增量應用突變(樂觀更新),而無需克隆和比較。因此,組件不僅不需要重新運行,而且當您進行每次更改時,它不需要重複重建電路板的整個狀態來再次意識到幾乎沒有什麼變化。最後,當它確實需要比較時,當伺服器最終返回我們的新資料時,它會根據更新的投影進行比較。保留引用,無需重新渲染任何內容。

雖然我相信這種方法將在未來為即時和本地優先系統帶來巨大的勝利,但我們今天可能已經在沒有意識到的情況下使用了投影。考慮包含索引訊號的反應式映射函數:

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

索引將作為反應性屬性投影到不包含索引的行列表上。現在這個原語是如此的基線,我可能不會用 createProjection 來實現它,但重要的是要理解它絕對是一個。

另一個例子是 Solid 晦澀難懂的 createSelector API。它允許您以高效的方式將選擇狀態投影到行列表上,以便更改所選內容不會更新每一行。感謝形式化的投影基元,我們不再需要特殊的基元:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

這將建立一個地圖,您可以在其中透過 id 查找,但僅存在選定的行。由於它是訂閱生命週期的代理,我們可以追蹤不存在的屬性,並在更新時仍然通知它們。變更 SelectionId 最多會更新 2 行:已選取的行和正在選取的新行。我們將 O(n) 運算變成 O(2)。

當我更多地使用這個原語時,我意識到它不僅完成了直接可變派生,而且可以用來動態地傳遞反應性。

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

此投影僅公開使用者的 id 和名稱,但保持 privateValue 無法存取。它做了一些有趣的事情,因為它對名稱應用了 getter。因此,當我們替換整個用戶時,投影會重新運行,而僅更新用戶名就可以運行效果,而無需重新運行投影。

這些用例只是少數,我承認它們需要更多的時間來理解。但我覺得預測是訊號故事中缺少的一環。


結論

Mutable Derivations in Reactivity

透過過去幾年對反應性推導的探索,我學到了很多。我對反應性的整體看法已經改變。可變性不再被視為必要的罪惡,而是在我身上成長為反應性的一個獨特支柱。一些粗粒度的方法或編譯器無法模擬的東西。

這是一個強有力的陳述,顯示了不可變反應性和可變反應性之間的根本區別。訊號和儲存不是同一件事。備忘錄和預測也不是。雖然我們也許能夠統一他們的 API,但也許我們不應該這樣做。

我透過遵循 Solid 的 API 形狀實現了這些實現,但其他解決方案對其訊號具有不同的 API。所以我想知道他們是否會得出相同的結論。公平地說,實施預測存在挑戰,而且故事還沒結束。但我想我們的思想探索就暫時結束了。我還有很多工作要做。

感謝您加入我的旅程。

以上是反應性的可變派生的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn