談?wù)剰?fù)雜應(yīng)用的狀態(tài)管理(上):為什么是 Zustand
編者按:本文作者是螞蟻集團(tuán)體驗(yàn)設(shè)計(jì)師聞冰(社區(qū)稱呼:空谷)?,本篇中,聞冰首先介紹了那些復(fù)雜應(yīng)用的狀態(tài)管理天坑,認(rèn)為 zustand 是當(dāng)下復(fù)雜狀態(tài)管理的最佳選擇,并從狀態(tài)共享、狀態(tài)變更、狀態(tài)派生、性能優(yōu)化等 6 個(gè)方面詮釋了選擇它的理由。本篇為上篇,下篇將介紹 Zustand 的漸進(jìn)式狀態(tài)管理實(shí)踐,敬請(qǐng)期待~
作為一名主業(yè)做設(shè)計(jì),業(yè)余搞前端的小菜雞,到 2020 年底為止都是用云謙大佬的 dva 一把梭。當(dāng)時(shí)整體的使用體驗(yàn)還是挺好的,對(duì)于我這樣的前端菜雞上手門檻低,而且學(xué)一次哪都可用,當(dāng)時(shí)從來沒愁過狀態(tài)管理。

直到 hooks 橫空出世, TypeScript 逐步流行。一方面,從 react hooks 出來以后,大量的文章開始鼓吹「你不需要 Redux」、「useState + Context」完全可用、「next-unstated」YYDS 等等。另一方面,由于 Dva 不再維護(hù),其在 ts 下的都沒有任何提示的問題也逐步暴露。
在嘗試一些小項(xiàng)目中使用 hooks 后感覺還行之后,作為小萌新的我也全面轉(zhuǎn)向了 hooks 的懷抱。中間其實(shí)一直沒怎么遇到問題,因?yàn)榇蟛糠智岸藨?yīng)用的復(fù)雜度也就那樣,hooks 問題不大。然后呢?然后從去年開始就在復(fù)雜應(yīng)用里踩坑了。

復(fù)雜應(yīng)用的狀態(tài)管理天坑
ProEditor 是內(nèi)部組件庫(kù) TechUI Studio 的編輯器組件。
業(yè)務(wù)組件 ProEditor 就是一個(gè)很典型的例子。由于 ProEditor 是個(gè)編輯器,對(duì)用戶來說編輯體驗(yàn)非常重要,是一個(gè)重交互操作的應(yīng)用,這就會(huì)牽扯到大量的狀態(tài)管理需求。
先簡(jiǎn)單來列下 ProEditor 的狀態(tài)管理需求有哪些:
? Editor 容器狀態(tài)管理與組件(Table)狀態(tài)管理拆分,但可聯(lián)動(dòng)消費(fèi);
容器狀態(tài)負(fù)責(zé)了一些偏全局配置的狀態(tài)維護(hù),比如畫布、代碼頁(yè)的切換,是否激活畫布交互等等,而組件的狀態(tài)則是保存了組件本身的所有配置和狀態(tài)。
這么做的好處在于不同組件可能會(huì)有不同的狀態(tài),而 Editor 的容器狀態(tài)可以復(fù)用,比如做 ProForm 的時(shí)候,Editor 的容器仍然可以是同一個(gè),組件狀態(tài)只需額外實(shí)現(xiàn) ProForm 的 Store 即可。
從上圖可以看到,Table 的狀態(tài)就是 Editor 的 config 字段,當(dāng) Table 改時(shí),會(huì)觸發(fā) Editor 的 config 字段同步更新。當(dāng) Editor 更新時(shí),也會(huì)觸發(fā)該數(shù)據(jù)更新。
最初的版本,我使用了 Provider + Context 的方式來做全局狀態(tài)管理。大概的寫法是這樣的:
由于這一版是 Context 一桿推到底,這造成了一些很離譜的交互反饋,就是每一次點(diǎn)擊其他任何地方(例如畫布代碼、組件的配置項(xiàng)),都會(huì)造成面板的 Tabs 重新渲染(左下圖)。右下圖是相應(yīng)的重渲染分析圖,可以看到任何動(dòng)作都造成了重新所有頁(yè)面元素的重渲染。而這還是最早期的 demo 版本,功能和數(shù)據(jù)量的才實(shí)現(xiàn)到 20% 左右。所以可以預(yù)見到如果不做任何優(yōu)化,使用體驗(yàn)會(huì)差到什么程度。

? 需要進(jìn)行復(fù)雜的數(shù)據(jù)處理
ProEditor 針對(duì)表格編輯,做了大量的數(shù)據(jù)變換操作。比如 ProTable 中針對(duì)?columns
?這個(gè)字段的更新就有 14 種操作。比如其中一個(gè)比較容易被感知的updateColumnByOneAPI
?就是基于 oneAPI 的字段信息更新,細(xì)顆粒度地調(diào)整 columns 里的字段信息。而這樣的字段修改類型的 store,在 ProEditor 中除了?columns
?還有一個(gè)?data
。
當(dāng)時(shí),為了保證數(shù)據(jù)變更方法的可維護(hù)性與 action 的不變性,我采用了 userReducer 做變更方法的管理。

因?yàn)橐坏┎捎米远x hooks ,就得寫成下面這樣才能保證不會(huì)重復(fù)渲染,會(huì)造成極大的心智負(fù)擔(dān),一旦出現(xiàn)數(shù)據(jù)不對(duì)的情況,很難排查到底是哪個(gè)方法或者依賴有問題。
但 useReducer 也有很大的局限性,例如不支持異步函數(shù)、不支持內(nèi)部的 reducer 互相調(diào)用,不支持和其他 state 聯(lián)動(dòng)(比如要當(dāng)參數(shù)穿進(jìn)去才可用),所以也不是最優(yōu)解。
? 是個(gè)可被外部消費(fèi)的組件
一旦提到組件,勢(shì)必要提非受控模式和受控模式。為了支持好我們自己的場(chǎng)景,且希望把 ProEditor 變成一個(gè)好用的業(yè)務(wù)組件,所以我們做了受控模式,畢竟一個(gè)好用的組件一定是要能同時(shí)支持好這兩種模式的。
在實(shí)際場(chǎng)景下,我們既需要配置項(xiàng)(config
)受控,同時(shí)也需要畫布交互狀態(tài)(interaction
)受控,例如下面的場(chǎng)景:在激活某個(gè)單元格狀態(tài)時(shí)點(diǎn)擊生成,我們需要將這個(gè)選中狀態(tài)進(jìn)行重置,才能生成符合預(yù)期的設(shè)計(jì)稿。

所以為了支持細(xì)顆粒度的受控能力,我們提供了多個(gè)受控值,供外部受控模式。
但當(dāng)我們一開始寫好這個(gè)受控 api,得到結(jié)果是這樣的:

對(duì),你沒看錯(cuò),死循環(huán)了。遇到這個(gè)問題時(shí)讓人頭極度禿,因?yàn)樵疽詾槭莻€(gè)很簡(jiǎn)單的功能,但是在 React 生命周期里的表現(xiàn)讓人費(fèi)解,尤其是使用 useEffect 做狀態(tài)管理的時(shí)候。
造成上述問題的原因大部分都是因?yàn)榻M件內(nèi) onChange 的時(shí)機(jī)設(shè)置。一旦代碼里用 useEffect 的方式去監(jiān)聽變更觸發(fā) onChange,有很大的概率會(huì)造成死循環(huán)。
? 未來還希望能支持撤銷重做、快捷鍵等能力
畢竟,現(xiàn)代的編輯器都是支持快捷鍵、歷史記錄、多人協(xié)同等增強(qiáng)型的功能的。這些能力怎么在編輯器的狀態(tài)管理中以低成本、易維護(hù)的方式進(jìn)行實(shí)施,也非常重要。

總之,開發(fā) ProEditor 的經(jīng)歷,一句話的血淚教訓(xùn)就是:
復(fù)雜應(yīng)用的狀態(tài)管理真的不能裸寫 hooks!
復(fù)雜應(yīng)用的狀態(tài)管理真的不能裸寫 hooks!
復(fù)雜應(yīng)用的狀態(tài)管理真的不能裸寫 hooks!
那些鼓吹裸寫 hooks 的人大概率是沒遇到過復(fù)雜 case,性能優(yōu)化、受控、action 互調(diào)、數(shù)據(jù)切片、狀態(tài)調(diào)試等坑,每一項(xiàng)都不是好惹的主,夠人喝上一壺。

為什么是 Zustand ?
其實(shí),復(fù)雜應(yīng)用只是開發(fā)者狀態(tài)管理需求的集中體現(xiàn)。如果我們把狀態(tài)管理當(dāng)成一款產(chǎn)品來設(shè)計(jì),我們不妨看看開發(fā)者在狀態(tài)管理下的核心需求是什么。
我相信通過以下這一串分析,你會(huì)發(fā)現(xiàn) zustand 是真真正正滿足「幾乎所有」?fàn)顟B(tài)管理需求的工具,并且在很多細(xì)節(jié)上做到了體驗(yàn)更優(yōu)。

? 狀態(tài)共享
狀態(tài)管理最必要的一點(diǎn)就是狀態(tài)共享。這也是 context 出來以后,大部分文章說不需要 redux 的根本原因。因?yàn)閏ontext 可以實(shí)現(xiàn)最最基礎(chǔ)的狀態(tài)共享。但這種方法(包括 redux 在內(nèi)),都需要在最外層包一個(gè) Provider。Context 中的值都在 Provider 的作用域下有效。
而 zustand 做到的第一點(diǎn)創(chuàng)新就是:默認(rèn)不需要 Provider。直接聲明一個(gè) hooks 式的 useStore 后就可以在不同組件中進(jìn)行調(diào)用。它們的狀態(tài)會(huì)直接共享,簡(jiǎn)單而美好。
由于沒有 Provider 的存在,所以聲明的 useStore 默認(rèn)都是單實(shí)例,如果需要多實(shí)例的話,zustand 也提供了對(duì)應(yīng)的 Provider 的書寫方式,這種方式在組件庫(kù)中比較常用。ProEditor 也是用的這種方式做到了多實(shí)例。
此外,zustand 的 store 狀態(tài)既可以在 react 世界中消費(fèi),也可以在 react 世界外消費(fèi)。
? 狀態(tài)變更
狀態(tài)管理除了狀態(tài)共享外,另外第二個(gè)極其必要的能力就是狀態(tài)變更。在復(fù)雜的場(chǎng)景下,我們往往需要自行組織相應(yīng)的狀態(tài)變更方法,不然不好維護(hù)。這也是考驗(yàn)一個(gè)狀態(tài)管理庫(kù)好不好用的一個(gè)必要指標(biāo)。
hooks 的?setState
?是原子級(jí)的變更狀態(tài),hold 不住復(fù)雜邏輯;而?useReducer
?的 hooks 借鑒了 redux 的思想,提供了 dispatch 變更的方式,但和 redux 的 reducer 一樣,這種方式?jīng)]法處理異步,且沒法互相調(diào)用,一旦遇上就容易捉襟見肘。
至于 redux ,哪怕是最新的?redux-toolkit
?中優(yōu)化大量 redux 的模板代碼,針對(duì)同步異步方法的書寫仍然讓人心生畏懼。
而在 zustand 中,函數(shù)可以直接寫,完全不用區(qū)分同步或者異步,一下子把區(qū)分同步異步的心智負(fù)擔(dān)降到了 0。
另外一個(gè)讓人非常舒心的點(diǎn)在于,zustand 會(huì)默認(rèn)將所有的函數(shù)保持同一引用。所以用 zustand 寫的方法,默認(rèn)都不會(huì)造成額外的重復(fù)渲染。(PS:這里再順帶吹一下 WebStorm 對(duì)于函數(shù)和變量的識(shí)別能力,非常好用)

在下圖可以看到,所有 zustand 的 useStore 出來的值或者方法,都是橙色的變量,具有穩(wěn)定引用,不會(huì)造成不必要的重復(fù)渲染。

而狀態(tài)變更函數(shù)的最后一個(gè)很重要,但往往又會(huì)被忽略的一點(diǎn),就是方法需要調(diào)用當(dāng)前快照下的值或方法。
在常規(guī)的開發(fā)心智中,我們往往會(huì)在異步方法中直接調(diào)用當(dāng)前快照的值來發(fā)起請(qǐng)求,或使用同步方法進(jìn)行狀態(tài)變更,這會(huì)有極好的狀態(tài)內(nèi)聚性。
比如說,我們有一個(gè)方法叫「廢棄草稿」,需要獲取當(dāng)前的一個(gè) id ,向服務(wù)器發(fā)起請(qǐng)求做數(shù)據(jù)變更,同時(shí)為了保證當(dāng)前界面的數(shù)據(jù)顯示有效性,變更完畢后,我們需要重新獲取數(shù)據(jù)。
我們來看看 hooks 版本和 zustand 的寫法對(duì)比,如下所示:
可以明顯看到,光是從代碼量上 zustand 的 store 比 hooks 減少了 30% 。不過另外容易被大家忽略,但其實(shí)更重要的是,?hooks 版本中互調(diào)帶來了引用變更的問題。
由于?deprecateDraft
?和?refetch
?都調(diào)用了?designId
,這就會(huì)使得當(dāng)?designId
?發(fā)生變更時(shí),deprecateDraft
?和?refetch
?的引用會(huì)發(fā)生變更,致使 react 觸發(fā)刷新。而這在有性能優(yōu)化需求的場(chǎng)景下非常陰間,會(huì)讓不該渲染的組件重新渲染。那這也是為什么react 要搞一個(gè)?useEvent
?的原因(RFC)。
而 zustand 則把這個(gè)問題解掉了。由于 zustand 在 create 方法中提供了?get
?對(duì)象,使得我們可以用 get 方法直接拿到當(dāng)前 store 中最新的 state 快照。這樣一來,變更函數(shù)的引用始終不變,而函數(shù)本身卻一直可以拿到最新的值。
在這一趴,最后一點(diǎn)要夸 zustand 的是,它可以直接集成 useReducer 的模式,而且直接在官網(wǎng)提供了示例。這樣就意味著之前在 ProEditor 中的那么多 action 可以極低成本完成遷移。
? 狀態(tài)派生
狀態(tài)派生是狀態(tài)管理中一個(gè)不被那么多人提起,但是在實(shí)際場(chǎng)景中被大量使用的東西,只是大家沒有意識(shí)到,這理應(yīng)也是狀態(tài)管理的一環(huán)。
狀態(tài)派生可以很簡(jiǎn)單,也可以非常復(fù)雜。簡(jiǎn)單的例子,比如基于一個(gè)name
?字段,拼接出對(duì)應(yīng)的 url 。

復(fù)雜的例子,比如基于 rgb 、hsl 值和色彩模式,得到一個(gè)包含色彩空間的對(duì)象。

如果不考慮優(yōu)化,其實(shí)都可以寫一個(gè)中間的函數(shù)作為派生方法,但作為狀態(tài)管理的一環(huán),我們必須要考慮相應(yīng)的優(yōu)化。
在 hooks 場(chǎng)景下,狀態(tài)派生的方法可以使用?useMemo
,例如:
而 zustand 用了類似 redux selector 的方法,實(shí)現(xiàn)相應(yīng)的狀態(tài)派生,這個(gè)方式使得 useStore 的用法變得極其靈活和實(shí)用。而這種 selector 的方式使得 zustand 下細(xì)顆粒度的性能優(yōu)化變?yōu)榭赡埽覂?yōu)化成本很低。
由于寫法 2 可以將 selector 抽為獨(dú)立函數(shù),那么我們就可以將其拆分到獨(dú)立文件來管理派生狀態(tài)。由于這些selector 都是純函數(shù),所以能輕松實(shí)現(xiàn)測(cè)試覆蓋。

? 性能優(yōu)化
講完?duì)顟B(tài)派生后把 zustand 的 selector 能力后,直接很順地就能來講講 zustand 的性能優(yōu)化了。
在裸 hooks 的狀態(tài)管理下,要做性能優(yōu)化得專門起一個(gè)專項(xiàng)來分析與實(shí)施。但基于 zustand 的 useStore 和 selector 用法,我們可以實(shí)現(xiàn)低成本、漸進(jìn)式的性能優(yōu)化。
比如 ProEditor 中一個(gè)叫?TableConfig
?的面板組件,對(duì)應(yīng)的左下圖中圈起來的部分。而右下圖則是相應(yīng)的代碼,可以看到這個(gè)組件從?useStore
?中 解構(gòu)了?tabKey
?和?internalSetState
?的方法。

然后我們用?useWhyDidYouUpdate
?來檢查下,如果直接用解構(gòu)引入,會(huì)造成什么樣的情況:

在上圖中可以看到,雖然?tabs
、internalSetState
?沒有變化,但是其中的 config 數(shù)據(jù)項(xiàng)(data、columns 等)發(fā)生了變化,進(jìn)而使得?TableConfig
?組件觸發(fā)重渲染。
而我們的性能優(yōu)化方法也很簡(jiǎn)單,只要利用 zustand 的 selector,將得到的對(duì)象聚焦到我們需要的對(duì)象,只監(jiān)聽這幾個(gè)對(duì)象的變化即可。
這樣一來,TableConfig 的性能優(yōu)化就做好了~

基于這種模式,性能優(yōu)化就會(huì)變成極其簡(jiǎn)單無腦的操作,而且對(duì)于前期的功能實(shí)現(xiàn)的侵入性極小,代碼的后續(xù)可維護(hù)性極高。

剩下的時(shí)間就可以和小伙伴去吹咱優(yōu)雅的性能優(yōu)化技巧了~( ̄︶ ̄)↗

就我個(gè)人的感受上, zustand 使用 selector 來作為性能優(yōu)化的思路真的很精巧,就像是給函數(shù)式的數(shù)據(jù)流加上了一點(diǎn)點(diǎn)主觀意愿上的響應(yīng)式能力,堪稱優(yōu)雅。

? 數(shù)據(jù)分形與狀態(tài)組合
如果子組件能夠以同樣的結(jié)構(gòu),作為一個(gè)應(yīng)用使用,這樣的結(jié)構(gòu)就是分形架構(gòu)。
數(shù)據(jù)分形在狀態(tài)管理里我覺得是個(gè)比較高級(jí)的概念。但從應(yīng)用上來說很簡(jiǎn)單,就是更容易拆分并組織代碼,而且具有更加靈活的使用方式,如下所示是拆分代碼的方式。但這種方式其實(shí)我還沒大使用,所以不多展開了。
我用的更多的是基于這種分形架構(gòu)下的各種中間件。由于這種分形架構(gòu),狀態(tài)就具有了很靈活的組合性,例如將當(dāng)前狀態(tài)直接緩存到 localStorage。在 zustand 的架構(gòu)下, 不用額外改造,直接加個(gè)?persist
?中間件就好。
在 ProEditor 中,我使用最多的就是?devtools
?這個(gè)中間件。這個(gè)中間件具有的功能就是:將這個(gè) Store 和Redux Devtools 綁定。
然后我們就可以在 redux-devtools 中愉快地查看數(shù)據(jù)變更了:

可能有小伙伴會(huì)注意到,為什么我這邊的狀態(tài)變更還有中文名,那是因?yàn)?devtools
?中間件為 zustand 的 set 方法,提供了一個(gè)額外參數(shù)。只要設(shè)置好相應(yīng)的 set 值的最后一個(gè)變量,就可以直接在 devtools 中看到相應(yīng)的變更事件名稱。
正是這樣強(qiáng)大的分形能力,我們基于社區(qū)里做的一個(gè) zundo 中間件,在 ProEditor 中提供了一個(gè)簡(jiǎn)易的撤銷重做 的 Demo示例。

而實(shí)現(xiàn)核心功能的代碼就只有一行~ ??

PS:至于一開始提到的協(xié)同能力,我在社區(qū)中也有發(fā)現(xiàn)中間件 zustand-middleware-yjs (不過還沒嘗試)。
? 多環(huán)境集成( react 內(nèi)外環(huán)境聯(lián)動(dòng) )
實(shí)際的復(fù)雜應(yīng)用中,一定會(huì)存在某些不在 react 環(huán)境內(nèi)的狀態(tài)數(shù)據(jù),以圖表、畫布、3D 場(chǎng)景最多。一旦要涉及到多環(huán)境下的狀態(tài)管理,可以讓人掉無數(shù)頭發(fā)。
而 zustand 說了,不慌,我已經(jīng)考慮到了,useStore
?上直接可以拿值,是不是很貼心~
雖然這個(gè)場(chǎng)景我還沒遇到,但是一想到 zustand 在這種場(chǎng)景下也能支持,真的是讓人十分心安。

其實(shí)還有其他不太值得單獨(dú)提的點(diǎn),比如 zustand 在測(cè)試上也相對(duì)比較容易做,直接用 test-library/react-hooks 即可。類型定義方面做的非常齊全……但到現(xiàn)在洋洋灑灑已經(jīng)寫了 6k 多字了,就不再展開了。
總結(jié):zustand 是當(dāng)下復(fù)雜狀態(tài)管理的最佳選擇
大概從去年12月份開始,我就一直在提煉符合我理想的狀態(tài)管理庫(kù)的需求,到看到 zustand 讓我眼前一亮。而通過在?pro-editor
?中大半年的實(shí)踐驗(yàn)證,我很篤定地認(rèn)為,zustand 就是我當(dāng)下狀態(tài)管理的最佳選擇,甚至是大部分復(fù)雜應(yīng)用的狀態(tài)管理的最佳選擇。
本來最后還想講講,我是怎么樣基于 Zustand 來做漸進(jìn)式的狀態(tài)管理的(從小應(yīng)用到復(fù)雜應(yīng)用的漸進(jìn)式生長(zhǎng)方案)。然后還想拿 ProEditor 為例講講 ProEditor 具體的狀態(tài)管理是如何逐步生長(zhǎng)的,包括如何組織的受控模式、如何集成 RxJS 處理復(fù)雜交互等等,算是幾個(gè)比較有意思的點(diǎn)。不過限于篇幅原因,這些內(nèi)容估計(jì)就得留到下次了。