淺析 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(不管是 signals 還是 computed)。依賴項可能會動態改變
- 懶執行的 computed: computed 值在被需要的時候運行
- 緩存: computed 值只在依賴項可能改變的情況下才會重新計算
- 立即執行的 effect: 當依賴中的某個內容變化時,effect 應該盡快運行。
關于 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 的一些特點來說,它具備一些非常好的屬性,例如在雙向鏈表節點中,以下操作會非常節省:
- 在 O(1) 時間內,將一個 signals 值插到鏈表的某一端
- 在 O(1) 時間內,刪除鏈表任何位置的一個節點(假設存在對應指針的情況下)
- 在 O(n) 時間內,遍歷鏈表中的節點
以上這些操作,都可以用于管理 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 可以懶執行和復用它的緩存:
- 如果自上次運行以來,任何地方的 signal 的值都沒有改變,那么退出 & 返回緩存值。
每次當普通 signal 改變時,它也會遞增一個全局版本號,這個版本號在所有的普通信號之間共享。每個計算信號都跟蹤他們看到的最后一個全局版本號。如果全局版本自上次計算以來沒有改變,那么可以早點跳過重新計算。無論如何,在這種情況下,都不可能對任何計算值進行任何更改。
- 如果 computed signals 正在監聽通知,并且自上次運行以來沒有被通知,那么退出 & 返回緩存值。
當 compute signals 從其依賴項中得到通知時,它標記緩存值已經過時。如前所述,compute signals 并不總是得到通知。但是當他們得到通知時,我們可以利用它。
- 按順序重新評估依賴項。檢查它們的版本號。如果沒有依賴項改變過它的版本號,即使在重新評估后,也退出 & 返回緩存值。
這個步驟是我們特別關心保持依賴項按使用順序排列的原因。如果一個依賴項發生改變,那么我們不希望重更新 compute list 中后來的依賴項,因為那可能只是不必要的工作。誰知道,也許那個第一個依賴項的改變導致下次 compute function 運行時丟棄了后面的依賴項。
- 運行 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 notallow={() => 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 分析:
圖片
參考資料
- Why Signals Are Better than Preact: https://www.youtube.com/watch?v=SO8lBVWF2Y8
- https://preactjs.com/guide/v10/signals/