作为一名主业做设计,业余搞前端的小菜鸡,到 2020 年底为止都是用云谦大佬的 dva 一把梭。当时整体的使用体验还是挺好的,对于我这样的前端菜鸡上手门槛低,而且学一次哪都可用,当时从来没愁过状态管理。
至今 Kitchen3 里仍然躺着用 dva 做状态管理的功能模块,写于 2020 年
直到 hooks 横空出世, TypeScript 逐步流行。一方面,从 react hooks 出来以后,大量的文章开始鼓吹「你不需要 Redux」、「useState + Context」完全可用、「next-unstated」YYDS 等等。另一方面,由于 Dva 不再维护,其在 ts 下的都没有任何提示的问题也逐步暴露。
在尝试一些小项目中使用 hooks 后感觉还行之后,作为小萌新的我也全面转向了 hooks 的怀抱。中间其实一直没怎么遇到问题,因为大部分前端应用的复杂度也就那样,hooks 问题不大。然后呢?然后从去年开始就在复杂应用里踩坑了。
ProEditor 是内部组件库 TechUI Studio 的编辑器组件。
业务组件 ProEditor 就是一个很典型的例子。由于 ProEditor 是个编辑器,对用户来说编辑体验非常重要,是一个重交互操作的应用,这就会牵扯到大量的状态管理需求。
先简单来列下 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
。
当时,为了保证数据变更方法的可维护性与 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
)受控,例如下面的场景:在激活某个单元格状态时点击生成,我们需要将这个选中状态进行重置,才能生成符合预期的设计稿。
所以为了支持细颗粒度的受控能力,我们提供了多个受控值,供外部受控模式。
// 自定 hook 的写法
const useDataColumns = () => {
const createOrUpdateColumnsByMockData = useCallback(()=>{
// ...
},[a,b]);
const createColumnsByOneAPI = useCallback(()=>{
// ...
},[c,d]);
const updateColumnsByOneAPI = useCallback(()=>{
// ...
},[a,b,c,d]);
// ...
}
但当我们一开始写好这个受控 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!
复杂应用的状态管理真的不能裸写 hooks!
那些鼓吹裸写 hooks 的人大概率是没遇到过复杂 case,性能优化、受控、action 互调、数据切片、状态调试等坑,每一项都不是好惹的主,够人喝上一壶。
其实,复杂应用只是开发者状态管理需求的集中体现。如果我们把状态管理当成一款产品来设计,我们不妨看看开发者在状态管理下的核心需求是什么。
我相信通过以下这一串分析,你会发现 zustand 是真真正正满足「几乎所有」状态管理需求的工具,并且在很多细节上做到了体验更优。
官网:https://zustand-demo.pmnd.rs/
状态管理最必要的一点就是状态共享。这也是 context 出来以后,大部分文章说不需要 redux 的根本原因。因为context 可以实现最最基础的状态共享。但这种方法(包括 redux 在内),都需要在最外层包一个 Provider。Context 中的值都在 Provider 的作用域下有效。
// 导致死循环的写法
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]);
// ...
}
而 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 一样,这种方式没法处理异步,且没法互相调用,一旦遇上就容易捉襟见肘。
至于 redux ,哪怕是最新的 redux-toolkit
中优化大量 redux 的模板代码,针对同步异步方法的书写仍然让人心生畏惧。
// 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 的写法对比,如下所示:
// 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 写法
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)。
而 zustand 则把这个问题解掉了。由于 zustand 在 create 方法中提供了 get
对象,使得我们可以用 get 方法直接拿到当前 store 中最新的 state 快照。这样一来,变更函数的引用始终不变,而函数本身却一直可以拿到最新的值。
在这一趴,最后一点要夸 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 。
复杂的例子,比如基于 rgb 、hsl 值和色彩模式,得到一个包含色彩空间的对象。
如果不考虑优化,其实都可以写一个中间的函数作为派生方法,但作为状态管理的一环,我们必须要考虑相应的优化。
在 hooks 场景下,状态派生的方法可以使用 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
的方法。
然后我们用 useWhyDidYouUpdate
来检查下,如果直接用解构引入,会造成什么样的情况:
在上图中可以看到,虽然 tabs
、internalSetState
没有变化,但是其中的 config 数据项(data、columns 等)发生了变化,进而使得 TableConfig
组件触发重渲染。
而我们的性能优化方法也很简单,只要利用 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 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 中间件
// 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 就是我当下状态管理的最佳选择,甚至是大部分复杂应用的状态管理的最佳选择。
本来最后还想讲讲,我是怎么样基于 Zustand 来做渐进式的状态管理的(从小应用到复杂应用的渐进式生长方案)。然后还想拿 ProEditor 为例讲讲 ProEditor 具体的状态管理是如何逐步生长的,包括如何组织的受控模式、如何集成 RxJS 处理复杂交互等等,算是几个比较有意思的点。不过限于篇幅原因,这些内容估计就得留到下次了。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8