React系列八:脱围机制
有些组件可能需要控制和同步 React 之外的系统。例如,你可能需要使用浏览器 API 聚焦输入框,或者在没有 React 的情况下实现视频播放器,或者连接并监听远程服务器的消息。在本章中,你将学习到一些脱围机制,让你可以“走出” React 并连接到外部系统。大多数应用逻辑和数据流不应该依赖这些功能。
使用ref 引用值
当你希望组件“记住”某些信息,但又不想让这些信息 触发新的渲染 时,你可以使用 ref 。
import { useRef } from 'react';
const ref = useRef(0);
useRef 返回一个这样的对象:
{
current: 0 // 你向 useRef 传入的值
}
你可以用 ref.current 属性访问该 ref 的当前值。这个值是有意被设置为可变的,意味着你既可以读取它也可以写入它。就像一个 React 追踪不到的、用来存储组件信息的秘密“口袋”。(这就是让它成为 React 单向数据流的“脱围机制”的原因 —— 详见下文!)
这里的 ref 指向一个数字,但是,像 state 一样,你可以让它指向任何东西:字符串、对象,甚至是函数。与 state 不同的是,ref 是一个普通的 JavaScript 对象,具有可以被读取和修改的 current 属性。
请注意,组件不会在每次递增时重新渲染。 与 state 一样,React 会在每次重新渲染之间保留 ref。但是,设置 state 会重新渲染组件,更改 ref 不会!
当一条信息用于渲染时,将它保存在 state 中。当一条信息仅被事件处理器需要,并且更改它不需要重新渲染时,使用 ref 可能会更高效。比如计时器使用uesState渲染时间变化,用useRef存储timer,用于clearInterval。
React官方建议在大多数情况下使用 state。ref 是一种“脱围机制”,你并不会经常用到它。
通常,当你的组件需要“跳出” React 并与外部 API 通信时,你会用到 ref —— 通常是不会影响组件外观的浏览器 API。以下是这些罕见情况中的几个:
- 存储 timeout ID
- 存储和操作 DOM 元素
- 存储不需要被用来计算 JSX 的其他对象。脱围机制
有些组件可能需要控制和同步 React 之外的系统。例如,你可能需要使用浏览器 API 聚焦输入框,或者在没有 React 的情况下实现视频播放器,或者连接并监听远程服务器的消息。在本章中,你将学习到一些脱围机制,让你可以“走出” React 并连接到外部系统。大多数应用逻辑和数据流不应该依赖这些功能。
使用 ref 操作 DOM
由于 React 会自动处理更新 DOM 以匹配你的渲染输出,因此你在组件中通常不需要操作 DOM。但是,有时你可能需要访问由 React 管理的 DOM 元素 —— 例如,让一个节点获得焦点、滚动到它或测量它的尺寸和位置。在 React 中没有内置的方法来做这些事情,所以你需要一个指向 DOM 节点的 ref 来实现
import { useRef } from 'react';
const myRef = useRef(null);
<div ref={myRef}>
然后就能:
// 你可以使用任意浏览器 API,例如:
myRef.current.scrollIntoView();
类似与vue3中的:
import {ref} from "vue"
const myRef = ref(null)
<div ref="myRef"></div>
但是vue3中使用的是字符串,React中使用的是对象。
以上就是ref基础的用法
ref 回调
有时候,你可能需要为列表中的每一项都绑定 ref ,而你又不知道会有多少项。像下面这样做是行不通的:
<ul>
{items.map((item) => {
// 行不通!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
这是因为 Hook 只能在组件的顶层被调用。不能在循环语句、条件语句或 map() 函数中调用 useRef 。
一种可能的解决方案是用一个 ref 引用其父元素,然后用 DOM 操作方法如 querySelectorAll 来寻找它的子节点。然而,这种方法很脆弱,如果 DOM 结构发生变化,可能会失效或报错。
另一种解决方案是将函数传递给 ref 属性。这称为 ref 回调。当需要设置 ref 时,React 将传入 DOM 节点来调用你的 ref 回调,并在需要清除它时传入 null 。这使你可以维护自己的数组或 Map,并通过其索引或某种类型的 ID 访问任何 ref。
我们可以新建一个Map
const itemsRef = useRef(new Map());
然后:
{
list.map((item) => (
<li
key={item}
ref={(node) => {
const map = itemsRef.current;
map.set(item, node); // 当元素挂载时,把 item 和 DOM 对象关联起来
return () => {
map.delete(item); // 当元素卸载或更新时,把这条关联关系移除
};
}}
>
<img src={item} />
</li>
));
}
这里被传给ref={}的函数就叫做ref回调,我们在其中将item和它的node节点作为Map保存在了itemsRef中。而ref回调中return的函数被成为清理函数,当元素更新或卸载时会执行其中的方法,我们在此使用map.delete(item)来防止内存泄漏和数据污染。(如果是更新元素,React 会再次执行 ref(newNode) 并返回新的清理函数)
itemsRef 保存的不是单个 DOM 节点,而是保存了包含列表项 ID 和 DOM 节点的 Map。(Ref 可以保存任何值!) 每个列表项上的 ref 回调负责更新 Map
在使用时,我们根据item再去获取到想要操作的node,再由node来调用DOM方法:
function scrollToItem(item) {
const map = itemsRef;
const node = map.get(item);
node.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
访问另一个组件的 DOM 节点
你可以 像其它 prop 一样 将 ref 从父组件传递给子组件
import { useRef } from 'react';
function MyInput({ ref }) {
return <input ref={ref} />;
}
function MyForm() {
const inputRef = useRef(null);
return <MyInput ref={inputRef} />
}
在上面这个例子中,父组件MyForm创建了一个名为 inputRef 的 ref,并且将它传递给了 MyInput 子组件。MyInput 将这个 ref 传递给 。因为 是一个 内置组件,React 会将 ref 的 .current 属性设置为这个 DOM 元素。
在 MyForm 中创建的 inputRef 现在指向 MyInput 返回的 DOM 元素。在 MyForm 中创建的点击处理程序可以访问 inputRef 并且调用 focus() 来将焦点设置在 上。
在上面的例子中,MyInput 暴露了原始的 DOM 元素 input。这让父组件可以对其调用focus()。然而,这也让父组件能够做其他事情 —— 例如,改变其 CSS 样式。在一些不常见的情况下,你可能希望限制暴露的功能。你可以用 useImperativeHandle 来做到这一点,这里不做讨论。
React 何时添加 refs
在 React 中,每次更新都分为 两个阶段:
在 渲染 阶段, React 调用你的组件来确定屏幕上应该显示什么。(是组件从执行开始到 return JSX 的过程。)
在 提交 阶段, React 把变更应用于 DOM。(是 return JSX 完成后,React 把虚拟 DOM 应用到真实 DOM 的过程。)
在渲染期间,DOM 节点尚未创建。因此 ref.current 将为 null。在渲染更新的过程中,DOM 节点还没有更新。所以读取它们还为时过早。
React 在提交阶段设置 ref.current。在更新 DOM 之前,React 将受影响的 ref.current 值设置为 null。更新 DOM 后,React 立即将它们设置到相应的 DOM 节点。
通常,你将从事件处理器访问 refs(就是定义方法,比如点击事件等)。 如果你想使用 ref 执行某些操作,但没有特定的事件可以执行此操作,你可能需要一个 effect。
同步更新 DOM
一些场景下,你想在添加一个新的列表项后将屏幕滚动到列表最后一项:
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();
但是它并不会滚动到最后,而是倒数第二项,因为在 React 中,state 更新是排队进行的。setTodos 不会立即更新 DOM。因此,当你将列表滚动到最后一个元素时,尚未添加newTodo。这就是滚动总是“落后”一项的原因。
要解决此问题,你可以强制 React 同步更新(“刷新”)DOM。 为此,从 react-dom 导入 flushSync 并将 state 更新包裹 到 flushSync 调用中:
flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
这将指示 React 当封装在 flushSync 中的代码执行后,立即同步更新 DOM。因此,当你尝试滚动到最后一个待办事项时,它已经在 DOM 中了
和Vue中的nextTick正好相反。 React 会“批处理”多个状态更新,把它们延迟执行(比如等事件循环后),以优化性能,而 flushSync 是打断这种延迟,让你立刻更新 DOM。
而nextTick是等 Vue 完成本轮 DOM 更新后再执行回调函数。所以它是延后执行的!用来保证你操作的是最新 DOM。
注意
避免更改由 React 管理的 DOM 节点。 对 React 管理的元素进行修改、添加子元素、从中删除子元素会导致不一致的视觉结果,崩溃。
但是,这并不意味着你完全不能这样做。它需要谨慎。 你可以安全地修改 React 没有理由更新的部分 DOM。 例如,如果某些 在 JSX 中始终为空,React 将没有理由去变动其子列表。 因此,在那里手动增删元素是安全的。
使用 Effect 进行同步
简单理解:React的useEffect就相当于Vue的onMounted + watch + onUnmounted 的组合体!
Effect 允许你在渲染结束后执行一些代码,以便将组件与 React 外部的某个系统相同步。比如获取到列表之后滚动到列表最后一项。
在接触 Effect 之前,你需要熟悉 React 组件中的两种逻辑类型:
渲染代码(在 描述 UI 中有介绍)位于组件的顶层。你在这里处理 props 和 state,对它们进行转换,并返回希望在页面上显示的 JSX。渲染代码必须是纯粹的——就像数学公式一样,它只应该“计算”结果,而不做其他任何事情。
事件处理程序(在 添加交互性 中有介绍)是组件内部的嵌套函数,它们不光进行计算, 还会执行一些操作。事件处理程序可能会更新输入字段、提交 HTTP POST 请求来购买产品,或者将用户导航到另一个页面。事件处理程序包含由特定用户操作(例如按钮点击或输入)引起的“副作用”(它们改变了程序的状态)
有时这还不够。考虑一个 ChatRoom 组件,它在页面上显示时必须连接到聊天服务器。连接到服务器并不是纯粹的计算(它是一个副作用),因此它不能在渲染期间发生。然而,并没有一个特定的事件(比如点击)能让 ChatRoom 被显示。
下面我们将介绍使用Effect来解决这类问题:
第一步:声明 Effect
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// 每次渲染后都会执行此处的代码
});
return <div />;
}
每当你的组件渲染时,React 会先更新页面,然后再运行 useEffect 中的代码。换句话说,useEffect 会“延迟”一段代码的运行,直到渲染结果反映在页面上。(这里类似Vue的onMounted,在页面渲染完成后执行)
示例:
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // 渲染期间不能调用 `play()`。
} else {
ref.current.pause(); // 同样,调用 `pause()` 也不行。
}
return <video ref={ref} src={src} loop playsInline />;
}
在渲染期间使用ref.current是行不通的,因为还没到提交阶段完成,此时的ref.current为null。这里的操作就不是纯粹的计算,而是“副作用”(任何改变程序状态或外部系统的行为)
我们只要改成这样,就能让其在页面渲染完成后执行:
import { useEffect, useRef } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
通过将 DOM 更新封装在 Effect 中,你可以让 React 先更新页面,然后再运行 Effect。当 VideoPlayer 组件渲染时(无论是否为首次渲染),会发生以下几件事:首先 React 会更新页面,确保 标签带着正确的 props 出现在 DOM 中;接着 React 将运行 Effect;最后 Effect 将根据 isPlaying 的值调用 play() 或 pause()。
默认情况下,Effect 会在 每次 渲染后运行。正因如此,以下代码会陷入死循环:
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
Effect 在渲染结束后运行。更新 state 会触发重新渲染。在 Effect 中直接更新 state 就像是把电源插座的插头插回自身:Effect 运行、更新 state、触发重新渲染、于是又触发 Effect 运行、再次更新 state,继而再次触发重新渲染。如此反复,从而陷入死循环
Effect 应该用于将你的组件与一个 外部 的系统保持同步。如果没有外部系统,你只是想根据其他状态调整一些状态,那么 你也许不需要 Effect。
外部系统:不属于 React 本身控制的数据或行为,如网络请求、计时器、 浏览器 API、第三方库、手动 DOM 操作、WebSocket、EventSource等
第二步:指定 Effect 的依赖项
默认情况下,Effect 会在 每次 渲染后运行。但往往 这并不是你想要的。
我们知道,在修改state是会触发组件的重新渲染,比如一个input在用户输入的时候就会不断触发Effect,这不是我们逾期的结果。
通过在调用 useEffect 时指定一个 依赖数组 作为第二个参数,你可以让 React 跳过不必要地重新运行 Effect。
useEffect(() => {
// ...
}, []);
我们将上面的例子改成:
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
指定 [isPlaying] 作为依赖数组会告诉 React:如果 isPlaying 与上次渲染时相同,就跳过重新运行 Effect。
这样在其他state更新导致组件重新渲染时,就不会触发这个useEffect。换句话说,只有isPlaying变化时,这个useEffect才会执行,这里也可以理解成Vue的watch。
依赖数组可以包含多个依赖项。只有当你指定的 所有 依赖项的值都与上一次渲染时完全相同,React 才会跳过重新运行该 Effect。React 使用 Object.is 来比较依赖项的值。
没有依赖数组和使用空数组 [] 作为依赖数组,行为是不同的:
useEffect(() => {
// 这里的代码会在每次渲染后运行
});
useEffect(() => {
// 这里的代码只会在组件挂载(首次出现)时运行
}, []);
useEffect(() => {
// 这里的代码不但会在组件挂载时运行,而且当 a 或 b 的值自上次渲染后发生变化后也会运行
}, [a, b]);
第三步:按需添加清理(cleanup)函数
useEffect(() => {
return () => {};
}, []);
这里return 的函数就是清理函数,如ref回调的清理函数类似,组件卸载或更新的时候会执行。
假如我们在useEffect中使用了定时器,那么我们就可以在这里使用clearInterval(timer)。这里相当于Vue中的onUnmounted
示例:
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
以上就是useEffect的完整用法。
如何处理在开发环境下 Effect 运行了两次?
React 有意在开发环境下重新挂载你的组件,来找到类似上例中的 bug。你需要思考的不是“如何只运行一次 Effect”,而是“如何修复我的 Effect 来让它在重新挂载后正常运行”。
通常,答案是实现清理函数。清理函数应该停止或撤销 Effect 所做的一切。原则是用户不应该感受到 Effect 只执行一次(在生产环境中)和连续执行“挂载 → 清理 → 挂载”(在开发环境中)之间的区别。虽然在开发环境下它可能会稍微慢一些,但问题不大,因为在生产环境下它不会多余地重新挂载。
通俗的说,故意让你的useEffect重复执行一下,让你提前知道组件重新渲染时会出现的问题。如果这里有问题,那么在修改state等操作让组件重新渲染的时候这个问题也会出现。(重新挂载破坏了应用的逻辑,通常便暴露了存在的 bug)
你可能不需要 Effect
Effect 是 React 范式中的一种脱围机制。它们让你可以 “逃出” React 并使组件和一些外部系统同步,比如非 React 组件、网络和浏览器 DOM。如果没有涉及到外部系统(例如,你想根据 props 或 state 的变化来更新一个组件的 state),你就不应该使用 Effect。移除不必要的 Effect 可以让你的代码更容易理解,运行得更快,并且更少出错。
useEffect 的本意是:同步组件与外部系统的副作用(如网络请求、订阅、手动 DOM),而不是你自己组件内部状态变化之间的联动计算。这些状态之间的联动计算,应该在:渲染时自动计算(派生状态)或用户操作事件中统一更新(事件处理函数)
以下是一些不需要Effect的场景:
计算属性
如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。这将使你的代码更快(避免了多余的 “级联” 更新)、更简洁(移除了一些代码)以及更少出错。
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ 非常好:在渲染期间进行计算
const fullName = firstName + ' ' + lastName;
// ...
}
这里的fullName就**类似于Vue中的计算熟悉computed。**在vue中,计算属性中的响应式值变化时,会触发computed进行重新计算该属性。而在React中,修改了state会重新执行组件函数,也就是说fullName的值会被重新计算。
缓存昂贵的计算
因为修改了state,组件函数主体会重新执行,也就是说计算属性在每次组件更新都会重新计算。上方的计算比较简短,重复执行问题不大。但可能有的属性计算量会很大、很耗时,每次更新组件重新计算就会给性能带来很大开销。
对此,我们可以使用React提供的useMemo来缓存。除非必要,否则不会重新计算这个属性。用法如下:
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ✅ 除非 todos 或 filter 发生变化,否则不会重新执行
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}
这会告诉 React,除非 todos 或 filter 发生变化,否则不要重新执行传入的函数。React 会在初次渲染的时候记住 getFilteredTodos() 的返回值。在下一次渲染中,它会检查 todos 或 filter 是否发生了变化。如果它们跟上次渲染时一样,useMemo 会直接返回它最后保存的结果。如果不一样,React 将再次调用传入的函数(并保存它的结果)。useMemo 不会让 第一次 渲染变快。它只是帮助你跳过不必要的更新。
现在就更像Vue的computedl了!
当 props 变化时重置 state
props变化,组件会重新渲染,但是组件在组件树中的位置没变,所以state不会被重置
有时候我们可能会需要props改变的时候重置state的状态。
在vue中,我们可能会用watch监听props的变化,然后重置某个响应式属性。
在React中我们可能也用相似的方法来处理:
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 避免:当 prop 变化时,在 Effect 中重置 state
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
但这是低效的,因为 ProfilePage 和它的子组件首先会用旧值渲染,然后再用新值重新渲染:
- 父组件更新 userId 后 → ProfilePage 会重新渲染,渲染时依然会用旧的 comment 值
- 然后 Effect 执行,setComment(''),又重新渲染一次。
如果需要完全重置所有的state,这时候我们可以像下面这样:
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ 当 key 变化时,该组件内的 comment 或其他 state 会自动被重置
const [comment, setComment] = useState('');
// ...
}
将传入的某一个props作为该组件的key,每当 key(这里是 userId)变化时,React 将重新创建 DOM,并 重置 Profile 组件和它的所有子组件的 state。
但有时候我们并不想全部重置,我们只想重置部分state
一个简单的例子:List 组件接收一个 items 列表作为 prop,然后用 state 变量 selection 来保持已选中的项。当 items 接收到一个不同的数组时,你想将 selection 重置为 null:
同样也不能使用:
useEffect(() => {
// 🔴 避免:当 prop 变化时,在 Effect 中调整 state
setSelection(null);
}, [items]);
因为这不太理想。每当 items 变化时,List 及其子组件会先使用旧的 selection 值渲染。然后 React 会更新 DOM 并执行 Effect。最后,调用 setSelection(null) 将导致 List 及其子组件重新渲染,重新启动整个流程。
稍微好一点的方式是:
function List({ items }) {
const [selection, setSelection] = useState(null);
// 好一些:在渲染期间调整 state
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
items改变时,组件重新渲染,所以会执行组件函数体。我们使用if (items !== prevItems) 来检查items是否和已存在的state相同,如果不同则重新赋值。
此时 React 还没有渲染 List 的子组件或更新 DOM,这使得 List 的子组件可以跳过渲染旧的 selection 值。
在渲染期间更新组件时,React 会丢弃已经返回的 JSX 并立即尝试重新渲染。为了避免非常缓慢的级联重试,React 只允许在渲染期间更新 同一 组件的状态。如果你在渲染期间更新另一个组件的状态,你会看到一条报错信息。条件判断 items !== prevItems 是必要的,它可以避免无限循环。你可以像这样调整 state,但任何其他副作用(比如变化 DOM 或设置的延时)应该留在事件处理函数或 Effect 中,以 保持组件纯粹。
虽然这种方式比 Effect 更高效,但大多数组件也不需要它。无论你怎么做,根据 props 或其他 state 来调整 state 都会使数据流更难理解和调试。
我们其实可以更换思路:
function List({ items }) {
const [selectedId, setSelectedId] = useState(null);
// ✅ 非常好:在渲染期间计算所需内容
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
现在完全不需要 “调整” state 了。如果包含已选中 ID 的项出现在列表中,则仍然保持选中状态。如果没有找到匹配的项,则在渲染期间计算的 selection 将会是 null。行为不同了,但可以说更好了,因为大多数对 items 的更改仍可以保持选中状态。
在事件处理函数中共享逻辑
避免在 Effect 中处理属于事件特定的逻辑。
当你不确定某些代码应该放在 Effect 中还是事件处理函数中时,先自问 为什么 要执行这些代码。Effect 只用来执行那些显示给用户时组件 需要执行 的代码。
比如一个通知应该在用户 按下按钮 后出现,那么应该将通知的逻辑放在一个事件中,在按下按钮时调用这个事件,而不是使用Effect监听操作的变化来展示通知消息。
发送 POST 请求
发送Post请求也与上文一样,需要在组件执行时执行的请求,应该用于useEffect中。但是处理属于事件特定的逻辑,比如表单的提交,就不应该使用Effect了。
链式计算
有时候你可能想链接多个 Effect,每个 Effect 都基于某些 state 来调整其他的 state:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 避免:链接多个 Effect 仅仅为了相互触发调整 state
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('游戏结束!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('游戏已经结束了。');
} else {
setCard(nextCard);
}
}
// ...
这段代码里有两个问题。
第一个问题是它非常低效:在链式的每个 set 调用之间,组件(及其子组件)都不得不重新渲染。在上面的例子中,在最坏的情况下(setCard → 渲染 → setGoldCardCount → 渲染 → setRound → 渲染 → setIsGameOver → 渲染)有三次不必要的重新渲染。
第二个问题是,即使不考虑渲染效率问题,随着代码不断扩展,你会遇到这条 “链式” 调用不符合新需求的情况。试想一下,你现在需要添加一种方法来回溯游戏的历史记录,可以通过更新每个 state 变量到之前的值来实现。然而,将 card 设置为之前的的某个值会再次触发 Effect 链并更改你正在显示的数据。这样的代码往往是僵硬而脆弱的。
正确的逻辑是,将这些逻辑封装非一个方法,在渲染期间进行计算
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ 尽可能在渲染期间进行计算
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('游戏已经结束了。');
}
// ✅ 在事件处理函数中计算剩下的所有 state
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('游戏结束!');
}
}
}
}
// ...
这样用户点击 → 状态只改一次(setCard 和其他状态同步调整),只渲染一次,所有逻辑集中在一个函数里,行为是确定的。
但是在某些情况下,你 无法 在事件处理函数中直接计算出下一个 state。例如,试想一个具有多个下拉菜单的表单,如果下一个下拉菜单的选项取决于前一个下拉菜单选择的值。这时,Effect 链是合适的,因为你需要与网络进行同步。
初始化应用
有些逻辑只需要在应用加载时执行一次。你可能想把它放在一个顶层组件的 Effect 中:
function App() {
// 🔴 避免:把只需要执行一次的逻辑放在 Effect 中
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
然后,你很快就会发现它在 开发环境会执行两次。这会导致一些问题——例如,它可能使身份验证 token 无效,因为该函数不是为被调用两次而设计的。一般来说,当组件重新挂载时应该具有一致性。包括你的顶层 App 组件。
尽管在实际的生产环境中它可能永远不会被重新挂载,但在所有组件中遵循相同的约束条件可以更容易地移动和复用代码。如果某些逻辑必须在 每次应用加载时执行一次,而不是在 每次组件挂载时执行一次,可以添加一个顶层变量来记录它是否已经执行过了:
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 只在每次应用加载时执行一次
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
你也可以在模块初始化和应用渲染之前执行它:
if (typeof window !== 'undefined') { // 检测我们是否在浏览器环境
// ✅ 只在每次应用加载时执行一次
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
顶层代码会在组件被导入时执行一次——即使它最终并没有被渲染。为了避免在导入任意组件时降低性能或产生意外行为,请不要过度使用这种方法。将应用级别的初始化逻辑保留在像 App.js 这样的根组件模块或你的应用入口中。
通知父组件有关 state 变化的信息
如果你需要在子组件的state变化时执行父组件的方法,你可能会想到这样:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 避免:onChange 处理函数执行的时间太晚了
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
}
但是这样实现,在isOn改变的时候,组件Toggle 首先更新它的 state,然后 React 会更新屏幕。然后 React 执行 Effect 中的代码,调用从父组件传入的 onChange 函数。现在父组件开始更新它自己的 state,开启另一个渲染流程。
最好的方法是,我们在setIsOn的时候手动调用onChange,而不是放到Effect中。在这个例子,我们还可以将isOn放在父组件中进行管理(这种方法叫状态提升)。
将数据传递给父组件
如子组件调用接口获取了一些数据,并且要将其传给父组件。你可能会想到当data变化的时候使用父组件的方法将数据同步给父组件:
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 避免:在 Effect 中传递数据给父组件
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
在 React 中,数据从父组件流向子组件。当你在屏幕上看到了一些错误时,你可以通过一路追踪组件树来寻找错误信息是从哪个组件传递下来的,从而找到传递了错误的 prop 或具有错误的 state 的组件。当子组件在 Effect 中更新其父组件的 state 时,数据流变得非常难以追踪。既然子组件和父组件都需要相同的数据,那么可以让父组件获取那些数据,并将其 向下传递 给子组件,这样可以保持数据流的可预测性(数据从父组件流向子组件)
订阅外部 store
有时候,你的组件可能需要订阅 React state 之外的一些数据。这些数据可能来自第三方库或内置浏览器 API。由于这些数据可能在 React 无法感知的情况下发变化,你需要在你的组件中手动订阅它们。这经常使用 Effect 来实现,例如:
function useOnlineStatus() {
// 不理想:在 Effect 中手动订阅 store
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
上面的代码的用处是:当浏览器的网络状态改变的时候,我们在useEffect中用addEventListener来监听网络状态并使用isOnline来记录设备是否离线。
尽管通常可以使用 Effect 来实现此功能,但 React 为此针对性地提供了一个 Hook 用于订阅外部 store:useSyncExternalStore
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function ChatIndicator() {
const isOnline = useSyncExternalStore(
subscribe, // 只要传递的是同一个函数,React 不会重新订阅
() => navigator.onLine, // 如何在客户端获取值
() => true // 如何在服务端获取值
);
// ...
}
**useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)**用来订阅外部状态源(如浏览器 API、WebSocket、Redux 等),并确保状态变动时 UI 自动更新。
subscribe:订阅函数,接收一个回调,当状态变动时触发这个回调( 告诉 React:我该在什么时候重新检查值(订阅机制))
getSnapshot:获取当前状态的函数(浏览器环境)(告诉 React:当前值是多)
getServerSnapshot:服务端渲染时如何获取初始状态;(SSR 用的,React 服务器端如何拿到初始值)
useSyncExternalStore的返回是获取当前状态的函数getSnapshot的返回,在上面isOnline的值其实就是navigator.onLine。每当 navigator.onLine 状态变化时(即网络连接/断开),React 会重新渲染依赖它的组件。
上面的例子中,isOnline的值依赖于subscribe和getSnapshot。subscribe中的callback被执行时,React就会知道isOnline可能发生了变化,于是会调用getSnapshot来进行检查。如果getSnapshot返回的结果与之前不同,那么就会更新isOnline并触发组件重新渲染
获取数据
在 Effect 中写一个数据获取请求是相当常见的:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 避免:没有清除逻辑的获取数据
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
page 和 query 的来源其实并不重要。只要该组件可见,你就能通过当前 page 和 query 的值,保持 results 和网络数据的 同步。
然而,上面的代码有一个问题。假设你快速地输入 “hello”。那么 query 会从 “h” 变成 “he”,“hel”,“hell” 最后是 “hello”。这会触发一连串不同的数据获取请求,但无法保证对应的返回顺序。例如,“hell” 的响应可能在 “hello” 的响应 之后 返回。由于它的 setResults() 是在最后被调用的,你将会显示错误的搜索结果。这种情况被称为 “竞态条件”:两个不同的请求 “相互竞争”,并以与你预期不符的顺序返回。
为了修复这个问题,你需要添加一个 清理函数 来忽略较早的返回结果:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
这确保了当你在 Effect 中获取数据时,除了最后一次请求的所有返回结果都将被忽略。(并不是取消了之前的请求,而是将前面请求的结果忽略,只要最后 一次的结果。)
上面的逻辑是:在 useEffect 中,每次依赖项(如 query 或 page)变化时,都会重新执行 effect。React 会在执行新的 effect 前,先调用上一次的清理函数,这样可以将上一次 effect 中的 ignore 设置为 true。如果旧的请求(如 fetchResults)晚于新的 effect 执行才返回,它所处的闭包中 ignore 是 true,从而避免了在组件状态已经变化时调用 setResults,避免了覆盖新数据或产生警告。
也可以将数据获取封装成一个自定义hook:
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}
响应式 Effect 的生命周期
组件生命周期 != Effect生命周期。
Effect 与组件有不同的生命周期。组件可以挂载、更新或卸载。Effect 只能做两件事:开始同步某些东西,然后停止同步它。如果 Effect 依赖于随时间变化的 props 和 state,这个循环可能会发生多次。React 提供了代码检查规则来检查是否正确地指定了 Effect 的依赖项,这能够使 Effect 与最新的 props 和 state 保持同步。
组件的生命周期:
当组件被添加到屏幕上时,它会进行组件的 挂载。
当组件接收到新的 props 或 state 时,通常是作为对交互的响应,它会进行组件的 更新。
当组件从屏幕上移除时,它会进行组件的 卸载。
而Effect的执行时机通常是:
组件挂载时执行
依赖项改变时重新执行
useEffect的执行规律:
useEffect在执行的时候会创建一个独立的上下文,清理函数不会立即执行。在组件卸载的时候,执行清理函数。如果组件没有卸载,而是又执行了一次(依赖项变化等情况),此时先执行上一个useEffect中的清理函数,又创建一个新的上下文,等到下一次组件卸载或useEffect更新时又执行清理函数。
关于Effect的一些使用要点:
1. 代码中的每个 Effect 应该代表一个独立的同步过程。删除一个 Effect 不会影响另一个 Effect 的逻辑,表明它们同步不同的内容,因此将它们拆分开是有意义的。也就是说每一个Effect尽量保持简洁,只做一件事。而不是将很多逻辑放在同一个useEffect中。
2. 如果useEffect的第二个参数未传,那么组件每次渲染都会执行。如果为[]或有依赖项,则只会在组件挂载(首次出现)时执行,重新渲染不执行。
3. Effect 会“响应”于响应式值。一些不会变化的值没有必要作为依赖性,因为其保持不变,将其指定为依赖项就没有意义。let定于的局部变量未由React追踪,并未参与 React 的数据流,故而即使作为依赖项发生改变时,不会引起Effect的执行。
4. 组件内部的所有值(包括 props、state 和组件体内的变量)都是响应式的。任何响应式值都可以在重新渲染时发生变化,所以需要将响应式值包括在 Effect 的依赖项中。可以简单理解为,useEffect中用到的变量(稳定不变的函数、常量等除外)都需要写在依赖项中。假如useEffect中用到的值可能是由state计算得出的。那么要将它写入依赖项。否则state变化了useEffect也不会重新执行。这里有一个误区:state变化会让组件重新渲染,但是useEffect并不一定会重新执行(因为在有依赖项或依赖项为[]的情况下,useEffect只会在组件挂在的时候执行,重新渲染并不执行。如果useEffect的第二个参数未传,确实能让useEffect在每次组件渲染的时候都会执行,但这容易在项目中变成隐患,并不推荐这样做。)
5. 尽量不要将 React 中的 useEffect 简单等同于 Vue 的 onMounted 和 onUnmounted。虽然它们在某些场景下表现出类似的行为(如副作用逻辑的注册和清理),但底层逻辑和触发时机是不同的。Vue 的 onMounted 和 onUnmounted 是明确绑定在组件生命周期上的钩子 —— 它们分别只在组件挂载后、卸载前执行一次,时机稳定、易于预期。而 React 的 useEffect 不完全等价于生命周期钩子。它并不是固定在“挂载”和“卸载”节点上执行,而是根据其依赖项数组的变化情况来决定是否执行。useEffect 可以实现类似 onMounted / onUnmounted 的效果,但它并不是生命周期钩子,而是副作用处理机制,行为由依赖项决定,更灵活也更容易出错。
关于Effect 生命周期的补充
之前,你是从组件的角度思考的。当你从组件的角度思考时,很容易将 Effect 视为在特定时间点触发的“回调函数”或“生命周期事件”,例如“渲染后”或“卸载前”。这种思维方式很快变得复杂,所以最好避免使用。
相反,始终专注于单个启动/停止周期。无论组件是挂载、更新还是卸载,都不应该有影响。只需要描述如何开始同步和如何停止。如果做得好,Effect 将能够在需要时始终具备启动和停止的弹性。
将事件从 Effect 中分开
本节介绍了useEffectEvent。useEffectEvent 是 React 的实验性 API,目前还未在稳定版本中正式发布。它旨在解决 useEffect 中访问函数和变量时的“闭包陷阱”问题。
假如:
function MyComponent({ a, b }) {
useEffect(() => {
const timer = setInterval(() => {
console.log('b is', b); // ❌ b 被闭包捕获,永远是初次的 b
}, 1000);
return () => clearInterval(timer);
}, [a]); // 只依赖 a,不会因为 b 变化而重设 timer
}
我们的目的是:在 a 变化时启动一个定时器,并在定时器中始终打印最新的 b。
然而,useEffect 在执行时会捕获当前作用域的变量,因此定时器内部访问的 b 会被“锁定”为首次渲染时的值。
如果将 b 加入依赖项,确实可以获取到最新的 b,但这会导致定时器在每次 b 变化时重新创建,带来不必要的副作用。
useEffectEvent 正是为了解决这类问题而设计的。它返回一个稳定的函数引用,但该函数内部始终可以访问组件最新的状态和 props(如 b):
import { experimental_useEffectEvent as useEffectEvent } from 'react';
function MyComponent({ a, b }) {
const logB = useEffectEvent(() => {
console.log('b is', b); // ✅ 总是最新的 b
});
useEffect(() => {
const timer = setInterval(() => {
logB(); // ✅ logB 不变,但内部 b 是最新的
}, 1000);
return () => clearInterval(timer);
}, [a]); // 只依赖 a
}
这样我们就可以既保持Effect只在 a 变化时执行,又能在Effect内部获取到最新的 b ——
相当于让 b“逃离了闭包”,避免了闭包中值过时的问题。
Effect Event 是 Effect 代码的非响应式“片段”。他们应该在使用他们的 Effect 的旁边。并且只在 Effect 内部调用 Effect Event,不要将 Effect Event 传给其他组件或者 Hook。
补充
useEffectEvent还有一个作用,当 useEffect 中需要调用一个从 props 传入的函数时,由于这个函数可能在父组件更新时发生变化,所以按理应当把它加入依赖项。但是,函数是引用类型,通常会在每次渲染时变化(即使逻辑不变),这可能会导致 useEffect 不必要地频繁执行(linter 通常会提示你添加它,但这样可能会产生副作用)。
React 提供的 useEffectEvent 可以返回一个稳定的函数引用,它内部始终引用最新的 props 函数逻辑。通过用 useEffectEvent 包裹传入的函数,我们就可以在 useEffect 中安全调用它,而不需要将原始 props 函数作为依赖项,也不会触发不必要的副作用。
移除 Effect 依赖
当编写 Effect 时,linter 会验证是否已经将 Effect 读取的每一个响应式值(如 props 和 state)包含在 Effect 的依赖中。这可以确保 Effect 与组件的 props 和 state 保持同步。不必要的依赖可能会导致 Effect 运行过于频繁,甚至产生无限循环。请按照本指南审查并移除 Effect 中不必要的依赖。
依赖应该和代码保持一致
使用linter对代码进行校验,类似ESLint,会校验useEffect依赖项填写不正确的问题。
注意,你不能“选择” Effect 的依赖。每个被 Effect 所使用的响应式值,必须在依赖中声明。依赖是由 Effect 的代码决定的。
移除一个依赖,你需要向 linter ,证明其不需要这个依赖。比如将变量改为非响应式值或放到组件外边。
如果你想改变依赖,首先要改变所涉及到的代码。你可以把依赖看作是 Effect 的代码所依赖的所有响应式值的列表。你不要 选择 把什么放在这个列表上。该列表 描述了 代码。要改变依赖,请改变代码。
移除非必需的依赖
确保代码是否需要Effect?
能够不用Effect的情况就尽量避免使用。
如果你想读取最新值而不“反应”它,请从 Effect 中提取出一个 Effect Event
确保Effect是否需要拆分?
如果两个依赖项的用途不同,就不要写在同一个Effect中。将其拆分为两个Effect,确保每个Effect只做一件事。
是否在读取一些状态来计算下一个状态?
例如:
setMessages([...messages, receivedMessage]);
这里使用了messages,就需要将其加入useEffect的依赖列表,但是我们并不想根据messages变化来重新执行useEffect。
我们只需要改成这样,使用一个state 更新函数 :
setMessages(msgs => [...msgs, receivedMessage]);
msgs 是 setMessages 内部自动传入的参数,也就是当前最新的 messages state。
补充
避免将对象和函数作为 Effect 的依赖,因为React组件重新渲染时,会新创建对象,每个新创建的对象和函数都被认为与其他所有对象和函数不同(在 JavaScript 中,如果对象和函数是在不同时间创建的,则它们被认为是不同的),所以会导致Effect重新执行。尝试将它们移到组件外部、Effect 内部,或从中提取原始值。
如果一个对象无法放在组件外部,那么就将它移动到Effect内部:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 所有依赖已声明
// ...
上面的例子也是避免了将options对象作为依赖项的问题。现在 options 已在 Effect 中声明,它不再是 Effect 的依赖。相反,Effect 使用的唯一响应式值是 roomId。由于 roomId 不是对象或函数,你可以确定它不会 无意间 变不同。
如果组件传入的prop就是对象呢?那就需要在Effect外部提取Effect需要的属性,再将属性作为Effect的依赖项。
function ChatRoom({ options }) {
const [message, setMessage] = useState('');
const { roomId, serverUrl } = options;
useEffect(() => {
const connection = createConnection({
roomId: roomId,
serverUrl: serverUrl
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ 所有依赖已声明
// ...
总之,请觉得避免使用函数或对象作为Effect的依赖项。
使用自定义 Hook 复用逻辑
自定义 Hook 是专门为复用 React 组件逻辑而设计的函数,必须遵守 Hook 规则;而普通函数则没有这些限制,仅仅是通用逻辑封装。
特殊的是:自定义Hook中可以使用其他的Hook,比如React内置的useState、useEffect等。这就让我们的自定义Hook能比普通函数做更多的事情。
注意区分是Hook还是普通函数,自定义Hook命名必须以use开头。如果你的Hook中没有用到任何的其他Hook,请将其修改为普通函数,也不要以use开头命名。
这是一个简单的自定义Hook的示例:
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
用到了useState保存一个状态isOnline,使用useEffect来监听网络在线状态的变化并保存到isOnline。最后return了isOnline,让组件读取到该值。
使用方法:
import { useOnlineStatus } from './useOnlineStatus.js';
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
大家可能会意识到这个Hook和React的组件很像,但它们的作用有很大不同,组件复用的是UI和交互,而自定义Hook复用的是代码逻辑,它不能渲染任何内容,只是处理逻辑和状态,使用方法也和组件不同。
注意
- 自定义 Hook 共享的只是状态逻辑而不是状态本身。对 Hook 的每个调用完全独立于对同一个 Hook 的其他调用。
- 当你需要在多个组件之间共享 state 本身时,需要 将变量提升并传递下去。
- 每当组件重新渲染,自定义 Hook 中的代码就会重新运行。我们应该把自定义 Hook 的代码看作组件主体的一部分。由于自定义 Hook 会随着组件一起重新渲染,所以组件可以一直接收到最新的 props 和 state。
- 把自定义 Hook 的代码看作组件主体的一部分,Hook中的state、Effect等也会让使用这个Hook的组件重新渲染。
- 自定义Hook也能同组件和普通函数一样,接收外部的参数来实现不同的逻辑。
- 我们知道Effect可以拆分成多个,但是拆分之后可能会不好管理,这时候我们可以将它们 封装到同一个Hook中。
总结
至此,对React官方文档的系统性学习告一段落。
感谢ChatGPT的指导让我更快的了解React并与原有的技术融会贯通。我回想起来学习Vue的时候,并没有AI的a帮助,深受各种难以理解的术语的折磨。
AI是个好东西,我将保持这个势头继续学习下去。
更多React相关内容请查看:https://zh-hans.react.dev/reference/react
评论