2023- 谈谈复杂应用的状态管理:为什么是Zustand
谈谈复杂应用的状态管理:为什么是Zustand
作为一名主业做设计,业余搞前端的小菜鸡,到
至今
直到
在尝试一些小项目中使用
复杂应用的状态管理天坑
ProEditor 是内部组件库TechUI Studio 的编辑器组件。
业务组件
先简单来列下
❶
容器状态负责了一些偏全局配置的状态维护,比如画布、代码页的切换,是否激活画布交互等等,而组件的状态则是保存了组件本身的所有配置和状态。
这么做的好处在于不同组件可能会有不同的状态,而
从上图可以看到,
最初的版本,我使用了
// 定义
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 ...
}
由于这一版是
❷ 需要进行复杂的数据处理
columns
这个字段的更新就有updateColumnByOneAPI
就是基于columns
还有一个 data
。
当时,为了保证数据变更方法的可维护性与
因为一旦采用自定义
// 自定 hook 的写法
const useDataColumns = () => {
const createOrUpdateColumnsByMockData = useCallback(() => {
// ...
}, [a, b]);
const createColumnsByOneAPI = useCallback(() => {
// ...
}, [c, d]);
const updateColumnsByOneAPI = useCallback(() => {
// ...
}, [a, b, c, d]);
// ...
};
但
❸ 是个可被外部消费的组件
一旦提到组件,势必要提非受控模式和受控模式。为了支持好我们自己的场景,且希望把
在实际场景下,我们既需要配置项(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);
}}
/>
);
};
但当我们一开始写好这个受控
对,你没看错,死循环了。 遇到这个问题时让人头极度秃,因为原本以为是个很简单的功能,但是在
// 导致死循环的写法
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 ?
其实,复杂应用只是开发者状态管理需求的集中体现。如果我们把状态管理当成一款产品来设计,我们不妨看看开发者在状态管理下的核心需求是什么。
我相信通过以下这一串分析,你会发现
❶ 状态共享
状态管理最必要的一点就是状态共享。这也是
// 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 状态共享
// 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>;
}
由于没有
此外,
❷ 状态变更
状态管理除了状态共享外,另外第二个极其必要的能力就是状态变更。在复杂的场景下,我们往往需要自行组织相应的状态变更方法,不然不好维护。这也是考验一个状态管理库好不好用的一个必要指标。
setState
是原子级的变更状态,useReducer
的
至于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 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();
// ...
};
另外一个让人非常舒心的点在于,
在下图可以看到,所有
而状态变更函数的最后一个很重要,但往往又会被忽略的一点,就是方法需要调用当前快照下的值或方法。
在常规的开发心智中,我们往往会在异步方法中直接调用当前快照的值来发起请求,或使用同步方法进行状态变更,这会有极好的状态内聚性。
比如说,我们有一个方法叫「废弃草稿
我们来看看
// 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);
}
},
})
可以明显看到,光是从代码量上
由于 deprecateDraft
和 refetch
都调用了 designId
,这就会使得当 designId
发生变更时,deprecateDraft
和 refetch
的引用会发生变更,致使useEvent
的原因(RFC
而get
对象,使得我们可以用
在这一趴,最后一点要夸
// 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
字段,拼接出对应的
复杂的例子,比如基于
如果不考虑优化,其实都可以写一个中间的函数作为派生方法,但作为状态管理的一环,我们必须要考虑相应的优化。
在useMemo
,例如:
// hooks 写法
const App = () => {
const [name, setName] = useState("");
const url = useMemo(() => URL_HITU_DS_BASE(name || ""), [name]);
// ...
};
而
// 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);
// ...
};
由于写法
❹ 性能优化
讲完状态派生后把
在裸
比如TableConfig
的面板组件,对应的左下图中圈起来的部分。而右下图则是相应的代码,可以看到这个组件从 useStore
中 解构了 tabKey
和 internalSetState
的方法。
然后我们用 useWhyDidYouUpdate
来检查下,如果直接用解构引入,会造成什么样的情况:
在上图中可以看到,虽然 tabs
、internalSetState
没有变化,但是其中的TableConfig
组件触发重渲染。
而我们的性能优化方法也很简单,只要利用
// 性能优化方法
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);
};
这样一来,
基于这种模式,性能优化就会变成极其简单无脑的操作,而且对于前期的功能实现的侵入性极小,代码的后续可维护性极高。
剩下的时间就可以和小伙伴去吹咱优雅的性能优化技巧了
就我个人的感受上,
❺ 数据分形与状态组合
如果子组件能够以同样的结构,作为一个应用使用,这样的结构就是分形架构。
数据分形在状态管理里我觉得是个比较高级的概念。但从应用上来说很简单,就是更容易拆分并组织代码,而且具有更加灵活的使用方式,如下所示是拆分代码的方式。但这种方式其实我还没大使用,所以不多展开了。
// 来自官方文档的示例
// 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),
}))
我用的更多的是基于这种分形架构下的各种中间件。由于这种分形架构,状态就具有了很灵活的组合性,例如将当前状态直接缓存到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 })),
}))
);
在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" }));
然后我们就可以在
可能有小伙伴会注意到,为什么我这边的状态变更还有中文名,那是因为 devtools
中间件为
正是这样强大的分形能力,我们基于社区里做的一个 zundo 中间件,在
而实现核心功能的代码就只有一行
PS:至于一开始提到的协同能力,我在社区中也有发现中间件 zustand-middleware-yjs (不过还没尝试
❻ 多环境集成( react 内外环境联动 )
实际的复杂应用中,一定会存在某些不在
而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 是当下复杂状态管理的最佳选择
大概从去年pro-editor
中大半年的实践验证,我很笃定地认为,
本来最后还想讲讲,我是怎么样基于
银弹存在吗?
在上篇《为什么是
首先想从云谦老师《数据流
所以怎么选? 没有银弹!如果站在社区开发者的角度来看。先看远程状态库是否满足需求,满足的话就无需传统数据流方案了;然后如果是非常非常简单的场景用
useState + Context ,复杂点的就不建议了,因为需要自行处理渲染优化,手动把Context 拆很细或者尝试用use-context-selector ;再看心智模型,按内部store 或外部store 区分选择,个人已经习惯后者,前者还在观望;如果选外部store ,无兼容性要求的场景优先用类Valtio 基于proxy 的方案,写入数据时更符合直觉,同时拥有全自动的渲染优化,有兼容性要求时则选Zustand 。
其实我对上述的这个决策方案并不是非常太认同,原因是状态管理会有一个重要但容易被忽略的核心需求:在遇到更加复杂的场景时,我们能不能用当前的模式轻松地承接住? 例如:
- 当前只是
5 个状态,但业务突然加了一坨复杂的业务需求,然后要膨胀到15 个状态。这个时候状态还容不容易维护?性能还能保证和之前一样吗? - 当前是
React 环境,但突然业务说要加个canvas 图表的需求,图表又有一些配置切换功能,还是用React ,这个时候还能愉快地共享状态么? - 当前是个业务应用,突然某个时候业务说要把这个应用板块抽成组件供外部复用,当前的模式能不能轻松实现受控的模式改造成组件
? (设想一下将语雀编辑器从应用抽取变成组件)
如果承接不住,那么就意味着推翻重写,这在我看来是不可接受的。理想的架构选型,就应该为可以预见的未来避开大部分坑,而不是遇到了再换一枪打一发。所以我自己做状态管理库决策选型的两个核心原则是:
虽然我自己的实践有限,但我还想说
基于Zustand 的渐进式状态管理
最近在给
基础的展示、选择、移除、搜索
为了满足上述的目标,这个组件具有下述功能:
- 展示图标列表;
- 选择、删除图标;
- 搜索图标;
- 切换
antd 图标和iconfont 图标类目; - 添加与删除
Iconfont 脚本(暂不准备加编辑) ; - 切换
iconfont 脚本展示不同的iconfont 脚本下的图标;
同时,由于这个组件需要被多个场景复用,因此它需要支持非受控模式与受控模式。同时为了提升研发效率,我也希望能用
讲完基础的需求后,一起来看看这个组件是如何通过
Step 1: store 初始化 :State
首先拿最简单的store.ts
文件,然后写下如下代码:
import create from 'zustand';
// 注意一个小细节,建议直接将该变量直接称为 useStore
export const useStore = create(() => ({
panelTabKey: 'antd',
})
在相应的组件(PickerPanel
)中引入 useStore
,用panelTabKey
。而需要修改状态时,可直接使用 useStore.setState
即可对 panelTabKey
进行修改。这样,
import { Segmented } from "antd";
import { useStore } from "../store";
const PickerPanel = () => {
const { panelTabKey } = useStore();
return (
// ...
<Segmented
value={panelTabKey}
onChange={(key) => {
useStore.setState({ panelTabKey: key });
}}
// 其他配置
size={"small"}
options={[
{ label: "Ant Design", value: "antd" },
{ label: "Iconfont", value: "iconfont" },
]}
block
/>
// ...
);
};
useStore
又包含了一个 setState
的方法,因此在需要setState
进行状态修改。 这是
Step 2: 状态变更方法:Action
在setState
来管理非常简单的状态,这些状态基本上用不着为其单独设定相应的变更功能。但是随着业务场景的复杂性增多,我们不可避免地会遇到存在一次操作需要变更多个状态的场景。
因此我们首先在
import create from 'zustand';
export const useStore = create(() => ({
panelTabKey: 'antd',
iconList: ...,
open: false,
filterKeywords: '',
icon: null,
})
如果我们直接用
import { useStore } from '../store';
const IconList = () => {
const { iconList } = useStore();
return (
<div>
{iconList.map((icon) => (
<IconThumbnail onClick={(icon) => {
useStore.setState({ icon, open: false, filterKeywords: undefined });
})} />
))}
</div>
);
};
但此时会遇到新的问题,如果我在另外一个地方也需要使用这样一段操作逻辑时,我要写两次么?当然不,这既不利于开发,也不利于维护。 所以,在这里我们需要抽取一个 selectIcon
方法专门用于选择图标这个操作,相关的状态只要都写在那里即可。而这就引出了状态管理的第二步:自定义store.ts
中直接声明并定义 selectIcon
函数,然后第一个入参改为
import create from 'zustand';
// 添加第一个入参 set
export const useStore = create((set) => ({
panelTabKey: 'antd',
iconList: ...,
open: false,
filterKeywords: '',
icon: null,
// 新增选择图标的 action
selectIcon: (icon) => {
set({ icon, open: false, filterKeywords: undefined });
},
})
对应在 IconList
中,只需引入 selectIcon
方法即可。
import { useStore } from "../store";
const IconList = () => {
const { iconList, selectIcon } = useStore();
return (
<div>
{iconList.map((icon) => (
<IconThumbnail onClick={selectIcon} />
))}
</div>
);
};
另外值得一提的两个小点:
Action 支持async/await
,直接给函数方法添加async 符号即可;zustand 默认做了变量的优化,只要是从useStore
解构获得的函数,默认是引用不变的,也就是使用zustand store 的函数本身并不会造成不必要的重复渲染。
Step 3: 复杂状态派生:Selector
在
那在
import create from 'zustand';
export const useStore = create(() => ({
// 数据源
panelTabKey: 'antd',
antdIconList,
iconfontIconList,
//...
})
// 展示用户会看到 icon list
export const displayListSelector = (s: typeof useStore) => {
// 首先判断下来自哪个数据源
const list = s.panelTabKey === 'iconfont' ? s.iconfontIconList : s.antdIconList
// 解构拿到 store 中的关键词
const { filterKeywords } = s;
// 然后做一轮筛选判断
return list.filter((i) => {
if (!filterKeywords) return true;
// 根据不同的图标类型使用不同的筛选逻辑
switch (i.type) {
case 'antd':
case 'internal':
return i.componentName.toLowerCase().includes(filterKeywords.toLowerCase());
case 'iconfont':
return i.props.type.toLowerCase().includes(filterKeywords.toString());
}
});
};
当定义完成
import { useStore, displayListSelector } from "../store";
const IconList = () => {
const { selectIcon } = useStore();
const iconList = useStore(displayListSelector);
return (
<div>
{iconList.map((icon) => (
<IconThumbnail onClick={selectIcon} />
))}
</div>
);
};
如此一来,就完成了复杂状态的派生实现。因为
另外,如果用
import { useStore, displayListSelector } from "../store";
import { isEqual } from "lodash";
const IconList = () => {
const { selectIcon } = useStore();
// 通过加入 isEqual 方法即可实现对 iconList 的性能优化
const iconList = useStore(displayListSelector, isEqual);
return (
<div>
{iconList.map((icon) => (
<IconThumbnail onClick={selectIcon} />
))}
</div>
);
};
最后,由于
Step 4: 结构组织与类型定义
经过一部分功能开发,一开始简单的 store.ts
文件开始变得很长了,同时估计也开始遇到类型定义不准确或找不到的情况了。那这对于后续项目的规模化发展非常不利,是时候做一次组织与整理了。
import create from 'zustand';
// 添加第一个入参 set
export const useStore = create((set) => ({
panelTabKey: 'antd',
iconList: ...,
antdIconList,
open: false,
filterKeywords: '',
icon: null,
iconfontScripts: [],
iconfontIconList: [],
onIconChange: null,
// 新增选择图标方法
selectIcon: (icon) => {
set({ icon, open: false, filterKeywords: undefined });
},
// 移除图标方法
resetIcon: () => {
set({ icon: undefined });
},
addSript:()=>{ /*...*/ },
updateScripts:()=>{ /*...*/ },
removeScripts:()=>{ /*...*/ },
selectScript:async (url)=>{ /*...*/ }
// ...
})
// 展示用户会看到 icon list
export const displayListSelector = (s: typeof useStore) => {
// 首先判断下来自哪个数据源
const list = s.panelTabKey === 'iconfont' ? s.iconfontIconList : s.antdIconList
// 解构拿到 store 中的关键词
const { filterKeywords } = s;
// 然后做一轮筛选判断
return list.filter((i) => {
if (!filterKeywords) return true;
// 根据不同的图标类型使用不同的筛选逻辑
switch (i.type) {
case 'antd':
case 'internal':
return i.componentName.toLowerCase().includes(filterKeywords.toLowerCase());
case 'iconfont':
return i.props.type.toLowerCase().includes(filterKeywords.toString());
}
});
};
所以在我建议在store.ts
重构为 store
文件夹,目录结构如下:
./store
├── createStore.ts // Action 与 store
├── selectors.ts // 状态派生
├── initialState.ts // State 类型定义与 初始状态
└── index.ts
如此划分的依据本质上还是基于
initialState.ts
:负责State —— 添加状态类型与初始化状态值;createStore.ts
: 负责书写创建Store 的方法与Action 方法;selectors.ts
: 负责Selector ——派生类选择器逻辑;
首先来看看 initialState
,这个文件中主要用于定于并导出后续在State
类型定义与 初始状态 initialState
。将
import type {
ExternalScripts,
IconfontIcon,
IconUnit,
ReactIcon,
} from "../types";
import { antdIconList } from "../contents/antdIcons";
export interface State {
iconfontScripts: ExternalScripts[];
icon?: IconUnit;
showEditor: boolean;
open: boolean;
panelTabKey: "antd" | "iconfont";
filterKeywords?: string;
activeIconfontScript?: string;
antdIconList: ReactIcon[];
iconfontIconList: IconfontIcon[];
}
export const initialState: State = {
open: false,
showEditor: false,
panelTabKey: "antd",
filterKeywords: "",
antdIconList,
iconfontScripts: [],
iconfontIconList: [],
onIconChange: null,
};
再来看看 createStore
,这个文件由于包含了
import create from "zustand";
import type { State } from "./initialState";
import { initialState } from "./initialState";
interface Action {
resetIcon: () => void;
togglePanel: () => void;
selectIcon: (icon: IconUnit) => void;
removeScripts: (url: string) => void;
selectScript: (url: string) => void;
toggleEditor: (visible: boolean) => void;
addScript: (script: ExternalScripts) => void;
updateScripts: (scripts: ExternalScripts[]) => void;
}
export type Store = State & Action;
export const useStore = create<Store>((set, get) => ({
...initialState,
resetIcon: () => {
set({ icon: undefined });
},
selectIcon: (icon) => {
set({ icon, open: false, filterKeywords: undefined });
},
addSript: () => {
/*...*/
},
updateScripts: () => {
/*...*/
},
removeScripts: () => {
/*...*/
},
selectScript: async (url) => {
/*...*/
},
}));
它做了这么几件事:
- 定义了
store 中Action 的类型,然后将State 和Action 合并为Store 类型,并导出了Store 的类型(比较重要) ; - 给
create 方法添加了Store 的类型,让store 内部识别到自己这个store 包含了哪些方法; - 将
initialState
解构导入store (原来定义state 的部分已经抽出去到initialState 里了) ; - 在
useStore 里逐一实现Action 中定义的方法;
所以将从 store.ts
重构到 createStore.ts
,基本上只有补充类型定义的工作量。
接下来再看下
import type { Store } from "./createStore";
import type { IconUnit } from "../types";
export const isEmptyIconfontScripts = (s: Store) =>
s.iconfontScripts.length === 0;
export const selectedListSelector = (s: Store): IconUnit[] =>
s.panelTabKey === "iconfont" ? s.iconfontIconList : s.antdIconList;
export const isEmptyIconListSelector = (s: Store) =>
selectedListSelector(s).length === 0;
export const displayListSelector = (s: Store) => {
const list = selectedListSelector(s);
const { filterKeywords } = s;
return list.filter((i) => {
if (!filterKeywords) return true;
switch (i.type) {
case "antd":
case "internal":
return i.componentName
.toLowerCase()
.includes(filterKeywords.toLowerCase());
case "iconfont":
return i.props.type.toLowerCase().includes(filterKeywords.toString());
}
});
};
最后在 index.ts
中输出相应的方法和类型即可:
export { useStore } from "./createStore";
export type { Store } from "./createStore";
export type { State } from "./initialState";
export * from "./selectors";
如此一来,我们通过 将
Step 5: 复杂Action 交互:get()
那
首先是要识别用户操作维度上的行为,在
- 先来看「添加数据源」 :用户感知到的添加数据源这个行为看起来简单,但在数据流上其实包含四个步骤:❶ 显示表单
-> ❷ 将数据源添加到数据源数组中-> ❸ 隐藏表单-> ❹ 选中添加的数据源。
- 再来看「选择数据源
」 :选择数据源就是当用户存在多个Iconfont 图标源的时候,用户可以按自己的诉求切换不同的Iconfont 数据源;
- 最后再看下「移除数据源
」 :移除数据源看似很简单,但是其实也存在坑。即:移除当前选中的数据源时,怎么处理选中态的逻辑?基于良好的用户体验考虑,我们会自动帮用户切换一个选中的数据源。但这里就会有边界问题:如果删除的数据源第一个,那么应该往后选择,如果删除的是最后一个数据源,那么应该往前选择。 |
删除第一个
|
删除最后一个
那这样的功能从数据流来看,移除数据源会包含三个阶段:❶ 从数据源数组中移除相应项
基于上述的分析,我们可以发现三层方法会存在一些重复的部分,主要是对数据源数组的更新与设定激活数据源。所以相应的
所以我们在
import create from "zustand";
import type { State } from "./initialState";
import { initialState } from "./initialState";
interface Action {
/* 一级 action */
addScript: (script: ExternalScripts) => void;
removeScripts: (url: string) => void;
selectScript: (url: string) => void;
/* 原子操作 action */
updateScripts: (scripts: ExternalScripts[]) => void;
toggleForm: (visible: boolean) => void;
}
export type Store = State & Action;
来看下具体的实现,在get()
方法,能从自身中拿到所有的状态(State & Action
// ...
export type Store = State & Action;
// create 函数的第二个参数 get 方法
export const useStore = create<Store>((set, get) => ({
...initialState,
// 用户行为 action //
addScript: (script) => {
// 从 get() 中就可以拿到这个 store 的所有的状态与方法
const { selectScript, iconfontScripts, updateScripts, toggleForm } = get();
// 1. 隐藏 Form
toggleForm(false);
// 2. 更新数据源
updateScripts(
produce(iconfontScripts, (draft) => {
if (!draft.find((i) => i.url === script.url)) {
draft.push(script);
}
})
);
// 3. 选择脚本
selectScript(script.url);
},
removeScripts: (url) => {
const { iconfontScripts, selectScript, updateScripts } = get();
const nextIconfontScripts = iconfontScripts.filter((i) => i.url !== url);
// 找到临近的图标库并选中
const currentIndex = iconfontScripts.findIndex((i) => i.url === url);
const nextIndex =
currentIndex === 0
? 0
: nextIconfontScripts.length <= currentIndex + 1
? currentIndex - 1
: currentIndex;
const nextScript = nextIconfontScripts[nextIndex]?.url;
updateScripts(nextIconfontScripts);
selectScript(nextScript);
},
// 原子操作方法 //
toggleForm: (visible) => {
set((s) => ({
...s,
showForm: typeof visible === "undefined" ? !s.showForm : visible,
}));
},
selectScript: async (url) => {
// 如果没有 url ,就说明是取消选择
if (!url) {
set({ activeIconfontScript: "", iconfontIconList: [] });
return;
}
// 2. 一个异步方法获取脚本中的图标列表
const iconfontList = await fetchIconList(url);
// 3. 设定选中后的数据更新
set({
activeIconfontScript: url,
iconfontIconList: iconfontList.map((i) => ({
type: "iconfont",
componentName: iconfontScripts.name,
scriptUrl: url,
props: { type: i },
})),
});
},
updateScripts: (scripts) => {
const { iconfontScripts } = get();
if (isEqual(iconfontScripts, scripts)) return;
set({ iconfontScripts: scripts });
},
}));
当完成相应的功能实现后,只需要在相应的触发入口中添加方法即可。
const IconfontScripts: FC = memo(() => {
const {
iconfontScripts,
showForm,
activeIconfontScript,
removeScripts,
selectScript,
toggleEditor,
} = useStore();
const isEmptyScripts = useStore(isEmptyIconfontScripts);
return (
<Flexbox gap={8}>
<Flexbox gap={4} horizontal>
{showForm ? (
<ActionIcon
onClick={() => toggleEditor(false)}
icon={<UpOutlined />}
/>
) : (
<Tag
onClick={() => {
toggleEditor(true);
}}
>
<PlusOutlined /> 添加
</Tag>
)}
<Flexbox horizontal>
{iconfontScripts.map((s) => {
const checked = s.url === activeIconfontScript;
return (
<Tag
onClose={() => {
removeScripts(s.url);
}}
onClick={() => {
selectScript(checked ? "" : s.url);
}}
>
{s.name}
</Tag>
);
})}
</Flexbox>
</Flexbox>
{showForm ? <ScriptEditor /> : null}
</Flexbox>
);
});
export default IconfontScripts;
基于这样的一种模式,哪怕是这样一个复杂的组件,在实现层面的研发心智仍然非常简单:
React 层面仍然只是一个渲染层;- 复杂的状态逻辑仍然以
hooks 式的模式进行引入; - 复杂的入口方法通过拆分子
Action 进行组合与复用;
而且我们在
Step 6: 从应用迈向组件:Context 与StoreUpdater
不知道有没有小伙伴从createContext
方法。 这个改造分为四步:
第一步: 创建createStore.ts
下
import create from 'zustand';
import createContext from 'zustand/context';
import type { StoreApi } from 'zustand';
import type { State } from './initialState';
import { initialState } from './initialState';
interface Action {
// *** /
}
export type Store = State & Action;
// 将 useStore 改为 createStore, 并把它改为 create 方法
export const createStore = ()=> create<Store>((set, get) => ({
...initialState,
resetIcon: () => {
set({ icon: undefined });
},
selectIcon: (icon) => {
set({ icon, open: false, filterKeywords: undefined });
},
addSript:()=>{ /*...*/ },
updateScripts:()=>{ /*...*/ },
removeScripts:()=>{ /*...*/ },
selectScript:async (url)=>{ /*...*/ }
}));
// 新建并导出一下 Provider、useStore、useStoreApi 三个对象
export const { Provider, useStore, useStoreApi } = createContext<StoreApi<Store>>();
import type { FC } from 'react';
import React, { memo } from 'react';
import App from './App';
import { Provider, createStore } from '../store';
type IconPickerProps = StoreUpdaterProps;
const IconPicker: FC<IconPickerProps> = (props) => {
return (
<Provider createStore={createStore}>
<App /> /* <- 这个App就是之前的引用入口 */
</Provider>
);
};
export default memo(IconPicker);
第二步:创建并添加受控更新组件 **StoreUpdater**
首先在组件入口处添加 StoreUpdater
组件。
import type { FC } from "react";
import React, { memo } from "react";
import App from "./App";
import StoreUpdater from "./StoreUpdater";
import type { StoreUpdaterProps } from "./StoreUpdater";
import { Provider, createStore } from "../store";
type IconPickerProps = StoreUpdaterProps;
const IconPicker: FC<IconPickerProps> = (props) => {
return (
<Provider createStore={createStore}>
<App />
<StoreUpdater {...props} />
</Provider>
);
};
export default memo(IconPicker);
那
简单来说,就是通过 StoreUpdater
这个组件,做到外部StoreUpdater
实现外部状态的受控。利用这样的思想,我们就可以很简单地把一个
如果子组件能够以同样的结构,作为一个应用使用,这样的结构就是分形架构。在分形架构下,每个应用都可以变成组件,被更大的应用合并消费。
具体来看看代码:
import type { FC } from "react";
import type { State } from "../store";
import type { IconUnit, ExternalScripts } from "../types";
import { useStoreApi } from "../store";
/**
* 更新方法
*/
export const useStoreUpdater = (
key: keyof T,
value: any,
deps = [value],
updater?
) => {
const store = useStoreApi();
useEffect(() => {
if (typeof value !== "undefined") {
store.setState({ [key]: value });
}
}, deps);
};
export interface StoreUpdaterProps
extends Partial<
Pick<
State,
"icon" | "onIconChange" | "iconfontScripts" | "onIconfontScriptsChange"
>
> {
defaultIcon?: IconUnit;
defaultIconfontScripts?: ExternalScripts[];
defaultActiveScripts?: ExternalScripts[];
}
const StoreUpdater: FC<StoreUpdaterProps> = ({
icon,
defaultIcon,
iconfontScripts,
defaultIconfontScripts,
onIconChange,
onIconfontScriptsChange,
}) => {
useStoreUpdater("icon", defaultIcon, []);
useStoreUpdater("icon", icon);
useStoreUpdater("onIconChange", onIconChange);
useStoreUpdater("iconfontScripts", iconfontScripts);
useStoreUpdater("iconfontScripts", defaultIconfontScripts, []);
useStoreUpdater("onIconfontScriptsChange", onIconfontScriptsChange);
return null;
};
export default StoreUpdater;
在 StoreUpdater
这个组件中,核心分为三个部分:
useStoreUpdater
:将外部的props 同步到store 内部的方法;StoreUpdaterProps
:从store 的State 中pick 出需要受控的状态,并相应补充defaultXX 的props ;StoreUpdater
:逐一补充调用外部组件props 的受控状态,将外部props 更新到store 的内部状态中;
针对受控组件来说,要实现一个状态StoreUpdater
中是也完全基于这个规则书写受控代码。不过这里有个很有意思的点:就是在受控模式下,把null
和 function
。 这样就能在
而useStoreApi
。这个Context
下的 useStore
方法。如果直接使用 useStore
,那么是获得不到挂在在useStore.setState({ ... })
这个方法?它在这个场景下发挥了巨大的作用,大大减少了更新受控useStoreApi
的原因。
这一步写完之后,组件接受外部
第三步:在相应的
import type {
ExternalScripts,
IconfontIcon,
IconUnit,
ReactIcon,
} from "../types";
import { antdIconList } from "../contents/antdIcons";
export interface State {
iconfontScripts: ExternalScripts[];
icon?: IconUnit;
showForm: boolean;
/**
* 开启面板
*/
open: boolean;
panelTabKey: "antd" | "iconfont";
filterKeywords?: string;
activeIconfontScript?: string;
antdIconList: ReactIcon[];
iconfontIconList: IconfontIcon[];
// 外部状态
onIconChange?: (icon: IconUnit) => void;
onIconfontScriptsChange?: (iconfontScripts: ExternalScripts[]) => void;
}
export const initialState: State = {
open: false,
showForm: false,
panelTabKey: "antd",
filterKeywords: "",
antdIconList,
iconfontScripts: [],
iconfontIconList: [],
onIconChange: null,
onIconfontScriptsChange: null,
};
而因为我们在
// ...
export type Store = State & Action;
export const createStore = () =>
create<Store>((set, get) => ({
...initialState,
selectIcon: (icon) => {
set({ icon, open: false, filterKeywords: undefined });
// 受控更新 icon
get().onIconChange?.(icon);
},
// 用户行为 action //
addScript: (script) => {
// 从 get() 中就可以拿到这个 store 的所有的状态与方法
const { selectScript, iconfontScripts, updateScripts, toggleForm } =
get();
// 1. 隐藏 Form
toggleForm(false);
// 2. 更新数据源
updateScripts(
produce(iconfontScripts, (draft) => {
if (!draft.find((i) => i.url === script.url)) {
draft.push(script);
}
})
);
// 3. 选择脚本
selectScript(script.url);
},
removeScripts: (url) => {
const { iconfontScripts, selectScript, updateScripts } = get();
const nextIconfontScripts = iconfontScripts.filter((i) => i.url !== url);
// 找到临近的图标库并选中
const currentIndex = iconfontScripts.findIndex((i) => i.url === url);
const nextIndex =
currentIndex === 0
? 0
: nextIconfontScripts.length <= currentIndex + 1
? currentIndex - 1
: currentIndex;
const nextScript = nextIconfontScripts[nextIndex]?.url;
updateScripts(nextIconfontScripts);
selectScript(nextScript);
},
// 原子操作方法 //
toggleForm: (visible) => {
set((s) => ({
...s,
showForm: typeof visible === "undefined" ? !s.showForm : visible,
}));
},
selectScript: async (url) => {
// 如果没有 url ,就说明是取消选择
if (!url) {
set({ activeIconfontScript: "", iconfontIconList: [] });
return;
}
// 2. 一个异步方法获取脚本中的图标列表
const iconfontList = await fetchIconList(url);
// 3. 设定选中后的数据更新
set({
activeIconfontScript: url,
iconfontIconList: iconfontList.map((i) => ({
type: "iconfont",
componentName: iconfontScripts.name,
scriptUrl: url,
props: { type: i },
})),
});
},
updateScripts: (scripts) => {
const { iconfontScripts } = get();
if (isEqual(iconfontScripts, scripts)) return;
set({ iconfontScripts: scripts });
// 受控更新 IconfontScripts
get().onIconfontScriptsChange?.(scripts);
},
}));
如此一来,组件的受控就完成了。
(可选)第四步:查找useStore.setState
的写法,那么这些写法在组件化之后需要做一点点小调整。因为useStoreApi
,并用
import { useStore, useStoreApi } from '../store';
const IconList = () => {
const { iconList } = useStore();
const storeApi = useStoreApi()
return (
<div>
{iconList.map((icon) => (
<IconThumbnail onClick={(icon) => {
storeApi.setState({ icon, open: false, filterKeywords: undefined });
})} />
))}
</div>
);
};
不过如果是真正的复杂应用,经历过
可以看到,基于
PS:这个 StoreUpdater
的用法我也是翻了StoreUpdater
比我这个组件可是多多了。而这样的
Step 7: 性能优化:又是selector ?
作为一个制作精良的组件,性能上一定不能拉胯。因此我们需要来做下优化了。最近云谦老师写了篇《关于
首先,在useWhyDidYouUpdate
的
但在上图中我们可以看到一个挺诡异的现象。就是明明 useWhyDidYouUpdate
已经包含了所有的该组件使用的状态,在修改搜索关键词时,useWhyDidYouUpdate
来检查变更,可以看到下面这样的情况:
即当关键词{ panelTabKey, icon, resetIcon }
这几个状态,那我「手动」做一个依赖收集不就好了么?那怎么手动做呢? 还记得
只需要利用shallow
浅比较能力。我们就能实现「人工的依赖收集
import { useStore, useStoreApi } from '../store';
const PickerPanel = () => {
const { panelTabKey, icon, resetIcon } = useStore();
// 其他
return <>{ /*... */ }<>
}
import shallow from 'zustand/shallow';
import type { Store } from '../store';
import { useStore, useStoreApi } from '../store';
const selector = (s: Store) => ({
panelTabKey: s.panelTabKey,
icon: s.icon,
resetIcon: s.resetIcon,
});
const PickerPanel = () => {
const { panelTabKey, icon, resetIcon } = useStore(selector,shallow);
// 其他
return <>{ /*... */ }<>
}
可以看到,除了多一个几乎一样的
- 前期撒开来默认解构
useStore ,不必担心未来的性能优化难题。等发现某些地方真的需要优化时,相应的套上selector 就好; - 反正只是加个
selector ,也可以写完应用定型后也可以顺手加一下; - 既然
selector 可以做优化,那我干脆全部都直接const x = useStore(s=>s.x)
,这样引入好,也直接优化完了。
Step 8: 研发增强:Devtools
当
// ...
import { devtools } from "zustand/middleware";
export type Store = State & Action;
// 多一个函数执行,然后包裹 devtools
export const createStore = () =>
create<Store>()(
devtools(
(set, get) => ({
...initialState,
// ... action
}),
{ name: "IconPicker" }
)
);
如此一来,我们就能够使用
image.png
不过大家可能会发现,这个时候每一次的数据变更,都是是
// ...
import "";
export type Store = State & Action;
// 多一个函数执行,然后包裹 devtools
export const createStore = () =>
create<Store>()(
devtools(
(set, get) => ({
...initialState,
// ... action
selectIcon: (icon) => {
set(
{ icon, open: false, filterKeywords: undefined },
false,
"选择 Icon"
);
get().onIconChange?.(icon);
},
}),
{ name: "IconPicker" }
)
);
基于这样的写法,我们甚至可以畅享一个面向用户的历史记录能力
还有吗?
写完上面
- 集成
redux reducer 或react useReducer 的写法; - 觉得事件处理麻烦,需要借用
rxjs 简化事件流处理; - 需要结合一些请求库,比如
swr 的使用方式,将hooks 集成到store 中; - 结合
persist 做本地数据缓存的方式; - 结合社区库
zundo 简单实现的一个历史记录功能; - 利用
subscribe 监听状态变更,自动更新内部状态; - 单一
store 的切片化; - 集成一些复杂三方库(例如
y-js ) ;
写到这里,就基本上把这大半年所有基于