說明
支持我們
,由
Joachim Viide

訊號提升

Preact Signals 的新版本為反應式系統的基礎帶來顯著的效能更新。請繼續閱讀,了解我們採用了哪些技巧來實現此目標。

我們最近 宣布 Preact Signals 套件的新版本

這篇文章將概述我們採取的步驟來最佳化 @preact/signals-core。這是作為特定於架構繫結的基礎套件,但也可以獨立使用。

訊號是 Preact 團隊對反應式程式設計的看法。如果您想要溫和地入門了解訊號是什麼以及它們如何與 Preact 結合,訊號公告部落格文章 已經為您準備好了。對於更深入的探討,請查看 官方文件

請注意,這些概念都不是我們發明的。反應式程式設計有一段歷史,並且已經在 JavaScript 世界中廣泛普及,例如 Vue.jsSvelteSolidJSRxJS 以及太多其他無法一一列舉的。向他們所有人致敬!

Signals 核心簡介

讓我們從 @preact/signals-core 套件中的基本功能概述開始。

以下程式碼片段使用從套件匯入的函式。僅在將新函式引入組合時才會顯示匯入陳述式。

訊號

純粹的訊號是我們反應式系統的基礎根源值。其他函式庫可能會稱它們為「可觀察物件」(MobXRxJS)或「參照」(Vue)。Preact 團隊採用 SolidJS 使用的「訊號」一詞。

訊號表示包裝在反應式外殼中的任意 JavaScript 值。您可以提供一個具有初始值的訊號,並可以在以後讀取和更新它。

import { signal } from "@preact/signals-core";

const s = signal(0);
console.log(s.value); // Console: 0

s.value = 1;
console.log(s.value); // Console: 1
在 REPL 中執行

訊號本身並不是很感興趣,直到與其他兩個基本元素,計算訊號效果結合使用。

計算訊號

計算訊號使用計算函式從其他訊號衍生新值。

import { signal, computed } from "@preact/signals-core";

const s1 = signal("Hello");
const s2 = signal("World");

const c = computed(() => {
  return s1.value + " " + s2.value;
});
在 REPL 中執行

提供給 computed(...) 的計算函式不會立即執行。這是因為計算訊號是延遲評估的,也就是說,當讀取它們的值時。

console.log(c.value); // Console: Hello World
在 REPL 中執行

計算值也會被快取。它們的計算函式可能會非常耗費資源,所以我們只希望在必要時才重新執行它們。執行中的計算函式會追蹤在執行期間實際讀取哪些訊號值。如果沒有任何值改變,那麼我們就可以略過重新計算。在上方的範例中,只要 a.valueb.value 保持不變,我們就可以無限期地重複使用先前計算的 c.value。為了促進這種相依性追蹤,這就是我們一開始需要將基本值包裝成訊號的原因。

// s1 and s2 haven't changed, no recomputation here
console.log(c.value); // Console: Hello World

s2.value = "darkness my old friend";

// s2 has changed, so the computation function runs again
console.log(c.value); // Console: Hello darkness my old friend
在 REPL 中執行

事實上,計算訊號本身就是訊號。計算訊號可以依賴於其他計算訊號。

const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);

console.log(quadruple.value); // Console: 4
count.value = 20;
console.log(quadruple.value); // Console: 80
在 REPL 中執行

相依性集合不必保持靜態。計算訊號只會對最新相依性集合中的變更做出反應。

const choice = signal(true);
const funk = signal("Uptown");
const purple = signal("Haze");

const c = computed(() => {
  if (choice.value) {
    console.log(funk.value, "Funk");
  } else {
    console.log("Purple", purple.value);
  }
});
c.value;               // Console: Uptown Funk

purple.value = "Rain"; // purple is not a dependency, so
c.value;               // effect doesn't run

choice.value = false;
c.value;               // Console: Purple Rain

funk.value = "Da";     // funk not a dependency anymore, so
c.value;               // effect doesn't run
在 REPL 中執行

這三件事——相依性追蹤、惰性、快取——是反應式函式庫中的常見功能。Vue 的計算屬性一個著名的範例

效果

計算訊號很適合沒有副作用的純函式。它們也很惰性。那麼,如果我們想要在不持續輪詢訊號值的情況下對訊號值變更做出反應,該怎麼辦?效果來救援!

與計算訊號一樣,效果也是透過函式(效果函式)建立,並且也會追蹤它們的相依性。然而,效果並非惰性,而是積極的。效果函式會在建立效果時立即執行,然後在相依性值變更時反覆執行。

import { signal, computed, effect } from "@preact/signals-core";

const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);

effect(() => {
  console.log("quadruple is now", quadruple.value);
});               // Console: quadruple value is now 4

count.value = 20; // Console: quadruple value is now 80
在 REPL 中執行

這些反應是由通知觸發的。當一個純粹的訊號改變時,它會通知其直接的依賴項。它們會反過來通知它們自己的直接依賴項,以此類推。如同在反應式系統中常見的,計算訊號會沿著通知路徑將自己標記為過期並準備重新計算。如果通知一路傳遞到一個效果,那麼該效果會排程自己,以便在所有先前排程的效果完成後立即執行。

當你完成一個效果時,請呼叫在效果第一次建立時傳回的處置器

const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);

const dispose = effect(() => {
  console.log("quadruple is now", quadruple.value);
});                 // Console: quadruple value is now 4

dispose();
count.value = 20;  // nothing gets printed to the console
在 REPL 中執行

還有其他函式,例如 batch,但這三個函式與以下的實作筆記最相關。

實作筆記

當我們著手實作上述基本結構的效能更佳的版本時,我們必須找到靈活的方法來執行所有下列子任務

  • 依賴追蹤:追蹤已使用的訊號(純粹或計算)。依賴項可能會動態改變。
  • 惰性:計算函式只應在需要時執行。
  • 快取:計算訊號只應在其依賴項可能已改變時重新計算。
  • 急切:當其依賴鏈中的某個項目改變時,一個效果應盡快執行。

一個反應式系統可以用數十億種不同的方式實作。@preact/signals-core 的第一個已發布版本是基於集合,因此我們將繼續使用該方法來對比和比較。

依賴追蹤

每當一個計算/效果函式開始評估時,它需要一種方法來擷取在其執行期間已讀取的訊號。為此,計算訊號或效果將自己設定為目前的評估內容。當讀取一個訊號的 .value 屬性時,它會呼叫一個 getter。getter 會將訊號新增為評估內容的依賴項,來源。內容也會新增為訊號的依賴項,目標

最後,訊號和效果始終會對其相依性和依賴項有最新的檢視。然後,每個訊號只要其值變更時,就可以通知其依賴項。效果和計算訊號可以參照其相依性集合,以便在效果被處置時取消訂閱這些通知。

Signals and effects always have an up-to-date view of their dependencies (sources) and dependents (targets)

同一個訊號可能會在同一個評估內容中被讀取多次。在這種情況下,對相依性和依賴項條目進行某種形式的重複資料刪除會很方便。我們也需要一種方法來處理變更的相依性集合:在每次執行時重建相依性集合,或逐步新增/移除相依性/依賴項。

JavaScript 的 Set 物件非常適合所有這些。與許多其他實作一樣,Preact Signals 的原始版本也使用了它們。集合允許在 常數 O(1) 時間(攤銷)中新增移除項目,以及在 線性 O(n) 時間 中反覆處理目前的項目。重複資料也會自動處理!難怪許多反應系統會利用集合(或 映射)。這項工作最適合的工具,就是這樣。

然而,我們想知道是否有其他方法。集合的建立成本可能相對昂貴,而且至少計算訊號可能需要兩個獨立的集合:一個用於相依性,一個用於依賴項。Jason 又再次成為十足的 Jason,並 對標竿 進行了集合反覆處理與陣列的比較。會有大量的反覆處理,所以所有這些都會累加。

Set iteration is just a tad slower than Array iteration

集合還有一個特性,它們會按照插入順序進行反覆處理。這很酷 - 這正是我們稍後在處理快取時所需要的。但是,有可能順序並非總是保持相同。觀察以下場景

const s1 = signal(0);
const s2 = signal(0);
const s3 = signal(0);

const c = computed(() => {
  if (s1.value) {
    s2.value;
    s3.value;
  } else {
    s3.value;
    s2.value;
  }
});
在 REPL 中執行

根據 s1,相依性的順序可能是 s1, s2, s3s1, s3, s2。必須採取特殊步驟才能保持集合順序:移除後再將項目加回去、在執行函數前清空集合,或為每個執行建立新的集合。每個方法都可能造成記憶體流失。而這一切只是為了應付相依性順序變更的理論情況,但實際上這種情況可能很少見。

還有許多其他方法可以處理這個問題。例如為相依性編號,然後排序。最後我們決定探索 連結串列

連結串列

連結串列通常被認為相當原始,但就我們的目的而言,它們有一些非常好的特性。如果您有一個雙向連結串列節點,則下列操作可以極為便宜

  • 在 O(1) 時間內將項目插入串列的一端。
  • 在 O(1) 時間內從串列中的任何位置移除一個節點(您已經有指標)。
  • 在 O(n) 時間內遍歷串列(每個節點 O(1))

事實證明,這些操作就是我們管理相依性/被相依性串列所需的全部。

讓我們從為每個相依性關係建立一個「來源節點」開始。節點的 source 屬性指向被相依的訊號。每個節點都有 nextSourceprevSource 屬性,分別指向相依性串列中下一個和上一個來源節點。效果或計算訊號會取得指向串列第一個節點的 sources 屬性。現在我們可以遍歷相依性,插入新的相依性,並從串列中移除相依性以重新排序。

Effects and computed signals keep their dependencies in a doubly-linked list

現在讓我們反過來做同樣的事情:為每個依賴項建立一個「目標節點」。節點的 target 屬性指向依賴效應或計算訊號。nextTargetprevTarget 建立一個雙向連結清單。一般和計算訊號會取得一個 targets 屬性,指向其依賴清單中的第一個目標節點。

Signals keep their dependents in a doubly-linked list

但嘿,依賴項和被依賴項是成對出現的。對於每個來源節點,一定會有一個對應的目標節點。我們可以利用這個事實,將「來源節點」和「目標節點」壓縮成「節點」。每個節點都變成一種四重連結的龐然大物,依賴項可以用它作為其依賴清單的一部分,反之亦然。

Each Node becomes a sort of quad-linked monstrosity that the dependent can use as a part of its dependency list, and vice versa

每個節點都可以附加額外的東西,以供簿記之用。在每個計算/效應函式之前,我們會遍歷先前的依賴項,並設定每個節點的「未使用」旗標。我們也會暫時將節點儲存在其 .source.node 屬性中,以供稍後使用。然後,函式就可以開始執行。

在執行期間,每次讀取依賴項時,都可以使用簿記值來找出此依賴項是否已在此次或先前的執行期間被看到。如果依賴項來自先前的執行,我們可以回收其節點。對於先前未見的依賴項,我們會建立新的節點。然後,將節點隨機排列,以保持它們按使用順序相反的順序。在執行結束時,我們會再次遍歷依賴項清單,清除仍掛著「未使用」旗標的節點。然後,我們會反轉剩餘節點的清單,以保持所有節點在稍後使用時井然有序。

這種微妙的死亡之舞讓我們可以為每個依賴項-被依賴項對分配只有一個節點,然後在依賴關係存在的情況下無限期地使用該節點。如果依賴項樹保持穩定,在初始建置階段之後,記憶體消耗也會有效地保持穩定。同時,依賴項清單會保持最新狀態,並按使用順序排列。每個節點的工作量恆定為 O(1)。太棒了!

熱切效果

藉由變更通知來處理相依關係追蹤後,熱切效果的實作相對簡單。訊號會通知其相依項有關值變更。如果相依項本身是具有相依項的計算訊號,則它會傳遞通知,以此類推。收到通知的效果會排程自己執行。

我們在此加入了一些最佳化。如果接收通知的一端之前已收到通知,且尚未執行,則它不會傳遞通知。當相依關係樹向外或向內擴展時,這可減輕通知雪崩效應。如果訊號值實際上沒有變更,則一般訊號也不會通知其相依項(例如 s.value = s.value)。但這只是出於禮貌。

為了讓效果能夠排程自己執行,需要有一份已排程效果的清單。我們在每個 Effect 執行個體中加入一個專屬屬性 .nextBatchedEffect,讓 Effect 執行個體同時擔任單向連結排程清單中的節點。這可減少記憶體消耗,因為重複排程相同的效果不需要額外的記憶體配置或解除配置。

插曲:通知訂閱與 GC

我們並未完全誠實。計算訊號實際上並非總是從其相依項取得通知。計算訊號僅在有像效果這類監聽訊號本身的項目時,才會訂閱相依項通知。這可避免類似以下情況的問題

const s = signal(0);

{
  const c = computed(() => s.value)
}
// c has gone out of scope

如果 c 一直訂閱來自 s 的通知,那麼 c 無法被垃圾回收,直到 s 也超出範圍。這是因為 s 會一直保留對 c 的參照。

這個問題有多個解決方案,例如使用 WeakRefs 或要求手動處理計算訊號。在我們的案例中,連結串列提供了一個非常便利的方式,可以動態訂閱和取消訂閱相依性通知,這要歸功於所有 O(1) 的東西。最終結果是您不必特別注意懸而未決的計算訊號參照。我們認為這是最符合人體工學和效能最佳的方法。

在計算訊號訂閱通知的情況下,我們可以使用該知識進行額外的最佳化。這讓我們想到惰性和快取。

惰性快取計算訊號

實作惰性計算訊號最簡單的方法是每次讀取其值時重新計算。不過,這不會非常有效率。這時快取和相依性追蹤就能發揮很大的作用。

每個純粹和計算訊號都有自己的版本號碼。每當它們注意到自己的值發生變化時,它們就會增加自己的版本號碼。當執行計算函式時,它會將其相依性的最後已見版本號碼儲存在節點中。我們本可以選擇將先前的相依性值儲存在節點中,而不是版本號碼。然而,由於計算訊號是惰性的,因此它們可能會無限期地保留過時且可能昂貴的值。所以我們認為版本編號是一個安全的折衷方案。

我們最終得到了以下演算法,用於找出計算訊號何時可以休息並重複使用其快取值

  1. 如果自上次執行後,任何地方的 no signal 已變更值,則退出並傳回快取值。

    每次純粹訊號變更時,也會增加一個全域版本號碼,在所有純粹訊號之間共用。每個計算訊號會追蹤它們所見過的最後一個全域版本號碼。如果自上次計算後全域版本沒有變更,則可以提早略過重新計算。在這種情況下,無論如何都不會有任何計算值的變更。

  2. 如果計算訊號正在聆聽通知,且自上次執行後尚未收到通知,則退出並傳回快取值。

    當計算訊號從其相依項收到通知時,它會將快取值標記為過時。如前所述,計算訊號並非總是會收到通知。但當我們收到通知時,可以利用它。

  3. 依序重新評估相依項。檢查它們的版本號碼。如果在重新評估後,沒有任何相依項變更其版本號碼,則退出並傳回快取值。

    此步驟是我們特別重視並小心維持相依項使用順序的原因。如果相依項變更,則我們不希望重新評估清單中後面的相依項,因為這可能只是不必要的作業。誰知道,也許第一個相依項的變更會導致下一個計算函數執行時捨棄後面的相依項。

  4. 執行計算函數。如果傳回值與快取值不同,則增加計算訊號的版本號碼。快取並傳回新值。

    這是最後的手段!但至少如果新值等於快取值,則版本號碼不會變更,而且後續的依賴項可以使用它來最佳化其自己的快取。

最後兩個步驟通常會遞迴到依賴項。這就是為什麼較早步驟的設計會嘗試短路遞迴。

殘局

以典型的 Preact 方式,過程中會加入多項較小的最佳化。 原始碼包含一些可能有用的註解。如果你好奇我們想出哪些種類的邊界案例來確保我們的實作強固,請查看 測試

這篇文章某種程度上是腦力激盪。它概述了我們採取的主要步驟,以讓 @preact/signals-core 版本 1.2.0 更好 - 根據某些「更好」的定義。希望這裡列出的一些想法會引起共鳴,並被其他人重複使用和重新組合。至少這是夢想!

非常感謝所有做出貢獻的人。謝謝你閱讀到這裡!這是一趟旅程。