前端狀態(tài)管理的進(jìn)化
背景
為什么要思考狀態(tài)管理設(shè)計(jì)
近些年,隨著工業(yè)互聯(lián)網(wǎng)的發(fā)展,越來越多的應(yīng)用選擇了在瀏覽器端實(shí)現(xiàn)。瀏覽器開放的功能也越來越多。
可是,在瀏覽器功能越來越強(qiáng)大的日子,前端也變得繁重起來。狀態(tài)倉庫需要存放的東西也越來越多。如一個簡單的前端監(jiān)控系統(tǒng),就涉及到錯誤展示,數(shù)據(jù)報(bào)表,錯誤篩選查詢等等功能。這其中有許多數(shù)據(jù)都是存在交集的 一旦我們的數(shù)據(jù)獲取存在交集,則就意味著有以下問題存在:
1種類型數(shù)據(jù)存在2份,數(shù)據(jù)上有冗余 獲取了不必要的數(shù)據(jù),造成了不必要的服務(wù)端壓力 無法很好的組合使用
當(dāng)然,以上是交集存在的問題。這也會導(dǎo)致單條數(shù)據(jù)不純,無法做到很高的抽象和通用性。久而久之,此類管理方式存放的數(shù)據(jù)模型會越來越混雜,越來越多,造成管理上的麻煩。
用后端的方式思考狀態(tài)編織
于是,我們非常希望可以將數(shù)據(jù)的管理模型使其更加抽象,使其可以在任意業(yè)務(wù)場景都可以靈活組裝和使用。這一點(diǎn)也和函數(shù)式編程中的“純函數(shù)”概念類似:
純函數(shù) + 純函數(shù) = 純函數(shù)
我們將視角轉(zhuǎn)向后端來看。假設(shè)錯誤監(jiān)控的后端接口,要給我們返回一條 錯誤捕獲信息,后端的數(shù)據(jù)查詢邏輯又該如何編寫呢?
下圖是2張表的聯(lián)查實(shí)現(xiàn)。其中一張issue表,一張error表。在后端的數(shù)據(jù)庫設(shè)計(jì)中,issue和error關(guān)聯(lián),常常以引用對方的id來實(shí)現(xiàn)。這樣我們就可以將2張表解耦設(shè)計(jì),在需要聯(lián)合查詢時再進(jìn)行組裝。

可以看到,得益于許多數(shù)據(jù)庫的多表聯(lián)查,后端可以輕松地從多張表中拿到想要的數(shù)據(jù),最后組裝起來,通過接口進(jìn)行返回。
狀態(tài)范式化
基于以上考慮,我們可以采用狀態(tài)范式化方案。在使用范式化方案之前,我們先來了解一下它到底是什么。
根據(jù)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. (每種類型的數(shù)據(jù)在狀態(tài)樹中應(yīng)該有屬于自己的表)
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.(每一條數(shù)據(jù)都應(yīng)當(dāng)把數(shù)據(jù)存在一個對象里面。項(xiàng)目的ID作為key,本身作為value)
Any references to individual items should be done by storing the item's ID.(對于單個數(shù)據(jù)模型的引用應(yīng)當(dāng)通過存儲ID來實(shí)現(xiàn))?
Arrays of IDs should be used to indicate ordering.(應(yīng)該用包含ID的數(shù)組來聲明所有數(shù)據(jù)的排列順序)
簡單來講,就是將我們的數(shù)據(jù)從立體化變?yōu)楸馄交瑢⒖梢猿橄蟮臄?shù)據(jù)模型進(jìn)一步獨(dú)立管理,數(shù)據(jù)之間連接模型用ID進(jìn)行引用連接查找,可以加快查找數(shù)據(jù)的速度。如:

這種存取查找方式,類似數(shù)據(jù)庫的多表聯(lián)查一樣。所以在很多時候,我們期待前端的范式化模型和數(shù)據(jù)庫的模型一一對應(yīng)。我們根據(jù)范式化的概念,可以將我們目前的狀態(tài)根據(jù)模型進(jìn)行抽象。根據(jù)模型將數(shù)據(jù)抽離,隨后根據(jù)查詢關(guān)系,做關(guān)聯(lián)引用
抽象完畢后,我們在業(yè)務(wù)中查找數(shù)據(jù)的方案也需要進(jìn)行聯(lián)合查詢。這樣以來,我們查詢的復(fù)雜度就由O(N)降為了O(1)。查詢性能大幅度提升
normalizr.js
當(dāng)然,這樣的數(shù)據(jù)組裝方式雖然讓讀取速度加快,但也讓源數(shù)據(jù)的分離實(shí)現(xiàn)變得復(fù)雜起來。這里我們可以使用 Redux 官方推薦的 normalizr.js,他可以根據(jù)預(yù)先設(shè)置好的數(shù)據(jù)模型,把我們的數(shù)據(jù)快速根據(jù)模型進(jìn)行剝離,我們的數(shù)據(jù)轉(zhuǎn)換可以變的更加簡單。
我們可以經(jīng)過簡單的數(shù)據(jù)模型定義,就可以將數(shù)據(jù)按照模型進(jìn)行分離。像上面的演示轉(zhuǎn)換結(jié)果一樣
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);
重復(fù)渲染的煩惱
當(dāng)然,redux 天生的狀態(tài)管理方案是存在巨大的性能問題的 —— 需要將狀態(tài)提升到公共組件去管理。這種實(shí)現(xiàn)方式常常會導(dǎo)致不必要的組件重新生成組件樹。舉個例子,我們有一個錯誤監(jiān)控系統(tǒng),當(dāng)我們獲取最新的錯誤信息列表時:雖然我們的錯誤信息條目有所增加,錯誤類型卻始終沒有變化。但只使用錯誤類型的組件依然觸發(fā)了重新渲染。我們當(dāng)然不希望這種狀況存在,畢竟如果碰到比較復(fù)雜的計(jì)算時,不必要的重復(fù)渲染往往對性能影響都比較大。
useSelector
當(dāng)然,我們可以借助 react-redux 的 useSelector 鉤子來篩選需要的 state。useSelector 自身擁有了多級緩存,可以確保只有在用到的數(shù)據(jù)更新時,才會觸發(fā)組件,不會造成不必要的組件更新。
從源碼中可以看到,每次提交 action 后,都會去執(zhí)行 equalityFn 函數(shù),將本次 selector 的執(zhí)行結(jié)果與上次的結(jié)果進(jìn)行對比。如果一致,則直接 return。不會觸發(fā)后面重復(fù)渲染的邏輯

但這種方案依然存在缺陷。在每次 action 提交后,雖然組件不會重新生成,useSelctor的selector的選擇函數(shù)依然會重新生成(雖然有 reselect,但緩存也是個成本)。且倡導(dǎo)一個useSelctor每次只返回單個非引用類型字段值,不然觸發(fā)淺比較會導(dǎo)致組件再次重新渲染。
Recoil
概念 & 優(yōu)勢
Recoil 是 Facebook 推出的基于 React 的狀態(tài)管理框架(目前還是試驗(yàn)階段)。它的最大優(yōu)勢就是可以基于正交有向圖,精準(zhǔn)的只觸發(fā)渲染狀態(tài)更新的組件,而這一切都是基于訂閱來實(shí)現(xiàn)。基于訂閱,也就避免了 useSelector 的選擇器,每次狀態(tài)更新都需要重新生成的問題。
下圖可以看到,比起之前redux一顆全局大的狀態(tài)樹的玩法,recoil 更推薦將狀態(tài)拆為一個個碎片狀態(tài),只與用到的組件進(jìn)行共享。

在recoil中,有2個最核心的概念:atom和 selector。atom是狀態(tài)的最小單位。當(dāng)atom被更新時,訂閱的組件也會被觸發(fā)更新。如果多個組件都訂閱了同一個atom,則它們共享這份狀態(tài)。你可以簡單地認(rèn)為,atom 是recoil中最小的數(shù)據(jù)源
const?fontSizeState?=?atom({
??key:?'fontSizeState',
??default:?14,
});
而 selector 的意義則是搭配 atom 使用。selector 可以為 atom 加入自定義的 getter 和setter。而 atom 發(fā)生更改時,訂閱它的 selector 也會發(fā)生變化,從而被訂閱 selector 的組件重新 render
const?fontSizeLabelState?=?selector({
??key:?'fontSizeLabelState',
??get:?({get})?=>?{
????const?fontSize?=?get(fontSizeState);
????const?unit?=?'px';
????return?`${fontSize}${unit}`;
??},
});
當(dāng)然,recoil 也支持狀態(tài)的讀寫粒度不一致問題。例如我的狀態(tài)中包含了 a 和 b 兩個屬性,我在讀的時候,只讀其中的 a 屬性,則只用到 b 屬性的組件不會更改。
這一點(diǎn)對性能的提升巨大,也一定程度上間接避免了recoil的狀態(tài)拆分過細(xì)問題
配合 Suspense
當(dāng)然,Recoil 最贊的地方是 狀態(tài)讀取支持異步函數(shù)。且同步異步可以混用,同步函數(shù)也可以接受異步讀取的值。 當(dāng)然,這一個點(diǎn)要配合 Suspense 優(yōu)勢才最大。
例如下面代碼。我在 selector 中定義的狀態(tài) get 為異步函數(shù),而在我組件中使用時卻是同步的。這對于使用者來說是無感使用的。
當(dāng)然,配合 Suspense 的效果更好,我們就不需要另外的狀態(tài),來判斷這個異步計(jì)算是否已經(jīng)拿到數(shù)據(jù)。
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>
??);
}
總結(jié)
用后端的狀態(tài)模型重新思考狀態(tài)設(shè)計(jì) 盡可能地抽象模型,保證單條數(shù)據(jù)的純度。便于在業(yè)務(wù)中靈活組裝 過于靈活的狀態(tài)設(shè)計(jì),可能會導(dǎo)致不必要的組件重復(fù)渲染。需要把控粒度 警惕不必要的重復(fù)渲染帶來的性能損耗??梢允褂镁彺娴炔呗员苊庵貜?fù)渲染
