React 学习笔记-系列
React-记-1
React-记-2
React-记-3

Context

父子组件通过 props 显式传递数据,当组件层级较深时,这种方式会变得很麻烦。Context(上下文)允许向所有后代组件传递数据(信息)。

不同于 Vue 的 provide/inject,React 的 Context 和 State 一样,是一个 hook,第一次使用会感觉有点奇怪。

基本操作:通过 createContext 创建一个 Context 对象(通常在独立的文件中),然后使用 Provider 组件向后代组件传递数据,后代组件通过 useContext hook 获取数据。

下面直接使用 React 官网的例子。
一个带层级的嵌套的标题区域,如果不使用 Context,需要一层一层的给每个 Heading 传递 level。

首先创建一个 Context 对象,用于传递标题的等级。

LevelContext.ts
1
2
3
import { createContext } from "react";
// 创建一个 Context 对象,默认值为 1
export const LevelContext = createContext(1);

Heading 组件很简单,从上下文中获取 level 返回对应等级的标题标签,别忘了还得接收标题内容 children。

Heading.tsx
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
import { useContext } from "react";
import { LevelContext } from "./LevelContext";

interface HeadingProps {
children: React.ReactNode;
}

export default function Heading({ children }: HeadingProps) {
// 从 Context 上下文中获取 level
const level = useContext(LevelContext);
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 上很合理,再由 Section 通过 Provider 组件value 属性提供 levelContext 上下文值给 Heading 组件。

区域的等级通常是递增的,所以后代 Section 组件也可以是之前的 level + 1,如果没有传入 level,则使用上下文中的 level + 1。

Section.tsx
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
import { LevelContext } from "./LevelContext.js";
import "./Section.css";

interface SectionProps {
level: number;
children: React.ReactNode;
}

export default function Section({ level, children }: SectionProps) {
const _level = useContext(LevelContext);
// 确定最终的 level
let finalLevel = level;
// 如果没有传入 level,则使用上下文中的 level + 1
if (finalLevel === undefined || finalLevel === null) {
finalLevel = _level + 1;
}
return (
<section className="section">
{/* 通过 Provider 组件的 value 属性提供该 Context 值*/}
<LevelContext.Provider value={finalLevel}>
{children}
</LevelContext.Provider>
</section>
);
}

最后当然是使用 Section 组件了。

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
import Heading from "./Heading.js";
import Section from "./Section.js";

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

后代组件始终会从最近的祖先 Provider 组件获取对应的 Context 值,如果没有 Provider 组件,会使用 Context 的默认值

祖先组件可以预先提供好各种 Context 值,而后代组件只需要关心自己需要的值,这样可以写出适应周围环境的组件。在主题切换、国际化、身份验证、路由等场景下,Context 会非常有用。

Consumer 组件订阅上下文

除了 Provider 组件与 useContext 配合。上下文对象上还有个 Consumer 组件,可以用于订阅 Context 对象变化。

不过现在大多数还是使用 useContext 更加简洁,但如果想更明确地使用 render props 模式,就可以使用 Consumer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export default function Heading({ children }: HeadingProps) {
return (
<LevelContext.Consumer>
{(level) => {
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);
}
}}
</LevelContext.Consumer>
);
}

displayName

displayName 是一个字符串,用于调试目的,React DevTools 使用它来确定 Provider 组件的名称,如果没有设置,则都显示为 Context.Provider

在创建 Context 时设置 displayName
1
2
export const LevelContext = createContext(1);
LevelContext.displayName = "LevelContext"; // 显示为 LevelContext.Provider

Context 与 State

Context 不局限于静态值,如果在下一次渲染时传递不同的值,React 将会更新依赖它的所有后代组件,所以 Context 经常与 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
export default function Page() {
const [level, setLevel] = useState(1);

return (
<>
<button onClick={() => setLevel(level + 1)}>增加等级</button>
<Section level={level}>
<Heading>主标题</Heading>
<Section>
<Heading>副标题</Heading>
<Heading>副标题</Heading>
<Heading>副标题</Heading>
<Section>
<Heading>子标题</Heading>
<Heading>子标题</Heading>
<Heading>子标题</Heading>
<Section>
<Heading>子子标题</Heading>
<Heading>子子标题</Heading>
<Heading>子子标题</Heading>
</Section>
</Section>
</Section>
</Section>
</>
);
}

Context 与 Reducer

Reducer 可以整合组件的状态更新逻辑,Context 可以将信息深入传递给其他组件,两者结合可以实现复杂的业务逻辑。文档

在上一章中,我们使用 Reducer 实现了一个 TaskList (前往)。

可以发现 onChangeTask 等事件处理程序以及 taskList 在 props 中被传递了多层,但 TaskList 本身并没有使用这些方法,只是作为中转,将这些方法继续传递给 TaskItem、AddTask 组件。

Reducer 有助于保持事件处理程序的简短明了。但随着应用规模越来越庞大,你就可能会遇到别的困难。目前,tasks 状态和 dispatch 函数仅在顶级 TaskApp 组件中可用。要让其他组件读取任务列表或更改它,你必须显式传递当前状态和事件处理程序,将其作为 props。
比起通过 props 传递它们,你可能想把 tasks 状态和 dispatch 函数都 放入 context。这样,所有的在 TaskApp 组件树之下的组件都不必一直往下传 props 而可以直接读取 tasks 和 dispatch 函数。

1、首先为数据和 dispatch 函数各创建一个 Context 对象。

TaskContext.ts
1
2
3
4
5
6
7
8
import { createContext } from "react";
import { TaskList } from "./type";
import { TaskAction } from "./TaskApp";

export const TaskContext = createContext<TaskList>([]);
export const TaskDispatchContext = createContext<React.Dispatch<TaskAction>>(
() => {}
);

2、在 TaskApp 组件中使用 Provider 组件提供数据和 dispatch 函数。

TaskApp.tsx
1
2
3
4
5
6
7
import { TaskContext, TaskDispatchContext } from "../Task/TaskContext";

<TaskContext.Provider value={taskList}>
<TaskDispatchContext.Provider value={dispatch}>
<TaskList></TaskList>
</TaskDispatchContext.Provider>
</TaskContext.Provider>

3、去掉各个后代组件的 props,直接使用 useContext 获取数据和 dispatch 函数。

TaskList.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { TaskContext } from "./TaskContext";

export default function TaskList() {
const taskList = useContext<TaskList>(TaskContext);
return (
<div className="task-list">
<AddTask></AddTask>
<ul>
{taskList.map((task) => (
<li key={task.id}>
<TaskItem task={task} />
</li>
))}
</ul>
</div>
);
}
TaskItem.tsx
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import { TaskDispatchContext } from "./TaskContext";

interface TaskProps {
task: Task;
}

export default function TaskItem({ task }: TaskProps) {
const [isEdit, setIsEdit] = useState(false);
const [title, setTitle] = useState(task.title);
const taskDispatchContext = useContext(TaskDispatchContext);

function onChange(task: Task) {
taskDispatchContext({ type: "change", payload: task });
}

function onDelete(task: Task) {
taskDispatchContext({ type: "delete", payload: task });
}

let taskContent = null;
if (isEdit) {
taskContent = (
<>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
/>
<button
onClick={() => {
if (!title.trim()) {
return;
}
setIsEdit(false);
onChange({ ...task, title });
}}
>
保存
</button>
<button
onClick={() => {
setIsEdit(false);
setTitle(task.title);
}}
>
取消
</button>
</>
);
} else {
taskContent = (
<>
<span>{task.title}</span>
<button
onClick={() => {
setIsEdit(true);
}}
>
编辑
</button>
</>
);
}

return (
<div>
<input
type="checkbox"
checked={task.done}
onChange={() => {
onChange({ ...task, done: !task.done });
}}
/>
{taskContent}
<button
onClick={() => {
onDelete(task);
}}
>
删除
</button>
</div>
);
}
AddTask.tsx
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
import { TaskDispatchContext } from "./TaskContext";

export default function AddTask() {
const [title, setTitle] = useState("");
const taskDispatchContext = useContext(TaskDispatchContext);

function onAddTask(task: Task) {
taskDispatchContext({ type: "add", payload: task });
}

return (
<div>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
/>
<button
onClick={() => {
if (!title.trim()) {
return;
}
onAddTask({ id: Date.now(), title, done: false });
setTitle("");
}}
>
添加
</button>
</div>
);
}

现在,TaskApp 组件不会向下传递任何事件处理程序,TaskList 也不会。每个组件都会读取它需要的 context。
state 仍然 “存在于” 顶层 Task 组件中,由 useReducer 进行管理。不过,组件树里的组件只要导入这些 context 之后就可以获取 tasks 和 dispatch。

封装 Provider 组件

为了更好的管理、提供数据,可以将 Context 与 Reducer 写在同一个文件中,并提供一个封装后的 Provider 组件。文档

TaskContext.tsx
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 { createContext, useReducer } from "react";
import { Task, TaskList } from "./type";

export const TaskContext = createContext<TaskList>([]);
export const TaskDispatchContext = createContext<React.Dispatch<TaskAction>>(
() => {}
);

const initialTasksList = [
{ id: 1, title: "任务1", done: false },
{ id: 2, title: "任务2", done: false },
{ id: 3, title: "任务3", done: false },
{ id: 4, title: "任务4", done: false },
];

interface TaskProviderProps {
children: React.ReactNode;
}

export default function TaskProvider({ children }: TaskProviderProps) {
const [taskList, dispatch] = useReducer(taskListReducer, initialTasksList);

return (
<TaskContext.Provider value={taskList}>
<TaskDispatchContext.Provider value={dispatch}>
{children}
</TaskDispatchContext.Provider>
</TaskContext.Provider>
);
}

export interface TaskAction {
type: "add" | "delete" | "change";
payload: Task;
}

function taskListReducer(taskList: Task[], action: TaskAction) {
switch (action.type) {
case "add":
return [action.payload, ...taskList];
case "delete":
return taskList.filter((item) => item.id !== action.payload.id);
case "change":
return taskList.map((item) => {
if (item.id === action.payload.id) {
return action.payload;
}
return item;
});
default:
return taskList;
}
}

在 TaskApp 中使用 TaskProvider 为后代组件提供数据。

TaskApp.tsx
1
2
3
4
5
6
7
8
9
10
import TaskList from "./TaskList";
import TaskProvider from "./TaskContext";

export default function TaskApp() {
return (
<TaskProvider>
<TaskList></TaskList>
</TaskProvider>
);
}

还可以封装 useContext 实现自定义 hook,使获取数据更加简洁。

像 useTasks 和 useTasksDispatch 这样的函数被称为自定义 Hook。 如果你的函数名以 use 开头,它就被认为是一个自定义 Hook。这让你可以使用其他 Hook,比如 useContext。

TaskContext.tsx
1
2
3
4
5
6
export function useTaskList() {
return useContext(TaskContext);
}
export function useTaskDispatch() {
return useContext(TaskDispatchContext);
}

现在所有的 context 和 reducer 连接部分都在 TasksContext 中。这保持了组件的干净和整洁,让我们专注于它们显示的内容,而不是它们从哪里获得数据。
TasksProvider 被视为页面的一部分,它知道如何处理 taskList。useTaskList 用来读取它们,useTaskDispatch 用来从组件树下的任何组件更新它们。

脱围机制

React 在设计上是平台无关性的,在 React 设计的系统里,通过单向数据流、声明式描述 UI 组件,已经可以构建一个像模像样的应用了。但在实际项目中,总是会需要和外部环境交互,比如请求数据、操作 DOM等。

React 提供了一些 API 以实现脱围机制,让 React 应用可以和外部环境交互。

ref

ref 和 state 一样可以存储组件的状态,但不会触发新的渲染。使用 useRef 为组件添加 ref。通常用于获取 DOM 元素、组件实例等

1
2
3
4
5
import { useRef } from 'react';
const ref = useRef(0);
// {
// current: 0 // 你向 useRef 传入的值
// }

useRef 返回一个具有 current 属性的对象,可以在渲染过程之外读取和修改 current 属性的值。在渲染期间操作 ref 是不可靠的。如果组件需要存储一些值,但不影响渲染逻辑,就可以使用 ref。

ref 本身是一个普通对象,当改变 current 值时,它会立即改变,而不像 state 的快照机制。

一个秒表的例子,使用 ref 存储定时器的 ID。
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 { useRef, useState } from "react";

export default function RefTest() {
const [startTime, setStartTime] = useState(0);
const [now, setNow] = useState(0);
const intervalRef = useRef<number | undefined>(undefined);
const secondsPassed = (now - startTime) / 1000;

function handleStart() {
// 保存开始时间
setStartTime(Date.now());
setNow(Date.now());

clearInterval(intervalRef.current);
// 每 10 毫秒更新一次当前时间,记录当前定时器的 ID
intervalRef.current = setInterval(() => {
setNow(Date.now());
}, 10);
}

function handleStop() {
clearInterval(intervalRef.current);
}

return (
<>
<h1>时间过去了: {secondsPassed.toFixed(3)}</h1>
<button onClick={handleStart}>开始</button>
<button onClick={handleStop}>停止</button>
</>
);
}

虽然 ref 本身的变化不会触发重新渲染,但其它 state 触发渲染后,ref 的变化也能反应到视图上。

总之,当你需要在多次渲染之间存储一些值,但不希望这些值的变化触发新的渲染时,就可以使用 ref。

操作 DOM

React 会自动处理更新 DOM 以匹配组件的渲染输出,通常情况下并不需要直接操作 DOM。但还是难免会需要使用 DOM API,比如获取元素的尺寸、滚动位置等,ref 就可以帮我们获取原生 DOM 元素。

1
2
const titleRef = useRef<HTMLDivElement>(null);
<div ref={titleRef}>秒表</div>

前面提到过,不能在渲染过程中读取 ref 的值,因为 ref 的值在渲染过程中可能还没有被赋值。但你可以在组件挂载后读取 ref 的值。

1
2
const titleRef = useRef<HTMLDivElement>(null);
console.log(titleRef.current); // null

ref 回调函数

每个组件的 ref 属性可以接受一个回调函数,当组件挂载、卸载、重渲染时,React 会调用这个函数,并传入组件的 DOM 元素。文档

这在获取数量不确定的 DOM 元素时非常有用,通常初始化 ref 为一个 Map,然后在回调函数中将 DOM 元素存入/移除 Map。

一个猫猫图片列表,点击按钮滚动到指定猫猫项。
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import { useRef, useState } from "react";

export default function RefList() {
// 猫猫图片列表
const [catList, setCatList] = useState(setupCatList);
// 获取每个列表项DOM
const itemsRef = useRef<Map<string, HTMLElement>>(new Map());

// 滚动到指定猫猫项
function scrollToCat(cat: string) {
const node = itemsRef.current.get(cat);
if (!node) return;
node.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}

return (
<>
<nav>
<button onClick={() => scrollToCat(catList[0])}>滚动到第一个</button>
<button onClick={() => scrollToCat(catList[catList.length - 1])}>
滚动到最后一个
</button>
</nav>
<div>
<ul
style={{
display: "flex",
gap: 10,
width: 500,
overflow: "auto",
}}
>
{/* 遍历渲染猫猫图片列表 */}
{catList.map((cat) => (
<li
key={cat}
// 将ref写成函数形式,将每个列表项的DOM节点存入Map
ref={(node) => {
const map = itemsRef.current;
// 节点存在说明是挂载,不存在说明是卸载
if (node) {
map.set(cat, node);
} else {
map.delete(cat);
}
}}
>
<img src={cat} />
</li>
))}
</ul>
</div>
</>
);
}

// 获取猫猫图片列表
function setupCatList() {
const catList = [];
for (let i = 0; i < 10; i++) {
catList.push("https://loremflickr.com/320/240/cat?lock=" + i);
}

return catList;
}

ref 回调的参数是 node,也就是 DOM 元素null(当ref分离时)。

  1. 首次渲染时,node 为 DOM 元素。
  2. 组件卸载时,node 为 null。
  3. 重渲染时,如果 ref 回调函数每次渲染都是不同的函数(通常就是这样),React 会先调用旧的回调函数,并传递 null,随后调用新的回调函数并传递 DOM 节点。
  4. 当某个元素被条件渲染移除时,React 会调用 ref 回调传递 null。

所以我们经常会写出如下代码,以确保 Map 中永远只有实际存在的 DOM 元素,防止内存泄漏。

1
2
3
4
5
6
7
8
ref={(node) => {
const map = itemsRef.current;
if (node) {
map.set(/** */);
} else {
map.delete(/** */);
}
}}

清理逻辑(Canary)

标题前带有 (Canary) 的内容是实验性的,版本策略Canary 渠道

除了主动判断 node 是否为 null,还可以使用回调函数的清理逻辑

ref 回调函数可以返回一个清理函数,当 ref 分离时(指当 DOM 元素从组件中被移除,或是 ref 指向的元素发生改变时),React 会调用这个清理函数。如果 ref 回调未返回函数,则当 ref 分离时,React 将以 null 重新调用该 ref 回调。

你可以写出如下代码,优化上面的例子
1
2
3
4
5
6
7
ref={(node) => {
const map = itemsRef.current;
map.set(cat, node);
return () => {
map.delete(cat);
}
}}

重渲染时,当你传递不同的 ref 回调,React 将调用前一个回调的清理函数(如果提供)。如果没有定义清理函数,ref则将使用null参数调用回调。下一个函数将使用 DOM 节点调用。
让 useEffect 绑定一个 ref,也能实现类似的效果。

forwardRef

ref 可以很轻松获取输出浏览器元素的内置组件的对应 DOM 元素,但对于自定义组件,ref 会报错。

1
2
3
4
const inputRef = useRef<HTMLInputElement>(null);
<MyInput ref={inputRef} />

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

这是因为 React 不知道这个 ref 该绑定到哪个 DOM 元素上,需要子组件使用 forwardRef() 创建组件函数,以便将外部 ref 传递给内置组件。文档

1
2
3
4
5
6
const MyInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>((props, ref) => {
return <input {...props} ref={ref} />;
});

forwardRef<T, P = {}> 接受两个泛型参数,第一个是 ref 的类型,第二个是组件的 props 类型。forwardRef 返回一个组件,这个组件可以接受 ref 属性,然后将 ref 传递给内置组件。

让父组件控制子组件的输入框 DOM 元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const MyInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>((props, ref) => {
return <input {...props} ref={ref} />;
});

export function MyForm() {
const inputRef = useRef<HTMLInputElement>(null);

function handleClick() {
inputRef.current!.focus();
}

return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}

useImperativeHandle

useImperativeHandle 通常与 forwardRef 配合,以自定义向 ref 暴露的对象。文档

useImperativeHandle 接收两个参数,第一个是 ref 对象,第二个是一个函数,返回一个对象,这个对象就是向外暴露的对象。这样就可以在父组件中通过 ref.current 调用子组件中的方法。

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
interface MyInputRef {
focus: () => void;
clear: () => void;
}

const MyInput = forwardRef<MyInputRef, InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);

useImperativeHandle(ref, () => {
return {
focus() {
inputRef.current!.focus();
},
clear() {
inputRef.current!.value = "";
},
};
});

return <input {...props} ref={inputRef} />;
}
);

export function MyForm() {
const inputRef = useRef<MyInputRef>(null);

function handleClick() {
inputRef.current!.focus();
}

function handleClear() {
inputRef.current!.clear();
}

return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
<button onClick={handleClear}>清空输入框</button>
</>
);
}

何时使用:

  1. 当你希望子组件的某些方法能够被父组件调用时,例如触发一个动画、清理某些资源,或者执行某些状态更新。
  2. 当你希望提供一个简单的 API 来与子组件交互,而不直接暴露子组件的实现细节。

flushSync

flushSync(cb) 会立即执行回调函数,通常用于强制同步更新组件的状态并立即重新渲染。文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

import { flushSync } from "react-dom";
export function FlushSync() {
const [count, setCount] = useState(0);
const pRef = useRef<HTMLParagraphElement>(null);

const handleClick = () => {
// 使用 flushSync 确保状态更新是同步的
flushSync(() => {
setCount(count + 1);
});
// 此时 pRef.current 已经是最新的 DOM 了
console.log(pRef.current?.textContent);
};

return (
<div>
<p ref={pRef}>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}

何时使用:

  1. 需要立即更新 UI:在某些情况下,你可能希望在处理某个事件时,立即看到 UI 的更新。例如,在复杂的交互中,如果需要在状态更改后立即读取更新后的值。
  2. 与 DOM 操作结合使用:在与 DOM 交互时,确保 React 的渲染与 DOM 的操作是同步的。
  3. 在调度器任务或异步任务中使用,而不是在渲染过程中使用。

flushSync 可能会严重影响性能,因此请谨慎使用。
flushSync 可能会强制挂起的 Suspense 边界显示其 fallback 状态。
flushSync 可能会在返回之前运行挂起的 Effect,并同步应用其包含的任何更新。
flushSync 可能会在必要时刷新回调函数之外的更新,以便刷新回调函数内部的更新。例如,如果有来自点击事件的挂起更新,React 可能会在刷新回调函数内部的更新之前刷新这些更新。

需要注意,你还是无法同步立刻获取 state 的最新值,因为 [[Scopes]] 中的闭包捕获的还是上一个值,详见闭包与内存泄漏

吐槽:React 确实不太好写,需要经常把握局部代码执行过程、时机,不过也可是我没写习惯,被Vue惯坏了(bushi)

Effect

Effect 是 React 的一个重要概念,用于处理副作用与外部系统同步,比如数据获取、订阅、DOM 操作等。文档

在React组件中有两种代码逻辑:

  1. 渲染代码:位于组件的顶层,在这里处理 props 和 state,对它们进行转换,并返回希望在页面上显示的 JSX,这是纯计算的过程,得到一个确定的输出。
  2. 事件处理程序:由用户主动的交互触发,比如点击按钮、输入框输入等,这些事件处理程序可能会引起状态的改变,产生副作用。

但副作用不光只由用户交互引起,一些无条件的、业务逻辑需要的操作也可能引起副作用,但这些代码显然不能放在渲染代码中,所以 React 提供了多种 Effect 来处理这些副作用。

Effect 在提交结束后、页面更新后运行。此时是将 React 组件与外部系统(如网络或第三方库)同步的最佳时机。
副作用:任何改变程序状态或外部系统的行为。而在 React 中,大写的 Effect 是 React 中的专有定义——由渲染引起的副作用。

useEffect

useEffect 当 React 组件完成渲染后,它会调用在组件内部定义的所有 useEffect 回调函数,你能在这里访问到最新的 state 和 DOM,因为其回调总是在更新后的组件状态下执行。

useEffect 的基本用法,每次渲染后获取最新的 count、DOM 元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useEffect, useRef, useState } from "react";

export default function EffectTest() {
const [count, setCount] = useState(0);
const pRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
console.log("effect", count, pRef.current?.textContent);
});
return (
<div>
<button
onClick={() => {
setCount(count + 1);
}}
>
增加
</button>
<p ref={pRef}>{count}</p>
</div>
);
}

每轮渲染(每次调用组件函数),都会产生一个新的 Effect,由于闭包的存在,每个 Effect 总是会捕获其创建时的 state 和 props,详见闭包与内存泄漏

依赖项

默认情况下,Effect 会在每次渲染后运行。但有时候我们只想在某些 state 或 props 改变时才运行 Effect,可以传入第二个参数,一个数组,包含 Effect 的依赖项。

只有所有依赖项的值都与上一次渲染时完全相同,React 才会跳过重新运行该 Effect。React 使用 Object.is 来比较依赖项的值。

空数组表示只在组件挂载时运行 Effect。
1
2
3
useEffect(() => {
// ...
}, []);

组件内部的所有值(包括 props、state 和组件体内的变量)都是响应式的。任何响应式值都可以在重新渲染时发生变化,都可以包括在 Effect 的依赖项中。

全局变量或组件外部可变值不能直接作为依赖项,这些值可以在 React 渲染数据流之外的任何时间发生变化,React 无法在其更改时重新同步 Effect。但你可以使用 useSyncExternalStore 来读取和订阅外部可变值,让其能被 React 追踪。

可以省略的依赖项:
组件内的 ref 可以在依赖数组中省略,ref 是稳定的,每轮渲染中调用同一个 useRef 时,总能获得相同的对象,永远不会导致重新运行 Effect。当然如果是外部传入的 ref,还是需要加入依赖项的。
useState 返回的 set 函数也是稳定的,因此通常也会被省略。

如果 lint 工具配置了 React,它将检查 Effect 代码中使用的每个响应式值是否已声明为其依赖项。
如果在省略某个依赖项时 linter 不会报错,那么这么做就是安全的。

依赖项并不是完全自由选择的,Effect 内部使用到的响应式值都应该作为依赖项,以确保 Effect 在这些值发生变化、触发渲染时可以重新运行。

清理函数

Effect 可以返回一个清理函数,用于清理 Effect 的副作用。通俗来说就是停止或撤销 Effect 所做的一切。

执行时机:React 会在每次 Effect 重新运行之前调用清理函数,并在组件卸载时最后一次调用清理函数。

清理 Effect 的副作用
1
2
3
4
5
6
7
8
useEffect(() => {
console.log("添加副作用");
window.addEventListener("scroll", handleScroll);
return () => {
console.log("清除副作用");
window.removeEventListener("scroll", handleScroll);
};
});

首次渲染时,你会在控制台看到如下输出,这是因为在开发环境中,React 会通过重新挂载组件来检测 BUG。

1
2
3
添加副作用
清除副作用
添加副作用

你需要思考的不是“如何只运行一次 Effect”,而是“如何修复我的 Effect 来让它在重新挂载后正常运行”。如果重新挂载破坏了应用的逻辑,通常便暴露了存在的 bug。

如果 Effect 需要获取数据,清理函数应中止请求或忽略其结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
useEffect(() => {
let ignore = false;

async function startFetching() {
const json = await fetchTodos(userId);
// 如果组件已经卸载,忽略结果
if (!ignore) {
setTodos(json);
}
}

startFetching();

return () => {
ignore = true;
};
}, [userId]);
// 当 userId 改变时,应该取消、忽略上一次请求

一些该看文档的内容

这些内容是如何更好地使用 Effect,以及如何避免一些错误,直接看文档就好。
你可能不需要 Effect
响应式 Effect 的生命周期
将事件从 Effect 中分开
移除 Effect 依赖

文档中举了很多例子,可以总结为以下内容:

  1. 事件处理程序仅在重复相同交互时重新运行,而 Effect 会根据依赖项(如 props 或 state)变化重新同步。
  2. Effect 是响应式的,必须将读取的响应式值指定为依赖项,依赖变化时会再次运行。
  3. 避免将对象和函数作为 Effect 的依赖,可将它们移至组件外部、Effect 内部,或提取原始值。
  4. 不必要的依赖关系会导致 Effect 过度运行,甚至无限循环。
  5. 改变依赖项需通过修改代码。
  6. 使用自定义 Hook 复用逻辑。

下面这个例子绘制了一个随鼠标移动的点,并制造了一些延迟跟随的重影效果。

自定义 Hook usePointerPosition 追踪当前指针位置,而 useDelayedValue 延迟一定毫秒数后返回传入的值。每次把上一次的位置延迟一段时间给下一个 Dot 位置,就制造了重影效果。

index.tsx
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
import { usePointerPosition } from "./usePointerPosition.ts";
import { useDelayedValue } from "./useDelayedValue.ts";
import Dot from "./Dot.js";
import "./index.css";
import { useRef } from "react";

export default function DotContainer() {
const containerRef = useRef(null);
// 实时的鼠标位置
const pos1 = usePointerPosition(containerRef);
// 延迟的鼠标位置,每次把上一次的位置延迟一段时间给下一个 Dot 位置
const pos2 = useDelayedValue(pos1, 100);
const pos3 = useDelayedValue(pos2, 200);
const pos4 = useDelayedValue(pos3, 100);
const pos5 = useDelayedValue(pos4, 50);
return (
<div className="dot-container" ref={containerRef}>
<Dot position={pos1} opacity={1} />
<Dot position={pos2} opacity={0.8} />
<Dot position={pos3} opacity={0.6} />
<Dot position={pos4} opacity={0.4} />
<Dot position={pos5} opacity={0.2} />
</div>
);
}
Dot.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface DotProps {
position: { x: number; y: number };
opacity: number;
}

export default function Dot({ position, opacity }: DotProps) {
return (
<div
style={{
position: "absolute",
backgroundColor: "pink",
borderRadius: "50%",
opacity,
transform: `translate(${position.x}px, ${position.y}px)`,
pointerEvents: "none",
left: -20,
top: -20,
width: 40,
height: 40,
}}
/>
);
}
useDelayedValue.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useState, useEffect } from "react";

export function useDelayedValue(
value: {
x: number;
y: number;
},
delay: number
) {
const [delayedValue, setDelayedValue] = useState(value);

useEffect(() => {
setTimeout(() => {
setDelayedValue(value);
}, delay);
}, [value, delay]);

return delayedValue;
}
usePointerPosition.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useState, useEffect } from "react";

export function usePointerPosition(
ref: React.MutableRefObject<HTMLElement | null>
) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
function handleMove(e: PointerEvent) {
setPosition({ x: e.offsetX, y: e.offsetY });
}
el.addEventListener("pointermove", handleMove);
return () => el.removeEventListener("pointermove", handleMove);
});
return position;
}

useEffectEvent(实验性)

useEffectEvent 可以提取非响应式逻辑到 Effect Event 中。其内部始终可以获取到 props 和 state 的最新值。

1
const onSomething = useEffectEvent(callback)

场景:当你需要在 Effect 中读取响应式值,但又不希望其变化触发 Effect 重新运行时,可以使用 useEffectEvent。通常可以修改代码避免这一情况,但有时候这是不可避免的,或者代价太大。

1
2
3
4
5
6
7
8
9
10
const [count, setCount] = useState(0);
const onScroll = useEffectEvent(() => {
console.log(count); // useEffectEvent 保证总是能够访问到最新的 count
});
useEffect(() => {
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, []);

上面的代码中,确保在读取了 count 后,不会因为 count 的变化而重新运行 Effect,只有在组件挂载时运行一次进行事件绑定。

但实际上,不使用 useEffectEvent 也可以达到相同的效果,只需要在 Effect 中使用 ref 保存 count 的值即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const [count, setCount] = useState(0);
const countRef = useRef(count);

// 更新 countRef 的值
useEffect(() => {
countRef.current = count;
}, [count]);

useEffect(() => {
const handleScroll = () => {
console.log(countRef.current);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);

使用 useEffectEvent 会更加简洁,但需要注意,useEffectEvent 是实验性的,可能会有变化。

  1. 避免了事件处理函数捕获旧状态的问题。
  2. 不需要使用 useRef 来手动跟踪状态。
  3. 减少了在依赖数组中加入多余的依赖,优化性能。

目前即使升级到了实验版本,也无法使用 useEffectEvent。

不过手写一个也不难,原理就使用 ref 保存状态,并利用对象的引用传递特性,保证每次读取的都是最新的状态。

useEffectEvent.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useMemo, useRef } from "react";

export default function useEffectEvent(fn: () => void) {
// 使用ref保存传入的函数,每次执行 useEffectEvent 时都会将最新的fn更新给ref
const fnRef = useRef(fn);
// 当fn发生变化时,更新fnRef.current
fnRef.current = useMemo(() => fn, [fn]);

// 使用ref保存需要返回的函数,此处使用ref的目的是为了保证每次返回的函数都是同一个
const effectEvenFn = useRef<(...args: any) => void>();
// 初始化effectEvenFn.current,保证每次返回的函数都是同一个
if (!effectEvenFn.current) {
// 返回的函数,每次执行都会调用fnRef.current
effectEvenFn.current = function (this, ...args: any) {
// fnRef.current 保存的是最新的 fn,而fnRef本身在多次渲染中是不会发生变化的
// 所以每次都能调用到最新的fn,保证了fn中能访问到最新的状态
return fnRef.current.apply(this, args);
};
}
return effectEvenFn.current;
}

本质还是闭包的问题,详见闭包与内存泄漏

useLayoutEffect

useLayoutEffect 与 useEffect 类似,但它会在 DOM 更新完成后立即执行,且会阻塞浏览器的绘制过程。

使用场景:直接操作 DOM 的情况,比如测量布局、同步样式或强制浏览器重绘。可以避免多次渲染的闪烁、布局错乱等问题。

useEffect 是在浏览器绘制完成后执行的,因此它不会阻塞浏览器的重绘。
useLayoutEffect 可能会影响性能。尽可能使用 useEffect。
在底层上 useLayoutEffect 在提交阶段同步调用,而 useEffect 在提交阶段异步调用,放入调度器队列中,作为宏任务执行,自然不会阻塞绘制。

1
2
3
4
5
6
7
const ref = useRef(null);
const [height, setHeight] = useState(0);

useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setHeight(height);
}, []);

useInsertionEffect

useInsertionEffect 在提交 DOM 更新之前执行,它是专为 CSS-in-JS 库的作者打造的。

专门用于在 DOM 更新提交前进行一些低级别的影响布局的 DOM 插入工作,这样可以减少浏览器回流、重绘,避免出现闪烁或样式未能及时插入的问题。

useInsertionEffect 会同时触发 cleanup 函数和 setup 函数。这会导致 cleanup 函数和 setup 函数的交错执行。

通常情况下,你不需要使用 useInsertionEffect。

生命周期

函数组件的本质是一个渲染纯函数,并不存在生命周期,但通过 Effect Hook 可以模拟类组件的生命周期。

Effect 执行顺序:

  1. 不同 Effect:
    useInsertionEffect -> 提交 DOM 更新 -> useLayoutEffect -> 页面渲染 -> useEffect
  2. 父子组件间:
    子组件的 Effect 会在父组件的 Effect 之前执行。
父组件
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
import Child from "./Child";

export default function Parent() {
const [, forceUpdate] = useState(0);

console.log("Parent fn");
useInsertionEffect(() => {
console.log("Parent insertionEffect");
});
useLayoutEffect(() => {
console.log("Parent layoutEffect");
});
useEffect(() => {
console.log("Parent rendered");
});
return (
<div>
Parent
<button
onClick={() => {
forceUpdate((prev) => prev + 1);
}}
>
父组件重新渲染
</button>
<Child></Child>
</div>
);
}
子组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default function Child() {

console.log("Child fn");
useInsertionEffect(() => {
console.log("Child insertionEffect");
});
useLayoutEffect(() => {
console.log("Child layoutEffect");
});
useEffect(() => {
console.log("Child rendered");
});
return <div>Child</div>;
}

父组件渲染时,控制台输出如下:

1
2
3
4
5
6
7
8
9
10
Parent fn
Child fn
Child insertionEffect
Parent insertionEffect
// 提交 DOM 更新
Child layoutEffect
Parent layoutEffect
// 页面渲染
Child rendered
Parent rendered

先执行父组件的组件函数,再执行子组件的组件函数。

React 按照深度优先遍历的顺序构建和更新组件树,因此子组件的 Effect 会比父组件先执行。这种执行顺序确保子组件的所有更新都准备好后,父组件再执行自己的布局和副作用逻辑,从而保证渲染的一致性和性能。

类组件已不再推荐使用,它的那堆生命周期就不看了,碰到再翻文档吧。