React 利用Reducer管理复杂状态及Context深层传递状态

React 利用Reducer管理复杂状态及Context深层传递状态

迁移状态逻辑至 Reducer 中

随着组件复杂度的增加,将很难一眼看清所有的组件状态更新逻辑。例如,下面的 TaskApp 组件有一个数组类型的状态 tasks,并通过三个不同的事件处理程序来实现任务的添加、删除和修改:

多事件处理程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { useState } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
const [tasks, setTasks] = useState(initialTasks);

function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}

function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}

function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}

return (
<>
<h1>布拉格的行程安排</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}

let nextId = 3;
const initialTasks = [
{id: 0, text: '参观卡夫卡博物馆', done: true},
{id: 1, text: '看木偶戏', done: false},
{id: 2, text: '打卡列侬墙', done: false},
];

这个组件的每个事件处理程序都通过 setTasks 来更新状态。随着这个组件的不断迭代,其状态逻辑也会越来越多。为了降低这种复杂度,并让所有逻辑都可以存放在一个易于理解的地方,可以通过三个步骤将逻辑移到组件之外的一个称为 reducer 的函数中(useReducer):

  1. 将设置状态的逻辑 修改 成 dispatch 的一个 action
  2. 编写 一个 reducer 函数
  3. 在组件中 使用 reducer

将设置状态的逻辑修改分派方式

移除所有的状态设置逻辑。只留下三个事件处理函数:

  • handleAddTask(text) 在用户点击 “添加” 时被调用。
  • handleChangeTask(task) 在用户切换任务或点击 “保存” 时被调用。
  • handleDeleteTask(taskId) 在用户点击 “删除” 时被调用。

使用 reducers 管理状态与直接设置状态略有不同。它不是通过设置状态来告诉 React “要做什么”,而是通过事件处理程序分派一个带着不同行为的 Flag 来指明 “用户刚刚做了什么”,以及如何处理该事件。

事件处理程序分派
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function handleAddTask(text) {  
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}

function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}

function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}

编写一个 reducer 函数

所谓 reducer 函数就是放置状态处理逻辑的地方。它接受两个参数,分别为当前 state 和 action 对象,并且返回的是更新后的 state。React 会将状态设置为从 reducer 返回的状态。在当前例子中,可以通过如下步骤将状态设置逻辑从事件处理程序移到 reducer 函数中:

  1. 声明当前状态(tasks)作为第一个参数;
  2. 声明 action 对象作为第二个参数;
  3. 从 reducer 返回 下一个 状态(React 会将旧的状态设置为这个最新的状态)。
事件分派
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function tasksReducer(tasks, action) {  
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('未知 action: ' + action.type);
}
}

在组件中使用 reducer

最后,需要将 tasksReducer 导入到组件中。以替换掉之前的 useState

使用 reducer
1
2
3
4
5
6
import { useReducer } from 'react';

// 删除状态
const [tasks, setTasks] = useState(initialTasks);
// 改用 reducer
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

使用 useReducer时,必须给它传递一个初始状态,它会返回一个有状态的值和一个设置该状态的函数(在这个例子中就是 dispatch 函数)。
useReducer 钩子接受 2 个参数:

  1. 一个 reducer 函数
  2. 一个初始的 state

然后返回如下内容:

  1. 一个有状态的值
  2. 一个 dispatch 函数(用来 “派发” 用户操作给 reducer)

这样,事件处理程序只通过派发不同的 action 来指定 发生了什么,而 reducer 函数通过响应不同的 actions 来决定 状态如何更新

对比 useState 和 useReducer

  • 代码体积: 通常,在使用 useState 时,一开始只需要编写少量代码。而 useReducer 必须提前编写 reducer 函数和需要调度的 actions。但是,当多个事件处理程序以相似的方式修改 state 时,useReducer 可以减少代码量。
  • 可读性: 当状态更新逻辑足够简单时,useState 的可读性还行。但是,一旦逻辑变得复杂起来,它们会使组件变得臃肿且难以阅读。在这种情况下,useReducer 允许将状态更新逻辑与事件处理程序分离开来。
  • 可调试性: 当使用 useState 出现问题时, 很难发现具体原因以及为什么。 而使用 useReducer 时, 可以在 reducer 函数中通过打印日志的方式来观察每个状态的更新,以及为什么要更新(来自哪个 action)。 如果所有 action 都没问题,就知道问题出在了 reducer 本身的逻辑中。 然而,与使用 useState 相比,必须单步执行更多的代码。
  • 可测试性: reducer 是一个不依赖于组件的纯函数。这就意味着可以单独对它进行测试。一般来说,最好是在真实环境中测试组件,但对于复杂的状态更新逻辑,针对特定的初始状态和 action,断言 reducer 返回的特定状态会很有帮助。

如何编写一个好的 reducers

  • reducers 必须是纯粹的。 这一点和状态更新函数是相似的,reducers 在是在渲染时运行的!(actions 会排队直到下一次渲染)。 这就意味着 reducers 必须无副作用,即当输入相同时,输出也是相同的。它们不应该包含异步请求、定时器或者任何副作用(对组件外部有影响的操作)。它们应该以不可变值的方式去更新对象和数组。
  • 每个 action 都描述了一个单一的用户交互,即使它会引发数据的多个变化。 举个例子,如果用户在一个由 reducer 管理的表单(包含五个表单项)中点击了 重置按钮,那么 dispatch 一个 reset_form 的 action 比 dispatch 五个单独的 set_field 的 action 更加合理。如果在一个 reducer 中打印了所有的 action 日志,那么这个日志应该是很清晰的,它能以某种步骤复现已发生的交互或响应。这对代码调试很有帮助。

使用 Context 深层传递参数

传递 props 是将数据通过 UI 树显式传递到使用它的组件的常用方法,但是当需要在组件树中深层传递参数以及需要在组件间复用相同的参数时,传递 props 就会变得很麻烦。最近的根节点父组件可能离需要数据的组件很远,状态提升到太高的层级会导致 “逐层传递 props” 的情况。

状态提升促使props逐级透传

此时,利用 Context 允许父组件向其下层无论多深的任何组件提供信息,而无需通过 props 显式传递。

Context:传递 props

Context 让父组件可以为它下面的整个组件树提供数据。官网示例主题App如下:

Context示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import Heading from './Heading.js';
import Section from './Section.js';

export default function Page() {
return (
<Section>
<Heading level={1}>主标题</Heading>
<Section>
<Heading level={2}>副标题</Heading>
<Heading level={2}>副标题</Heading>
<Heading level={2}>副标题</Heading>
<Section>
<Heading level={3}>子标题</Heading>
<Heading level={3}>子标题</Heading>
<Heading level={3}>子标题</Heading>
<Section>
<Heading level={4}>子子标题</Heading>
<Heading level={4}>子子标题</Heading>
<Heading level={4}>子子标题</Heading>
</Section>
</Section>
</Section>
</Section>
);
}

export function Section({ children }) {
return (
<section className="section">
{children}
</section>
);
}

export function Heading({ level, children }) {
switch (level) {
case 1:
return <h1>{children}</h1>;
case 2:
return <h2>{children}</h2>;
case 3:
return <h3>{children}</h3>;
case 4:
return <h4>{children}</h4>;
case 5:
return <h5>{children}</h5>;
case 6:
return <h6>{children}</h6>;
default:
throw Error('未知的 level:' + level);
}
}

此示例可以改为将 level 参数传递给 <Section> 组件而不是传给 <Heading> 组件,以强制使同一个 section 中的所有标题都有相同的尺寸:

状态提升
1
2
3
4
5
<Section level={3}>  
<Heading>关于</Heading>
<Heading>照片</Heading>
<Heading>视频</Heading>
</Section>

但是当 Section 组件嵌套使用并且不同层级下的标题有不同的级别时,就需要知道离 Heading 最近的 Section 是谁才能够实现目的,这就可以通过 context 来实现:

  1. 创建 一个 context。(可以将其命名为 LevelContext, 因为它表示的是标题级别。)
  2. 在需要数据的组件内 使用 刚刚创建的 context。(Heading 将会使用 LevelContext。)
  3. 在指定数据的组件中 提供 这个 context。 (Section 将会提供 LevelContext。)

这样,Context 可以让父节点,甚至是很远的父节点都可以为其内部的整个组件树提供数据:

使用Context远距离共享props

创建 context

首先,创建 context:

Context 创建
1
2
3
import { createContext } from 'react';

export const LevelContext = createContext(1);

createContext 只需默认值一个参数。在这里, 1 表示最大的标题级别,但可以传递任何类型的值(甚至可以传入一个对象)。

使用 Context

然后,从 Heading 组件的 props 中删掉 level 参数并从 LevelContext 中读取值:

从 Context 中读取值
1
2
3
4
5
export default function Heading({ children }) {  

const level = useContext(LevelContext);
// ...
}

useContext 是一个 Hook,只能在 React 组件中(不是循环或者条件里)立即调用 Hook。**useContext 告诉 React Heading 组件想要读取 LevelContext**。
如此一来 Heading 组件就无需传递 level 参数,而是让 Section 组件代替 Heading 组件接收 level 参数:

通过 Section 接收 level 参数
1
2
3
4
5
<Section level={4}>  
<Heading>子子标题</Heading>
<Heading>子子标题</Heading>
<Heading>子子标题</Heading>
</Section>

提供 context

最后,需要提供正在使用 context,因为当前 React 还不知道从哪里获取这个 context。Section 组件目前渲染传入它的子组件:

Section渲染子组件
1
2
3
4
5
6
7
export default function Section({ children }) { 
return (
<section className="section">
{children}
</section>
);
}

需要用 context provider 将子组件包裹起来以提供 LevelContextSection 的子组件:

为子组件提供 Context
1
2
3
4
5
6
7
8
9
export function Section({ level, children }) { 
return (
<section className="section">
<LevelContext.Provider value={level}>
{children}
</LevelContext.Provider>
</section>
);
}

这告诉 React:“如果在 <Section> 组件中的任何子组件请求 LevelContext,则给他们这个 level。” 组件会使用 UI 树中在它上层最近的那个 <LevelContext.Provider> 传递过来的值。此外,可以在提供 context 的组件和使用它的组件之间的层级插入任意数量的组件。这包括像 <div> 这样的内置组件和自己创建的组件。

使用 context 之前

  1. 从传递 props 开始。 如果组件看起来不起眼,那么通过十几个组件向下传递一堆 props 并不罕见。这有点像是在埋头苦干,但是这样做可以让哪些组件用了哪些数据变得十分清晰!
  2. 抽象组件并将 JSX 作为 children 传递 给它们。 如果通过很多层不使用该数据的中间组件(并且只会向下传递)来传递数据,这通常意味着在此过程中可以使用抽象组件。举个例子,有时可能想传递一些像 posts 的数据 props 到不会直接使用这个参数的组件,类似 <Layout posts={posts} />。取而代之的是,让 Layout 把 children 当做一个参数,然后渲染 <Layout><Posts posts={posts} /></Layout>。这样就减少了定义数据的组件和使用数据的组件之间的层级。

Context 的使用场景

  • 主题: 如果应用允许用户更改其外观(例如暗夜模式),可以在应用顶层放一个 context provider,并在需要调整其外观的组件中使用该 context。
  • 当前账户: 许多组件可能需要知道当前登录的用户信息。将它放到 context 中可以方便地在树中的任何位置读取它。某些应用还允许同时操作多个账户(例如,以不同用户的身份发表评论)。在这些情况下,将 UI 的一部分包裹到具有不同账户数据的 provider 中会很方便。
  • 路由: 大多数路由解决方案在其内部使用 context 来保存当前路由。这就是每个链接“知道”它是否处于活动状态的方式。
  • 状态管理: 随着应用的增长,最终在靠近应用顶部的位置可能会有很多 state。许多遥远的下层组件可能想要修改它们。通常将 reducer 与 context 搭配使用来管理复杂的状态并将其传递给深层的组件来避免过多的麻烦。

总结

在组件状态较为复杂时,可以通过 Reducer 整合相近行为的事件处理函数,通过事件分派的方式,减少需要管理的状态个数。如果需要远距离向下共享状态,则可以通过 Context 实现。

React 利用Reducer管理复杂状态及Context深层传递状态

https://jblogs.tech/2024/03/framworks/front_framworks/react/reducer-state-mgr-and-context/

作者

Jeill

发布于

2024-03-17

更新于

2024-03-24

许可协议

评论