淺析 Preact Signals 及實現原理

介紹

Preact Signals 是 Preact 團隊在 22 年 9 月引入的一個特性。我們可以將它理解爲一種細粒度響應式數據管理的方式,這個在很多前端框架中都會有類似的概念,例如 SolidJS、Vue3 的 Reactivity、Svelte 等等。

Preact Signals 在命名上參考了 SolidJS 的 Signals 的概念,不過兩個框架的實現方式和行爲都有一些區別。在 Preact Signals 中,一個 signal 本質上是個擁有 .value 屬性的對象,你可以在一個 React 組件中按照如下方式使用:

import { signal } from '@preact/signals';

const count = signal(0);

function Counter() {
  const value = count.value;
  
  return (
    <div>
      <p>Count: {value}</p>
      <button onClick={() => count.value ++}>Click</button>
    </div>
  )
}

通過這個例子,我們可以看到 Signal 不同於 React Hooks 的地方: 它是可以直接在組件外部調用的。

同時這裏我們也可以看到,在組件中聲明瞭一個叫 count 的 signal 對象,但組件在消費對應的 signal 值的時候,只用訪問對應 signal 對象的 .value 值即可。

在開始具體的介紹之前,筆者先從 Preact 官方文檔中貼幾個關於 Signal API 的介紹,讓讀者對 Preact Signals 這套數據管理方式有個基本的瞭解。

API

以下爲 Preact Signals 提供的一些 Common API:

signal(initialValue)

這個 API 表示的就是個最普通的 Signals 對象,它算是 Preact Signals 整個響應式系統最基礎的地方。

當然,在不同的響應式庫中,這個最基礎的原語對象也會有不同的名稱,例如 Mobx、RxJS 的 Observers,Vue 的 Refs。而 Preact 這裏參考了和 SolidJS 一樣的術語 signal

Signal 可以表示包裝在響應式裏層的任意 JS 值類型,你可以創建一個帶有初始值的 signal,然後可以隨意讀和更新它:

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

computed(fn)

Computed Signals 通過 computed(fn) 函數從其它 signals 中派生出新的 signals 對象:

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

const s1 = signal('hello');
const s2 = signal('world');

const c = computed(() ={
  return s1.value + " " + s2.value
})

不過需要注意的是,computed 這個函數在這裏並不會立即執行,因爲按照 Preact 的設計原則,computed signals 被規定爲懶執行的 (這個後面會介紹),它只有在本身值被讀取的時候纔會觸發執行,同時它本身也是隻可讀的:

console.log(c.value) // hello world

同時 computed signals 的值是會被緩存的。一般而言,computed(fn) 運行開銷會比較大, Preact 只會在真正需要的時候去重新更新它。一個正在執行的 computed(fn) 會追蹤它運行期間讀取到的那些 signals 值,如果這些值都沒變化,那麼是會跳過重新計算的步驟的。

因此在上面的示例中,只要 s1.value 和 s2.value 的值不變化,那麼 c.value 的值永遠不會重新計算。

同樣,一個 computed signal 也可以被其它的 computed signal 消費:

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

同時 computed 依賴的 signals 也並不需要是靜態的,它只會對最新的依賴變更發生重新執行:

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

我們可以通過這個 Demo 看到,c 這個 computed signal 只會在它最新依賴的 signal 對象值發生變化的時候去觸發重新執行。

effect(fn)

上一節中介紹的 Computed Signals 一般都是一些不帶副作用的純函數 (所以它們可以在初次懶執行)。這節要介紹的 Effect Signals 則是用來處理一些響應式中的副作用使用。

和 Computed Signals 一樣的是,Effect Signals 同樣也會對依賴進行追蹤。但 Effect 則不會懶執行,與之相反,它會在創建的時候立即執行,然後當它追蹤的依賴值發生變化的時候,它會隨着變化而更新:

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

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

effect(() ={
  // is now 4
  console.log('quadruple is now', quadruple.value);
})

count.value = 20; // is now 80

這裏的 effect 執行是由 Preact Signals 內部的通知機制觸發的。當一個普通的 signal 發生變化的時候,它會通知它的直接依賴項,這些依賴項同樣也會去通知它們自己對應的直接依賴項,依此類推。

在 Preact 的內部實現中,通知路徑中的 Computed Signals 會被標記爲 OUTDATED 的狀態,然後再去做重新執行計算操作。如果一個依賴變更通知一直傳播到一個 effect 上面,那麼這個 effect 會被安排到當其自身前面的 effect 函數執行完之後再執行。

如果你只想調用一次 effect 函數,那麼可以把它賦值爲一個函數調用,等到這個函數執行完,這個 effect 也會一起結束:

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 is now 4
dispose();
count.value = 20;

batch(fn)

用於將多個值的更新在回調結束時合成爲一個。batch 的處理可以被嵌套,並且只有當最外層的處理回調完成後,更新纔會刷新:

const name = signal('Dong');
const surname = signal('Zoom');

// Combine both writes into one
batch(() ={
  name.value = 'Haha';
  surname.value = 'Nana';
})

實現方式

在開始介紹之前,我們結合前面的 API 介紹,來強調一些 Preact Signals 本身的設計性原則:

關於 Signals 的具體實現方式具體可以參考: https://github.com/preactjs/signals 。

依賴追蹤

不管什麼時候評估實現 compute / effect 這兩個函數,它們都需要一種在其運行時期捕獲他們會讀取到的 signal 的方式。Preact Signals 給 Compute 和 Effect 這兩個 Signals 都設置了其自身對應的 context 。

當讀取 Signal 的 .value 屬性時,它會調用一次 getter ,getter 會將 signal 當成當前 context 依賴項源頭給添加進來。這個 context 也會被這個 signal 添加爲其依賴項目標。

到最後,signal 和 effects 對其自身的依賴關係以及依賴者都會有個最新的試圖。每個 signal 都可以在其 .value 值發生改變的時候通知到它的依賴者。例如在一個 effect 執行完成之後釋放掉了,effect 和 computed signals 都是可以通知他們依賴集去取消訂閱這些通知的。

同一個 signals 可能在一個 context 裏面被讀取多次。在這種情況下,進行依賴項的去重會很方便。然後我們還需要一種處理 發生變化依賴項集合 的方式: 要麼在每次重新觸發運行時 時再重建依賴項集合,要麼遞增地添加 / 刪除依賴項 / 依賴者。

Preact Signals 在早期版本中使用到了 JS 的 Set 對象去處理這種情況 (Set 本身的性能比較不錯,能在 O(1) 時間內去添加 / 刪除子項,同時能在 O(N) 的時間裏面遍歷當前集合,對於重複的依賴項,Set 也會自動去重)。

但創建 Sets 的開銷可能相對 Array 要更昂貴 (從空間上看),因爲 Signals 至少需要創建兩個單獨的 Sets : 存儲依賴項和依賴者。

同時 Sets 中也有個屬性,它們是按照插入順序來進行迭代。這對於 Signals 中處理緩存的情況會很方便,但也有些情況下,Signals 插入的順序並不是總保持不變的,例如以下情況:

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
  }
})

可以看到,這這次代碼中,依賴項的順序取決於 s1 這個 signal,順序要麼是 s1、s2、s3,要麼是 s1、s3、s2。按照這種情況,就必須採取一些其他的步驟來保證 Sets 中的內容順序是正常的: 刪除然後再添加項目,清空函數運行前的集合,或者爲每次運行創建一個新的集合。每種方法都有可能導致內存抖動。而所有這些只是爲了處理理論上可能,但可能很少出現的,依賴關係順序改變的情況。

而 Preact Signals 則採用了一種類似雙向鏈表的數據結構去存儲解決了這個問題。

鏈表

鏈表是一種比較原始的存儲結構,但對於實現 Preact Signals 的一些特點來說,它具備一些非常好的屬性,例如在雙向鏈表節點中,以下操作會非常節省:

以上這些操作,都可以用於管理 Signals 中的依賴 / 依賴列表。

Preact 會首先給每個依賴關係都創建一個 source Node 。而對應 Node 的 source 屬性會指向目前正在被依賴的 Signal。同時每個 Node 都有 nextSource 和 prevSource 屬性,分別指向依賴列表中的下一個和前一個 source Nodes 。Effect 和 Computed Signals 獲得一個指向鏈表第一個 Node 的 sources 屬性,然後我們可以去遍歷這裏面的一些依賴關係,或者去插入 / 刪除新的依賴關係。

然後處理完上面的依賴項步驟後,我們再反過來去做同樣的事情: 給每個依賴者創建一個 Target Node 。Node 的 target 屬性則會指向它們依賴的 Effect 或 Computed Signals。nextTarget 和 prevTarget 構建一個雙項鍊表。普通和 computed Signals Node 節點中會有個targets 屬性用於指向他們依賴列表中的第一個 Target Node:

但一般依賴項和依賴者都是成對出現的。對於每個 source Node 都會有一個對應的 target Node 。本質上我們可以將 source Nodes 和 target Nodes 統一合併爲 Nodes 。這樣每個 Node 本質上會有四條鏈節,依賴者可以作爲它依賴列表的一部分使用,如下圖所示:

在每個 computed / effect 函數執行之前,Preact 會迭代以前的依賴關係,並設置每個 Node 爲 unused 的標誌位。同時還會臨時把 Node 存儲到它的 .source.node 屬性中用於以後使用。

在函數執行期間,每次讀取依賴項時,我們可以使用節點以前記錄的值 (上次的值) 來發現該依賴項是否在這次或者上次運行時已經被記錄下來,如果記錄下來了,我們就可以回收它之前的 Node(具體方式就是將這個節點的位置重新排序)。如果是沒見過的依賴項,我們會創建一個新的 Node 節點,然後將剩下的節點按照使用的時期進行逆序排序。

函數運行結束後,Preact Signals 會遍歷依賴列表,將打上了 unused 標誌的 Nodes 節點給刪除掉。然後整理一下剩餘的鏈表節點。

這種鏈表結構可以讓每次只用給每個依賴項 - 依賴者的關係對分配一個 Node,然後只要依賴關係是存在的,這個節點是可以一直用的 (不過需要更新下節點的順序而已)。如果項目的 Signals 依賴樹是穩定的,內存也會在構建完成後一直保持穩定。

立即執行的 effect

有了上面依賴追蹤的處理,通過變更通知實現的立即執行的 effect 會很容易。Signals 通知其依賴者們,自己的值發生了變化。如果依賴者本身是個有依賴者的 computed signals,那麼它會繼續往前傳遞通知。依此類推,接到通知的 effect 會自己安排自己運行。

如果通知的接收端,已經被提前通知了,但還沒機會執行,那它就不會向前傳遞通知了。這會減輕當前依賴樹擴散出去或者進來時形成的通知踩踏。如果 signals 本身的值實際上沒發生變化,例如 s.value = s.value。普通的 signal 也不會去通知它的依賴者。

Effect 如果想調度它自身,需要有個排序好的調度表。Preact 給每個 Effect 實例都添加了專門的 .nextBatchedEffect 屬性,讓 Effect 實例作爲單向調度列表中的節點進行雙重作用,這減少了內存抖動,因爲反覆調度同一個效果不需要額外的內存分配或釋放。

通知訂閱和垃圾回收

computed signals 實際上並不總是從他們的依賴關係中獲取通知的。只有當有像 effect 這樣的東西在監聽 signals 本身時,compute signals 纔會訂閱依賴通知。這避免了下面的一些情況:

const s = signal(0);

{
  const c = computed(() => s.value)
}
// c 並不在同一個作用域下

如果 c 總是訂閱來自 s 的通知,那麼 c 無法被垃圾回收,直到 s 也去它這個 scope 上面去。主要因爲 s 會繼續掛在一個對 c 的引用上。

在 Preact Signals 中,鏈表提供了一種比較好的辦法去動態訂閱和取消訂閱依賴通知。

在那些 computed signal 已經訂閱了通知的情況下,我們可以利用這個做一些額外的優化。後面會介紹 computed 懶執行和緩存。

Computed signals 的懶執行 & 緩存

實現懶執行 computed Signals 的最簡單方法是每次讀取其值時都重新計算。不過,這不是很高效。這就是緩存和依賴跟蹤需要幫助優化的地方。

每個普通和 Computed Signals 都有它們自己的版本號。每次當其值變化時,它們會增加版本號。當運行一個 compute fn 時,它會在 Node 中存儲上次看到的依賴項的版本號。我們原本可以選擇在節點中存儲先前的依賴值而不是版本號。然而,由於 computed signals 是懶執行的,這些依賴值可能會永遠掛在一些過期或者無限循環執行的 Node 節點上。因此,我們認爲版本編號是一種安全的折中方法。

我們得出了以下算法,用於確定當 computed signals 可以懶執行和複用它的緩存:

  1. 如果自上次運行以來,任何地方的 signal 的值都沒有改變,那麼退出 & 返回緩存值。

每次當普通 signal 改變時,它也會遞增一個全局版本號,這個版本號在所有的普通信號之間共享。每個計算信號都跟蹤他們看到的最後一個全局版本號。如果全局版本自上次計算以來沒有改變,那麼可以早點跳過重新計算。無論如何,在這種情況下,都不可能對任何計算值進行任何更改。

  1. 如果 computed signals 正在監聽通知,並且自上次運行以來沒有被通知,那麼退出 & 返回緩存值。

當 compute signals 從其依賴項中得到通知時,它標記緩存值已經過時。如前所述,compute signals 並不總是得到通知。但是當他們得到通知時,我們可以利用它。

  1. 按順序重新評估依賴項。檢查它們的版本號。如果沒有依賴項改變過它的版本號,即使在重新評估後,也退出 & 返回緩存值。

這個步驟是我們特別關心保持依賴項按使用順序排列的原因。如果一個依賴項發生改變,那麼我們不希望重更新 compute list 中後來的依賴項,因爲那可能只是不必要的工作。誰知道,也許那個第一個依賴項的改變導致下次 compute function 運行時丟棄了後面的依賴項。

  1. 運行 compute function。如果返回的值與緩存值不同,那麼遞增計算信號的版本號。緩存並返回新值。

這是最後的手段!但如果新值等於緩存的值,那麼版本號不會改變,而線路下方的依賴項可以利用這一點來優化他們自己的緩存。

最後兩個步驟經常遞歸到依賴項中。這就是爲什麼早期的步驟被設計爲嘗試短路遞歸的原因。

一些思考

JSX 渲染

Signal 在 Preact JSX 語法進行傳值的時候,可以直接傳對應的 Signal 對象而不是具體的值,這樣在 Signal 對象的值發生變化的時候,可以在組件不經過重新渲染的情況下觸發值的變化 (本質上是把 Signal 值綁定到 DOM 值上)。

例如以下組件:

import { render } from 'preact'
import { signal } from '@preact/signals'

const count = signal(1);

// Component 跳過流程是怎麼處理
// 可能對 state less 的組件跳過 render(function component)
funciton Counter() {
  console.log('render')
  return (
    <>
     <p>Count: {count}</p>
     <button onClick={() => count.value ++}>Add Count</button>
    </>
  )
}

render(<TodoList />, document.getElement('app'))

這個地方如果傳的是個 count 的 signal 對象,那麼在點擊 button 的時候,這裏的 Counter 組件並不會觸發 re-render ,如果是個 signal 值,那麼它會觸發更新。

關於把 Signals 在 JSX 中渲染成文本值,可以直接參考: https://github.com/preactjs/signals/pull/147

這裏渲染的原理是 Preact Signal 本身會去劫持原有的 Diff 執行算法:

把對應的 signal value 存到 vnode.__np 這個節點屬性上面去,並且這裏會跳過原有的 diff 算法執行邏輯 (這裏的 old(value) 執行函數)。

然後在 diff 完之後的更新的時候,直接去把對應的 signals 值更新到真實的 dom 節點上面去即可:

Preact signals 和 hooks 之間關係

兩者並不互斥,可以一起使用,因爲兩者所依賴的更新的邏輯不一樣。

Preact Signals 對比 Hooks 帶來收益

Preact Signals 本身在狀態管理上區別於 React Hooks 上的一個點在於: Signals 本身是基於應用的狀態圖去做數據更新,而 Hooks 本身則是依附於 React 的組件樹去進行更新。

本質上,一個應用的狀態圖比組件樹要淺很多,更新狀態圖造成的組件渲染遠遠低於更新狀態樹所產生的渲染性能損耗,具體差異可以參考分別使用 Hooks 和 Signals 的 Devtools Profile 分析:

參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UNemKm6FKt0SEOAcS9QAcg