跳到主要内容

使用 Zustand 管理你的 React 状态

前言

自从 React Hooks 出来以后, 我也全面转向了 Hooks 的怀抱, 也可能是因为大量的文章开始鼓吹 -_-!

在之前,全局状态管理并不像今天这样。我清楚地记得有一段时间,在高阶组件中的状态管理最佳选择是使用Redux, connect 加上 mapStateToPropsmapDispatchToProps

到如今,Hooks 出现后一切都变了。不仅现有的解决方案变得更容易使用,而且新的解决方案也诞生了。

状态管理

目前的状态管理库大大小小都有一些差异化,也是因为这些差异化才使得我们向前跃进!

先看一下 Npm 下载 React 状态管理库的趋势:

下载趋势

关注趋势

⇪ 增长迅速~ 我很快的就去尝试使用它,在使用中也非常符合我的预期! 所以,我要向你推荐一下它!

Zustand

关于Zustand,我喜欢上它的第一点就是:小而精悍!

这是一个很小的库(v4.1.4 是 1.1KB Minified + Gzipped),它提供了一个简单的 API 来创建全局状态存储并通过selectors订阅它们。 它类似Redux, 又比Redux简易。

就像 React 本身一样, Zustand 并不固执己见。如果需要, 您可以将其与immer结合使用。

基础使用

基础使用示例
/** 数据集中管理 */
import { StoreApi, UseBoundStore, create } from "zustand";

type Store = {
bears: number;
childBears: number;
count: number;
actions: Record<string, () => void>;
};

const initialState = {
count: 1,
bears: 0,
childBears: 0,
};

export const useStore = create<Store>((set) => ({
...initialState,
actions: {
incrementCount: () => set((state) => ({ count: state.count + 1 })),
incrementBears: () => set((state) => ({ bears: state.bears + 1 })),
/** 重置为初始值 */
reset: () => set(initialState),
},
}));

/** 手动创建 Selector 选择器 */
export const useBears = () => useStore((state) => state.bears);
export const useCount = () => useStore((state) => state.count);
export const useActions = () => useStore((state) => state.actions);

/** ========== 使用以下方式自动生成选择器 ========== */
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;

const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
_store: S
) => {
let store = _store as WithSelectors<typeof _store>;
store.use = {};
for (let k of Object.keys(store.getState())) {
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
}

return store;
};
export const useBearStore = createSelectors(useStore);
/** ========== 使用以上方式自动生成选择器 ========== */

export default useStore;
  • 父级组件使用时,会向下 diff 子级组件, 可以使用 memo 做比较更新, 防止额外的渲染

  • 子级组件使用时不会想上 diff 父级, 与 useContext 不同, 仅在使用的地方向下 Diff

  • 深层嵌套对象建议使用中间件 immer, 创建下一个不可变状态树

    import { immer, produce } from "zustand/middleware/immer";
    immerInc: () =>
    set(
    produce((state: State) => {
    ++state.deep.nested.obj.count;
    })
    );

为什么是 Zustand?

如果我们把状态管理当成一款产品来设计,我们不妨看看开发者在状态管理下的核心需求是什么。

我相信通过以下这一串分析,你会发现 Zustand 是真真正正满足“几乎所有”状态管理需求的工具,并且在很多细节上做到了体验优化。 ZustandLOGO

状态共享

状态管理最必要的一点就是状态共享。

这也是 useContext 出来以后, 大部分文章说不需要 Redux 的根本原因。 因为 useContext 可以实现最最基础的状态共享。但这种方式(包括 Redux 在内),都需要在最外层包一个ProvideruseContext 中的值都在 Provider 的作用域下有效。

// Context 状态共享

// context.tsx
import { createContext, useContext } from 'react';

interface ContextProps {
readOnly: boolean;
disabled: boolean;
fieldKey: string;
fieldNames: Record<'title' | 'code' | 'imgUrl', string>;
}

export const Context = createContext<ContextProps>({
readOnly: false,
disabled: false,
fieldKey: 'name',
fieldNames: { title: 'productName', code: 'productCode', imgUrl: 'productImageUrl' },
});

export default useStore = () => useContext(Context);

// index.tsx
import { Context } from './context';

const App = () => {
return (
<Context.Provider value={contextValue}>
<Component />
</Context.Provider>
)
}

// Component.tsx
import useStore from './context';

const Component = () => {
const { disabled, readOnly } = useStore();

// ...
return ...
}

作为一个 Hooks 的重度拥护者,我常常会这样使用, 我觉得很方便。

但是, 我在项目中发现不少复杂 case 中使用了Provider + useContext的方式来做全局状态管理。-_-!

在复杂应用中, 性能优化、受控、action 互调、数据切片、状态调试等坑,每一项都不是好惹的主,够人喝上一壶! 最终,稍有不慎便会跌入体验差、问题多甚至死循环的深坑中。

我喜欢使用 Hooks,也因此在组内被吐槽: ‘乔克~ 最喜欢用 Hooks 了'、'乔克可是 Hooks 的重度拥护者’… 我真的是: "乔~"无奈

关于useContext的使用不在这里多讲, 我希望使用者能够正确的认识使用时机,从而避免'滥用'。

Zustand 做到的第一点创新就是: 默认不需要 Provider。 直接声明一个 Hooks 式的 useStore 后就可以在不同的组件中进行调用。它们的状态会直接共享,简单而美好。

// Zustand 状态共享

// store.ts
import { create } from "zustand";

export const useStore = create((set) => ({
bears: 0,
fish: 0,
eatFish: () => set((state) => ({ fish: state.fish - 1 })),
removeAllBears: () => set({ bears: 0 }),
}));

// Control.tsx
import { useStore } from "./store";

const Control = () => {
const { fish, eatFish } = useStore();

return (
<div>
<span>{fish}</span>
<button onClick={eatFish}>吃鱼</button>
</div>
);
};

// BearCounter.tsx
import { useStore } from "./store";

const BearCounter = () => {
const bears = useStore((state) => state.bears);
return <h1>{bears} around here...</h1>;
};

由于没有 Provider 的存在,所以声明的 useStore 默认都是单实例, 如果需要多实例的话, Zustand 也提供对应的 Provider书写方式, 这种方式在组件库中比较常用。

此外, Zustandstore 状态既可以在 React 中消费,也可以在 React 外消费。

状态变更

状态管理除了状态共享外,另外第二个极其必要的能力就是状态变更。

在复杂的场景下,我们往往需要自行组织相应的状态变更方法,不然不好维护。 这也是考验一个状态管理库好不好用的一个必要指标。

HookssetState 是原子级的变更状态, hold 不住复杂逻辑; 而useReducerHooks 借鉴了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写的方法, 默认都不会造成额外的重复渲染。

如果你习惯使用useReducer的模式, 它可以直接集成, 而且直接在官网提供了示例。 这样就意味着你可以极低成本地完成迁移。

状态派生

状态派生是一个不被那么多人提起, 但是在实际场景中被大量使用的东西。

状态派生可以很简单, 也可以非常复杂。简单的例子, 比如基于一个name字段, 拼接出对应的值。

如果不考虑优化, 其实都可以写一个中间的函数作为派生方法, 但作为状态管理的一环, 我们必须要考虑相应的优化。

Hooks 场景下, 状态派生的方法可以使用 useMemo, 例如:

const App = () => {
const [name, setName] = useState("");
const tell = useMemo(() => `我的名字是: ${name || ""}`, [name]);
// ...
};

Zustand用了类似 Redux Selector 的方法, 实现相应的状态派生, 这个方式使得 useStore 的用法变得极其灵活和使用。 而这种 Selector 的方式使得 Zustand 下细颗粒度的性能优化变为可能, 且优化成本极低。

// zustand 的 selector 用法

// 写法1
const App = () => {
const tell = useStore((s) => `我的名字是: ${s.name || ""}`);
// ...
};

// 写法2 将 selector 单独抽为函数
export const useTellSelector = () =>
useStore((s) => `我的名字是: ${s.name || ""}`);
const App = () => {
const tell = useTellSelector();
// ...
};

独立管理派生状态

由于写法 2可以将Selector抽为独立函数, 那么我们就可以将其拆分到独立文件来管理派生状态。 Selector

性能优化

讲完派生状态知道了 ZustandSelector 能力后, 就可以讲讲 Zustand 的性能优化了。

在裸 Hooks 的状态管理下, 要做性能优化得专门起一个专项来分析与实施。 但基于ZustanduseStoreSelector用法, 我们可以实现低成本、渐进式的性能优化。

仅导出自定义的 Hooks

这是首要技巧, 对于任何 React 开发都满足。

只要利用ZustandSelector, 就可以很简单的做到性能优化。

export const useFish = () => useStore((state) => state.fish);
export const useEatFish = () => useStore((state) => state.eatFish);
// 💡 仅订阅指定状态变化
const Control = () => {
const fish = useFish();
const eatFish = useEatFish();

return (
<div>
<span>{fish}</span>
<button onClick={eatFish}>吃鱼</button>
</div>
);
};

此外,它还避免了意外地订阅整个状态:

// ❌ 这将订阅整个状态
const Control = () => {
const { fish, eatFish } = useStore();

return (
<div>
<span>{fish}</span>
<button onClick={eatFish}>吃鱼</button>
</div>
);
};

虽然结果可能是相同的, 上面的代码将订阅你的整个状态, 这意味着你的组件将在某个状态更新后全部重新渲染, 即使 fish 没有改变。

虽然选择器在Zustand中是可选的, 但我认为你应该始终使用它们。即使我们有一个只有一个状态值的存储, 我也会编写一个自定义钩子, 只是为了将来能够添加更多状态。

多使用原子选择器

Zustand通过比较选择器的结果和之前渲染的结果来决定何时通知组件更新。默认情况下, 它通过严格的相等检查来实现。

实际上, 这意味着选择器必须返回稳定的结果。如果返回一个新的ArrayObject, 它将始终被视为更改, 即使内容相同:

// 🚨 选择器在每次调用时都会返回一个新对象
const { bears, fish } = useStore((state) => ({
bears: state.bears,
fish: state.fish,
}));

// 😮 所以这两者是等价的
const { bears, fish } = useStore();

如果要从选择器返回 ObjectArray, 可以将比较函数调整为使用浅比较:

import shallow from "zustand/shallow";

// ⬇️ much better, because optimized
const { bears, fish } = useStore(
(state) => ({ bears: state.bears, fish: state.fish }),
shallow
);

然后我更倾向于导出两个独立的Selectors

export const useBears = () => useStore((state) => state.bears);
export const useFish = () => useStore((state) => state.fish);

假如组件需要多个值, 引入多个 Hooks 即可。

Actions 和 State 分离

Actions 是不变的函数, 它更新 State 中的值, 所以它们不是真正的"状态"。

将 Actions 作为一个额外的对象在我们的存储中分离, 将允许我们将它们作为一个单个 Hook 暴露出来, 以便在我们的任何组件中使用, 而不会对性能产生任何影响:

// store.tsx
export const useStore = create((set) => ({
bears: 0,
fish: 0,
actions: {
eatFish: () => set((state) => ({ fish: state.fish - 1 })),
removeAllBears: () => set({ bears: 0 }),
},
}));

/** 手动创建 Selector 选择器 */
export const useBears = () => useStore((state) => state.bears);
export const useFish = () => useStore((state) => state.fish);
export const useActions = () => useStore((state) => state.actions);

现在可以只使用一个 Hooks 导出所有的 actions

const App = () => {
const { eatFish } = useActions();

return (
<div>
<button onClick={eatFish}>吃鱼</button>
</div>
);
};

这看起来和上面提到的原子选择器相反, 但是事实并非如此, 由于 actions不会改变, actions可以看出是一个原子块。

Model Actions as Events, not Setters

这是一个通用的提示, 无论你是否使用useReducerReduxZustand。 这个技巧直接来自Redux 风格指南。 它将帮助你将业务逻辑保存在存储中, 而不是保留在组件中。 上面的例子已经使用了这种模式——逻辑(例如: “吃鱼”)存在于状态中。组件只是调用操作, 而存储决定如何处理它。

保持状态小型化

Redux不同的是, Zustand鼓励你拥有多个小型状态。每个状态可以对单个状态负责。如果你需要组合它们, 你可以使用自定义钩子:

const currentUser = useCredentialsStore((state) => state.currentUser);
const user = useUsersStore((state) => state.users[currentUser]);

结合其他库

老实说, 我并不经常需要组合多个Zustand状态, 因为应用程序中的大多数状态要么是服务器状态, 要么是 URL 状态。 与将两个状态组合起来相比, 我更可能将Zustand状态与useQueryuseParams结合起来。 同样的原则也适用于: 如果你需要将其他 Hook 与Zustand状态结合起来, 自定义 Hooks 也许是你最好的选择

例如: 我喜欢使用 useQuery 管理服务器状态

const useFilterStore = create((set) => ({
applied: [],
actions: {
addFilter: (filter) =>
set((state) => ({ applied: [...state.applied, filter] })),
},
}));

export const useAppliedFilters = () => useFilterStore((state) => state.applied);
export const useFiltersActions = () => useFilterStore((state) => state.actions);

// 🚀 将zustand存储与useQuery结合起来
export const useFilteredTodos = () => {
const filters = useAppliedFilters();
return useQuery({
queryKey: ["todos", filters],
queryFn: () => getTodos(filters),
});
};

在这里, 应用的筛选器驱动查询, 因为筛选器是查询键的一部分。每次调用addFilter(可以在 UI 中的任何地方执行)时, 都会自动触发一个新查询, 这个查询也可以在 UI 中的任何地方使用。我觉得这事一个非常简洁明了的说明, 也不神奇。

数据分形与状态组合

如果子组件能够以同样的结构, 作为一个应用使用, 这样的结构就是分形架构。

数据分形在状态管理里我觉得是个比较高级的概念。但从应用上来说很简单, 就是更容易拆分并组织代码, 而且具有更加灵活的使用方式,

常用例如将多个小型 Store 进行组合。

// 第一个 Store:
export const createFishSlice = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
});
//另一个 Store:
export const createBearSlice = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
});

现在可以将这两个存储合并到一个 Store 中:

import { create } from "zustand";
import { createBearSlice } from "./bearSlice";
import { createFishSlice } from "./fishSlice";

export const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}));

在 React 组件中的用法

import { useBoundStore } from "./stores/useBoundStore";

const App = () => {
const bears = useBoundStore((state) => state.bears);
const fishes = useBoundStore((state) => state.fishes);
const addBear = useBoundStore((state) => state.addBear);
return (
<div>
<h2>Number of bears: {bears}</h2>
<h2>Number of fishes: {fishes}</h2>
<button onClick={() => addBear()}>Add a bear</button>
</div>
);
};

分形架构下的各种中间件

基于这种分形架构, 状态就具有了很灵活的组合性, 例如将当前状态直接缓存到 localStorage。 在Zustand的架构下, 不用额外改造, 直接加个persist中间件就好。

// 使用自带的 Persist Middleware
import { create } from "zustand";
import { createBearSlice } from "./bearSlice";
import { createFishSlice } from "./fishSlice";
import { persist } from "zustand/middleware";

export const useBoundStore = create(
persist(
(...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}),
{ name: "bound-store" }
)
);

多环境集成(React 内外环境联动)

实际的复杂应用中, 一定会存在某些不在 React 环境内的状态数据,以图表、画布、3D 场景最多。 一旦涉及到多环境下的状态管理, 可以让人掉无数头发。

Zustand说了, 不慌, 我已经考虑到了, useStore上直接可以拿值, 是不是很贴心~

// 官方示例
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

// 1. 创建Store
const useStore = create(
subscribeWithSelector(() => ({ paw: true, snout: true, fur: true }))
)

// 2. react 环境外直接拿值
const paw = useStore.getState().paw

// 3. 提供外部事件订阅
const unsub1 = useStore.subscribe(console.log)
const unsub2 = useStore.subscribe((state) => state.paw, console.log)

// 4. react 世界外更新值
useStore.setState({ paw: false })
unsub1()
unsub2()

// 销毁商店(移除所有监听器)
useStore.destroy();

// 5. 在 react 环境内使用
const Component = () => {
const paw = useStore((state) => state.paw)
...
}

总结: Zustand 是当下复杂状态管理的最佳选择

我一直在寻求最佳的状态管理库, 直到看到Zustand让我眼前一亮。

我很笃定地认为, Zustand就是我当下状态管理的最佳选择, 甚至是大部分复杂应用的状态管理的最佳选择