淺談前端狀態管理的進化

背景

爲什麼要思考狀態管理設計

可是,在瀏覽器功能越來越強大的日子,前端也變得繁重起來。狀態倉庫需要存放的東西也越來越多。如一個簡單的前端監控系統,就涉及到錯誤展示,數據報表,錯誤篩選查詢等等功能。這其中有許多數據都是存在交集的 一旦我們的數據獲取存在交集,則就意味着有以下問題存在:

  1. 1 種類型數據存在 2 份,數據上有冗餘

  2. 獲取了不必要的數據,造成了不必要的服務端壓力

  3. 無法很好的組合使用

當然,以上是交集存在的問題。這也會導致單條數據不純,無法做到很高的抽象和通用性。久而久之,此類管理方式存放的數據模型會越來越混雜,越來越多,造成管理上的麻煩。

用後端的方式思考狀態編織

於是,我們非常希望可以將數據的管理模型使其更加抽象,使其可以在任意業務場景都可以靈活組裝和使用。這一點也和函數式編程中的 “純函數” 概念類似:

純函數 + 純函數 = 純函數

我們將視角轉向後端來看。假設錯誤監控的後端接口,要給我們返回一條 錯誤捕獲信息,後端的數據查詢邏輯又該如何編寫呢?

下圖是 2 張表的聯查實現。其中一張issue表,一張error表。在後端的數據庫設計中,issueerror關聯,常常以引用對方的 id 來實現。這樣我們就可以將 2 張表解耦設計,在需要聯合查詢時再進行組裝。

image

可以看到,得益於許多數據庫的多表聯查,後端可以輕鬆地從多張表中拿到想要的數據,最後組裝起來,通過接口進行返回。

狀態範式化

基於以上考慮,我們可以採用狀態範式化方案。在使用範式化方案之前,我們先來了解一下它到底是什麼。

根據redux官方文檔的介紹(https://redux.js.org/usage/structuring-reducers/normalizing-state-shape#designing-a-normalized-state):

Each type of data gets its own "table" in the state. (每種類型的數據在狀態樹中應該有屬於自己的表)

Each "data table" should store the individual items in an object, with the IDs of the items as keys and the items themselves as the values.(每一條數據都應當把數據存在一個對象裏面。項目的 ID 作爲 key,本身作爲 value)

Any references to individual items should be done by storing the item's ID.(對於單個數據模型的引用應當通過存儲 ID 來實現) 

Arrays of IDs should be used to indicate ordering.(應該用包含 ID 的數組來聲明所有數據的排列順序)

簡單來講,就是將我們的數據從立體化變爲扁平化,將可以抽象的數據模型進一步獨立管理,數據之間連接模型用 ID 進行引用連接查找,可以加快查找數據的速度。如:

這種存取查找方式,類似數據庫的多表聯查一樣。所以在很多時候,我們期待前端的範式化模型和數據庫的模型一一對應。我們根據範式化的概念,可以將我們目前的狀態根據模型進行抽象。根據模型將數據抽離,隨後根據查詢關係,做關聯引用

抽象完畢後,我們在業務中查找數據的方案也需要進行聯合查詢。這樣以來,我們查詢的複雜度就由 O(N) 降爲了 O(1)。查詢性能大幅度提升

normalizr.js

當然,這樣的數據組裝方式雖然讓讀取速度加快,但也讓源數據的分離實現變得複雜起來。這裏我們可以使用 Redux 官方推薦的 normalizr.js,他可以根據預先設置好的數據模型,把我們的數據快速根據模型進行剝離,我們的數據轉換可以變的更加簡單。

我們可以經過簡單的數據模型定義,就可以將數據按照模型進行分離。像上面的演示轉換結果一樣

import { normalize, schema } from 'normalizr';

// Define a users schema
const user = new schema.Entity('users');

// Define your comments schema
const comment = new schema.Entity('comments'{
  commenter: user
});

// Define your article
const article = new schema.Entity('articles'{
  author: user,
  comments: [comment]
});

const normalizedData = normalize(originalData, article);

重複渲染的煩惱

當然,redux 天生的狀態管理方案是存在巨大的性能問題的 —— 需要將狀態提升到公共組件去管理。這種實現方式常常會**導致不必要的組件重新生成組件樹。**舉個例子,我們有一個錯誤監控系統,**當我們獲取最新的錯誤信息列表時:雖然我們的錯誤信息條目有所增加,錯誤類型卻始終沒有變化。但只使用錯誤類型的組件依然觸發了重新渲染。**我們當然不希望這種狀況存在,畢竟如果碰到比較複雜的計算時,不必要的重複渲染往往對性能影響都比較大。

useSelector

當然,我們可以藉助 react-reduxuseSelector 鉤子來篩選需要的 stateuseSelector 自身擁有了多級緩存,可以確保只有在用到的數據更新時,纔會觸發組件,不會造成不必要的組件更新。

從源碼中可以看到,每次提交 action 後,都會去執行 equalityFn 函數,將本次 selector 的執行結果與上次的結果進行對比。如果一致,則直接 return。不會觸發後面重複渲染的邏輯

但這種方案依然存在缺陷。在每次 action 提交後,雖然組件不會重新生成,useSelctorselector的選擇函數依然會重新生成(雖然有 reselect,但緩存也是個成本)。且倡導一個useSelctor每次只返回單個非引用類型字段值,不然觸發淺比較會導致組件再次重新渲染。

Recoil

概念 & 優勢

RecoilFacebook 推出的基於 React 的狀態管理框架(目前還是試驗階段)。它的最大優勢就是可以基於正交有向圖,精準的只觸發渲染狀態更新的組件,而這一切都是基於訂閱來實現。基於訂閱,也就避免了 useSelector 的選擇器,每次狀態更新都需要重新生成的問題。

下圖可以看到,比起之前redux一顆全局大的狀態樹的玩法,recoil 更推薦將狀態拆爲一個個碎片狀態,只與用到的組件進行共享。

recoil中,有 2 個最核心的概念:atomselectoratom是狀態的最小單位。當atom被更新時,訂閱的組件也會被觸發更新。如果多個組件都訂閱了同一個atom,則它們共享這份狀態。你可以簡單地認爲,atom 是 recoil 中最小的數據源

const fontSizeState = atom({
  key: 'fontSizeState',
  default: 14,
});

selector 的意義則是搭配 atom 使用。selector 可以爲 atom 加入自定義的 gettersetter。而 atom 發生更改時,訂閱它的 selector 也會發生變化,從而被訂閱 selector 的組件重新 render

const fontSizeLabelState = selector({
  key: 'fontSizeLabelState',
  get: ({get}) ={
    const fontSize = get(fontSizeState);
    const unit = 'px';
    return `${fontSize}${unit}`;
  },
});

當然,recoil 也支持狀態的讀寫粒度不一致問題。例如我的狀態中包含了 ab 兩個屬性,我在讀的時候,只讀其中的 a 屬性,則只用到 b 屬性的組件不會更改。

這一點對性能的提升巨大,也一定程度上間接避免了recoil的狀態拆分過細問題

配合 Suspense

當然,Recoil 最讚的地方是 狀態讀取支持異步函數。且同步異步可以混用,同步函數也可以接受異步讀取的值。 當然,這一個點要配合 Suspense 優勢才最大。

例如下面代碼。我在 selector 中定義的狀態 get 爲異步函數,而在我組件中使用時卻是同步的。這對於使用者來說是無感使用的。

當然,配合 Suspense 的效果更好,我們就不需要另外的狀態,來判斷這個異步計算是否已經拿到數據。

const currentUserIDState = atom({
  key: 'CurrentUserID',
  default: 1,
});

const currentUserNameQuery = selector({
  key: 'CurrentUserName',
  get: async ({get}) ={
    const response = await myDBQuery({
      userID: get(currentUserIDState),
    });
    return response.name;
  },
});

function CurrentUserInfo() {
  const userName = useRecoilValue(currentUserNameQuery);
  return <div>{userName}</div>;
}

function MyApp() {
  return (
    <RecoilRoot>
      <React.Suspense fallback={<div>加載中。。。</div>}>
        <CurrentUserInfo />
      </React.Suspense>
    </RecoilRoot>
  );
}

總結

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