动态

详情 返回 返回

深入解析:React中的信號組件與細粒度更新 - 动态 详情

引言

在主流的前端開發框架中,無論是ReactVue還是Svelte,核心都是圍繞着更高效地進行UI渲染展開的。

為了實現高性能,基於DOM總是比較慢這個假設前提,其最核心的要解決的問題有兩個:

  • 響應式更新
  • 細粒度更新

為了將響應式更新細粒度更新優化到極致,各種框架是八仙過海,各顯神通。以最流行的ReactVue為例,

  • 首先兩者均引入了Virtual DOM的概念。
  • Vue的靜態模板編譯,通過編譯時的靜態分析,來優化細粒度更新邏輯,在編譯階段儘可能地分析出該渲染的DOM。
  • React使用JSX動態語法,本質上是一個函數,難以進行靜態分析,所以React只能在運行時想辦法。

    • 因此React就有了Fiber的概念,通過Fiber的調度來實現優化渲染邏輯,但是Fiber的調度邏輯很複雜,官方搞這玩意折騰了有一年。
    • 然後就是一堆的React.memo的優化手段,但是應用複雜時,駕馭起來也有比較大的心智負擔
    • 因此,官方又搞了個React Compiler,通過編譯時的靜態分析,來為代碼自動添加React.memo邏輯,但這玩意從提出到現在怎麼也有兩年了,還在實驗階段。估計也是不太好搞。

由於Virtual DOM的特性,無論是React還是Vue,本質上都是在Virtual DOM上進行diff算法,然後再進行patch操作,差別就是diff算法的實現方式不同。

但是無論怎麼整, 在Virtual DOMdiff算法加持下,狀態的變化總是難以精準地與DOM對應匹配。

通俗説,就是當state.xxx更新時,不是直接找到使用state.xxxDOM進行精準更新,而是通過Virtual DOMdiff算法比較算出需要更新的DOM元素,然後再進行patch操作。

問題是,這種diff可能會有誤傷,可能會比較出不需要重新渲染的DOM,需要針對各種邊界情況進行各處算法優化,對開發者也有一定的心智負擔,比如在在大型React應用中對React.memo的使用,或者在Vue中的模板優化等等。

  • Q: 為什麼説在大型應用中使用React.memo是一種心智負擔?
  • A: 實際上React.memo的邏輯本身很簡單,無論老手或小白均可以輕鬆掌握。但是在大型應用中,一方面組件的嵌套層級很深,組件之間的依賴關係很複雜,另外一方面,組件數量成百上千。如果都要使用React.memo來優化渲染,就是一種很大的心智負擔。如果採用後期優化,則問題更加嚴重,往往需要使用一些性能分析工具才可以進行針對性的優化。簡單地説,當應用複雜後,React.memo才會成為負擔。

因此框架的最核心的問題就是能根據狀態的變化快速找到依賴於該狀態的DOM的進行重新渲染,即所謂的細粒度更新

即然基於Virtual DOMdiff算法在解決細粒度更新方面存在問題,那麼是否可以不進行diff算法,直接找到state.xxx對應的DOM進行更新呢?

方法是有的,就是前端最紅的signal的概念。

事實上signal概念很早就有了,但是自出了Svelte之類的框架,它不使用Virtual DOM,不需要diff算法,而是引入signal概念,可以在信號觸發時只更新變化的部分,真正的細粒度更新,並且性能也非常好。

這一下子就把ReactVue之類的Virtual DOM玩家們給打蒙了,一時間signal成了前端開發的新寵。
所有的前端框架均在signal靠攏,Sveltesolidjs成了signal流派的代表,就連Vue也不能免俗,Vue Vapor就是Vuesignal實現(還沒有發佈)。

什麼是信號?

引用卡頌老師關於signal的一篇文章Signal:更多前端框架的選擇。

卡頌老師説signal的本質,是將對狀態的引用以及對狀態值的獲取分離開。

大神就是大神,一句話就把signal的本質説清楚了。但是也把我等普通人給説懵逼了,這個概念逼格太高太抽象了,果然是大神啊。

下面我們按凡人的思維來理一理signal,構建一套signal機制的基本流程原理如下:

  • 第1步: 讓狀態數據可觀察

讓狀態數據變成響應式或者可觀察,辦法就是使用Proxy或者Object.defineProperty等方法,將狀態數據變成一個可觀察對象,而不是一個普通的數據對象。

可觀察對象的作用就是攔截對狀態的訪問,當狀態發生讀寫變化時,就可以收集依賴信息。

讓數據可觀察有多種方法,比如mobx就不是使用Proxy,而是使用Classget屬性來實現的。甚至你也可以用自己的一套API來實現。只不過現在普遍使用Proxy實現。核心原理就是要攔截對狀態的訪問,從而收集依賴信息

:::warning{title=注意}
讓狀態數據可觀察的目的是為了感知狀態數據的變化,這樣才能進行下一步的響應。感知的顆粒度越細,就越能實現細粒度更新。
:::

  • 第2步:信號發佈/訂閲

由於可以通過攔截對狀態的訪問,因此,我們就可以知道什麼時候讀寫狀態了,那麼我們就可以在讀寫狀態時,發佈一個信號,通知訂閲者,狀態發生了變化。

因此,我們就需要一個信號發佈/訂閲的機制,來登記什麼信號發生了變化,以及誰訂閲了這個信號。

您可以使用類似mittEventEmitter之類的庫來構建信號發佈/訂閲,也可以自己寫一個。

信號發佈/訂閲最核心的事實上就是一個訂閲表,記錄了誰訂閲了什麼信號,在前端就是哪個DOM渲染函數,依賴於哪個信號(狀態變化)。

:::warning{title=提示}
建立一個發佈/訂閲機制的目的是為了建立渲染函數狀態數據之間的映射關係,當態數據發生變化時,根據此來查詢到依賴於該狀態數據的渲染函數,然後執行這些渲染函數,從而實現細粒度更新
:::

  • 第3步:渲染函數

接下來我們編寫DOM的渲染函數,如下:

  function render() {
      element.textContent = countSignal.value.toString();
  }

在此渲染函數中:

  • 我們直接更新DOM元素,沒有任何的diff算法,也沒有任何的Virtual DOM
  • 函數使用訪問狀態數據count來更新DOM元素,由於狀態是可觀察的,因此當執行countSignal.value時,我們就可以攔截到對count的訪問,也就是説我們收集到了該DOM元素依賴於count狀態數據。
  • 有了這個DOM Render狀態數據的依賴關係,我們就可以在signal的信號發佈/訂閲機制中登記這個依賴關係.
收集依賴的作用就是建立渲染函數與狀態之間的關係。
  • 第3步:註冊渲染函數

最後我們將render函數註冊到signal的訂閲者列表中,當count狀態數據發生變化時,我們就可以通知render函數,從而更新DOM元素。

手擼信號

按照上述信號的基本原理,下面是一個簡單的signal的示例,我們創建一個signal對象countSignal,並且創建一個DOM元素countElement,當countSignal發生變化時,我們更新countElementtextContent

        class Signal<T> {
          private _value: T;
          private _subscribers: Array<(value: T) => void> = [];
          constructor(initialValue: T) {
              this._value = initialValue;
          }
          get value(): T {
              return this._value;
          }
          set value(newValue: T) {
              if (this._value !== newValue) {
                  this._value = newValue;
                  this.notifySubscribers();
              }
          }
          subscribe(callback: (value: T) => void): () => void {
              this._subscribers.push(callback);
              return () => {
                  this._subscribers = this._subscribers.filter(subscriber => subscriber !== callback);
              };
          }

          private notifySubscribers() {
              this._subscribers.forEach(callback => callback(this._value));
          }
      }

      const countSignal = new Signal<number>(0);
      const countElement = document.getElementById('count')!;
      const incrementButton = document.getElementById('increment')!;

      function render() {
          countElement.textContent = countSignal.value.toString();
      }
      function increment() {
          countSignal.value += 1;
      }
      countSignal.subscribe(render);
      incrementButton.addEventListener('click', increment);
      render(); 
<h1>計數器: <span id="count">0</span></h1>
<button id="increment">增加</button>

在React中使用信號

那麼我們如何在React中使用signal呢?

從上面我們可以知道,signal驅動的前端框架是完全不需要Virtual DOM的。

而本質上React並不是一個Signal框架,其渲染調度是基於Virtual DOMfiberdiff算法的。

因此,React並不支持signal的概念,除排未來ReactVue一樣升級Vue Vapor mode進行重大升級,拋棄Virtual DOM,否則在React在中是不能真正使用如同solidjsSveltesignal概念的。

但是無論是Virtual DOM還是signal,核心均是為了解決細粒度更新的問題,從而提高渲染性能。

因此,我們可以結合ReactReact.memouseMemo等方法來模擬signal的概念,實現細粒度更新

這樣我們就有了信號組件的概念,其本質上是使用React.memo包裹的ReactNode組件,將渲染更新限制在較細的範圍內。
image.png

  • 核心是一套依賴收集和事件分發機制,用來感知狀態變化,然後通過事件分發變化。
  • 信號組件本質上就是一個普通的是React組件,但使用React.memo(()=>{.....},()=>true)進行包裝,diff總是返回true,用來隔離DOM渲染範圍。
  • 然後在該信號組件內部會從狀態分發中訂閲所依賴的狀態變化,當狀態變化時重新渲染該組件。
  • 由於diff總是返回true,因此重新渲染就被約束在了該組件內部,不會引起連鎖反應,從而實現了細粒度更新

信號組件

AutoStore是最近開源的一個響應式狀態庫,其提供了非常強大的狀態功能,主要特性如下:

  • 響應式核心:基於Proxy實現,數據變化自動觸發視圖更新。
  • 就地計算屬性:獨有的就地計算特性,可以在狀態樹中任意位置聲明computed屬性,計算結果原地寫入。
  • 依賴自動追蹤:自動追蹤computed屬性的依賴,只有依賴變化時才會重新計算。
  • 異步計算:強大的異步計算控制能力,支持超時、重試、取消、倒計時、進度等高級功能。
  • 狀態變更監聽:能監聽get/set/delete/insert/update等狀態對象和數組的操作監聽。
  • 信號組件:支持signal信號機制,可以實現細粒度的組件更新。
  • 調試與診斷:支持chromeRedux DevTools Extension調試工具,方便調試狀態變化。
  • 嵌套狀態:支持任意深度的嵌套狀態,無需擔心狀態管理的複雜性。
  • 表單綁定:強大而簡潔的雙向表單綁定,數據收集簡單快速。
  • 循環依賴:能幫助檢測循環依賴減少故障。
  • Typescript: 完全支持Typescript,提供完整的類型推斷和提示
  • 單元測試:提供完整的單元測試覆蓋率,保證代碼質量。

AutoStore可以為React引入信號組件,實現細粒度的更新渲染,讓React也可以享受signal帶來的絲滑感受。

以下是AutoStore中的信號組件的一個簡單示例:

/**
* title: 信號組件
* description: 通過`state.age=n`直接寫狀態時,需要使用`{$('age')}`來創建一個信號組件,內部會訂閲`age`的變更事件,用來觸發局部更新。
*/
import { createStore } from '@autostorejs/react';
import { Button,ColorBlock } from "x-react-components"

const { state , $ } = createStore({
  age:18
})

export default () => {

  return <div>
      {/* 引入Signal機制,可以局部更新Age */}
      <ColorBlock>Age+Signal :{$('age')}</ColorBlock>
      {/* 當直接更新Age時,僅在組件當重新渲染時更新 */}
      <ColorBlock>Age :{state.age}</ColorBlock>
      <Button onClick={()=>state.age=state.age+1}>+Age</Button>
    </div>
}
  • 信號組件僅僅是模擬signal實現了細粒度更新,其本質上是使用React.memo包裹的ReactNode組件。
  • 創建$來創建信號組件時,$signal的快捷名稱。因此上面的{$('age')}等價於{signal("age")}
  • 更多的信號組件的用法請參考signal。

小結

由於React沉重的歷史包袱,在可以預見的未來,React應該不會支持真正意義上的signal

在卡頌老師\`的Signal:更多前端框架的選擇中也提到,

React團隊成員對此的觀點是:

  • 有可能引入類似Signal的原語
  • Signal性能確實好,但不太符合React的理念

AutoStore所支持的信號組件的概念,可以視為模擬signal或者類似Signal的原語,使得我們可以在React中實現細粒度更新,而不用再去糾結React.memo的使用。

AutoStore

Add a new 评论

Some HTML is okay.