Open arvinxx opened 10 months ago
作为一名主业做设计,业余搞前端的小菜鸡,到 2020 年底为止都是用 @云谦 大佬的 dva 一把梭。当时整体的使用体验还是挺好的,对于我这样的前端菜鸡上手门槛低,而且学一次哪都可用,当时从来没愁过状态管理。
至今 Kitchen3 里仍然躺着用 dva 做状态管理的功能模块,写于 2020 年
直到 hooks 横空出世, TypeScript 逐步流行。一方面,从 react hooks 出来以后,大量的文章开始鼓吹「你不需要 Redux」、「useState + Context」完全可用、「next-unstated」YYDS 等等。另一方面,由于 Dva 不再维护,其在 ts 下的都没有任何提示的问题也逐步暴露。
在尝试一些小项目中使用 hooks 后感觉还行之后,作为小萌新的我也全面转向了 hooks 的怀抱。中间其实一直没怎么遇到问题,因为大部分前端应用的复杂度也就那样,hooks 问题不大。然后呢?然后从去年开始就在复杂应用里踩坑了。
ProTableEditor 是内部组件库 TechUI Studio 的编辑器组件。
业务组件 ProTableEditor 就是一个很典型的例子。由于 ProTableEditor 是个编辑器,对用户来说编辑体验非常重要,是一个重交互操作的应用,这就会牵扯到大量的状态管理需求。
先简单来列下 ProEditor 的状态管理需求有哪些:
❶ Editor 容器状态管理与组件(Table)状态管理拆分,但可联动消费;
容器状态负责了一些偏全局配置的状态维护,比如画布、代码页的切换,是否激活画布交互等等,而组件的状态则是保存了组件本身的所有配置和状态。
这么做的好处在于不同组件可能会有不同的状态,而 Editor 的容器状态可以复用,比如做 ProForm 的时候,Editor 的容器仍然可以是同一个,组件状态只需额外实现 ProForm 的 Store 即可。
从上图可以看到,Table 的状态就是 Editor 的 config 字段,当 Table 改时,会触发 Editor 的 config 字段同步更新。当 Editor 更新时,也会触发该数据更新。
最初的版本,我使用了 Provider + Context 的方式来做全局状态管理。大概的写法是这样的:
// 定义 export const useStudioStore = (props?: ProEditorProps) => { // ... const tableStore = useTableStore(props?.value); const [tabKey, switchTab] = useState(TabKey.canvas); const [activeConfigTab, switchConfigTab] = useState<TableConfigGroup>(TableConfigGroup.Table); // ... } export const StudioStore = createContextStore(useStudioStore, {}); // 消费 const NavBar: FC<NavBarProps> = ({ logo }) => { const { tabKey } = useContext(StudioStore); return ... }
由于这一版是 Context 一杆推到底,这造成了一些很离谱的交互反馈,就是每一次点击其他任何地方(例如画布代码、组件的配置项),都会造成面板的 Tabs 重新渲染(左下图)。右下图是相应的重渲染分析图,可以看到任何动作都造成了重新所有页面元素的重渲染。而这还是最早期的 demo 版本,功能和数据量的才实现到 20% 左右。所以可以预见到如果不做任何优化,使用体验会差到什么程度。
❷ 需要进行复杂的数据处理
ProEditor 针对表格编辑,做了大量的数据变换操作。比如 ProTable 中针对 columns 这个字段的更新就有 14 种操作。比如其中一个比较容易被感知的updateColumnByOneAPI 就是基于 oneAPI 的字段信息更新,细颗粒度地调整 columns 里的字段信息。而这样的字段修改类型的 store,在 ProEditor 中除了 columns 还有一个 data。
columns
updateColumnByOneAPI
data
当时,为了保证数据变更方法的可维护性与 action 的不变性,我采用了 userReducer 做变更方法的管理。
因为一旦采用自定义 hooks ,就得写成下面这样才能保证不会重复渲染,会造成极大的心智负担,一旦出现数据不对的情况,很难排查到底是哪个方法或者依赖有问题。
// 自定 hook 的写法 const useDataColumns = () => { const createOrUpdateColumnsByMockData = useCallback(()=>{ // ... },[a,b]); const createColumnsByOneAPI = useCallback(()=>{ // ... },[c,d]); const updateColumnsByOneAPI = useCallback(()=>{ // ... },[a,b,c,d]); // ... }
但 useReducer 也有很大的局限性,例如不支持异步函数、不支持内部的 reducer 互相调用,不支持和其他 state 联动(比如要当参数穿进去才可用),所以也不是最优解。
❸ 是个可被外部消费的组件
一旦提到组件,势必要提非受控模式和受控模式。为了支持好我们自己的场景,且希望把 ProEditor 变成一个好用的业务组件,所以我们做了受控模式,毕竟一个好用的组件一定是要能同时支持好这两种模式的。
在实际场景下,我们既需要配置项(config)受控,同时也需要画布交互状态(interaction)受控,例如下面的场景:在激活某个单元格状态时点击生成,我们需要将这个选中状态进行重置,才能生成符合预期的设计稿。
config
interaction
所以为了支持细颗粒度的受控能力,我们提供了多个受控值,供外部受控模式。
// ProEditor 外部消费的 Demo 示意 export default () => { const [status, setStatus] = useState(); const { config, getState } = useState(); return ( <ProEditor // config 和 onConfigChange 是一对 config={config} onConfigChange={({ config }) => { setConfig(config); }} // interaction 和 onInteractionChange 是另一对受控 interaction={status} onInteractionChange={(s) => { setStatus(s); }} /> ); }
但当我们一开始写好这个受控 api,得到结果是这样的:
对,你没看错,死循环了。 遇到这个问题时让人头极度秃,因为原本以为是个很简单的功能,但是在 React 生命周期里的表现让人费解,尤其是使用 useEffect 做状态管理的时候。
// 导致死循环的写法 const useTableStore = (state: Partial<Omit<ProTableConfigStore, 'columns' | 'data'>>) => { const { defaultConfig, config: outsourceValue, mode } = props; const { columns, isEmptyColumns, dispatchColumns } = useColumnStore(defaultConfig?.columns, mode); // 受控模式 内部值与外部双向通信 useEffect(() => { // 没有外部值和变更时不更改 if (!outsourceValue) return; // 相等值的时候不做更新 if (isEqual(dataStore, outsourceValue)) return; if (outsourceValue.columns) { dispatchColumns({ type: 'setAll', columns: outsourceValue.columns }); } }, [dataStore, outsourceValue]); const dataStore = useMemo(() => { const v = { ...store, data, columns } as ProTableConfigStore; // dataStore 变更时需要对外变更一次 if (props.onChange && !isEqual(v, outsourceValue)) { props.onChange?.({ config: v, props: tableAsset.generateProps(v), isEmptyColumns, }); } return v; }, [data, store, columns, outsourceValue]); // ... }
造成上述问题的原因大部分都是因为组件内 onChange 的时机设置。一旦代码里用 useEffect 的方式去监听变更触发 onChange,有很大的概率会造成死循环。
❹ 未来还希望能支持撤销重做、快捷键等能力
毕竟,现代的编辑器都是支持快捷键、历史记录、多人协同等增强型的功能的。这些能力怎么在编辑器的状态管理中以低成本、易维护的方式进行实施,也非常重要。
总之,开发 ProEditor 的经历,一句话的血泪教训就是:
复杂应用的状态管理真的不能裸写 hooks!
那些鼓吹裸写 hooks 的人大概率是没遇到过复杂 case,性能优化、受控、action 互调、数据切片、状态调试等坑,每一项都不是好惹的主,够人喝上一壶。
其实,复杂应用只是开发者状态管理需求的集中体现。如果我们把状态管理当成一款产品来设计,我们不妨看看开发者在状态管理下的核心需求是什么。
我相信通过以下这一串分析,你会发现 zustand 是真真正正满足「几乎所有」状态管理需求的工具,并且在很多细节上做到了体验更优。
官网: https://zustand-demo.pmnd.rs/
状态管理最必要的一点就是状态共享。这也是 context 出来以后,大部分文章说不需要 redux 的根本原因。因为context 可以实现最最基础的状态共享。但这种方法(包括 redux 在内),都需要在最外层包一个 Provider。 Context 中的值都在 Provider 的作用域下有效。
// Context 状态共享 // store.ts export const StoreContext = createStoreContext(() => { ... }); // index.tsx import { appState, StoreContext } from './store'; root.render( <StoreContext.Provider value={appState}> <App /> </StoreContext.Provider> ); // icon.tsx import { StoreContext } from './store'; const ReplaceGuide: FC = () => { const { i18n, hideGuide, settings } = useContext(StoreContext); // ... return ... }
而 zustand 做到的第一点创新就是:默认不需要 Provider。直接声明一个 hooks 式的 useStore 后就可以在不同组件中进行调用。它们的状态会直接共享,简单而美好。
// Zustand 状态共享 // store.ts import create from 'zustand' export const useStore = create(set => ({ count: 1, inc: () => set(state => ({ count: state.count + 1 })), })) // Control.tsx import { useStore } from './store'; function Control() { return <button onClick={()=>{ useStore.setState((s)=>({...s,count: s.count - 5 })) }}>-5</button> } // AnotherControl.tsx import { useStore } from './store'; function AnotherControl() { const inc = useStore(state => state.inc) return <button onClick={inc}> +1 </button> } // Counter.tsx import { useStore } from './store'; function Counter() { const { count } = useStore() return <h1>{count}</h1> }
由于没有 Provider 的存在,所以声明的 useStore 默认都是单实例,如果需要多实例的话,zustand 也提供了对应的 Provider 的书写方式,这种方式在组件库中比较常用。 ProEditor 也是用的这种方式做到了多实例。
此外,zustand 的 store 状态既可以在 react 世界中消费,也可以在 react 世界外消费。
状态管理除了状态共享外,另外第二个极其必要的能力就是状态变更。在复杂的场景下,我们往往需要自行组织相应的状态变更方法,不然不好维护。这也是考验一个状态管理库好不好用的一个必要指标。
hooks 的 setState 是原子级的变更状态,hold 不住复杂逻辑;而 useReducer 的 hooks 借鉴了 redux 的思想,提供了 dispatch 变更的方式,但和 redux 的 reducer 一样,这种方式没法处理异步,且没法互相调用,一旦遇上就容易捉襟见肘。
setState
useReducer
至于 redux ,哪怕是最新的 redux-toolkit 中优化大量 redux 的模板代码,针对同步异步方法的书写仍然让人心生畏惧。
redux-toolkit
// redux-toolkit 的用法 import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' import { userAPI } from './userAPI' // 1. 创建异步函数 const fetchUserById = createAsyncThunk( 'users/fetchByIdStatus', async (userId, thunkAPI) => { const response = await userAPI.fetchById(userId) return response.data } ) const usersSlice = createSlice({ name: 'users', initialState: { entities: [], loading: 'idle' }, // 同步的 reducer 方法 reducers: { }, // 异步的 AsyncThunk 方法 extraReducers: (builder) => { // 2. 将异步函数添加到 Slice 中 builder.addCase(fetchUserById.fulfilled, (state, action) => { state.entities.push(action.payload) }) }, }) // 3. 调用异步方法 dispatch(fetchUserById(123))
而在 zustand 中,函数可以直接写,完全不用区分同步或者异步,一下子把区分同步异步的心智负担降到了 0。
// zustand store 写法 // store.ts import create from 'zustand'; const initialState = { // ... }; export const useStore = create((set, get) => ({ ...initialState, createNewDesignSystem: async () => { const { params, toggleLoading } = get(); toggleLoading(); const res = await dispatch('/hitu/remote/create-new-ds', params); toggleLoading(); if (!res) return; set({ created: true, designId: res.id }); }, toggleLoading: () => { set({ loading: !get().loading }); } })); // CreateForm.tsx import { useStore } from './store'; const CreateForm: FC = () => { const { createNewDesignSystem } = useStore(); // ... }
另外一个让人非常舒心的点在于,zustand 会默认将所有的函数保持同一引用。所以用 zustand 写的方法,默认都不会造成额外的重复渲染。(PS:这里再顺带吹一下 WebStorm 对于函数和变量的识别能力,非常好用)
在下图可以看到,所有 zustand 的 useStore 出来的值或者方法,都是橙色的变量,具有稳定引用,不会造成不必要的重复渲染。
而状态变更函数的最后一个很重要,但往往又会被忽略的一点,就是方法需要调用当前快照下的值或方法。
在常规的开发心智中,我们往往会在异步方法中直接调用当前快照的值来发起请求,或使用同步方法进行状态变更,这会有极好的状态内聚性。
比如说,我们有一个方法叫「废弃草稿」,需要获取当前的一个 id ,向服务器发起请求做数据变更,同时为了保证当前界面的数据显示有效性,变更完毕后,我们需要重新获取数据。
我们来看看 hooks 版本和 zustand 的写法对比,如下所示:
// hooks 版本 export const useStore = () => { const [designId, setDesignId] = useState(); const [loading, setLoading] = useState(false); const refetch = useCallback(() => { if (designId) { mutateKitchenSWR('/hitu/remote/ds/versions', designId); } }, [designId]); const deprecateDraft = useCallback(async () => { setLoading(true); const res = await dispatch('/hitu/remote/ds/deprecate-draft', designId); setLoading(false); if (res) { message.success('草稿删除成功'); } // 重新获取一遍数据 refetch(); }, [designId, refetch]); return { designId, setDesignId, loading, deprecateDraft, refetch, } };
// zustand 写法 const initialState = { designId: undefined, loading: false }; export const useStore = create((set, get) => ({ ...initialState, deprecateDraft: async () => { set({ loading: true }); const res = await dispatch('/hitu/remote/ds/deprecate-draft', get().designId); set({ loading: false }); if (res) { message.success('草稿删除成功'); } // 重新获取一遍数据 get().refetch(); }, refetch: () => { if (get().designId) { mutateKitchenSWR('/hitu/remote/ds/versions', get().designId); } }, })
可以明显看到,光是从代码量上 zustand 的 store 比 hooks 减少了 30% 。不过另外容易被大家忽略,但其实更重要的是, hooks 版本中互调带来了引用变更的问题。
由于 deprecateDraft 和 refetch 都调用了 designId,这就会使得当 designId 发生变更时,deprecateDraft 和 refetch 的引用会发生变更,致使 react 触发刷新。而这在有性能优化需求的场景下非常阴间,会让不该渲染的组件重新渲染。那这也是为什么react 要搞一个 useEvent 的原因(RFC)。
deprecateDraft
refetch
designId
useEvent
而 zustand 则把这个问题解掉了。由于 zustand 在 create 方法中提供了 get 对象,使得我们可以用 get 方法直接拿到当前 store 中最新的 state 快照。这样一来,变更函数的引用始终不变,而函数本身却一直可以拿到最新的值。
get
在这一趴,最后一点要夸 zustand 的是,它可以直接集成 useReducer 的模式,而且直接在官网提供了示例。这样就意味着之前在 ProEditor 中的那么多 action 可以极低成本完成迁移。
// columns 的 reducer 迁移 import { columnsConfigReducer } from './columns'; const createStore = create((set,get)=>({ /** * 控制 Columns 的复杂数据变更方法 */ dispatchColumns: (payload) => { const { columns, internalUpdateTableConfig, updateDataByColumns } = get(); // 旧的 useReducer 直接复用过来 const nextColumns = columnsConfigReducer(columns, payload); internalUpdateTableConfig({ columns: nextColumns }, 'Columns 配置'); updateDataByColumns(nextColumns); }, })
状态派生是状态管理中一个不被那么多人提起,但是在实际场景中被大量使用的东西,只是大家没有意识到,这理应也是状态管理的一环。
状态派生可以很简单,也可以非常复杂。简单的例子,比如基于一个name 字段,拼接出对应的 url 。
name
复杂的例子,比如基于 rgb 、hsl 值和色彩模式,得到一个包含色彩空间的对象。
如果不考虑优化,其实都可以写一个中间的函数作为派生方法,但作为状态管理的一环,我们必须要考虑相应的优化。
在 hooks 场景下,状态派生的方法可以使用 useMemo,例如:
useMemo
// hooks 写法 const App = () => { const [name,setName]=useState('') const url = useMemo(() => URL_HITU_DS_BASE(name || ''),[name]) // ... }
而 zustand 用了类似 redux selector 的方法,实现相应的状态派生,这个方式使得 useStore 的用法变得极其灵活和实用。而这种 selector 的方式使得 zustand 下细颗粒度的性能优化变为可能,且优化成本很低。
// zustand 的 selector 用法 // 写法1 const App = () => { const url = useStore( s => URL_HITU_DS_BASE(s.name || '')); // ... } // 写法2 将 selector 单独抽为函数 export const dsUrlSelector = (s) => URL_HITU_DS_BASE(s.name || ''); const App = () => { const url = useStore(dsUrlSelector); // ... }
由于写法 2 可以将 selector 抽为独立函数,那么我们就可以将其拆分到独立文件来管理派生状态。由于这些selector 都是纯函数,所以能轻松实现测试覆盖。
讲完状态派生后把 zustand 的 selector 能力后,直接很顺地就能来讲讲 zustand 的性能优化了。
在裸 hooks 的状态管理下,要做性能优化得专门起一个专项来分析与实施。但基于 zustand 的 useStore 和 selector 用法,我们可以实现低成本、渐进式的性能优化。
比如 ProEditor 中一个叫 TableConfig 的面板组件,对应的左下图中圈起来的部分。而右下图则是相应的代码,可以看到这个组件从 useStore 中 解构了 tabKey 和 internalSetState 的方法。
TableConfig
useStore
tabKey
internalSetState
然后我们用 useWhyDidYouUpdate 来检查下,如果直接用解构引入,会造成什么样的情况:
useWhyDidYouUpdate
在上图中可以看到,虽然 tabs、internalSetState 没有变化,但是其中的 config 数据项(data、columns 等)发生了变化,进而使得 TableConfig 组件触发重渲染。
tabs
而我们的性能优化方法也很简单,只要利用 zustand 的 selector,将得到的对象聚焦到我们需要的对象,只监听这几个对象的变化即可。
// 性能优化方法 import shallow from 'zustand/shallow'; // zustand 提供的内置浅比较方法 import { useStore, ProTableStore } from './store' const selector = (s: ProTableStore) => ({ tabKey: s.tabKey, internalSetState: s.internalSetState, }); const TableConfig: FC = () => { const { tabKey, internalSetState } = useStore(selector, shallow); }
这样一来,TableConfig 的性能优化就做好了~
基于这种模式,性能优化就会变成极其简单无脑的操作,而且对于前期的功能实现的侵入性极小,代码的后续可维护性极高。
剩下的时间就可以和小伙伴去吹咱优雅的性能优化技巧了~( ̄︶ ̄)↗
就我个人的感受上, zustand 使用 selector 来作为性能优化的思路真的很精巧,就像是给函数式的数据流加上了一点点主观意愿上的响应式能力,堪称优雅。
如果子组件能够以同样的结构,作为一个应用使用,这样的结构就是分形架构。
数据分形在状态管理里我觉得是个比较高级的概念。但从应用上来说很简单,就是更容易拆分并组织代码,而且具有更加灵活的使用方式,如下所示是拆分代码的方式。但这种方式其实我还没大使用,所以不多展开了。
// 来自官方文档的示例 // https://github.com/pmndrs/zustand/blob/main/docs/typescript.md#slices-pattern import create, { StateCreator } from 'zustand' interface BearSlice { bears: number addBear: () => void eatFish: () => void } const createBearSlice: StateCreator< BearSlice & FishSlice, [], [], BearSlice > = (set) => ({ bears: 0, addBear: () => set((state) => ({ bears: state.bears + 1 })), eatFish: () => set((state) => ({ fishes: state.fishes - 1 })), }) interface FishSlice { fishes: number addFish: () => void } const createFishSlice: StateCreator< BearSlice & FishSlice, [], [], FishSlice > = (set) => ({ fishes: 0, addFish: () => set((state) => ({ fishes: state.fishes + 1 })), }) const useBoundStore = create<BearSlice & FishSlice>()((...a) => ({ ...createBearSlice(...a), ...createFishSlice(...a), }))
我用的更多的是基于这种分形架构下的各种中间件。由于这种分形架构,状态就具有了很灵活的组合性,例如将当前状态直接缓存到 localStorage。在 zustand 的架构下, 不用额外改造,直接加个 persist 中间件就好。
persist
// 使用自带的 Persist Middleware import create from 'zustand' import { persist } from 'zustand/middleware' interface BearState { bears: number increase: (by: number) => void } const useBearStore = create<BearState>( persist((set) => ({ bears: 0, increase: (by) => set((state) => ({ bears: state.bears + by })), })) )
在 ProEditor 中,我使用最多的就是 devtools 这个中间件。这个中间件具有的功能就是:将这个 Store 和Redux Devtools 绑定。
devtools
// devtools 中间件 // store 逻辑 const vanillaStore = (set,get)=> ({ syncOutSource: (nextState) => { set({ ...get(), ...nextState }, false, `受控更新:${Object.keys(nextState).join(' ')}`); }, syncOutSourceConfig: ({ config }) => { // ... set({ ...get(), ...config }, false, `受控更新:🛠 组件配置`); // ... }, }); const createStore = create( devtools(vanillaStore, { name: 'ProTableStore' }) );
然后我们就可以在 redux-devtools 中愉快地查看数据变更了:
可能有小伙伴会注意到,为什么我这边的状态变更还有中文名,那是因为 devtools 中间件为 zustand 的 set 方法,提供了一个额外参数。只要设置好相应的 set 值的最后一个变量,就可以直接在 devtools 中看到相应的变更事件名称。
正是这样强大的分形能力,我们基于社区里做的一个 zundo 中间件,在 ProEditor 中提供了一个简易的撤销重做 的 Demo示例。
而实现核心功能的代码就只有一行~ 😆
PS:至于一开始提到的协同能力,我在社区中也有发现中间件 zustand-middleware-yjs (不过还没尝试)。
实际的复杂应用中,一定会存在某些不在 react 环境内的状态数据,以图表、画布、3D 场景最多。一旦要涉及到多环境下的状态管理,可以让人掉无数头发。
而 zustand 说了,不慌,我已经考虑到了,useStore 上直接可以拿值,是不是很贴心~
// 官方示例 // 1. 创建Store const useDogStore = create(() => ({ paw: true, snout: true, fur: true })) // 2. react 环境外直接拿值 const paw = useDogStore.getState().paw // 3. 提供外部事件订阅 const unsub1 = useDogStore.subscribe(console.log) // 4. react 世界外更新值 useDogStore.setState({ paw: false }) const Component = () => { // 5. 在 react 环境内使用 const paw = useDogStore((state) => state.paw) ...
虽然这个场景我还没遇到,但是一想到 zustand 在这种场景下也能支持,真的是让人十分心安。
其实还有其他不太值得单独提的点,比如 zustand 在测试上也相对比较容易做,直接用 test-library/react-hooks 即可。类型定义方面做的非常齐全……但到现在洋洋洒洒已经写了 6k 多字了,就不再展开了。
大概从去年12月份开始,我就一直在提炼符合我理想的状态管理库的需求,到看到 zustand 让我眼前一亮。而通过在 pro-editor 中大半年的实践验证,我很笃定地认为,zustand 就是我当下状态管理的最佳选择,甚至是大部分复杂应用的状态管理的最佳选择。
pro-editor
本来最后还想讲讲,我是怎么样基于 Zustand 来做渐进式的状态管理的(从小应用到复杂应用的渐进式生长方案)。然后还想拿 ProEditor 为例讲讲 ProEditor 具体的状态管理是如何逐步生长的,包括如何组织的受控模式、如何集成 RxJS 处理复杂交互等等,算是几个比较有意思的点。不过限于篇幅原因,这些内容估计就得留到下次了。
React 就像一个财主家的傻小孩,屁颠屁颠地揣着100w去小卖部买可乐喝
作为一名主业做设计,业余搞前端的小菜鸡,到 2020 年底为止都是用 @云谦 大佬的 dva 一把梭。当时整体的使用体验还是挺好的,对于我这样的前端菜鸡上手门槛低,而且学一次哪都可用,当时从来没愁过状态管理。
直到 hooks 横空出世, TypeScript 逐步流行。一方面,从 react hooks 出来以后,大量的文章开始鼓吹「你不需要 Redux」、「useState + Context」完全可用、「next-unstated」YYDS 等等。另一方面,由于 Dva 不再维护,其在 ts 下的都没有任何提示的问题也逐步暴露。
在尝试一些小项目中使用 hooks 后感觉还行之后,作为小萌新的我也全面转向了 hooks 的怀抱。中间其实一直没怎么遇到问题,因为大部分前端应用的复杂度也就那样,hooks 问题不大。然后呢?然后从去年开始就在复杂应用里踩坑了。
复杂应用的状态管理天坑
业务组件 ProTableEditor 就是一个很典型的例子。由于 ProTableEditor 是个编辑器,对用户来说编辑体验非常重要,是一个重交互操作的应用,这就会牵扯到大量的状态管理需求。
先简单来列下 ProEditor 的状态管理需求有哪些:
❶ Editor 容器状态管理与组件(Table)状态管理拆分,但可联动消费;
容器状态负责了一些偏全局配置的状态维护,比如画布、代码页的切换,是否激活画布交互等等,而组件的状态则是保存了组件本身的所有配置和状态。
这么做的好处在于不同组件可能会有不同的状态,而 Editor 的容器状态可以复用,比如做 ProForm 的时候,Editor 的容器仍然可以是同一个,组件状态只需额外实现 ProForm 的 Store 即可。
从上图可以看到,Table 的状态就是 Editor 的 config 字段,当 Table 改时,会触发 Editor 的 config 字段同步更新。当 Editor 更新时,也会触发该数据更新。
最初的版本,我使用了 Provider + Context 的方式来做全局状态管理。大概的写法是这样的:
由于这一版是 Context 一杆推到底,这造成了一些很离谱的交互反馈,就是每一次点击其他任何地方(例如画布代码、组件的配置项),都会造成面板的 Tabs 重新渲染(左下图)。右下图是相应的重渲染分析图,可以看到任何动作都造成了重新所有页面元素的重渲染。而这还是最早期的 demo 版本,功能和数据量的才实现到 20% 左右。所以可以预见到如果不做任何优化,使用体验会差到什么程度。
❷ 需要进行复杂的数据处理
ProEditor 针对表格编辑,做了大量的数据变换操作。比如 ProTable 中针对
columns
这个字段的更新就有 14 种操作。比如其中一个比较容易被感知的updateColumnByOneAPI
就是基于 oneAPI 的字段信息更新,细颗粒度地调整 columns 里的字段信息。而这样的字段修改类型的 store,在 ProEditor 中除了columns
还有一个data
。当时,为了保证数据变更方法的可维护性与 action 的不变性,我采用了 userReducer 做变更方法的管理。
因为一旦采用自定义 hooks ,就得写成下面这样才能保证不会重复渲染,会造成极大的心智负担,一旦出现数据不对的情况,很难排查到底是哪个方法或者依赖有问题。
但 useReducer 也有很大的局限性,例如不支持异步函数、不支持内部的 reducer 互相调用,不支持和其他 state 联动(比如要当参数穿进去才可用),所以也不是最优解。
❸ 是个可被外部消费的组件
一旦提到组件,势必要提非受控模式和受控模式。为了支持好我们自己的场景,且希望把 ProEditor 变成一个好用的业务组件,所以我们做了受控模式,毕竟一个好用的组件一定是要能同时支持好这两种模式的。
在实际场景下,我们既需要配置项(
config
)受控,同时也需要画布交互状态(interaction
)受控,例如下面的场景:在激活某个单元格状态时点击生成,我们需要将这个选中状态进行重置,才能生成符合预期的设计稿。所以为了支持细颗粒度的受控能力,我们提供了多个受控值,供外部受控模式。
但当我们一开始写好这个受控 api,得到结果是这样的:
对,你没看错,死循环了。 遇到这个问题时让人头极度秃,因为原本以为是个很简单的功能,但是在 React 生命周期里的表现让人费解,尤其是使用 useEffect 做状态管理的时候。
造成上述问题的原因大部分都是因为组件内 onChange 的时机设置。一旦代码里用 useEffect 的方式去监听变更触发 onChange,有很大的概率会造成死循环。
❹ 未来还希望能支持撤销重做、快捷键等能力
毕竟,现代的编辑器都是支持快捷键、历史记录、多人协同等增强型的功能的。这些能力怎么在编辑器的状态管理中以低成本、易维护的方式进行实施,也非常重要。
总之,开发 ProEditor 的经历,一句话的血泪教训就是:
复杂应用的状态管理真的不能裸写 hooks!
复杂应用的状态管理真的不能裸写 hooks!
复杂应用的状态管理真的不能裸写 hooks!
那些鼓吹裸写 hooks 的人大概率是没遇到过复杂 case,性能优化、受控、action 互调、数据切片、状态调试等坑,每一项都不是好惹的主,够人喝上一壶。
为什么是 Zustand ?
其实,复杂应用只是开发者状态管理需求的集中体现。如果我们把状态管理当成一款产品来设计,我们不妨看看开发者在状态管理下的核心需求是什么。
我相信通过以下这一串分析,你会发现 zustand 是真真正正满足「几乎所有」状态管理需求的工具,并且在很多细节上做到了体验更优。
官网: https://zustand-demo.pmnd.rs/
❶ 状态共享
状态管理最必要的一点就是状态共享。这也是 context 出来以后,大部分文章说不需要 redux 的根本原因。因为context 可以实现最最基础的状态共享。但这种方法(包括 redux 在内),都需要在最外层包一个 Provider。 Context 中的值都在 Provider 的作用域下有效。
而 zustand 做到的第一点创新就是:默认不需要 Provider。直接声明一个 hooks 式的 useStore 后就可以在不同组件中进行调用。它们的状态会直接共享,简单而美好。
由于没有 Provider 的存在,所以声明的 useStore 默认都是单实例,如果需要多实例的话,zustand 也提供了对应的 Provider 的书写方式,这种方式在组件库中比较常用。 ProEditor 也是用的这种方式做到了多实例。
此外,zustand 的 store 状态既可以在 react 世界中消费,也可以在 react 世界外消费。
❷ 状态变更
状态管理除了状态共享外,另外第二个极其必要的能力就是状态变更。在复杂的场景下,我们往往需要自行组织相应的状态变更方法,不然不好维护。这也是考验一个状态管理库好不好用的一个必要指标。
hooks 的
setState
是原子级的变更状态,hold 不住复杂逻辑;而useReducer
的 hooks 借鉴了 redux 的思想,提供了 dispatch 变更的方式,但和 redux 的 reducer 一样,这种方式没法处理异步,且没法互相调用,一旦遇上就容易捉襟见肘。至于 redux ,哪怕是最新的
redux-toolkit
中优化大量 redux 的模板代码,针对同步异步方法的书写仍然让人心生畏惧。而在 zustand 中,函数可以直接写,完全不用区分同步或者异步,一下子把区分同步异步的心智负担降到了 0。
另外一个让人非常舒心的点在于,zustand 会默认将所有的函数保持同一引用。所以用 zustand 写的方法,默认都不会造成额外的重复渲染。(PS:这里再顺带吹一下 WebStorm 对于函数和变量的识别能力,非常好用)
在下图可以看到,所有 zustand 的 useStore 出来的值或者方法,都是橙色的变量,具有稳定引用,不会造成不必要的重复渲染。
而状态变更函数的最后一个很重要,但往往又会被忽略的一点,就是方法需要调用当前快照下的值或方法。
在常规的开发心智中,我们往往会在异步方法中直接调用当前快照的值来发起请求,或使用同步方法进行状态变更,这会有极好的状态内聚性。
比如说,我们有一个方法叫「废弃草稿」,需要获取当前的一个 id ,向服务器发起请求做数据变更,同时为了保证当前界面的数据显示有效性,变更完毕后,我们需要重新获取数据。
我们来看看 hooks 版本和 zustand 的写法对比,如下所示:
可以明显看到,光是从代码量上 zustand 的 store 比 hooks 减少了 30% 。不过另外容易被大家忽略,但其实更重要的是, hooks 版本中互调带来了引用变更的问题。
由于
deprecateDraft
和refetch
都调用了designId
,这就会使得当designId
发生变更时,deprecateDraft
和refetch
的引用会发生变更,致使 react 触发刷新。而这在有性能优化需求的场景下非常阴间,会让不该渲染的组件重新渲染。那这也是为什么react 要搞一个useEvent
的原因(RFC)。而 zustand 则把这个问题解掉了。由于 zustand 在 create 方法中提供了
get
对象,使得我们可以用 get 方法直接拿到当前 store 中最新的 state 快照。这样一来,变更函数的引用始终不变,而函数本身却一直可以拿到最新的值。在这一趴,最后一点要夸 zustand 的是,它可以直接集成 useReducer 的模式,而且直接在官网提供了示例。这样就意味着之前在 ProEditor 中的那么多 action 可以极低成本完成迁移。
❸ 状态派生
状态派生是状态管理中一个不被那么多人提起,但是在实际场景中被大量使用的东西,只是大家没有意识到,这理应也是状态管理的一环。
状态派生可以很简单,也可以非常复杂。简单的例子,比如基于一个
name
字段,拼接出对应的 url 。复杂的例子,比如基于 rgb 、hsl 值和色彩模式,得到一个包含色彩空间的对象。
如果不考虑优化,其实都可以写一个中间的函数作为派生方法,但作为状态管理的一环,我们必须要考虑相应的优化。
在 hooks 场景下,状态派生的方法可以使用
useMemo
,例如:而 zustand 用了类似 redux selector 的方法,实现相应的状态派生,这个方式使得 useStore 的用法变得极其灵活和实用。而这种 selector 的方式使得 zustand 下细颗粒度的性能优化变为可能,且优化成本很低。
由于写法 2 可以将 selector 抽为独立函数,那么我们就可以将其拆分到独立文件来管理派生状态。由于这些selector 都是纯函数,所以能轻松实现测试覆盖。
❹ 性能优化
讲完状态派生后把 zustand 的 selector 能力后,直接很顺地就能来讲讲 zustand 的性能优化了。
在裸 hooks 的状态管理下,要做性能优化得专门起一个专项来分析与实施。但基于 zustand 的 useStore 和 selector 用法,我们可以实现低成本、渐进式的性能优化。
比如 ProEditor 中一个叫
TableConfig
的面板组件,对应的左下图中圈起来的部分。而右下图则是相应的代码,可以看到这个组件从useStore
中 解构了tabKey
和internalSetState
的方法。然后我们用
useWhyDidYouUpdate
来检查下,如果直接用解构引入,会造成什么样的情况:在上图中可以看到,虽然
tabs
、internalSetState
没有变化,但是其中的 config 数据项(data、columns 等)发生了变化,进而使得TableConfig
组件触发重渲染。而我们的性能优化方法也很简单,只要利用 zustand 的 selector,将得到的对象聚焦到我们需要的对象,只监听这几个对象的变化即可。
这样一来,TableConfig 的性能优化就做好了~
基于这种模式,性能优化就会变成极其简单无脑的操作,而且对于前期的功能实现的侵入性极小,代码的后续可维护性极高。
剩下的时间就可以和小伙伴去吹咱优雅的性能优化技巧了~( ̄︶ ̄)↗
就我个人的感受上, zustand 使用 selector 来作为性能优化的思路真的很精巧,就像是给函数式的数据流加上了一点点主观意愿上的响应式能力,堪称优雅。
❺ 数据分形与状态组合
如果子组件能够以同样的结构,作为一个应用使用,这样的结构就是分形架构。
数据分形在状态管理里我觉得是个比较高级的概念。但从应用上来说很简单,就是更容易拆分并组织代码,而且具有更加灵活的使用方式,如下所示是拆分代码的方式。但这种方式其实我还没大使用,所以不多展开了。
我用的更多的是基于这种分形架构下的各种中间件。由于这种分形架构,状态就具有了很灵活的组合性,例如将当前状态直接缓存到 localStorage。在 zustand 的架构下, 不用额外改造,直接加个
persist
中间件就好。在 ProEditor 中,我使用最多的就是
devtools
这个中间件。这个中间件具有的功能就是:将这个 Store 和Redux Devtools 绑定。然后我们就可以在 redux-devtools 中愉快地查看数据变更了:
可能有小伙伴会注意到,为什么我这边的状态变更还有中文名,那是因为
devtools
中间件为 zustand 的 set 方法,提供了一个额外参数。只要设置好相应的 set 值的最后一个变量,就可以直接在 devtools 中看到相应的变更事件名称。正是这样强大的分形能力,我们基于社区里做的一个 zundo 中间件,在 ProEditor 中提供了一个简易的撤销重做 的 Demo示例。
而实现核心功能的代码就只有一行~ 😆
PS:至于一开始提到的协同能力,我在社区中也有发现中间件 zustand-middleware-yjs (不过还没尝试)。
❻ 多环境集成( react 内外环境联动 )
实际的复杂应用中,一定会存在某些不在 react 环境内的状态数据,以图表、画布、3D 场景最多。一旦要涉及到多环境下的状态管理,可以让人掉无数头发。
而 zustand 说了,不慌,我已经考虑到了,
useStore
上直接可以拿值,是不是很贴心~虽然这个场景我还没遇到,但是一想到 zustand 在这种场景下也能支持,真的是让人十分心安。
其实还有其他不太值得单独提的点,比如 zustand 在测试上也相对比较容易做,直接用 test-library/react-hooks 即可。类型定义方面做的非常齐全……但到现在洋洋洒洒已经写了 6k 多字了,就不再展开了。
总结:zustand 是当下复杂状态管理的最佳选择
大概从去年12月份开始,我就一直在提炼符合我理想的状态管理库的需求,到看到 zustand 让我眼前一亮。而通过在
pro-editor
中大半年的实践验证,我很笃定地认为,zustand 就是我当下状态管理的最佳选择,甚至是大部分复杂应用的状态管理的最佳选择。本来最后还想讲讲,我是怎么样基于 Zustand 来做渐进式的状态管理的(从小应用到复杂应用的渐进式生长方案)。然后还想拿 ProEditor 为例讲讲 ProEditor 具体的状态管理是如何逐步生长的,包括如何组织的受控模式、如何集成 RxJS 处理复杂交互等等,算是几个比较有意思的点。不过限于篇幅原因,这些内容估计就得留到下次了。