目前为止,我们已经讨论了可以实现全局状态的基本方案。在这一章,我们会基于一个已经发布的库进行讲解 - Zustand。
Zustand(github.com/pmndrs/zust…) 是一个用于为React创建 模块状态的 微型库。Zustand是基于 不可变更新模型,这意味着对象是无法被更新的,只能用新的对象来替代。渲染优化,是基于手动地使用selector 函数来实现的。它还提供了一个直观且好用的store创建API。
在这一章,我们会探索 模块状态 和 订阅 是 如何 被使用的,以及相关API是如何使用的。
在这一章,我们会讨论这些 主题:
- 理解模块状态 和 不可变状态
- 添加React 钩子以优化 重新渲染
- 使用 阅读状态 和 更新状态 来工作
- 处理结构化数据
- 这个库的 优点 和 缺点
理解模块状态 和 不可变状态
Zustand是用来创建持有状态的仓库的库。它主要是用于 模块状态,这意味着你可以定义一个store,并输出它。它是基于 不可变状态模型的,这意味着你不可以修改状态的属性。你只能通过创建新对象的方式来更新状态,尽管没有被更改的状态必须被复用。使用不可变状态模型的好处是,你只要检查对象的引用是否一样即可;你不用进行深度比较。
下面是一个可以用于创建count 状态的用例。它以 创建 store 函数为参数,并返回一个初始值:
// store.ts
import create from "zustand";
export const store = create(() => ({ count: 0 }));
store 暴露了 像 getState, setState 和 subscribe这些函数。你可以使用getState来读取值,使用setState来设置值:
console.log(store.getState()); // ---> { count: 0 }
store.setState({ count: 1 })
console.log(store.getState()); // ---> { count: 1 }
这个状态是不可变的,你无法通过类似 ++state.count的方式来更新值。下面是一个不合法的更新值的方式:
const state1 = store.getState();
state1.count = 2; // invalid
store.setState(state1);
state1.count = 2是一个违法的用法,所以它无法如预期运行。所以,这个状态依然没有变。这个写法并没有改变state的引用地址,所以该库无法检测到属性的更新。
如果要更新状态,必须要用一个新的状态。你可以使用store.setState({ count: 2 })这样的写法。store.setState也可以用一个函数来更新:
store.setState((prev) => ({ count: prev:count + 1 }));
这种写法也叫函数更新,它让我们可以基于前值来更新状态。
目前为止,state里只有一个count属性。state应该有多个属性:
export const store = create(() => ({
count: 0,
text: "hello",
}));
同样,这个state需要被不可变地更新:
store.setState({
count: 1,
text: "hello",
})
然而,store.setState会合并新的状态和旧的状态。因此,你只需要声明你需要改变的状态即可:
console.log(store.getState());
store.setState({
count: 2,
})
console.log(store.getState());
第一个 console.log 打印的是 { count: 1, text: 'hello' }, 第二个 console.log 打印的是 { count: 2, text: 'hello' }
这段代码只改变了 count属性,并没有改变 text属性。这是因为setState里有这段逻辑:
Object.assign({} , oldState, newState);
Object.assign返回了一个合并了oldState和newState属性的新对象。
最后要讨论的,是store.subscribe方法。store.subscribe允许你注册一个回调函数。这些被注册的回调函数,会在每次 store更新时,被触发。它是这样工作的:
store.subscribe(() => {
});
store.setState({ count: 3 });
调用了store.subscribe后,"store state is changed" 会显示在控制台上。对亏了store.subscribe,它是实现React 钩子的 重要助手。
在这个部分,我们学习了Zustand的基本用法。你会发现,它和第四章的内容很相似。本质上来说,Zustand是一个基于不可变对象模型 和 订阅 的 微形库。
在下一部分,我们会学习如何在React中使用store。
使用React 钩子以优化 重新渲染
对于全局状态,优化重新渲染是非常重要的,因为并不是所有的组件要使用其所有的状态。接下来让我们了解一下 Zustand 是如何解决这个问题的。
为了在React中使用store,我们需要自行定义一个钩子。而Zustand的create 函数所 创建的 store 可以在 store 中 使用。
为了遵守React钩子的命名规则,我们可以把自定义钩子命名为useStore:
// store.ts
import create from "zustand";
export const useStore = create(() => {
count: 0,
text: "hello",
})
之后,我们可以在React组件中使用 useStore。如果useStore钩子被触发了,它会返回整个 state对象,会返回所有的属性。我们看看下面这个例子:
// store.ts
import { useStore } from "./store.ts";
const Component = () => {
const { count, text } = useSore();
return <div>count: {count}</div>
}
这个组件会展示 count,这个组件会在store的值发生变化时,进行重新渲染。虽然多数情况下这样做没问题,但如果只是文本值发生了改变,而计数值并未改变,该组件实际上输出的仍是相同的 JavaScript 语法扩展(JSX)元素,用户在屏幕上也看不到任何变化。因此,这意味着改变文本值会导致额外的重新渲染。
当我们想避免额外的重新渲染时,我们可以声明一个selector函数。我们可以这样重写刚刚的例子:
const Component = () => {
const count = useStore((state) => state.count);
return <div>count: {count}</div>
}
通过使用selector函数,这个组件只会在count发生变化时,才重新渲染。
这种基于选择器的额外重渲染控制,就是我们所说的手动渲染优化。选择器避免重渲染的工作方式是比较选择器函数返回的结果。在定义选择器函数时,你需要谨慎确保其返回稳定的结果,以避免重渲染。
比如说,下面这个例子并不能很好的控制重新渲染,因为这个 selector 函数会返回一个 有对象的数组:
const Component = () => {
const [{ count }] = useStore(
(state) => [{ count: state.count }]
);
return <div>count: {count}</div>
}
结果是,这个组件会在即使count没有变化时,也进行重新渲染。当我们使用选择器进行渲染优化时,这是一个陷阱(隐患)。
总之,基于选择器的渲染优化的优点在于其行为相当可预测,因为你显式地编写了选择器函数。然而,其缺点是需要理解对象引用的概念。
在本节中,我们学习了如何使用 Zustand 创建的 Hook,以及如何通过选择器优化重渲染。
接下来,我们将通过一个极简示例学习如何在 React 中使用 Zustand。
使用 阅读状态 和 更新状态 来工作
虽然 Zustand 是一个可以以多种方式使用的库,但它在读取状态和更新状态方面有一定的模式。让我们通过一个小示例来学习如何使用 Zustand。
让我们看看这个小例子:
type StoreState = {
count1: number;
count2: number;
};
const useStore = create<StoreState>(() => ({
count1: 0,
count2: 0
}))
这段代码返回了一个有 count1 和 count2 两个属性的 store。需要注意的是,StoreState是TypeScript中的类型定义。
接下来,我们要定义用于展示 count1 的 Counter1 组件。我们必须提前定义selectCount选择函数,并把它传递给useStore:
const selectCount1 = (state: StoreState) => state.count1;
const Counter1 = () => {
const count1 = useStore(selectCount1);
const inc1 = () => {
useStore.setState(
(prev) => ({ count1: prev.count1 + 1 })
);
};
return (
<div>
count1: {count1} <button onClick={inc1}>+1</button>
</div>
)
}
我们要注意 inc1 函数是如何被定义的。我们可以在store内触发setState函数。这是一个典型的模式,而且我们可以通过把函数定义在store里来提升代码的可读性和复用性。
传递给create函数的 store创建函数 可以接收一些参数;而第一个参数便是 store里 的 setState函数。让我们利用这个特性,再次定义我们的store:
type StoreState = {
count1: number;
count2: number;
inc1: () => void;
inc2: () => void;
}
const useStore = create<StoreState>((set) => {
count1: 0,
count2: 0,
inc1: () => set(
(prev) => ({ count1: prev.count1 + 1})
),
inc2: () => set(
(prev) => ({ count1: prev.count2 + 1})
),
})
现在,我们的 store 有了两个 新的 函数 属性: inc1 和 inc2。注意,把 setState 简写 成 set,是一个常用的 惯例。
使用了新的store后,我们还要定义 Counter2组件:
const selectCount2 = (state: StoreState) => state.count2;
const selectInc2 = (state: StoreState) => state.inc2;
const Counter2 = () => {
const count2 = useStore(selectCount1);
const inc2 = useStore(selectInc2);
return (
<div>
count2: {count2} <button onClick={inc2}>+1</button>
</div>
)
}
在这个示例中,我们有一个名为 selectInc2 的新选择器函数,而 inc2 函数仅仅是 useStore 的返回结果。同样地,我们可以向状态存储(store)中添加更多函数,这使得某些逻辑能够存放在组件之外。你可以将状态更新逻辑与状态值放置在相近的位置,这就是 Zustand 的 setState 会合并新旧状态的原因。我们在 “理解模块状态与不可变状态” 章节中也讨论过这一点,在那里我们学习了如何使用 Object.assign。
如果我们想要创建一个派生状态该怎么办?我们可以为派生状态使用选择器。首先,让我们看一个简单的示例。以下是一个新组件,它会展示 count1 和 count2 的总数:
const Total = () => {
const count1 = useStore(selectCount1)
const count2 = useStore(selectCount2)
return (
<div>
total: {count1 + count2}
</div>
)
}
这是一个有效的模式,可以保持现状。但存在一个会导致额外重新渲染的边缘情况:当 count1 增加且 count2 减少相同数值时,总数不会改变,但组件仍会重新渲染。为避免这种情况,我们可以为派生状态使用选择器函数。
以下示例展示了一个用于计算总数的新选择器函数 selectTotal 的用法:
const selectTotal =
(state: StoreState) => state.count1 + state.count2;
const Total = () => {
const total = useStore(selectTotal);
return (
<div>
total: {total}
</div>
)
}
这段代码,之后在total发生变化时才重新渲染。
有了这个selector函数,我们可以在selector函数内计算total。虽然这个模式是可行的,我们再看看另一个方法:在store内创建total。如果我们能在store内创建total,store内可以记住total的结果,这样就避免了在组件使用其时的额外计算。这个模式并不是很普遍的被使用,但是如果这个计算量非常重时,这样是可行的。我们可以实现一个简单的例子:
const useStore = create((set) => {
count1: 0,
count2: 0,
total: 0,
inc1: () => set((prev) => {
...prev,
count1: prev.count1 + 1,
total: prev.count1 + 1 + prev.count2
}),
inc2: () => set((prev) => {
...prev,
count2: prev.count2 + 1,
total: prev.count1 + 1 + prev.count2
}),
})
还有一个复杂一点的实现方式,其理念是同时计算多个属性并保持同步。另一个库,Jotai,就可以很好的处理这个方式。
最后,我们要实现App组件:
const App = () => {
<>
<Counter1 />
<Counter2 />
<Total />
</>
}
当你运行这个应用时,你就会看到这个画面:
如果你点击第一个按钮,会看到屏幕上 count1 标签后的数字 和 总数 这两个数值都会增加。如果点击第二个按钮,会看到 count2 标签后的数字 和 总数 这两个数值都会增加。
在本节中,我们学习了 Zustand 中常用的状态读取和更新方式。接下来,我们将学习如何处理结构化数据以及如何使用数组
处理结构化数据
上面的例子,只是处理一些简单的数据,是很简单的。在真实的开发场景中,我们需要处理对象、数组,以及对象和数组的集合。让我们学习如何通过Zustand来处理这样的场景。这次,我们来实现一个 Todo 应用。在这个应用中,你可以做这些事情:
- 创建一个新的待办事项
- 查看待办事项列表
- 转化待办事项的状态。
- 删除一个代表事项。
首先,我们必须在创建store前,定义相关类型。首先要定义的类型是Todo对象,它有id,title 和 done 这些熟悉:
type Todo = {
id: number;
title: string;
done: boolean;
}
如此一来,我们就可以在Todo的基础上构建StoreState了。这个store的值是todos,而todos是一个以Todo为成员的数组。此外,还有三个函数:addTodo, removeTodo, 和 toggleTodo - 它们是用来操作todos的值的:
type StoreState = {
todos: Todo[];
addTodo: (title: string) => void;
removeTodo: (id: number) => void;
toggleTodo: (id: number) => void;
}
todos属性是一个以对象为成员的数组。
接下来,我们要定义一个store。它也是一个调用了useStore的钩子。当它被创建时,它有一个空的todos数组,addTodo,removeTodo,toggleTodo函数。nextId是定义在create函数外的,是用来创建唯一id的简单实现:
let nextId = 0;
const useStore = create<StoreState>((set) => ({
todos: [],
addTodo: (title) =>
set((prev) => ({
todos: [
...prev.todos,
{ id: ++nextId, title, done: false },
],
})),
removeTodo: (id) =>
set((prev) => ({
todos: prev.todos.filter((todo) => todo.id !== id),
})),
toggleTodo: (id) =>
set((prev) => ({
todos: prev.todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done} :
todo
),
})),
}))
请注意,addTodo、removeTodo 和 toggleTodo 函数是以不可变的方式实现的。它们不会直接修改现有的对象和数组,而是创建新的对象和数组。
在我们定义一个TodoList组件之前,我们要先创建一个用于渲染每一个todo事项的TodoItem组件:
const selectRemoveTodo =
(state: StoreState ) => state.reomveTodo;
const selectToggleTodo =
(state: StoreState) => state.toggleTodo;
const TodoItem = ({ todo }: { todo: Todo }) => {
const removeTodo = useStore(selectRemoveTodo);
const toggleTodo = useStore(selectToggleTodo);
return (
<div>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span
style={{
textDecoration:
todo.done ? "line-through" " "none",
}}
>
{todo.title}
</span>
<button
onClick={() => removeTodo(todo.id)}
>
Delete
</button>
</div>
)
}
这个TodoItem组件以todo对象为属性,它是一个简单的基于状态的组件。这个TodoItem组件有两个按钮:用于删除代表事项的removeTodo按钮,和改变实践状态的toggleTodo勾选框。
之后,我们缓存这个组件:
const MemoedTodoItem = memo(TodoItem);
现在,我们可以讨论这个如何帮助我们的应用。我们可以定义TodoList组件。它需要使用用于获取todos属性的selectTods的selector函数。TodoList组件会遍历todos数组,并渲染对应的MemoedTodoItem组件。
使用缓存组件是很重要的,因为它避免了额外的重新渲染。因为我们是通过不可变模式来更新todo对象的,所以todos数组里大量的对象是保持不变的。如果传递给MemoedTodoItem组件的todo对象没有变化,这个组件不会重新渲染。然而,其子组件只会在其对应的todo对象变化时,发生重新渲染。
下面的代码,展示了selectTodos函数和TodoItem组件:
const selectTodos = (state: StoreState) => state.todos;
const TodoList = () => {
const todos = useStore(selectTodos);
return (
<div>
{todos.map((todo) => (
<MemoedTodoItem key={todo.id} todo={todo} />
))}
</div>
)
}
TodoList组件渲染了todo数组,并渲染了其对应的MemoedTodoItem组件。
剩下要做的,就是添加一个新的 todo 事项。NewTodo组件用于渲染一个文本输入框和一个按钮,并在按钮被点击时调用addTodo函数。selectAddTodo 是一个用于从状态存储(store)中选择 addTodo 函数的函数:
const selectAddTodo = (state: StoreState) => state.addTodo;
const NewTodo = () => {
const addTodo = useStore(selectAddTodo);
const [text, setText] = useState("");
const onClick = () => {
addTodo(text);
setText(""); // [1]
}
return (
<div>
<input
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button onClick={onClick} disabled={!text}> // [2]
Add
</button>
</div>
)
}
有两个提升了用户体验的点值得我们注意:
- 当按钮被点击时,会清空文本框 [1]。
- 当文本框为空时,不允许点击按钮 [2]。
最后,我们就完成了这个Todo应用:
const App = () => (
<>
<TodoList />
<NewTodo />
</>
)
运行这个应用,你会看到一个文本输入框,和一个被置灰的添加按钮:
如果你输入了一下文本,并点击添加按钮,todo事项就会出现
点击勾选框,会更改事项的状态:
点击删除按钮,则会将这个事项从视图移除
只要你愿意,你想添加多少事项就加多少。所有这些功能都是通过我们在本节中讨论的所有代码实现的。由于状态存储(store)状态的不可变更新以及 React 提供的 memo 函数,重新渲染得到了优化。
在本节中,我们通过一个典型的待办事项应用示例学习了如何处理数组。接下来,我们将总体讨论这个库(Zustand)和这种实现方式的优缺点。
这个库的 优点 和 缺点
接下来,我们讨论一下Zustand或者其他采用类似模式的库的优点与缺点。
回顾一下,以下是 Zustand 的读取和写入状态的方式:
- 读状态:使用selector函数来优化重新渲染
- 写状态:基于不可变状态模型
其核心在于,React本身是基于状态不可变模型进行优化的。其中一个例子便是useState。React 基于不可变性,通过对象引用相等性来优化重新渲染。以下示例说明了这种行为:
const countObj = { value: 0 };
const Component = () => {
const [count, setCount] = useState(countObj);
const handleClick = () => {
setCount(countObj);
}
useEffect(() => {
console.log("component updated");
})
return (
<>
{count.value}
<button onClick={handleClick}>Update</button>
</>
)
}
此时,即使你点击了Update按钮,控制台也不会显示“component updated”信息。这是因为,在React看来,只要这个对象的引用没有发生变化,那么countObj就没有变化:
const handleClick = () => {
countObj.value += 1;
setCount(countObj);
}
此时,你调用了handleClick按钮,countObj的value发生了变化,但是countObj的引用地址并没有发生变化。因此,React认为countObj是没有发生变化的。这就是React基于不可变对象的意义。同样的现象,也会发生在memo和useMemo里。
Zustand 的状态模型完全地遵守了对象不可变模型。所以,Zustand的渲染优化是基于不可变性的 - 这意味着,如果一个selector函数返回了相同的对象引用地址,或者相同的原始值,React会认为这个对象并没有发生变化,进而避免重新渲染。
另一方面,Zustand的缺点就在于基于selector函数的渲染优化。这要求我们理解对象引用相等性,并且选择器的代码往往需要更多的样板代码。
总之,Zustand(或任何采用这种方式的其他库)是对 React 原理的一种简洁扩展。如果你需要一个包体积小的库,或者熟悉引用相等性和记忆化技术,又或者倾向于手动优化渲染,那么它是一个不错的选择。
概要
在本章中,我们学习了 Zustand 库。这是一个在 React 中使用模块状态的轻量级库。我们通过一个计数器示例和一个待办事项(Todo)示例来掌握如何使用该库。理解对象引用相等性通常是我们使用这个库的基础。你可以根据自己的需求和本章所学内容,选择这个库或类似的状态管理方案。
本章未讨论 Zustand 的某些方面,包括 middleware(中间件)和非模块状态使用:
- 中间件允许你为状态存储创建器添加某些功能;
- 非模块状态使用则是在 React 生命周期中创建状态存储。
这些内容在选择库时可作为额外参考因素。你应始终参考库的文档以获取更多最新信息。
在下一章中,我们将学习另一个库 —— Jotai。