ref的研究(useRef)
使用 ref 引用值
当你希望组件“记住”某些信息,但又不想让这些信息 触发新的渲染 时,你可以使用 ref 。
给你的组件添加 ref
你可以通过从 React 导入 useRef Hook 来为你的组件添加一个 ref:
import { useRef } from 'react';
在你的组件内,调用 useRef Hook 并传入你想要引用的初始值作为唯一参数。例如,这里的 ref 引用的值是“0”:
const ref = useRef(0);
useRef 返回一个这样的对象:
{
current: 0 // 你向 useRef 传入的值
}
你可以用 ref.current 属性访问该 ref 的当前值。这个值是有意被设置为可变的,意味着你既可以读取它也可以写入它。就像一个 React 追踪不到的、用来存储组件信息的秘密“口袋”。(这就是让它成为 React 单向数据流的“应急方案”的原因 —— 详见下文!)
这里,每次点击按钮时会使 ref.current 递增:
import { useRef } from 'react';
export default function Counter() {
const ref = useRef(0);
function handleClick() {
ref.current = ref.current + 1;
console.log('你点击了 ' + ref.current + ' 次!');
}
return <button onClick={handleClick}>点击我!</button>;
}
这里的 ref 指向一个数字,但是,像 state 一样,你可以让它指向任何东西:字符串、对象,甚至是函数。与 state 不同的是,ref 是一个普通的 JavaScript 对象,具有可以被读取和修改的 current 属性。
请注意,组件不会在每次递增时重新渲染。 与 state 一样,React 会在每次重新渲染之间保留 ref。但是,设置 state 会重新渲染组件,更改 ref 不会!
示例:制作秒表
你可以在单个组件中把 ref 和 state 结合起来使用。例如,让我们制作一个秒表,用户可以通过按按钮来使其启动或停止。为了显示从用户按下“开始”以来经过的时间长度,你需要追踪按下“开始”按钮的时间和当前时间。此信息用于渲染,所以你会把它保存在 state 中:
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
当用户按下“开始”时,你将用 setInterval 每 10 毫秒更新一次时间:
import { useState } from 'react';
export default function Stopwatch() {
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
function handleStart() {
// 开始计时。
setStartTime(Date.now());
setNow(Date.now());
setInterval(() => {
// 每 10ms 更新一次当前时间。
setNow(Date.now());
}, 10);
}
let secondsPassed = 0;
if (startTime != null && now != null) {
secondsPassed = (now - startTime) / 1000;
}
return (
<>
<h1>时间过去了: {secondsPassed.toFixed(3)}</h1>
<button onClick={handleStart}>开始</button>
</>
);
}
当按下“停止”按钮时,你需要取消现有的 interval,以便让它停止更新 now state 变量。你可以通过调用 clearInterval 来完成此操作。但你需要为其提供 interval ID,此 ID 是之前用户按下 Start、调用 setInterval 时返回的。你需要将 interval ID 保留在某处。 由于 interval ID 不用于渲染,你可以将其保存在 ref 中:
import { useRef, useState } from 'react';
export default function Stopwatch() {
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
const intervalRef = useRef(null);
function handleStart() {
setStartTime(Date.now());
setNow(Date.now());
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setNow(Date.now());
}, 10);
}
function handleStop() {
clearInterval(intervalRef.current);
}
let secondsPassed = 0;
if (startTime != null && now != null) {
secondsPassed = (now - startTime) / 1000;
}
return (
<>
<h1>时间过去了: {secondsPassed.toFixed(3)}</h1>
<button onClick={handleStart}>开始</button>
<button onClick={handleStop}>停止</button>
</>
);
}
当一条信息用于渲染时,将它保存在 state 中。当一条信息仅被事件处理器需要,并且更改它不需要重新渲染时,使用 ref 可能会更高效。
ref 和 state 的不同之处
也许你觉得 ref 似乎没有 state 那样“严格” —— 例如,你可以改变它们而非总是必须使用 state 设置函数。但在大多数情况下,我们建议你使用 state。ref 是一个“应急方案”,你并不会经常用到它。 以下是 state 和 ref 的对比:
| ref | state |
|---|---|
useRef(initialValue)返回 { current: initialValue } | useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数 ( [value, setValue]) |
| 更改时不会触发重新渲染 | 更改时触发重新渲染。 |
可变 —— 你可以在渲染过程之外修改和更新 current 的值。 | “不可变” —— 你必须使用 state 设置函数来修改 state 变量,从而排队重新渲染。 |
你不应在渲染期间读取(或写入) current 值。 | 你可以随时读取 state。但是,每次渲染都有自己不变的 state 快照。 |
这是一个使用 state 实现的计数器按钮:
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return <button onClick={handleClick}>你点击了 {count} 次</button>;
}
因为 count 的值将会被显示,所以为其使用 state 是合理的。当使用 setCount() 设置计数器的值时,React 会重新渲染组件,并且屏幕会更新以展示新的计数。
如果你试图用 ref 来实现它,React 永远不会重新渲染组件,所以你永远不会看到计数变化!看看点击这个按钮如何 不更新它的文本:
import { useRef } from 'react';
export default function Counter() {
const countRef = useRef(0);
function handleClick() {
// 这样并未重新渲染组件!
countRef.current = countRef.current + 1;
console.log('countRef.current: ', countRef.current);
}
return <button onClick={handleClick}>你点击了 {countRef.current} 次</button>;
}
这就是为什么在渲染期间读取 ref.current 会导致代码不可靠的原因。如果需要,请改用 state。
何时使用 ref
通常,当你的组件需要“跳出” React 并与外部 API 通信时,你会用到 ref —— 通常是不会影响组件外观的浏览器 API。以下是这些罕见情况中的几个:
- 存储计时器ID timeout ID
- 存储和操作 DOM 元素
- 存储不需要被用来计算 JSX 的其他对象。
如果你的组件需要存储一些值,但不影响渲染逻辑,请选择 ref。
ref 的最佳实践
遵循这些原则将使你的组件更具可预测性:
- 将 ref 视为应急方案。 当你使用外部系统或浏览器 API 时,ref 很有用。如果你很大一部分应用程序逻辑和数据流都依赖于 ref,你可能需要重新考虑你的方法。
- 不要在渲染过程中读取或写入
ref.current。 如果渲染过程中需要某些信息,请使用 state 代替。由于 React 不知道ref.current何时发生变化,即使在渲染时读取它也会使组件的行为难以预测。(唯一的例外是像if (!ref.current) ref.current = new Thing()这样的代码,它只在第一次渲染期间设置一次 ref。)
React state 的限制不适用于 ref。例如,state 就像 每次渲染的快照,并且 不会同步更新。但是当你改变 ref 的 current 值时,它会立即改变:
ref.current = 5;
console.log(ref.current); // 5
这是因为 ref 本身是一个普通的 JavaScript 对象, 所以它的行为就像对象那样。
当你使用 ref 时,也无需担心 避免变更。只要你改变的对象不用于渲染,React 就不会关心你对 ref 或其内容做了什么。
ref 和 DOM
你可以将 ref 指向任何值。但是,ref 最常见的用法是访问 DOM 元素。例如,如果你想以编程方式聚焦一个输入框,这种用法就会派上用场。当你将 ref 传递给 JSX 中的 ref 属性时,比如 <div ref={myRef}>,React 会将相应的 DOM 元素放入 myRef.current 中。你可以在 使用 ref 操作 DOM 中阅读更多相关信息。
使用 ref 操作 DOM
由于 React 会自动处理更新 DOM 以匹配你的渲染输出,因此你在组件中通常不需要操作 DOM。但是,有时你可能需要访问由 React 管理的 DOM 元素 —— 例如,让一个节点获得焦点、滚动到它或测量它的尺寸和位置。在 React 中没有内置的方法来做这些事情,所以你需要一个指向 DOM 节点的 ref 来实现。
获取指向节点的 ref
要访问由 React 管理的 DOM 节点,首先,引入 useRef Hook:
import { useRef } from 'react';
然后,在你的组件中使用它声明一个 ref:
const myRef = useRef(null);
最后,将其作为 ref 属性传给 DOM 节点:
<div ref={myRef}>
useRef Hook 返回一个对象,该对象有一个名为 current 的属性。最初,myRef.current 是 null。当 React 为这个 <div> 创建一个 DOM 节点时,React 会把对该节点的引用放入 myRef.current。然后,你可以从 事件处理器 访问此 DOM 节点,并使用在其上定义的内置浏览器 API。
// 你可以使用任意浏览器 API,例如:
myRef.current.scrollIntoView();
示例: 使文本输入框获得焦点
在本例中,单击按钮将使输入框获得焦点:
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
console.log('inputRef: ', inputRef);
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}
要实现这一点:
- 使用
useRefHook 声明inputRef。 - 像
<input ref={inputRef}>这样传递它。这告诉 React 将这个<input>的 DOM 节点放入inputRef.current。 - 在
handleClick函数中,从inputRef.current读取 input DOM 节点并使用inputRef.current.focus()调用它的focus()。 - 用
onClick将handleClick事件处理器传递给<button>。
虽然 DOM 操作是 ref 最常见的用例,但 useRef Hook 可用于存储 React 之外的其他内容,例如计时器 ID 。与 state 类似,ref 能在渲染之间保留。你甚至可以将 ref 视为设置它们时不会触发重新渲染的 state 变量!你可以在使用 Ref 引用值中了解有关 ref 的更多信息。
示例: 滚动至一个元素
一个组件中可以有多个 ref。在这个例子中,有一个由三张图片和三个按钮组成的轮播,点击按钮会调用浏览器的 scrollIntoView() 方法,在相应的 DOM 节点上将它们居中显示在视口中:
import { useRef } from 'react';
export default function CatFriends() {
const firstCatRef = useRef(null);
const secondCatRef = useRef(null);
const thirdCatRef = useRef(null);
function handleScrollToFirstCat() {
firstCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
function handleScrollToSecondCat() {
secondCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
function handleScrollToThirdCat() {
thirdCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
return (
<>
<nav>
<button onClick={handleScrollToFirstCat}>Tom</button>
<button onClick={handleScrollToSecondCat}>Maru</button>
<button onClick={handleScrollToThirdCat}>Jellylorum</button>
</nav>
<div>
<ul>
<li>
<img src='https://placekitten.com/g/200/200' alt='Tom' ref={firstCatRef} />
</li>
<li>
<img src='https://placekitten.com/g/300/200' alt='Maru' ref={secondCatRef} />
</li>
<li>
<img src='https://placekitten.com/g/250/200' alt='Jellylorum' ref={thirdCatRef} />
</li>
</ul>
</div>
</>
);
}
访问另一个组件的 DOM 节点
当你将 ref 放在像 <input /> 这样输出浏览器元素的内置组件上时,React 会将该 ref 的 current 属性设置为相应的 DOM 节点(例如浏览器中实际的 <input /> )。
但是,如果你尝试将 ref 放在 你自己的 组件上,例如 <MyInput />,默认情况下你会得到 null。这个示例演示了这种情况。请注意单击按钮 并不会 聚焦输入框:
import { useRef } from 'react';
function MyInput(props) {
return <input {...props} />;
}
export default function MyForm() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}
为了帮助您注意到这个问题,React 还会向控制台打印一条错误消息:
发生这种情况是因为默认情况下,React 不允许组件访问其他组件的 DOM 节点。甚至自己的子组件也不行!这是故意的。Refs 是一个应急方案,应该谨慎使用。手动操作 另一个 组件的 DOM 节点会使你的代码更加脆弱。
相反,想要 暴露其 DOM 节点的组件必须选择该行为。一个组件可以指定将它的 ref “转发”给一个子组件。下面是 MyInput 如何使用 forwardRef API:
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
它是这样工作的:
<MyInput ref={inputRef} />告诉 React 将对应的 DOM 节点放入inputRef.current中。但是,这取决于MyInput组件是否允许这种行为, 默认情况下是不允许的。MyInput组件是使用forwardRef声明的。 这让从上面接收的inputRef作为第二个参数ref传入组件,第一个参数是props。MyInput组件将自己接收到的ref传递给它内部的<input>。
现在,单击按钮聚焦输入框起作用了:
import { forwardRef, useRef } from 'react';
const MyInput = forwardRef(function (props, ref) {
return <input {...props} ref={ref} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
console.log('inputRef: ', inputRef);
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}
在设计系统中,将低级组件(如按钮、输入框等)的 ref 转发到它们的 DOM 节点是一种常见模式。另一方面,像表单、列表或页面段落这样的高级组件通常不会暴露它们的 DOM 节点,以避免对 DOM 结构的意外依赖。
React 何时添加 refs
在 React 中,每次更新都分为 两个阶段:
- 在 渲染 阶段, React 调用你的组件来确定屏幕上应该显示什么。
- 在 提交 阶段, React 把变更应用于 DOM。
通常,你 不希望 在渲染期间访问 refs。这也适用于保存 DOM 节点的 refs。在第一次渲染期间,DOM 节点尚未创建,因此 ref.current 将为 null。在渲染更新的过程中,DOM 节点还没有更新。所以读取它们还为时过早。
React 在提交阶段设置 ref.current。在更新 DOM 之前,React 将受影响的 ref.current 值设置为 null。更新 DOM 后,React 立即将它们设置到相应的 DOM 节点。
通常,你将从事件处理器访问 refs。 如果你想使用 ref 执行某些操作,但没有特定的事件可以执行此操作,你可能需要一个 effect。我们将在下一页讨论 effect。
使用 refs 操作 DOM 的最佳实践
Refs 是一个应急方案。你应该只在你必须“跳出 React”时使用它们。这方面的常见示例包括管理焦点、滚动位置或调用 React 未暴露的浏览器 API。
如果你坚持聚焦和滚动等非破坏性操作,应该不会遇到任何问题。但是,如果你尝试手动修改 DOM,则可能会与 React 所做的更改发生冲突。
为了说明这个问题,这个例子包括一条欢迎消息和两个按钮。第一个按钮使用 条件渲染 和 state 切换它的显示和隐藏,就像你通常在 React 中所做的那样。第二个按钮使用 remove() DOM API 将其从 React 控制之外的 DOM 中强行移除.
尝试按几次“通过 setState 切换”。该消息会消失并再次出现。然后按 “从 DOM 中删除”。这将强行删除它。最后,按 “通过 setState 切换”:
import { useRef, useState } from 'react';
export default function Counter() {
const [show, setShow] = useState(true);
const ref = useRef(null);
return (
<div>
<button onClick={() => setShow(!show)}>通过 setState 切换</button>
<button onClick={() => ref.current.remove()}>从 DOM 中删除</button>
{show && <p ref={ref}>Hello world</p>}
</div>
);
}
在你手动删除 DOM 元素后,尝试使用 setState 再次显示它会导致崩溃。这是因为你更改了 DOM,而 React 不知道如何继续正确管理它。
避免更改由 React 管理的 DOM 节点。 对 React 管理的元素进行修改、添加子元素、从中删除子元素会导致不一致的视觉结果,或与上述类似的崩溃。
但是,这并不意味着你完全不能这样做。它需要谨慎。 你可以安全地修改 React *没有理由* 更新的部分 DOM。 例如,如果某些 <div> 在 JSX 中始终为空,React 将没有理由去变动其子列表。 因此,在那里手动增删元素是安全的。
Effect的研究(useEffect)
与Effect同步
某些组件需要与外部系统同步。例如,您可能希望根据 React 状态控制非 React 组件,设置服务器连接,或者在组件出现在屏幕上时发送分析日志。effect允许你在渲染后运行一些代码,以便你可以将你的组件与 React 之外的一些系统同步。
什么是Effect,它们与事件有何不同?
在进入Effects之前,你需要熟悉 React 组件中的两种逻辑:
- 呈现代码(在描述 UI 中介绍)位于组件的顶层。这是你获取道具和状态的地方,转换它们,然后返回你想在屏幕上看到的JSX。呈现代码必须是纯的。就像数学公式一样,它应该只计算结果,而不做任何其他事情。
- 事件处理程序(在添加交互性中介绍)是组件中的嵌套函数,用于执行操作,而不仅仅是计算它们。事件处理程序可能会更新输入字段、提交 HTTP POST 请求以购买产品或将用户导航到另一个屏幕。事件处理程序包含由特定用户操作(例如,按钮单击或键入)引起的“副作用”(它们更改程序的状态)。
有时这还不够。考虑一个组件ChatRoom,只要它在屏幕上可见,就必须连接到聊天服务器。连接到服务器不是纯粹的计算(这是一种副作用),因此在渲染期间不会发生。但是,没有像单击这样的单个特定事件会导致ChatRoom显示。
**Effect 允许您指定由呈现本身而不是特定事件引起的副作用。**在聊天中发送消息是一个事件,因为它是由用户单击特定按钮直接引起的。但是,设置服务器连接是一种 Effect ,因为无论哪种交互导致组件出现,它都应该发生。效果在屏幕更新后的提交结束时运行。这是将 React 组件与某些外部系统(如网络或第三方库)同步的好时机。
注意
在这里和后面的这篇文章中,大写的“Effect”指的是上面特定于 React 的定义,即由渲染引起的副作用。为了参考更广泛的编程概念,我们将说“副作用”。
您可能不需要Effect
**不要急于将Effect添加到组件中。**请记住,Effect 通常用于“跳出”您的 React 代码并与某些外部系统同步。这包括浏览器 API、第三方小部件、网络等。如果Effect仅根据其他状态调整某些状态,则可能不需要Effect。
如何编写Effect
若要编写Effect,请按照以下三个步骤操作:
- **声明Effect。**默认情况下,Effect将在每次渲染后运行。
- **指定Effect依赖项。**大多数Effect只应在需要时重新运行,而不是在每次渲染后重新运行。例如,淡入动画应仅在组件出现时触发。仅当组件出现和消失或聊天室更改时,才应连接和断开与聊天室的连接。您将学习如何通过指定依赖项来控制这一点。
- **根据需要添加清理。**某些Effect需要指定如何停止、撤消或清理它们正在执行的操作。例如,“连接”需要“断开连接”,“订阅”需要“取消订阅”,“获取”需要“取消”或“忽略”。您将学习如何通过返回清理函数来执行此操作。
让我们详细看一下这些步骤中的每一个。
步骤 1:声明Effect
要在组件中声明一个 Effect,请从 React 导入 useEffect Hook:
import { useEffect } from 'react';
然后,在组件的顶层调用它,并在 Effect 中放置一些代码:
function MyComponent() {
useEffect(() => {
// Code here will run after *every* render
});
return <div />;
}
每次组件渲染时,React 都会更新屏幕*,然后在*useEffect里面运行代码。换句话说,useEffect 会“延迟”一段代码的运行,直到该渲染反映在屏幕上。
让我们看看如何使用 Effect 与外部系统同步。考虑一个 React 组件<VideoPlayer>。通过向它传递isPlaying来控制它是在播放还是暂停:
<VideoPlayer isPlaying={isPlaying} />;
您的自定义组件isPlaying会基于浏览器内置的 <video> 标签来渲染:
function VideoPlayer({ src, isPlaying }) {
// TODO: do something with isPlaying
return <video src={src} />;
}
但是,浏览器<video>标签没有isPlayingprop。控制它的唯一方法是在 DOM 元素上手动调用 play()和 pause() 方法。您需要将 isPlaying prop 的值与 play() 和 pause() 的调用同步,该值告诉视频当前是否应该正在播放。
我们需要首先获取 DOM 节点<video>的ref。
您可能会在渲染期间尝试调用play()或者pause(),但这是不正确的:
import { useRef, useState } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // Calling these while rendering isn't allowed.
} else {
ref.current.pause(); // Also, this crashes.
}
return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
return (
<>
<button onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? '暂停' : '播放'}</button>
<VideoPlayer isPlaying={isPlaying} src='https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4' />
</>
);
}
运行的报错
App.js: Cannot read properties of null (reading 'pause') (9:16) 6 | if (isPlaying) { 7 | ref.current.play(); // Calling these while rendering isn't allowed. 8 | } else { > 9 | ref.current.pause(); // Also, this crashes. ^ 10 | } 11 | 12 | return <video ref={ref} src={src} loop playsInline />;
此代码不正确的原因是它尝试在渲染期间对 DOM 节点执行某些操作。在 React 中,渲染应该是 JSX 的纯粹计算,不应该包含修改 DOM 等副作用。
而且,当VideoPlayer第一次调用时,它的 DOM 还不存在!目前还没有一个 DOM 节点可以调用play()或者pause(),因为 React 不知道要创建什么 DOM,直到你返回 JSX。
这里的解决方案是使用 useEffect 包装副作用,将其移出渲染计算:
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 将更新屏幕,确保<video>标签在 DOM 中使用正确的props。然后 React 将运行你的 Effect。最后,您的 Effect 将调用play()|或pause()取决于isPlaying 的值。
多次按播放/暂停键,然后查看视频播放器如何与该isPlaying值保持同步:
import { useEffect, useRef, useState } 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 controls />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
return (
<>
<button onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? '暂停' : '播放'}</button>
<VideoPlayer isPlaying={isPlaying} src='https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4' />
</>
);
}
在此示例中,您同步到 React 状态的“外部系统”是浏览器媒体 API。你可以使用类似的方法将遗留的非 React 代码(如 jQuery 插件)包装到声明式 React 组件中。
请注意,在实践中控制视频播放器要复杂得多。调用play()可能会失败,用户可能会使用内置浏览器控件播放或暂停等等。这个例子非常简化和不完整。
陷阱
默认情况下,Effect在每次渲染后运行。这就是为什么这样的代码会产生无限循环的原因:
const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); });Effect 作为渲染的结果运行。设置状态会触发渲染。在 Effect 中立即设置状态就像将电源插座插入自身一样。Effect 运行,它设置状态,这会导致重新渲染,这会导致 Effect 运行,它再次设置状态,这会导致另一个重新渲染,依此类推。
Effect 通常应将组件与外部系统同步。如果没有外部系统,而您只想根据其他状态调整某些状态,则可能不需要Effect 。
步骤 2:指定Effect依赖项
默认情况下,Effect 在每次渲染后运行。通常,这不是您想要的:
- 有时,它很慢。与外部系统同步并不总是即时的,因此除非必要,否则您可能希望跳过同步。例如,您不希望每次击键都重新连接到聊天服务器。
- 有时,这是错误的。例如,您不希望在每次击键时触发组件淡入动画。动画只应在组件首次出现时播放一次。
为了演示此问题,下面是前面的示例,其中包含一些调用console.log和更新父组件状态的文本输入。请注意键入如何导致 Effect 重新运行:
import { useEffect, useRef, useState } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Calling video.play()');
ref.current.play();
} else {
console.log('Calling video.pause()');
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? '暂停' : '播放'}</button>
<VideoPlayer isPlaying={isPlaying} src='https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4' />
</>
);
}
你可以告诉 React 跳过不必要的重新运行 Effect,方法是指定一个依赖项数组作为调用useEffect的第二个参数。首先在第 14 行向上面的示例添加一个空数组[]:
useEffect(() => {
// ...
}, []);
您应该看到一个错误,指出:React Hook useEffect has a missing dependency: 'isPlaying'
import { useEffect, useRef, useState } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Calling video.play()');
ref.current.play();
} else {
console.log('Calling video.pause()');
ref.current.pause();
}
}, []); // This causes an error
return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? '暂停' : '播放'}</button>
<VideoPlayer isPlaying={isPlaying} src='https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4' />
</>
);
}
lint错误
14:6 - React Hook useEffect has a missing dependency: 'isPlaying'. Either include it or remove the dependency array.
问题是 Effect 中的代码依赖于isPlaying prop 来决定做什么,但这种依赖关系没有明确声明。若要解决此问题,请添加isPlaying到依赖项数组:
useEffect(() => {
if (isPlaying) { // It's used here...
// ...
} else {
// ...
}
}, [isPlaying]); // ...so it must be declared here!
现在声明了所有依赖项,因此没有错误。指定为[isPlaying]依赖数组告诉 React 如果isPlaying与上次渲染期间相同,它应该跳过重新运行您的 Effect。通过此更改,在输入中键入不会导致效果重新运行,但按播放/暂停会导致:
import { useEffect, useRef, useState } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Calling video.play()');
ref.current.play();
} else {
console.log('Calling video.pause()');
ref.current.pause();
}
}, [isPlaying]);
return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? 'Pause' : 'Play'}</button>
<VideoPlayer isPlaying={isPlaying} src='https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4' />
</>
);
}
依赖项数组可以包含多个依赖项。只有当你指定的所有依赖项都具有与上一个渲染期间完全相同的值时,React 才会跳过重新运行 Effect 。React 使用 Object.is 比较来比较依赖值。有关详细信息,请参阅 useEffect 参考。
**请注意,不能“选择”依赖项。**如果您指定的依赖项与 React 根据 Effect 中的代码所期望的不匹配,您将收到 lint 错误。这有助于捕获代码中的许多错误。如果不希望重新运行某些代码,请编辑 Effect 代码本身,使其“不需要”该依赖项。
陷阱
没有依赖关系数组和空
[]依赖关系数组的行为是不同的:useEffect(() => { // 这在每次渲染后运行 }); useEffect(() => { // 这只在mount时运行(当组件出现时)。 }, []); useEffect(() => { // 这运行在mount *和*如果a或b自上次渲染以来发生了变化 }, [a, b]);我们将在下一步中仔细研究“mount”的含义。
步骤 3:根据需要添加清理
考虑一个不同的例子。您正在编写一个组件ChatRoom,该组件需要在聊天服务器出现时连接到聊天服务器。您将获得一个 createConnection() API,该 API 返回一个带有 connect() 和 disconnect() 方法的对象。如何在向用户显示组件时保持连接?
首先编写效果逻辑:
useEffect(() => {
const connection = createConnection();
connection.connect();
});
每次重新渲染后连接到聊天会很慢,因此您添加依赖项数组:
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
效果中的代码不使用任何属性或状态,因此依赖项数组为 [](空)。这告诉 React 只在组件“挂载”时运行这段代码,即第一次出现在屏幕上。
让我们尝试运行此代码:
// App.js
import { useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
return <h1>Welcome to the chat!</h1>;
}
// chat.js
export function createConnection() {
// A real implementation would actually connect to the server
return {
connect() {
console.log('✅ Connecting...');
},
disconnect() {
console.log('❌ Disconnected.');
}
};
}
此效果仅在组件挂载时运行,因此您可能希望"✅ Connecting..."在控制台中打印一次。但是,如果您检查控制台,“正在连接...”✅会被打印两次。为什么会这样?
假设该ChatRoom组件是具有许多不同屏幕的较大应用程序的一部分。用户在ChatRoom页面上开始他们的旅程。组件装载并调用 connection.connect()。然后假设用户导航到另一个屏幕,例如,导航到“设置”页面。ChatRoom组件将卸载。最后,用户单击“返回”并再次挂载 ChatRoom组件。这将建立第二个连接,但第一个连接从未被销毁!当用户在应用中导航时,连接会不断堆积。
如果没有大量的手动测试,像这样的错误很容易被遗漏。为了帮助您快速发现它们,在开发中,React 会在初始挂载后立即重新挂载每个组件一次。
查看"✅ Connecting..."日志两次有助于您注意到真正的问题:当组件卸载时,您的代码不会关闭连接。
要解决此问题,请从 Effect 返回清理函数:
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
React 每次都会在 Effect 再次运行之前调用你的清理函数,最后一次在组件卸载(被删除)时调用。让我们看看实现清理功能时会发生什么:
// App.js
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, []);
return <h1>Welcome to the chat!</h1>;
}
// chat.js
export function createConnection() {
// A real implementation would actually connect to the server
return {
connect() {
console.log('✅ Connecting...');
},
disconnect() {
console.log('❌ Disconnected.');
}
};
}
现在,您可以在开发中获得三个控制台日志:
"✅ Connecting...""❌ Disconnected.""✅ Connecting..."
**这是开发中的正确行为。**通过重新挂载你的组件,React 会验证离开和返回导航不会破坏你的代码。断开连接然后再次连接正是应该发生的事情!当您很好地实现清理时,运行一次效果与运行它、清理它和再次运行它之间应该没有用户可见的区别。有一个额外的连接/断开连接调用对,因为 React 正在探测您的代码是否存在开发中的错误。这是正常的 - 不要试图让它消失!
**在生产中,您只会看到“连接...”✅打印一次。**重新挂载组件仅在开发中发生,以帮助您找到需要清理的效果。您可以关闭严格模式以选择退出开发行为,但我们建议您保持打开状态。这使您可以找到许多类似于上面的错误。
如何处理开发中两次的 Effect 发射?
React 有意在开发中重新挂载组件以查找错误,如上一个例子所示。正确的问题不是“如何运行一次效果”,而是“如何修复我的 Effect,使其在重新安装后正常工作”。
通常,答案是实现清理功能。清理功能应停止或撤消 Effect 正在执行的任何操作。经验法则是,用户不应能够区分运行一次的 Effect(如在生产中)和设置→清理→设置序列(如您在开发中看到的那样)。
您将编写的大多数 Effect 都适合以下常见模式之一。
控制非 React 小部件
有时你需要添加未写入 React 的 UI 小部件。例如,假设您要向页面添加地图组件。它有一个setZoomLevel()方法,您希望使缩放级别zoomLevel与 React 代码中的状态变量保持同步。您的效果将如下所示:
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
请注意,在这种情况下不需要清理。在开发中,React 会调用 Effect 两次,但这不是问题,因为用相同的setZoomLevel调用两次不会做任何事情。它可能稍微慢一些,但这并不重要,因为它不会在生产中不必要地重新装载。
某些 API 可能不允许连续调用它们两次。例如,内置 dialog 元素的 showModal 方法在调用两次时会引发。实现清理功能并使其关闭对话框:
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
在开发中,您的 Effect 将调用 showModal(),然后立即调用 close(),然后再次调用 showModal()。这与调用showModal()一次具有相同的用户可见行为,正如您在生产中看到的那样。
订阅活动
如果您的效果订阅了某些内容,清理功能应取消订阅:
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
在开发中,您的 Effect 将调用 addEventListener(),然后立即调用 removeEventListener(),然后使用相同的处理程序再次调用 addEventListener()。因此,一次只有一个活动订阅。这与在生产中调用一次addEventListener()具有相同的用户可见行为。
触发动画
如果 Effect 在其中对某些内容进行动画处理,则清理函数应将动画重置为初始值:
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Trigger the animation
return () => {
node.style.opacity = 0; // Reset to the initial value
};
}, []);
在开发中,不透明度将设置为 1,然后设置为 0,然后再设置为 1。这应该与将其设置为1直接具有相同的用户可见行为,这是在生产中发生的情况。如果使用支持补间的第三方动画库,则清理函数应将时间轴重置为其初始状态。
获取数据
如果您的 Effect 获取了某些内容,则清理函数应该中止获取或忽略其结果:
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
您无法“撤消”已经发生的网络请求,但清理功能应确保不再相关的提取不会继续影响您的应用程序。如果userId从 'Alice' 更改为 'Bob',则清理可确保'Alice'响应被忽略,即使响应在 'Bob' 之后到达。
在**开发中,您将在“网络”选项卡中看到两个提取。**这没有错。使用上述方法,第一个 Effect 将立即被清理,因此其变量ignore将设置为 true。因此,即使有额外的请求,由于检查if (!ignore),它不会影响状态。
**在生产中,只有一个请求。**如果开发中的第二个请求困扰您,最好的方法是使用一个解决方案来删除重复数据的请求并在组件之间缓存它们的响应:
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
这不仅可以改善开发体验,还可以使您的应用程序感觉更快。例如,按“后退”按钮的用户不必等待某些数据再次加载,因为它将被缓存。您可以自己构建这样的缓存,也可以使用效果中手动获取的众多替代方法之一。
发送分析
请考虑以下代码,该代码在页面访问时发送分析事件:
useEffect(() => {
logVisit(url); // Sends a POST request
}, [url]);
在开发中,对于每个 URL ,logVisit 将被调用两次,因此您可能会尝试尝试解决此问题。**我们建议保持此代码不变。**与前面的示例一样,运行一次和运行两次之间没有用户可见的行为差异。从实际的角度来看,logVisit不应在开发中执行任何操作,因为您不希望来自开发计算机的日志扭曲生产指标。每次保存其文件时,组件都会重新挂载,因此无论如何它都会在开发中记录额外的访问。
在生产环境中,不会有重复的访问日志。
若要调试要发送的分析事件,可以将应用部署到过渡环境(在生产模式下运行),或暂时选择退出严格模式及其仅限开发的重新装载检查。您还可以从路由更改事件处理程序而不是效果发送分析。为了进行更精确的分析,交集观察器可以帮助跟踪视口中有哪些组件以及它们保持可见的时间。
非效果:初始化应用程序
某些逻辑只应在应用程序启动时运行一次。您可以将其放在组件之外:
if (typeof window !== 'undefined') { // Check if we're running in the browser.
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
这保证了此类逻辑在浏览器加载页面后仅运行一次。
无效:购买产品
有时,即使您编写了清理函数,也无法防止用户看到运行 Effect 两次的后果。例如,您的效果可能会发送一个 POST 请求,例如购买产品:
useEffect(() => {
// 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
fetch('/api/buy', { method: 'POST' });
}, []);
您不会想购买该产品两次。但是,这也是您不应该将此逻辑放在效果中的原因。如果用户转到另一个页面,然后按“返回”,该怎么办?您的效果将再次运行。您不想在用户访问页面时购买产品;您希望在用户单击“购买”按钮时购买它。
购买不是由渲染引起的;它是由特定的交互引起的。它应仅在用户按下按钮时运行。删除效果并将 /api/buy 请求移动到“购买”按钮事件处理程序中:
function handleClick() {
// ✅ Buying is an event because it is caused by a particular interaction.
fetch('/api/buy', { method: 'POST' });
}
这**说明,如果重新挂载破坏了应用程序的逻辑,这通常会发现现有的错误。**从用户的角度来看,访问页面不应与访问页面、单击链接并按“返回”没有什么不同。React 通过在开发中重新挂载它们来验证您的组件是否符合此原则。
将一切整合在一起
这个游乐场可以帮助您“感受”效果在实践中的工作方式。
此示例使用 setTimeout 计划一个控制台日志,其中输入文本在效果器运行三秒后显示。清理功能取消挂起的超时。首先按“安装组件”:
import { useEffect, useState } from 'react';
function Playground() {
const [text, setText] = useState('a');
useEffect(() => {
function onTimeout() {
console.log('⏰ ' + text);
}
console.log('🔵 Schedule "' + text + '" log');
const timeoutId = setTimeout(onTimeout, 3000);
return () => {
console.log('🟡 Cancel "' + text + '" log');
clearTimeout(timeoutId);
};
}, [text]);
return (
<>
<label>
What to log: <input value={text} onChange={e => setText(e.target.value)} />
</label>
<h1>{text}</h1>
</>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>{show ? 'Unmount' : 'Mount'} the component</button>
{show && <hr />}
{show && <Playground />}
</>
);
}
您将首先看到三个日志:Schedule "a" log、Cancel "a" log 和再次 Schedule "a" log。三秒钟后还会有日志说 a。正如你之前所知道的,额外的调度/取消对是因为 React 在开发过程中重新挂载组件,以验证你是否很好地实现了清理。
现在编辑输入以说 abc。如果操作速度足够快,则会看到Cancel "ab" log紧跟 Schedule "ab" log 和 Schedule "abc" log。**React 总是在下一个渲染的效果之前清理上一个渲染的效果。**这就是为什么即使您快速输入输入,一次最多安排一个超时。编辑输入几次并观看控制台,以了解效果是如何清理的。
在输入中键入内容,然后立即按“卸载组件”。请注意卸载如何清理上次渲染的效果。在这里,它会在有机会触发之前清除最后一次超时。
最后,编辑上面的组件并注释掉清理功能,以免取消超时。尝试快速键入abcde。你期望在三秒钟内发生什么?超时内console.log(text)会打印最新text并生成五个abcde日志吗?尝试检查一下您的直觉!
三秒钟后,您应该看到一系列日志(a、ab、abc、abcd 和 abcde),而不是五个abcde日志。**每个效果都从其相应的渲染中“捕获”text值。**状态text改变并不重要:text = 'ab'渲染 Effect 将始终看到 'ab'。换句话说,每个渲染的效果彼此隔离。如果你好奇这是如何工作的,你可以阅读关于闭包的信息。
Effect的生命周期
React 16.8 版本正式发布了 Hook 机制,React生命周期分为 Class Component(类组件) 生命周期与 Function Component (函数组件)生命周期。
Function Component 是更彻底的状态驱动抽象,甚至没有 Class Component 生命周期的概念,只有一个状态,而 React 负责同步到 DOM。
回顾下在 Class Component 的数据请求:
- 在
componentDidMount初始化发请求; - 在
componentDidUpdate判断参数是否变化,变化就调用请求函数重新请求数据; - 在
componentWillUnmount生命周期取消发送请求。
那么在函数组件中我们该如何做呢?答案是 useEffect 。
useEffect
useEffect 就是一个 Effect Hook ,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMount 、 componentDidUpdate 和 componentWillUnmount 具有相同的用途,只不过被合并成了一个 API。
useEffect 做了什么:
- 使用
useEffect相当于告诉 React 组件需要在渲染后执行某些操作,React 将在执行 DOM 更新之后调用它。 - React 保证了每次运行
useEffect的时候,DOM 已经更新完毕。
useEffect 默认情况下,它在第一次渲染之后和每次更新之后都会执行。
Class 组件 Demo:
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
componentDidMount() {
document.title = `You clicked ${this.state.count} times`;
}
componentDidUpdate() {
document.title = `You clicked ${this.state.count} times`;
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}
Function Component 重写该案例:
- 请记得 React 会等待浏览器完成画面渲染之后才会延迟调用 useEffect。
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
// 相当于 componentDidMount 和 componentDidUpdate:
useEffect(() => {
// 使用浏览器的 API 更新页面标题
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
需要清除的 effect
在 class 组件中,我们去监听原生 DOM 事件时,会在 componentDidMount 这个生命周期中去做,因为在这里可以获取到已经挂载的真实 DOM。我们也会在组件卸载的时候去取消事件监听避免内存泄露。那么在 useEffect 中该如何实现呢?
通过在 useEffect 中返回一个函数,它便可以清理副作用:
- 如果需要清除操作 useEffect 函数需返回一个清除函数
- 为防止内存泄漏,清除函数会在组件卸载前执行。另外,如果组件多次渲染(通常如此),则在执行下一个 effect 之前,上一个 effect 就已被清除
useEffect(() => {
console.log('effect');
return () => {
console.log("清除函数");
};
});
清理规则是:
- 首次渲染不会进行清理,会在下一次渲染,清除上一次的副作用;
- 卸载阶段也会执行清除操作。
Effect依赖
如果需要 useEffect 按照某种条件运行,可以给 useEffect 传递第二个参数
- 如果想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个空数组([])作为第二个参数。这就告诉 React 你的 effect 不依赖于 props 或 state 中的任何值,所以它永远都不需要重复执行
useEffect(() => {
console.log('effect');
}, []);
- [count] 只有当 count 的值发生变化时该 userEffect 才会执行
useEffect(() => {
console.log('effect', props.number);
return () => {
console.log('清除函数');
};
}, [count]);
网络请求中的应用
在 useEffect 中我们会去请求后台数据,通过前面的学习我们也了解到每次更新组件时我们都会再次去执行 useEffect ,但其实我们并不需要每次更新组件都发送请求。那么碰到这样的问题如何处理呢?
回顾上面是不是类似于 componentDidUpdate 中发送请求呢?直觉是对的,在componentDidUpdate 中我们是通过判断参数是否变化来避免每次都发送请求,那么在 useEffect hook 中我们也是异曲同工,通过第二个参数是否发生变换来决定是否重复执行,如果第二参数为空数组,则表示只在初始化执行一次,后续更新不会再次调用。
useEffect(() => {
fetchData(instanceId){...}
fetchData(instanceId);
}, [instanceId]);
上面例子是通过 fetchData 函数去请求后台数据,具体函数体我们就省略了,然后你会发现useEffect 的第二个参数添加了一个数组,其中添加了一个参数 instanceId,它表示只有当instanceId 变化时,我们才去再次执行 useEffect。这样就可以避免我们多次请求后台数据。
当然我们的依赖项还可以传入一个空数组,就表示只在初始化时执行一次:
useEffect(() => {
fetchData(instanceId){...}
fetchData(instanceId);
}, []);
useCallback
const memoizedCallback = useCallback(
() => {
doSomething(a);
},
[a],
);
把内联回调函数及依赖项数组作为参数传入 useCallback ,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。
通俗来讲当参数 a 发生变化时,会返回一个新的函数引用赋值给 memoizedCallback 变量,因此这个变量就可以当做 useEffect 第二个参数。这样就有效的将逻辑分离出来。
function Parent(){
const [query,setQuery] = useState('q');
const fetchData = useCallback(()=>{
...省略函数体的具体实现
},[query]);
return <Child fetchData={fetchData} />
}
function Child({fetchData}){
const [data,setData] = useState(null);
useEffect(()=>{
fetchData().then(setData);
},[fetchData])
}
经过 useCallback 包装过的函数可以当作普通变量作为 useEffect 的依赖。 useCallback做的事情,就是在其依赖变化时,返回一个新的函数引用,触发 useEffect 的依赖变化,并激活其重新执行。
现在我们不需要在 useEffect 依赖中直接对比 query 参数了,而可以直接对比 fetchData 函数。useEffect 只要关心 fetchData 函数是否变化,而 fetchData 参数的变化在 useCallback 时关心,能做到 依赖不丢、逻辑内聚,从而容易维护。
表单绑定
表单的组件分为受控组件和非受控组件
- 受控组件:由React管理了表单元素的value
- 非受控组件:表单元素的value就是原生的DOM管理的
受控组件
在 HTML 中,表单元素(如<input>、 <textarea> 和 <select>)之类的表单元素通常自己维护 state,并根据用户输入进行更新。而在 React 中,可变状态(mutable state)通常保存在组件的 state 属性中,并且只能通过使用 setState()来更新。
我们可以把两者结合起来,使 React 的 state 成为“唯一数据源”。渲染表单的 React 组件还控制着用户输入过程中表单发生的操作。被 React 以这种方式控制取值的表单输入元素就叫做“受控组件”。
对于受控组件来说,输入的值始终由 React 的 state 驱动。
在input上监听输入框的变化使用onChange监听事件:input原生DOM中change事件是输入变化并失去焦点时触发,在react中onChange是输入变化时触发,类似原生DOM的input事件
input[type=text]双向绑定,input组件绑定的是value属性的值:
- value 绑定状态
- onChange 监听事件并修改状态
import { useState } from 'react';
export default function App() {
const [formData, setFormData] = useState({
username: '',
age: '',
});
// 提交表单数据
function handleSubmit(ev) {
ev.preventDefault();
const { username, age } = formData;
console.log('提交的数据 username, age: ', username, age);
}
// 保存表单数据到state中
function handleChange(ev) {
const { name, value } = ev.target;
setFormData(() => ({
...formData,
...{
[name]: value,
},
}));
}
return (
<div>
<form onSubmit={handleSubmit}>
<input type='text' name='username' value={formData.username} onChange={handleChange} /> <br />
<input type='text' name='age' value={formData.age} onChange={handleChange} /> <br />
<input type='submit' value='提交' />
</form>
</div>
);
}
给多个input组件绑定同一个函数的时候,也可以采用下面的写法
function handleChange(ev, field) {
const { value } = ev.target;
setFormData(() => ({
...formData,
...{
[field]: value,
},
}));
}
<input type='text' name='username' value={formData.username} onChange={ev => handleChange(ev, 'username')} /> <br />
<input type='text' name='age' value={formData.age} onChange={ev => handleChange(ev, 'age')} /> <br />
或者使用函数柯里化
function handleChange(field) {
return function (ev) {
const { value } = ev.target;
setFormData(() => ({
...formData,
...{
[field]: value,
},
}));
};
}
<input type='text' name='username' value={formData.username} onChange={handleChange('username')} /> <br />
<input type='text' name='age' value={formData.age} onChange={handleChange('age')} /> <br />
非受控组件
在大多数情况下,我们推荐使用受控组件来处理表单数据。在一个受控组件中,表单数据是由 React 组件来管理的。另一种替代方案是使用非受控组件,这时表单数据将交由 DOM 节点来处理。
要编写一个非受控组件,而不是为每个状态更新都编写数据处理函数,你可以使用 ref 来从 DOM 节点中获取表单数据。
在 React 渲染生命周期时,表单元素上的 value 将会覆盖 DOM 节点中的值,在非受控组件中,你经常希望 React 能赋予组件一个初始值,但是不去控制后续的更新。 在这种情况下, 你可以指定一个 defaultValue 属性,而不是 value。
- 使用defaultValue 的组件,其value值就是用户输入的内容,React完全不管理输入的过程
同样,<input type="checkbox"> 和 <input type="radio"> 支持 defaultChecked,<select> 和 <textarea> 支持 defaultValue。
import { useRef, useState } from 'react';
export default function App() {
const nameRef = useRef(null);
const ageRef = useRef(null);
const [formData, setFormData] = useState({
username: '',
age: '',
});
// 提交表单数据
function handleSubmit(ev) {
ev.preventDefault();
console.log('提交的数据 username, age: ', nameRef.current.value, ageRef.current.value);
}
return (
<div >
<form onSubmit={handleSubmit}>
<input type='text' name='username' defaultValue={formData.username} ref={nameRef} /> <br />
<input type='text' name='age' defaultValue={formData.age} ref={ageRef} /> <br />
<input type='submit' value='提交' />
</form>
</div>
);
}
因为非受控组件将真实数据储存在 DOM 节点中,所以在使用非受控组件时,有时候反而更容易同时集成 React 和非 React 代码。如果你不介意代码美观性,并且希望快速编写代码,使用非受控组件往往可以减少你的代码量。否则,你应该使用受控组件。
- 文件输入:在 HTML 中,
<input type="file">可以让用户选择一个或多个文件上传到服务器,或者通过使用 File API 进行操作。
<input type="file" />
在 React 中,<input type="file" /> 始终是一个非受控组件,因为它的值只能由用户设置,而不能通过代码控制。
对比受控组件和非受控组件
- 非受控组件: 用户输入A --> input 中显示A;
- 受控组件: 用户输入A --> 触发onChange事件 --> saveData中设置:formData.username= “A” --> 渲染input使他的value变成A;
正是因为这样,强烈推荐使用受控组件,因为它能更好的控制组件的生命流程。
其他受控组件
- textarea绑定的是value属性的值:双向绑定用与input[type=text]法一致
import { useState } from 'react';
export default function App() {
const [message, setMessage] = useState('');
return (
<div>
<textarea value={message} onChange={(ev)=>setMessage(ev.target.value)}></textarea>
</div>
);
}
- 复选框 checkbox 绑定的不是 value 属性 ,而是 checked 属性,绑定的是布尔值:
- checked 绑定状态
- onChange 监听事件
import { useState } from 'react';
export default function App() {
const [formData, setFormData] = useState({
isChoose: false,
});
function handleChange(ev) {
setFormData({ isChoose: ev.target.checked });
}
return (
<div>
性别:
<input type='checkbox' checked={formData.isChoose} onChange={handleChange} />
{formData.isChoose ? '男' : '女'}
</div>
);
}
- 单选框 radio 绑定value属性的值
import { useState } from 'react';
export default function App() {
const [formData, setFormData] = useState({
sex: '',
});
function handleChange(ev) {
setFormData({ sex: ev.target.value });
}
return (
<div>
性别:
<input type='radio' name='sex' value='男' onChange={handleChange} />男
<input type='radio' name='sex' value='女' onChange={handleChange} />女
</div>
);
}
- select绑定绑定的是option标签value属性的值:双向绑定用法与input[type=text]一致
import { useState } from 'react';
export default function App() {
const [hobby, setHobby] = useState('');
return (
<div>
<p> 1:{formData.hobby}</p>
选择喜欢的专业:
<select value={hobby} onChange={(ev)=>setHobby(ev.target.value)}>
<option value='' disabled>
请选择
</option>
<option value='html'>html</option>
<option value='js'>js</option>
<option value='css'>css</option>
</select>
</div>
);
}
组件间共享数据 (状态提升)
有时候,你希望两个组件的状态始终同步更改。要实现这一点,可以将相关 state 从这两个组件上移除,并把 state 放到它们的公共父级,再通过 props 将 state 传递给这两个组件。这被称为“状态提升”,这是编写 React 代码时常做的事。
状态提升的例子
在这个例子中,父组件 MyApp 渲染了 2 个独立的 MyButton 组件。
MyAppMyButtonMyButton
每个 MyButton 组件都有一个 count ,用于控制点击的次数。
import { useState } from 'react';
export default function MyApp() {
return (
<div>
<h1>计数器</h1>
<MyButton />
<MyButton />
</div>
);
}
function MyButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>点击了 {count} 次</button>;
}
在这个示例中,每个 MyButton 都有自己独立的 count,当每个按钮被点击时,只有被点击按钮的 count 才会发生改变,发现点击其中一个按钮并不会影响另外一个,他们是独立的:
| [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-o7uZphrb-1682432582912)(zh-hans.react.dev/_next/image…)] | [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T0NFVjsY-1682432582913)(zh-hans.react.dev/_next/image…)] |
|---|---|
起初,每个 MyButton 的 count state 均为 0 | 第一个 MyButton 会将 count 更新为 1 |
假设现在您想改变这种行为,以便在任何时候共享数据并一起更新。 在这种设计下,为了使得 MyButton 组件显示相同的 count 并一起更新,您该如何做到这一点呢?你需要将各个按钮的 state “向上” 移动到最接近包含所有按钮的组件之中。
要协调好这两个按钮,我们需要分 3 步将状态“提升”到他们的父组件中。
- 从子组件中 移除 state 。
- 从父组件 传递 硬编码数据。
- 为共同的父组件添加 state ,并将其与事件处理函数一起向下传递。
这样, MyApp 父组件就可以控制 2 个 MyButton组件,保证同两个MyButton组件共享数据。
状态提升三步走
第 1 步: 从子组件中移除状态
你将把 MyButton 组件对 count 的控制权交给他们的父组件。这意味着,父组件会将 count 作为 prop 传给子组件 MyButton。
首先,将 MyButton 的 state 上移到 MyApp 中,先从 MyButton 组件中 删除下面这一行:
const [count, setCount] = useState(0);
然后,把 count 加入 MyButton 组件的 props 中:
function MyButton({ count }) {
现在 MyButton 的父组件就可以通过 向下传递 prop 来 控制 count。但相反地,MyButton 组件对 count 的值 没有控制权 —— 现在完全由父组件决定!
export default function MyApp() {
return (
<div>
<h1>计数器</h1>
<MyButton />
<MyButton />
</div>
);
}
function MyButton({ count = 0 }) {
// ... we're moving code from here ...
return <button>点击了 {count} 次</button>;
}
第 2 步: 从公共父组件传递数据
为了实现状态提升,必须定位到你想协调的 两个 子组件最近的公共父组件:
MyApp(最近的公共父组件)MyButtonMyButton
在这个例子中,公共父组件是 MyApp。因为它位于两个按钮之上,可以控制它们的 props,所以它将成为当前按钮数据的“控制之源”。通过 MyApp 组件将硬编码值 count(例如 1 )传递给两个按钮:
export default function MyApp() {
return (
<div>
<h1>计数器</h1>
<MyButton count={1} />
<MyButton count={2} />
</div>
);
}
function MyButton({ count = 0 }) {
return <button>点击了 {count} 次</button>;
}
你可以尝试修改 MyApp 组件中 count 的值,并在屏幕上查看结果。
第 3 步: 为公共父组件添加状态
在这个例子中,共享数据并一起更新。这意味着 MyApp 这个父组件需要记录 按钮 被点击的次数。在 MyApp 组件中添加以下代码,来记录按钮被点击的次数,并添加 handleClick 函数来改变count的值:
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
在任意一个 MyButton 中点击按钮都需要更改 MyApp 中的count的值。 MyButton 中无法直接设置状态 count 的值,因为该状态是在 MyApp 组件内部定义的。 MyApp 组件需要 显式允许 MyButton 组件通过 将事件处理程序作为 prop 向下传递 来更改其状态:
将 MyApp 中的点击事件处理函数handleClick以及 state (count)一同向下传递到 每个 MyButton 中:
<MyButton count={count} onCountChange={handleClick} />
<MyButton count={count} onCountChange={handleClick} />
最后,改变 MyButton 以 读取 从父组件传递来的 prop:
function MyButton({ count = 0, onCountChange }) {
return <button onClick={onCountChange}>点击了 {count} 次</button>;
}
现在 MyButton 组件中的 <button> 将使用 onCountChange 这个属性作为其点击事件的处理程序:
import { useState } from 'react';
export default function MyApp() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<div>
<h1>计数器</h1>
<MyButton count={count} onCountChange={handleClick} />
<MyButton count={count} onCountChange={handleClick} />
</div>
);
}
function MyButton({ count = 0, onCountChange }) {
return <button onClick={onCountChange}>点击了 {count} 次</button>;
}
当你点击按钮时,onClick 处理程序会启动。每个按钮的 onCountChange prop 会被设置为 MyApp 内的 handleClick 函数,所以函数内的代码会被执行。该代码会调用 setCount(count + 1),使得 state 变量 count 递增。新的 count 值会被作为 prop 传递给每个按钮,因此它们每次展示的都是最新的值。这被称为“状态提升”。通过向上移动 state,我们实现了在组件间共享它。
| [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SbKxZxDI-1682432582914)(zh-hans.react.dev/_next/image…)] | [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rEpoyxzA-1682432582914)(zh-hans.react.dev/_next/image…)] |
|---|---|
起初,MyApp 的 count state 为 0 并传递给了两个子组件 | 点击后,MyApp 将 count state 更新为 1,并将其传递给两个子组件 |
此刻,当你点击任何一个按钮时,MyApp 中的 count 都将改变,同时会改变 MyButton 中的两个 count。
这样,我们就完成了对状态的提升!将状态移至公共父组件中可以让你更好的管理这两个按钮。使用count记录点击的次数。而通过向下传递事件处理函数handleClick可以让子组件修改父组件的状态。
每个状态都对应唯一的数据源
在 React 应用中,很多组件都有自己的状态。一些状态可能“活跃”在叶子组件(树形结构最底层的组件)附近,例如输入框。另一些状态可能在应用程序顶部“活动”。例如,客户端路由库也是通过将当前路由存储在 React 状态中,利用 props 将状态层层传递下去来实现的!
**对于每个独特的状态,都应该存在且只存在于一个指定的组件中作为 state **。这一原则也被称为拥有 “可信单一数据源”。它并不意味着所有状态都存在一个地方——对每个状态来说,都需要一个特定的组件来保存这些状态信息。你应该 将状态提升 到公共父级,或 将状态传递 到需要它的子级中,而不是在组件之间复制共享的状态。
你的应用会随着你的操作而变化。当你将状态上下移动时,你依然会想要确定每个状态在哪里“活跃”。这都是过程的一部分!
组件之间的通信
-
父传子:父组件向子组件传值使用props,React数据流动是单向的,子组件只能使用props中的数据不能修改。
-
子传父:子组件向子组件传值,需要父组件提前传一个函数给子组件,以便子组件在适当的时候,将子组件中的数据通过调用这个函数,再传递给父组件
-
兄弟组件之间通信:
-
状态提升
-
消息发布订阅
-
状态管理Redux
-
父传子
// 父组件
export default function App() {
const [count, setCount] = useState(0);
const [zhangsan, setZhangsan] = useState({
name: '张三',
age: 18,
});
return (
<div>
<h1>Hello world!</h1>
<button onClick={() => setCount(val => val + 1)}>按钮 count = {count} </button>
<hr />
{/*
count={count} 直接传递count属性,在子组件的props中接收
{...zhangsan} 传递一个对象的属性,在子组件中,使用 props.name 和 props.age 获取传递的数据
*/}
<MA count={count} zhangsan={zhangsan} {...zhangsan}></MA>
</div>
);
}
// 子组件
export default function MA(props) {
const { count, name, age, zhangsan } = props;
return (
<div>
<h2>MA组件</h2>
<p> count: {count}</p>
<p> name: {name}</p>
<p> age: {age}</p>
<p>zhangsan: {zhangsan.name}- {zhangsan.age}</p>
</div>
);
}
子传父
// 父组件
export default function App() {
const [count, setCount] = useState(0);
// countChange是在父组件定义的函数,但是是在子组件中调用的函数
const countChange = val => {
console.log('countChange 执行了 val:', val);
setCount(val);
};
return (
<div>
<h1>Hello world!</h1>
<p> count: {count}</p>
<hr />
{/* onCountChange={countChange} 把父组件的函数传递给子组件 */}
<MA onCountChange={countChange}></MA>
</div>
);
}
// 子组件
export default function MA({ onCountChange }) {
const btnClick = () => {
// 调用父组件传递的函数并传值
onCountChange(100);
};
return (
<div>
<h2>MA组件</h2>
<button onClick={btnClick}>传值到父组件</button>
</div>
);
}
兄弟组件
状态提升
- 通过状态提升,把组件的状态定义在父组件中,父组件作为数据的中转
// 父组件
export default function App() {
const [count, setCount] = useState(0);
// countChange是在父组件定义的函数,但是是在子组件中调用的函数
const countChange = val => {
console.log('countChange 执行了 val:', val);
setCount(val);
};
return (
<div>
<h1>Hello world!</h1>
<hr />
<MA count={count} onCountChange={countChange}></MA>
<MB count={count} onCountChange={countChange}></MB>
</div>
);
}
//子组件1
export default function MA({ count, onCountChange }) {
const btnClick = () => {
onCountChange(count+ 1);
};
return (
<div>
<h2>MA组件</h2>
<p> count: {count}</p>
<button onClick={btnClick}>传值到父组件</button>
</div>
);
}
//子组件2
export default function MB({ count, onCountChange }) {
const btnClick = () => {
onCountChange(count + 2);
};
return (
<div>
<h2>MB组件</h2>
<p> count: {count}</p>
<button onClick={btnClick}>传值到父组件</button>
</div>
);
}
消息发布订阅
pubsub.js消息发布订阅(推荐使用)
- 这种发布订阅方式,是目前开发中比较常用的兄弟组件通信方法。
- 其实pubsub.js不只适用于兄弟组件通信,其实任意层级、任意关系的组件通信,都可以使用pubsub的发布订阅通信,功能很强大。
- vue中也可以使用这个插件,因为这个插件是用原生js写的
- 文档:www.npmjs.com/package/pub…
第一步:下载pubsub.js
npm install pubsub-js --save
第二步:在组件MA中发布消息
//父组件
export default function App() {
return (
<div>
<MA></MA>
<MB></MB>
</div>
);
}
// 子组件1
import PubSub from 'pubsub-js';
export default function MA() {
const [num, setNum] = useState(0);
const btnClick = () => {
// 如果需要使用 next 状态,可以在将其传递给函数之前将其保存在变量中:
const nextNum = num + 1;
setNum(() => nextNum);
// 发布消息
// 参数1:消息名
// 参数2:数据,可以是数字、字符串、对象等类型
PubSub.publish('send-data', { val: nextNum });
};
return (
<div>
<h2>MA组件</h2>
<p> num: {num}</p>
<button onClick={btnClick}>传值到MB组件</button>
</div>
);
}
第三步:在组件MB中订阅消息
// 子组件2
export default function MB() {
const [num, setNum] = useState(0);
// 订阅消息
// 参数1:消息名
// 参数2:收到消息的回调,
// msg:是消息名, data:传递的数据
const token = PubSub.subscribe('send-data', (msg, data) => {
console.log('msg:', msg, 'data:', data);
setNum(data.val);
});
useEffect(() => {
return () => {
console.log('清除函数');
// 移除订阅
PubSub.unsubscribe(token);
};
}, []);
return (
<div>
<h2>MB组件</h2>
<p> num: {num}</p>
</div>
);
}
自定义 Hook
文档:zh-hans.legacy.reactjs.org/docs/hooks-…
import { useState } from 'react';
import MA from './components/MA';
function App() {
const [list] = useState([
{ name: '张三', age: 20, id: 2 },
{ name: '李四', age: 21, id: 6 },
{ name: '王五', age: 22, id: 8 },
]);
return (
<div className='App'>
<h1>App</h1>
{list.map(user => (
<MA {...user} key={user.id}></MA>
))}
</div>
);
}
- 自定义hooks
// config/hooks.jsx
import { useState, useEffect } from 'react';
export const useFriendStatus = friendID => {
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
if (friendID > 5) {
setIsOnline(true);
} else {
setIsOnline(false);
}
return () => {
setIsOnline(null);
};
});
return isOnline;
};
- 使用hooks
import { useFriendStatus } from '../../config/hooks';
const MA = props => {
const { name, age, id } = props;
const isOnline = useFriendStatus(id);
return (
<div className='m-a' style={{ background: '#e1e1e1' }}>
<p style={{ color: isOnline ? 'red' : 'blue' }}> 姓名:{name} </p>
<p> 年龄:{age} </p>
</div>
);
};
[下篇地址,两篇有关联,因篇幅限制分开](ReactHook学习(第三篇-N) - 掘金 (juejin.cn))