第四章 - 脱围机制
使用 Effect 同步
有些组件需要与外部系统同步。例如,你可能希望根据 React state 控制非 React 组件、设置服务器连接或在组件出现在屏幕上时发送分析日志。Effects 会在渲染后运行一些代码,以便可以将组件与 React 之外的某些系统同步。
什么时 Effect,它与事件 (event) 有何不同?
在谈到 Effect 之前,你需要熟悉 React 组件中的两种逻辑类型:
- 渲染逻辑代码 (在 描述 UI 中有介绍)位于组件的顶层。你将在这里接收 props 和 state,并对它们进行转换,最终返回你想在屏幕上看到的 JSX。渲染的代码必须是纯粹的------就像数学公式一样,它只应该"计算"结果,而不做其他任何事情。
- 事件处理程序 (在 添加交互性 中介绍)是嵌套在组件内部的函数,而不仅仅是计算函数。事件处理程序可能会更新输入字段、提交 HTTP POST 请求以购买产品,或者将用户导航到另一个屏幕。事件处理程序包含由特定用户操作(例如按钮点击或键入)引起的"副作用"(它们改变了程序的状态)。
有时这还不够。考虑一个 ChatRoom
组件,它在屏幕上可见时必须连接到聊天服务器。连接到服务器不是一个纯计算(它包含副作用),因此它不能在渲染过程中发生。然而,并没有一个特定的事件(比如点击)导致 ChatRoom
被显示。
Effect允许你指定由渲染本身,而不是特定事件引起的副作用。在聊天中发送消息是一个事件,因为它直接由用户点击特定按钮引起。然而,建立服务器连接是Effect,因为它应该发生无论哪种交互导致组件出现。Effect在屏幕更新后的提交阶段运行。 这是一个很好的时机,可以将React组件与某个外部系统 (如网络或第三方库) 同步。
你可能不需要Effect
不要随意在你的组件中使用Effect。记住,Effect通常用于暂时跳出React代码并与一些外部系统进行同步。这包括浏览器API,第三方小部件,以及网络等等。如果你想用Effect仅根据其他状态调整某些状态,那么你可能不需要Effect。
如何编写 Effect
编写Effect 需要遵顼以下三个规则:
- 声明Effect。默认情况下,Effect会在每次commit后都会执行。
- 指定Effect依赖。大多数Effect应该按需执行,而不是在每次渲染后都执行。例如,,淡入动画应该只在组件出现时触发。连接和断开服务器的操作只应在组件出现和消失时,或者切换聊天室时执行。文章将介绍如何通过指定依赖来控制如何按需执行。
- 必要时添加清理(cleanup)函数。有时 Effect 需要指定如何停止,撤销,或者清除它的效果。例如,"连接"操作需要"断连","订阅"需要"退订","获取"既需要"取消"也需要"忽略"。你将学习如何使用 清理函数 来做到这一切。
第一步:声明 Effect
首先在React中引入 useEffect Hook;然后在组件顶部调用它,并传入每次渲染时都需要执行的代码
react
import {useEffect} from 'react';
function MyComponent () {
useEffect(() => {
// 每次渲染后都会执行此处代码
});
return <div></div>
}
当你的组件渲染时,React将更新屏幕,然后运行 useEffect 中的代码。换句话说,useEffect 会把这段代码放到屏幕更新渲染之后执行。
让我们看看如何使用 Effect 与外部系统同步。考虑一个 React组件。通过传递布尔类型的 isPlaying
prop 以控制是播放还是暂停:
react
<VideoPlayer isPlaying={isPlaying} />;
自定义的 组件渲染了内置的 标签:
react
function VideoPlayer({ src, isPlaying }) {
// TODO:使用 isPlaying 做一些事情
return <video src={src} />;
}
但是,浏览器的 标签没有 isPlaying 属性。控制它的唯一方式是在 DOM元素上调用 play() 和 pause() 方法。因此,你需要将 isPlaying prop的值 与 paly() 和 pause() 等函数的调用同步进行,该属性用于告知当前视频是否应该播放。
首先要获取 <video>
DOM 节点的 对象引用。
你可能会尝试在渲染期间调用 play()
或 pause()
,但这种做法是错的:
react
import { useState, useRef, useEffect } from 'react';
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 />;
}
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"
/>
</>
);
}
这段代码之所以 不正确,是因为它试图在渲染期间对DOM节点进行操作。在React中,JSX的渲染必须是纯粹操作,不应该包含任何像修改DOM的副作用。
而且,当第一次调用 VideoPlayer
时,对应的 DOM 节点甚至还不存在!如果连 DOM 节点都没有,那么如何调用 play()
或 pause()
方法呢!在返回 JSX 之前,React 不知道要创建什么 DOM。
解决办法是 使用 useEffect
包裹副作用,把它分离到渲染逻辑的计算过程之外:
react
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先更新屏幕,确定相关DOM创建好了以后在运行Effect。
当 VideoPlayer
组件渲染时(无论是否为首次渲染),都会发生以下事情。首先,React 会刷新屏幕,确保 <video>
元素已经正确地出现在 DOM 中;然后,React 将运行 Effect;最后,Effect 将根据 isPlaying
的值调用 play()
或 pause()
。
陷阱
一般来说,Effect会在每次渲染后执行,而以下代码会陷入死循环中:
react
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
每次渲染结束都会执行 Effect;而更新 state 会触发重新渲染。但是新一轮渲染时又会再次执行 Effect,然后 Effect 再次更新 state......如此周而复始,从而陷入死循环。
Effect 通常应该使组件与 外部 系统保持同步。如果没有外部系统,你只想根据其他状态调整一些状态,那么 你也许不需要 Effect。
第二步:指定 Effect依赖
一般来说,Effect会在每次渲染时执行。但更多时候,并不需要每次渲染的时候都执行Effect。
- 有时这会拖慢运行速度。因为与外部系统的同步操作总是有一定时耗,在非必要时可能希望跳过它。例如,没有人会希望每次用键盘打字的时候都重新连接聊天服务器
- 有时这会导致程序逻辑错误。例如,组件的淡入动画只需要在第一轮渲染出现时播放一次,而不是每次触发新一轮渲染后都播放
将 依赖数组传入useEffect的第二个参数,以告诉React跳过不必要地重新运行Effect。
react
useEffect(() => {
// ...
}, []);
依赖项数组可以包含多个依赖项。当指定的所有依赖项在上一次渲染期间的值与当前值完全相同时,React会跳过该Effect。React使用Object.is 比较依赖项的值。
请注意,不能随便选择依赖项。如果你指定的依赖项不能与Effect代码所期望的相匹配时,lint将会报错,这将帮助你找到代码中的问题,那么你应当 重新编辑 Effect 代码本身,使其不需要该依赖项。
陷阱
没有依赖数组作为第二个参数与依赖数组为空数组的行为是不一致的:
react
useEffect(() => {
// 这里的代码会在每次渲染后执行
});
useEffect(() => {
// 这里的代码只会在组件挂载后执行 - 即只在页面初始化的时候执行一次
}, []);
useEffect(() => {
//这里的代码只会在每次渲染后,并且 a 或 b 的值与上次渲染不一致时执行
}, [a, b]);
深入讨论 - 为什么依赖项数组中的ref可省略
下面的Effect 同时使用了 ref 与 isPlaying prop,但是只有 isPlaying 被声明为了依赖项:
react
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
这是因为ref具有稳定的标识:React保证每轮渲染中调用 useRef 所产生的引用对象时,获取到的对象引用总是相同的,即获取到的对象引用永远不会改变,所以他不会导致重新运行 Effect。因此,依赖数组中是否包含它并不重要。当然也可以包括它
useState 返回的 set 函数 也有稳定的标识符,所以也可以把它从依赖数组中忽略调。如果在忽略某个依赖项时linter不会报错,那么这么做就是安全的。
但是,仅在 linter 可以"看到"对象稳定时,忽略稳定依赖项的规则才会起作用。例如,如果ref是从父组件传递的,则必须在依赖数组中指定它。这样做是合适的,因为无法确定父组件是否始终传递相同的 ref,或者可能是有条件地传递几个ref之一。因此,你的 Effect 将取决于传递的是哪个ref。
第三步:按需添加清理 (cleanup) 函数
考虑一个不同的例子。你正在编写一个 ChatRoom
组件,该组件出现时需要连接到聊天服务器。现在为你提供了 createConnection()
API,该 API 返回一个包含 connect()
与 disconnection()
方法的对象。考虑当组件展示给用户时,应该如何保持连接?
从编写 Effect 逻辑开始:
react
useEffect(() => {
const connection = createConnection();
connection.connect();
});
每次重新渲染后连接到聊天室会很慢,因此可以添加依赖数组:
react
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
在这个例子中,Effect中的代码没有使用任何props 或 state,此时指定依赖数组为空数组。这告诉react仅在组件挂载时运行此代码,即首次出现在屏幕这一阶段。
试试运行下面的代码:
react
import { useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
return <h1>欢迎来到聊天室!</h1>;
}
这里的 Effect 仅在组件挂载时执行,所以 "✅ 连接中......"
在控制台中只会打印一次。然而控制台实际打印 "✅ 连接中......"
了两次!为什么会这样?
想象 ChatRoom
组件是一个大规模的 App 中许多界面中的一部分。用户切换到含有 ChatRoom
组件的页面上时,该组件被挂载,并调用 connection.connect()
方法连接服务器。然后想象用户此时突然导航到另一个页面,比如切换到"设置"页面。这时,ChatRoom
组件就被卸载了。接下来,用户在"设置"页面忙完后,单击"返回",回到上一个页面,并再次挂载 ChatRoom
。这将建立第二次连接,但是,第一次时创建的连接从未被销毁!当用户在应用程序中不断切换界面再返回时,与服务器的连接会不断堆积。
如果不进行大量的手动测试,这样的错误很容易被遗漏。为了帮助你快速发现它们,在开发环境中,React 会在初始挂载组件后,立即再挂载一次。
观察到 "✅ 连接中......"
出现了两次,可以帮助找到问题所在:在代码中,组件被卸载时没有关闭连接。
为了解决这个问题,可以在 Effect 中返回一个 清理(cleanup) 函数。
react
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
}
})
在每次重新执行 Effect 之前,react都会调用清理函数;组件被卸载时,也会调用清理函数。让我们看看执行清理函数会做些什么:
react
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>欢迎来到聊天室!</h1>;
}
现在在开发模式下,控制台会打印三条记录:
"✅ 连接中......"
"❌ 连接断开。"
"✅ 连接中......"
在开发环境下,出现这样的结果才是符合预期的。重复挂载组件,可以确保在 React 中离开和返回页面时不会导致代码运行出现问题。上面的代码中规定了挂载组件时连接服务器、卸载组件时断连服务器。所以断开、连接再重新连接是符合预期的行为。当为 Effect 正确实现清理函数时,无论 Effect 执行一次,还是执行、清理、再执行,用户都不会感受到明显的差异。所以,在开发环境下,出现额外的连接、断连时,这是 React 正在调试你的代码。这是很正常的现象,不要试图消除它!
在生产环境下,"✅ 连接中......"
只会被打印一次 。也就是说仅在开发环境下才会重复挂载组件,以帮助你找到需要清理的 Effect。你可以选择关闭 严格模式 来关闭开发环境下特有的行为,但我们建议保留它。这可以帮助发现许多上面这样的错误。
如何处理在开发环境中 Effect 执行两次
在开发环境中,React有意重复挂载你的组件,以查找像上面示例中的错误。正确的态度是"如何修复Effect以便它在重复挂载后能正常工作",而不是如何只运行一次 Effect。
通常的解决办法是实现清理函数。清理函数应该停止或撤销Effect正在执行的任何操作。简单来说,用户不应该感受到Effect只执行一次 (如在生产环境中) 和执行 "挂载--清理--挂载"过程 (如在开发环境中)之间的差异。
控制非 React 组件
有时需要添加不是使用React编写的UI小部件。例如,假设你要向页面添加地图组件,并且它有一个setZoomLevel()
方法,你希望调整缩放级别(zoom level)并与 React 代码中的 zoomLevel
state 变量保持同步。Effect 看起来应该与下面类似:
react
useEffect(() = > {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel])
请注意,在这种情况下不需要清理。在开发环境中,React会调用 Effect两次,但这两次挂载时依赖项zoomLevel
都是相同的,所以会跳过执行第二次挂载时的 Effect。开发环境中它可能会稍微慢一些,但这问题不大,因为它在生产中不会进行不必要的重复挂载。
某些API可能不允许连续调用两次。例如,内置的 dialog
元素的 showModal
方法在连续调用两次时会抛出异常,此时实现清理函数并使其关闭对话框:
react
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
在开发环境中,Effect将调用showModal(),然后立即调用close(),然后再次调用 showModal()。这与只调用一次showModal() 的效果相同。也正如在生产环境中看到那样。
订阅事件
如果 Effect 订阅了某些事件,清理函数应该退订这些事件:
react
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
在开发环境中,Effect 会调用 addEventListener() ,然后立即调用 removeEventListener()
,然后再调用相同的 addEventListener()
,这与只订阅一次事件的 Effect 等效;这也与用户在生产环境中只调用一次 addEventListener()
具有相同的感知效果。
触发动画
如果Effect 对某些内容加入了动画,清理函数应该将动画重置:
react
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // 触发动画
return () => {
node.style.opacity = 0; // 重置为初始值
};
}, []);
在开发环境中,透明度由 1
变为 0
,再变为 1
。这与在生产环境中,直接将其设置为 1
具有相同的感知效果,如果你使用支持过渡的第三方动画库,你的清理函数应将时间轴重置为其初始状态。
获取数据
如果 Effect 将会获取数据,清理函数应该要么终止该数据获取操作,要么忽略其结果:
react
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'
之后到达。
在开发环境中,浏览器调试工具的"网络"选项卡中会出现两个 fetch 请求 。这是正常的。使用上述方法,第一个 Effect 将立即被清理,而 ignore
将被设置为 true
。因此,即使有额外的请求,由于有 if (!ignore)
判断检查,也不会影响程序状态。
在生产环境中,只会显示发送了一条获取请求。如果开发环境中,第二次请求给你造成了困扰,最好的方法是使用一种可以删除重复请求、并缓存请求响应的解决方案:
react
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
这不仅可以提高开发体验,还可以让你的应用程序速度更快。例如,用户按下按钮时,如果数据已经被缓存了,那么就不必再次等待加载。你可以自己构建这样的缓存,也可以使用很多在 Effect 中手动加载数据的替代方法。
深入探讨 - Effect中有哪些好的数据获取替代方案
在 Effect 中调用 fetch
请求数据 是一种非常受欢迎的方式,特别是在客户端应用中。然而,它非常依赖手动操作,有很多的缺点:
- Effect 不能在服务端执行。这意味着服务器最初传递的 HTML 不会包含任何数据。客户端的浏览器必须下载所有 JavaScript 脚本来渲染应用程序,然后才能加载数据------这并不高效。
- 直接在 Effect 中获取数据容易产生网络瀑布(network waterfall)。首先渲染了父组件,它会获取一些数据并进行渲染;然后渲染子组件,接着子组件开始获取它们的数据。如果网络速度不够快,这种方式比同时获取所有数据要慢得多。
- 直接在 Effect 中获取数据通常意味着无法预加载或缓存数据。例如,在组件卸载后然后再次挂载,那么它必须再次获取数据。
- 这不是很符合人机交互原则 。如果你不想出现像 条件竞争(race condition) 之类的问题,那么你需要编写更多的样板代码。
以上所列出来的缺点并不是 React 特有的。在任何框架或者库上的组件挂载过程中获取数据,都会遇到这些问题。与路由一样,要做好数据获取并非易事,因此我们推荐以下方法:
- 如果你正在使用 框架 ,使用其内置的数据获取机制。现代 React 框架集成了高效的数据获取机制,不会出现上述问题。
- 否则,请考虑使用或构建客户端缓存 。目前受欢迎的开源解决方案是 React Query、useSWR 和 React Router v6.4+。你也可以构建自己的解决方案,在这种情况下,你可以在幕后使用 Effect,但是请注意添加用于删除重复请求、缓存响应和避免网络瀑布(通过预加载数据或将数据需求提升到路由)的逻辑。
如果这些方法都不适合你,你可以继续直接在 Effect 中获取数据。
发送分析报告
考虑在访问页面时发送日志分析:
react
useEffect(() => {
logVisit(url); // 发送 POST 请求
}, [url]);
在开发环境中,logVisit
会为每个 URL 发送两次请求,所以你可能会想尝试解决这个问题。不过我们建议不必修改此处代码 ,与前面的示例一样,从用户的角度来看,运行一次和运行两次之间不会 感知 到行为差异。从实际的角度来看,logVisit
不应该在开发环境中做任何影响生产事情。由于每次保存代码文件时都会重新挂载组件,因此在开发环境中会额外记录访问次数。
在生产环境中,不会产生有重复的访问日志。
为了调试发送的分析事件,可以将应用部署到一个运行在生产模式下的暂存环境,或者暂时取消 严格模式 及其仅在开发环境中重新加载检查;还可以从路由变更事件处理程序中发送分析数据,而不是从 Effect 中发送。为了更精确的分析,可以使用 Intersection Observer 来跟踪哪些组件位于视口中以及它们保持可见的时间。
初始化应用时不需要使用 Effect 的情形
某些逻辑应该只在应用程序启动时运行一次。比如,验证登录状态和加载本地程序数据。你可以将其放在组件之外:
react
if (typeof window !== 'undefined') { // 检查是否在浏览器中运行
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ......
}
这保证了这种逻辑在浏览器加载页面后只运行一次。
不要在 Effect 中执行购买商品一类的操作
有时,即使编写了一个清理函数,也不能避免执行两次Effect。例如,Effect 包含会发送 Post请求以执行购买操作
react
useEffect(() => {
// 🔴 错误:此处的 Effect 会在开发环境中执行两次,这在代码中是有问题的。
fetch('/api/buy', { method: 'POST' });
}, []);
一方面,开发环境下,Effect 会执行两次,这意味着购买操作执行了两次,但是这并非是预期的结果,所以不应该把这个业务逻辑放在 Effect 中。另一方面,如果用户转到另一个页面,然后按"后退"按钮回到了这个界面,该怎么办?Effect 会随着组件再次挂载而再次执行。所以,当用户重新访问某个页面时,不应当执行购买操作;当只有用户点击"购买"按钮时,才执行购买操作。
因此,"购买"的操作不应由组件的挂载、渲染引起的;它是由特定的交互作用引起的,它应该只在用户按下按钮时运行。因此,它不应该写在 Effect 中,应当把 /api/buy
请求操作移动到购买按钮事件处理程序中:
react
function handleClick() {
// ✅ 购买商品应当在事件中执行,因为这是由特定的操作引起的。
fetch('/api/buy', { method: 'POST' });
}
这个例子说明如果重新挂载破坏了应用程序的逻辑,则通常含有未被发现的错误。从用户的角度来看,访问一个页面不应该与访问它、点击链接然后按下返回键再次查看页面有什么不同。React 通过在开发环境中重复挂载组件以验证组件是否遵守此原则。
深入探讨 - 每一轮渲染都有自己的Effect
你可以将 useEffect 认为其将一段行为"附加"到渲染输出。考虑这种情况
react
export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>欢迎来到 {roomId}!</h1>;
}
让我们看看当用户在应用程序中切换页面时到底发生了什么
初始渲染
用户访问 <ChatRoom roomId="general" />
,在这里让我们 假设 roomId
的值为 'general'
:
react
// 首次渲染时的 JSX(roomId 为 "general")
return <h1>欢迎来到 general!</h1>;
Effect 也是渲染输出的一部分。首次渲染的Effect变为:
react
//首先渲染时的 Effect(roomId 为 "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// 首次渲染时的依赖项(roomId 为 "general")
['general']
React 将会执行用于连接到 'general'
聊天室的 Effect。
依赖项相同时的重新渲染
让我们探讨下 <ChatRoom roomId="general" />
的重复渲染。JSX 的输出结果仍然相同:
react
// 第二次渲染时的 JSX(roomId 为 "general")
return <h1>Welcome to general!</h1>;
React 看到渲染输出没有改变,所以它不会更新 DOM 。
第二次渲染的 Effect 如下所示:
react
// 第二次渲染时的 Effect(roomId 为 "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// 第二次渲染时的依赖项(roomId 为 "general")
['general']
React 将第二次渲染时的 ['general']
与第一次渲染时的 ['general']
进行比较。因为所有的依赖项都是相同的,React 会忽略第二次渲染时的 Effect。所以此时 Effect 不会被调用。
依赖项不同时的重新渲染
接下来,用户开始访问 <ChatRoom roomId="travel" />
。注意这里 roomId
的属性值改为了 'travel'
,返回的是不同的 JSX 输出结果:
react
// 第三次渲染时的 JSX(roomId 为 "travel")
return <h1>欢迎来到 travel!</h1>;
这时的React会更新DOM,将 "欢迎来到 general"
更新为 "欢迎来到 travel"
。
第三次渲染的 Effect 如下所示:
react
// 第三次渲染时的 Effect(roomId 为 "travel")
() => {
const connection = createConnection('travel');
connection.connect();
return () => connection.disconnect();
},
// 第三次渲染时的依赖项(roomId 为 "travel")
['travel']
React 将第三次渲染时的 ['travel']
与第二次渲染时的 ['general']
相互比较。会发现依赖项不同:Object.is('travel', 'general')
为 false
:所以这次的 Effect 不能跳过。
在 React 执行第三次渲染的 Effect 之前,它需要清理最近渲染的 Effect 。第二次渲染的 Effect 被跳过了。所以 React 需要清理第一次渲染时的 Effect。如果你回看第一次渲染的 Effect,你可以看到第一次渲染时的清理函数需要执行的内容,是在 createConnection('general')
所创建的连接上调用 disconnect()
。也就是从 'general'
聊天室断开连接。
之后,React 执行第三次渲染的 Effect。它连接到 'travel'
聊天室。
组件卸载
最后,假设用户离开了当前页面,ChatRoom
组件将被卸载时,React 会执行最近的 Effect 的清理函数,也就是第三次渲染时 Effect 的清理函数。第三次渲染后再清理时,清理函数破坏了 createConnection('travel')
方法创建的连接。因此,该应用程序与 travel
房间断开了连接。
仅开发环境下的行为
在 严格模式 下,React 在每次挂载组件后都会重新挂载组件(但是组件的 state 与 创建的 DOM 都会被保留)。它可以帮助你找出需要添加清理函数的 Effect,以及早暴露出像条件竞争那样的问题。此外,每当你在开发环境中保存更新代码文件时,React 也会重新挂载 Effect,不过这两种行为都仅限于开发环境。
摘要
- 与事件不同,Effect是由渲染本身,而非特定交互引起的。
- Effect允许你将组件与某些外部系统 (第三方API,网络等) 同步
- 默认情况下,Effect在每次渲染 (包括初始渲染) 后运行
- 如果 React 的所有依赖项都与上次渲染时的值相同,则将跳过本次Effect
- 不能随意选择依赖项,他们是由Effect内部代码决定的。
- 空的依赖数组 ([]) 对应于组件 "挂载",即添加到屏幕上。
- 仅在严格模式下的开发环境中,React会挂载两次组件,以对Effect进行压力测试
- 如果Effect因为重新挂载而中断,那么需要实现一个清理函数
- React将在下次Effect运行之前以及卸载期间这两个时候调用清理函数