React 在组件间共享状态

React 在组件间共享状态

状态共享的一个例子

要实现两个组件的状态始终同步更改,可以将相关 state 从这两个组件上移除,并把 state 放到它们的公共父级,再通过 props 将 state 传递给这两个组件。这个操作被称为“状态提升”。共享状态的场景在前端开发中非常常见,这里整理官网提供的状态提升示例,进一步巩固状态提升的掌握。示例如下:

状态提升前
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
import { useState } from 'react';

function Panel({ title, children }) {
const [isActive, setIsActive] = useState(false);
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={() => setIsActive(true)}>
显示
</button>
)}
</section>
);
}

export default function Accordion() {
return (
<>
<h2>哈萨克斯坦,阿拉木图</h2>
<Panel title="关于">
阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
</Panel>
<Panel title="词源">
这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中“苹果”的意思,经常被翻译成“苹果之乡”。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
</Panel>
</>
);
}

上述示例中,父组件 Accordion 渲染了 2 个独立的 Panel 组件,每个 Panel 组件都有一个布尔值 isActive,用于独立的控制组件自己的内容是否可见。假设想改变这种行为,以便在任何时候只展开一个面板。这个时候,isActive 就不能放置于组件自身,需要将其提升至父组件中,以便可以将 isActive 状态共享给两个子组件。这可以通过如下三步实现:

  1. 从子组件中 移除 state 。
  2. 从父组件 传递 硬编码数据。
  3. 为共同的父组件添加 state ,并将其与事件处理函数一起向下传递。

从子组件中移除状态

首先,将 Panel 组件对 isActive 的控制权交给他们的父组件,由父组件将 isActive 作为 prop 传给子组件 Panel。因此,先从 Panel 组件中删除 isActive 状态,并把 isActive 加入 Panel 组件的 props 中:

移除子组件 isActive 状态
1
2
3
4
5
// 从子组件中删除这一行
const [isActive, setIsActive] = useState(false);

// 子组件的 props 中加入 isActive
function Panel({ title, children, isActive }){ ... }

从公共父组件传递硬编码数据

然后,为了实现状态提升,须定位到想协调的两个子组件最近的公共父组件

  • Accordion (最近的公共父组件)
    • Panel
    • Panel

在这个示例中,最近的公共父组件是 Accordion。因为它位于两个面板之上,可以控制两个子组件的 props,所以它将成为当前激活面板的“控制之源”。这里通过 Accordion 组件将 isActive 硬编码值(例如 true )传递给两个面板,可以看到不同取值下两个子组件的行为:

传递 isActive 的硬编码状态给两个子组件
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
import { useState } from 'react';

export default function Accordion() {
return (
<>
<h2>哈萨克斯坦,阿拉木图</h2>
<Panel title="关于" isActive={true}>
阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
</Panel>
<Panel title="词源" isActive={true}>
这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中“苹果”的意思,经常被翻译成“苹果之乡”。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
</Panel>
</>
);
}

function Panel({ title, children, isActive }) {
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={() => setIsActive(true)}>
显示
</button>
)}
</section>
);
}

为公共父组件添加状态

状态提升通常会改变原状态的数据存储类型,以便子组件更加精确的识别想要的行为,但是改变并不是必须的,本示例就是如此。但在官网的示例中,改用数字作为当前被激活 Panel 的索引,而不是 boolean 值,即当 activeIndex0 时,激活第一个面板,为 1 时,激活第二个面板。
另一方面,在任意一个 Panel 中点击“显示”按钮都需要更改 Accordion 中的激活索引值。但在 Panel 中无法直接设置状态 activeIndex 的值,因为该状态是在 Accordion 组件内部定义的。不过Accordion 组件需要可以 显式允许 Panel 组件通过 将事件处理程序作为 prop 向下传递 来更改其状态父组件中的状态:

父组件传递共享 isActive 状态及设值函数
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
import { useState } from 'react';

export default function Accordion() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<>
<h2>哈萨克斯坦,阿拉木图</h2>
<Panel
title="关于"
isActive={activeIndex === 0}
onShow={() => setActiveIndex(0)}
>
阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
</Panel>
<Panel
title="词源"
isActive={activeIndex === 1}
onShow={() => setActiveIndex(1)}
>
这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中“苹果”的意思,经常被翻译成“苹果之乡”。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
</Panel>
</>
);
}

function Panel({
title,
children,
isActive,
onShow
}) {
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={onShow}>
显示
</button>
)}
</section>
);
}

每个状态都对应唯一的数据源

需要注意的是:对于每个独特的状态,都应该存在且只存在于一个指定的组件中作为 state。这一原则也被称为拥有 “可信单一数据源”。它并不意味着所有状态都存在一个地方——对每个状态来说,都需要一个特定的组件来保存这些状态信息。应该 将状态提升 到公共父级,或 将状态传递 到需要它的子级中,而不是在组件之间复制共享的状态。此外,应用会随着操作而变化。当将状态上下移动时,依然会需要确定每个状态在哪里“活跃”。

state 的保留和重置

状态与渲染树中的位置相关

虽说各个组件的 state 是各自独立的,但是这些状态是保存在 React 中的,React 可以跟踪组件在渲染树中的位置将它保存的每个状态与正确的组件关联起来。开发过程中,可以控制在重新渲染过程中何时对 state 进行保留和重置。例如:

组件状态与位置
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
import { useState } from 'react';

export default function App() {
const counter = <Counter />;
return (
<div>
{counter}
{counter}
</div>
);
}

function Counter() {
const [score, setScore] = useState(0);
const [hover, setHover] = useState(false);

let className = 'counter';
if (hover) {
className += ' hover';
}

return (
<div
className={className}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
>
<h1>{score}</h1>
<button onClick={() => setScore(score + 1)}>
加一
</button>
</div>
);
}

在上述示例代码中,两个 counter 对象被先后两次渲染,尽管看上去是同一个对象,但是在 UI 树中,可以理解为原始对象的副本,而对应节点的状态则是由位置决定,React 根据位置关联对应的组件虚拟 Dom。前面文章提到过,状态是相互隔离的。因此,在此例子中,若点击各自组件的计数按钮,会发现两个组件计数器独自变化。下图为部分 UI 树:

Counter 渲染树

此外,只有当在树中相同的位置渲染相同的组件时,React 才会一直保留着组件的 state。可以增加一个复选框,控制第二个计数器的是否渲染,将两个计数器的值递增,然后取消勾选 “渲染第二个计数器” 复选框,接着再次勾选它。可以发现,第二个组件的计数在重新勾选后,会从0开始。

增加复选框控制第二个组件渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export default function App() {
const [showB, setShowB] = useState(true);
return (
<div>
<Counter />
{showB && <Counter />}
<label>
<input
type="checkbox"
checked={showB}
onChange={e => {
setShowB(e.target.checked)
}}
/>
渲染第二个计数器
</label>
</div>
);
}
...

相同位置的相同组件会使得 state 被保留下来

不过有一点乍一看可能与上述描述不一致,第一个组件如果被再次被渲染,只要该组件还被渲染在 UI 树的相同位置,React 就会保留它的 state。 这个相同位置指的是其在UI树中的位置,与在 jsx 代码中的位置无关。举个例子:

两个组件在同一位置被渲染
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
import { useState } from 'react';

export default function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? (
<Counter isFancy={true} />
) : (
<Counter isFancy={false} />
)}
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => {
setIsFancy(e.target.checked)
}}
/>
使用好看的样式
</label>
</div>
);
}

function Counter({ isFancy }) {
const [score, setScore] = useState(0);
const [hover, setHover] = useState(false);

let className = 'counter';
if (hover) {
className += ' hover';
}
if (isFancy) {
className += ' fancy';
}

return (
<div
className={className}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
>
<h1>{score}</h1>
<button onClick={() => setScore(score + 1)}>
加一
</button>
</div>
);
}

当勾选或清空复选框的时候,计数器 state 并没有被重置。不管 isFancytrue 还是 false,根组件 App 返回的 div 的第一个子组件都是 <Counter />,因为是位于相同位置的相同组件,所以对 React 来说,它是同一个计数器。这也就是说,在UI树中相同位置,渲染同类型组件,就会被视为同一个组件被渲染,同时相应的状态得以保留。与之不同的是,如果在相同位置渲染不同类型组件,则会使 state 重置,包括该组件的整个子树都会被重置
一般来说,如果想在重新渲染时保留 state,几次渲染中的树形结构就应该相互“匹配”。结构不同会 React 在将一个组件从树中移除时销毁它的 state。因此,永远要将组件定义在最上层,并且在组件内部嵌套定义组件

在相同位置重置 state

默认情况下,React 会在一个组件保持在同一位置时保留它的 state。但有时候,可能想要重置一个组件的 state。这里有两个方法可以实现重置 state:

  1. 将组件渲染在不同的位置(界面显示效果,实际在UI树中可能为左右不同位置)
  2. 使用 key 赋予每个组件一个明确的身份

方法一比较简单,将组件渲染放置与独立的作用域内就可以实现。主要看方法二,这是最通用的方法。在 渲染列表 时,通常会指定一个合适的 key ,但 key 不只可以用于列表!还可以使用 key 来让 React 区分任何组件。默认情况下,React 使用父组件内部的顺序(“第一个计数器”、“第二个计数器”)来区分组件。但是 key 可以告诉 React 这不仅仅是 第一个 或者 第二个 计数器,还是一个特定的计数器。这样,在不同组件之间切换便不会使 state 被保留下来,例如如下示例:

为不同组件指定对应的key
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
import { useState } from 'react';

export default function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}
<button onClick={() => {
setIsPlayerA(!isPlayerA);
}}>
下一位玩家!
</button>
</div>
);
}

function Counter({ person }) {
const [score, setScore] = useState(0);
const [hover, setHover] = useState(false);

let className = 'counter';
if (hover) {
className += ' hover';
}

return (
<div
className={className}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
>
<h1>{person} 的分数:{score}</h1>
<button onClick={() => setScore(score + 1)}>
加一
</button>
</div>
);
}

指定一个 key 能够让 React 将 key 本身而非它们在父组件中的顺序作为位置的一部分。这就是为什么尽管用 JSX 将组件渲染在相同位置,但在 React 看来它们是两个不同的计数器。因此它们永远都不会共享 state。每当一个计数器出现在屏幕上时,它的 state 会被创建出来。每当它被移除时,它的 state 就会被销毁。在它们之间切换会一次又一次地使它们的 state 重置。
需要强调的是:key 不是全局唯一的,它们只能指定其在父组件内部的顺序,为 React 确定同一位置组件提供辅助信息

总结

组件间共享状态可以通过“状态提升”将需要共享的状态放至最近的公共父组件中,再通过 props 传递给子组件;如果要改变状态,还可以将父组件的状态处理函数一同传递给子组件。
此外,同一类型组件若在 UI 树中的相同位置被多次渲染,其状态会得到相应的保留,如果两个条件不满足任何一个,都会使得状态被刷新。

作者

Jeill

发布于

2024-03-16

更新于

2024-03-20

许可协议

评论