使用React + TypeScript:使用效果进行同步
React官方网站的文档已于2023年3月16日进行了修订(参见「Introducing react.dev」)。本文是对高级应用中「Synchronizing with Effects」的简洁总结。然而,我们在代码中加入了TypeScript,同时省略了面向初学者的JavaScript基础知识解释。
请参考其他本系列解说文章中的“React + TypeScript: React公式ドキュメント的基础解说《学习React》”来了解更多信息。
某些组件需要与外部系统进行同步,例如以下情况。
-
- React以外のコンポーネントを状態にもとづいて制御したいとき。
-
- サーバーとの接続を確立したいとき。
- 分析用のログをコンポーネントが画面に表示されたら送りたいとき。
在渲染完成后,效果会执行其代码。这样,它可以与React之外的系统和组件进行同步。
「效果」和「事件」的区别是什么。
在解释特效之前,让我们先理解React组件内的两个逻辑。
コードのレンダリング: コンポーネントのトップレベルに存在します(「React + TypeScript: ユーザーインタフェースを組み立てる」参照)。プロパティや状態を受け取り、変換して、画面に表示すべきJSXを返すのがこの場所です。コードのレンダリングは純粋でなければなりません。数式と同じく、結果を導出するだけです。他のことは行いません。
イベントハンドラ: コンポーネントの中に含まれた関数です(「React + TypeScript: 発生したイベントを処理する」参照)。純粋な計算以外の処理も行います。たとえば、以下のような用途です。イベントハンドラは副作用(プログラムの状態変更)を含みます。実行を引き起こすのは、ユーザーの特定のアクション(たとえば、ボタンクリックやキーボード入力)です。
テキスト入力フィールドの更新。
商品を購入するためのHTTP POSTリクエストの送信。
ユーザーの別画面への遷移。
然而,仅凭这两个组件还不足够。让我们考虑一下 ChatRoom 组件。当它显示在屏幕上时,必须始终与服务器连接。由于连接服务器不是纯粹的计算(因为它涉及副作用),所以无法在渲染过程中进行。然而,也没有特定的事件(如点击)来触发显示 ChatRoom。
特效定义了副作用,并且在特定事件之外,在渲染自身时执行。通过聊天发送消息是一个事件,由用户点击特定的按钮引发。相比之下,与服务器建立连接是一个特效。服务器连接与组件展示的互动方式无关。特效的执行在提交结束之后,屏幕更新后进行。可以说,这是与外部系统(如网络和第三方库)同步React组件的最合适时机。
【注】在接下来的说明中,”Effect”指的是React固有的定义,即基于渲染的副作用。在广义的编程概念中,我们称之为”副作用”。
有时候不需要效果。
将效果立即添加到组件中并不明智。通常,效果用于从React中“出去”。比如,与下面的外部系统同步。
-
- ブラウザAPI。
-
- サードパーティのウィジェット。
- ネットワークなど。
如果特效只是基于一种状态来调整另一种状态,那可能是不必要的。
特效的书写方法
效果应按照以下三个步骤书写。
-
- 请声明特效。默认情况下,特效的执行是在每次渲染结束后。
指定特效的依赖数组。大多数特效应该只在需要时重新执行。不需要在每次渲染后处理。例如,淡入动画应该在组件显示时开始。连接或断开聊天室是在组件显示/隐藏或聊天室变化时进行的。有关使用依赖数组控制的方法将在后面介绍。
必要时需要进行清理。某些特效可能需要停止、撤消或定义清理方法。例如,连接需要断开,注册需要解除,获取可能需要取消或忽略后续处理。清理函数应从特效中返回。
让我们具体地看一下三个步骤。
步骤1:宣布效果
在组件中声明效果,请首先从React中导入useEffect钩子。然后,在组件的顶层调用该钩子。将代码添加到回调函数的参数体中。
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// 毎回のレンダーが終わったあと実行される
});
return <div />;
}
每当组件渲染时,React会更新屏幕,然后执行useEffect中的代码。换句话说,useEffect能够将代码的执行直到渲染结果呈现在屏幕上之后进行”延迟”。
让我们使用效果以确保与外部系统同步。我们来考虑一个播放视频的组件。传递给该组件的属性isPlaying是一个控制播放和停止的布尔状态变量。
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
return (
<>
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Pause' : 'Play'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="ビデオファイルのパス"
/>
</>
);
}
VideoPlayer组件会在JSX中返回一个内嵌在浏览器中的
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
// isPlayingとビデオ再生を同期させる
return <video src={src} />;
};
然而,isPlaying并不是
为了达到这个目的,首先我们要获得DOM节点
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
const ref = useRef<HTMLVideoElement>(null);
console.log('ref:', ref.current); // 確認用
// レンダー中にDOMノードを操作すべきではない
if (isPlaying) {
ref.current?.play();
} else {
ref.current?.pause();
}
return <video ref={ref} src={src} loop playsInline />;
};
使用这段代码,你可以播放和停止VideoPlayer组件的视频。但是,这段代码并不理想。通过添加的console.log()用于确认,控制台应该首先显示以下输出。
参考:无
当VideoPlayer组件在首次渲染时,DOM还不存在时,意味着正在尝试操作ref.current。在React中,渲染必须是JSX的纯计算,应该避免包含副作用,如DOM操作。
当首次调用VideoPlayer时,尚不存在DOM。React在返回JSX之前,无法确定应该创建什么样的DOM。
【注意】在官方网站的代码示例中,会返回错误。这是因为它省略了判断ref.current是否为null的步骤。
为了解决这个问题,我们可以将副作用放在useEffect里面,并将其从渲染计算过程中分离出来。这样,console.log()到控制台的null输出应该会消失(示例001)。
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
useEffect(() => {
console.log("ref:", ref.current); // 確認用
if (isPlaying) {
ref.current?.play();
} else {
ref.current?.pause();
}
});
};
React + TypeScript: 使用Effects进行同步的示例001
DOM的更改被包裹在效果中。然后,React首先更新屏幕。之后执行效果。
当
-
まず、Reactによる画面の更新です。これで、DOMの中に
つぎに、Reactが実行するのはエフェクトです。
そのうえで、エフェクトはplay()またはpause()メソッドを、isPlayingの値に応じて呼び出します。
在这个代码示例中,将React的状态与”外部系统”同步的是浏览器的媒体API。当我们将非React的传统代码(如jQuery插件等)包装在声明性的React组件中时,也可以使用类似的方法。
此外,视频播放器的控制实际上更加复杂。调用play()可能会失败。用户可能会使用浏览器内置的控件进行播放或暂停操作。请注意,此代码示例是非常简化和不完全的。
当您使用效果来设置状态时,会陷入无限循环的情况。
默认情况下,效果会在每次渲染后执行。因此,以下代码会陷入无限循环。
警告:超出了最大更新深度。
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
}
执行效果是渲染的结果。设定状态会引发渲染。但是,在上述代码中,正在渲染的效果中改变了状态。这将再次引发渲染。这种循环是无限的。
一般而言,效果通常用于将组件与外部系统进行同步。如果没有外部系统,仅想将一种状态调整为另一种状态,可能就不需要效果。
步骤2:指定效果的依赖数组
默认情况下,效果会在每次渲染后执行。这可能不是你所期望的。
-
処理の遅れにつながります。外部システムとはすぐに同期できるとはかぎりません。必要なければ省いてしまってもよさそうです。たとえば、チャットサーバーに、キー入力するたびに接続し直したいとは考えないでしょう。
毎回のレンダー後に実行するのが間違いの場合もあります。コンポーネントにフェードインアニメーションを定めたとしましょう。開始はキー入力ではなく、コンポーネントがはじめて表示された1回だけにすべきです。
前述的示例001没有指定useEffect钩子的依赖数组(第二个参数)。为了说明这种情况下的问题,让我们在父组件(App)中添加一个文本输入字段()。输入的文本将被保存在状态变量(text)中。每次键入文本时,效果都会被调用,并且应该能够确认console.log()的输出。
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
useEffect(() => {
console.log("ref:", ref.current); // 確認用
if (isPlaying) {
ref.current?.play();
} else {
ref.current?.pause();
}
});
};
export default function App() {
const [text, setText] = useState('');
return (
<>
<input
value={text}
onChange={({ target: { value } }) => setText(value)}
/>
</>
);
}
在`useEffect`钩子函数的第二个参数中,传入包含依赖项的依赖数组。只要依赖项保持不变,该效果将不会被重复执行。首先,我们尝试传入一个空数组`[]`,也就是没有依赖。在组件的初始渲染后,该效果将被执行一次,并且不会再次执行。
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
useEffect(() => {
console.log("ref:", ref.current); // 確認用
if (isPlaying) {
ref.current?.play();
} else {
ref.current?.pause();
}
// });
}, []);
};
然而,无论您输入文本或按下按钮,都不会发生任何事情。原因是,代码检查器通过以下警告通知我们:即使按下按钮导致了isPlaying值的变化,但由于它没有包含在依赖数组中,所以useEffect不会重新执行。
React Hook useEffect出现了一个缺少的依赖项:’isPlaying’。要么将其包括在内,要么删除依赖项数组。
按照林塔的指示将”isPlaying”添加到useEffect的依赖数组(第2个参数)中(示例002)。这样,只有当按钮被按下且”isPlaying”的值与上次渲染时的值不同时,才会执行效果。由于输入文本框不影响效果对”text”的依赖,因此它将被省略执行。
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
useEffect(() => {
if (isPlaying) { // 依存値
} else {
}
//}, []);
}, [isPlaying]); // 依存値は依存配列に含める
};
样本002 ■ React + TypeScript:与Effects 02同步
在依存数组中,可以添加多个依存值。在重新执行React效果之前,React会检查所有给定的依存值。如果它们与上一次渲染时完全相同,那么效果将不会被重新执行。这时,React使用的是Object.is来比较依赖值(参见”useEffect”的”参考”)。
依赖值不是随意”选择”的。如果给定的依赖值与React期望的依赖数组基于效果内的代码不匹配,则会从代码检查器中收到警告。这样可以检测到许多代码中的错误。如果在效果中有不想重新执行的代码,请重新编写以”不依赖”的方式来完成。
使用Effect的第二个参数(依赖数组)
依赖数组是useEffect的第二个参数,根据指定的方式执行操作。值得注意的是,所谓的挂载时是指组件被显示时的情况。
useEffect(() => {
// 毎回レンダーされたあとに実行される
}); // 第2引数なし
useEffect(() => {
// マウント時のみ実行される
}, []); // 空の依存配列
useEffect(() => {
// マウント時と、aかbの値が前回のレンダーから変わった場合に実行される
}, [a, b]); // 依存値を配列に加える
ref可以不包含在依赖数组中。
前述的代码示例中的效果不仅引用了isPlaying,还引用了ref。但是,代码检查工具并不要求将ref添加到依赖数组中。
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
useEffect(() => {
if (isPlaying) {
ref.current?.play();
} else {
ref.current?.pause();
}
}, [isPlaying]);
};
这是因为ref对象始终保持不变且相同。React确保从相同的useRef调用中获取的对象在每次渲染中始终相同。换句话说,它不会发生变化,也不会引发效果的重新执行。因此,不需要将ref包含在依赖项中,但即使包含也没有问题。
useState返回的数组的第二个元素set函数始终保持不变,因此经常被省略在依赖数组中。如果不包含在依赖值中也不会收到linter的警告,是安全的,可以省略。
只有当linter能够确定对象不会改变时,我们才能始终从依赖中排除相同的对象。例如,如果ref是从父组件传递的,那么它必须添加到依赖数组中。父组件并不总是传递相同的对象,而是根据条件可能选择不同的ref。换句话说,effect取决于接收哪个ref。
步骤3:根据需要进行清理操作。
再举一个例子,假设我们创建了一个ChatRoom组件,一旦显示出来就必须连接到聊天服务器。createConnection()API会返回一个具有connect()(连接)和disconnect()(断开连接)方法的对象。在组件显示给用户的时间内,我们应该如何保持连接?
首先,我们从特效的逻辑角度考虑。
useEffect(() => {
const connection = createConnection();
connection.connect();
});
每次渲染之后,如果重复连接到聊天,只会增加负载。因此,解决方法是添加依赖数组。
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
在效果内的代码中没有使用属性或状态。因此,依赖数组为空。然后,React仅在组件第一次被挂载并显示在屏幕上时才执行此代码(示例003)。
React + TypeScript: 效果同步的示例003
console.log()函数用于在控制台打印输出,我们在createConnection()(模块chat.ts)返回的connect()(✅ 连接中…)和disconnect()(❌ 已断开.)之后,为了进行确认而添加。控制台输出如下所示:connect()被调用了两次,这意味着已经连接成功。
✅ 连接中…
✅ 连接中…
这是关于React 18开发环境的规范(“React + TypeScript: React 18中组件挂载时useEffect被执行两次”)。让我们将ChatRoom组件视为大型应用程序的一部分,具有多个屏幕。
-
ユーザーが、ChatRoomページから閲覧を始めました。コンポーネントがマウントされ、connection.connect()が呼び出されます。
つぎに遷移したのは別の画面、たとえば設定ページです。
ChatRoomコンポーネントはアンマウントされます。
そのうえで、ユーザが戻るボタンをクリックしたらどうでしょう。
ChatRoomが再びマウントされ、設定されるのはふたつめの接続です。
然而,第一个连接并没有被丢弃。每次用户在应用程序内移动时,连接会不断重复。
除非进行细致入微的手动测试,否则很难发现此类错误。为了解决这个问题,React在开发环境中会在首次挂载后重新挂载所有组件一次。
由于出现了两次”✅ Connecting…”的日志输出,我注意到了问题。也就是说,当组件被卸载时,没有关闭连接的代码。
当组件卸载时,将后处理作为清理函数从useEffect中返回(示例004)。
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
在React中,每次重新执行效果之前都会调用清理函数。而最后一次调用清理函数是在组件被卸载(从屏幕上移除)时。
示例004■React + TypeScript:与Effects同步
现在,在开发环境中,将在控制台上输出3条日志。
✅ 连接中…
❌ 连接已断开。
✅ 连接中…
关于这个动作,我有两点补充。
这是在开发环境中的正确操作。通过重新挂载组件,React确保了即使从其他页面返回也不会导致代码错误。断开连接再重新连接正是我们期望的结果。如果清理工作被正确实现,对于用户来说几乎看不出下面两个步骤的区别。
-
エフェクトを1度だけ実行。
エフェクト実行後クリーンアップしたうえで再実行。
只存在一组额外的连接/断开调用。这可以用来检测在开发过程中React代码是否潜藏着错误。由于这是正常运行所需的,我们不应该排除它。
在正式环境中,“✅ 连接中…”的输出只会发生一次。组件的重新挂载只会在开发时进行,并且可以检查效果是否需要清理。StrictMode是为了添加开发时的功能。但请不要取消此设置,因为它有助于发现上述各种错误。
在开发过程中正确处理会被执行两次的效果。
React在开发过程中有意地重新装载组件,通过这种方式,还发现了前面的代码例子中的错误。因此,问如何在一次执行中执行效果是不正确的。应该考虑如何修改效果以使其在重新装载时能够正常工作。
通常情况下,可以通过执行清理函数来解决。清理函数会停止或撤销效果的操作。目标是使用户无需意识到以下两点的差异。
-
エフェクトが1度実行された場合(本番環境と同じ)。
エフェクトの実行がクリーンアップされて再実行された場合(開発環境と同じ)。
在开发过程中遇到一个需要运行两次的效果,我们应该如何应对呢?下面是一个典型的例子。
控制除了React以外的小部件。
有时候,您可能想要在React中添加未使用React编写的UI小部件。例如,让我们在页面中插入一个地图组件。该组件具有setZoomLevel()方法,该值必须与React代码的状态变量zoomLevel同步。操作可能如下所示。
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
此特效無需進行清理。在開發環境中,React會執行兩次特效。然而,即使將setZoomLevel以相同數值呼叫兩次,也不會發生任何特別的事情。儘管可能會增加一些負載,但在正式環境中,不會有不必要的重新掛載,所以這不是問題。
有些API可能不允许连续调用两次。例如,内置的元素的showModal方法,如果调用时对话框已经打开,则会抛出异常。需要实现一个清理函数,必须关闭对话框。
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
在开发环境中,首先调用showModal()函数,然后立即使用close()函数关闭,接着再次调用showModal()函数。用户来看,这个行为和在生产环境中只调用一次showModal()函数没有区别。
检测事件
在使用特效时,若将其注册给事件等进行检测的监听器,请在清理函数中删除注册。
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
在开发环境中,这个效果在调用addEventListener()后立即使用removeEventListener()进行删除,然后再次在相同的处理器上执行addEventListener()。结果是,只有一个有效的监听器。对于用户来说,与在生产环境中只调用一次addEventListener()相同。
开始动画
一旦特效执行动画后,清理函数必须将其恢复到初始值。
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // アニメーションの開始
return () => {
node.style.opacity = 0; // 初期値に戻す
};
}, []);
在开发环境中,设置效果的不透明度值1将在清理操作中被重置为0,并重新设置为1。从用户的角度来看,在生产环境中直接设定值1的行为与之前并无差别。需要注意的是,如果使用的动画第三方库支持缓动动画,清理函数还需要将时间轴恢复到初始状态。
获取数据
在开始使用效果收集外部数据时,必须要求清理函数终止计算或忽略结果。
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’之后接收到。
在开发环境中,您可以通过开发者工具中的[网络]选项卡确认两个请求。所以没有问题。通过以上所示的代码示例的处理,首个效果将立即被清除,变量ignore将被设定为true。因此,可以避免在条件判断if (!ignore)中对状态造成影响。结果是后续的请求将生效。
在正式环境中,请求只会发生一次。在开发过程中可能会关注第二次请求。在这种情况下,最适合的方法是确保请求不重复,并使用组件之间的缓存来处理响应。
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
}
不仅开发体验会提高,而且应用程序也可以加快速度。例如,即使用户按下返回按钮,也不需要等待加载。因为数据被缓存了,所以不需要重新加载。这样的缓存可以自己构建。或者,可以选择各种库来替代手动提取效果。
通过除了效果以外的方式获取数据
在完全基于客户端的应用程序中,从效果中调用fetch是常见的用于获取数据的方法。然而,这种方式需要大量手动工作,并且存在以下问题。
在服务器上不会运行效果。换句话说,经过服务器渲染的初始HTML中没有数据,只显示加载中的状态。客户端的计算机需要下载所有JavaScript文件。然后才能渲染应用程序,并最终了解需要加载的数据。这可以说是效率低下。
如果直接从效果中取得数据,可能会出现“网络瀑布”的情况。假设渲染了父组件并获取了数据,接下来当渲染子组件时,可能会开始获取那些数据。如果网络不够快,速度会大幅降低,相比同时获取所有数据。
直接使用效果进行数据提取的话,通常不会进行数据预加载和缓存。例如,当组件被卸载后重新挂载时,需要重新获取数据。
编写代码并不容易。如果没有相当数量的样板代码,就不可能编写出不会产生类似竞争状态错误的fetch调用。
这些问题不仅限于React。如果要在挂载时获取数据,这适用于任何库。与路由一样,正确实现数据获取并不容易。我建议以下两种方式:
- フレームワークを使う場合: 組み込みのデータフェッチの機能を用いてください。モダンなReactフレームワークには、データフェッチの仕組みが統合されています。効率的かつ上述のような問題もありません。
フレームワークを使わない場合: クライアントサイドキャッシュの利用または構築をお考えください。よく用いられるオープンソースのソリューションとしては、React QueryやuseSWR、およびReact Router 6.4+が挙げられます。ソリューションを自前で構築してもよいでしょう。その場合、内部的にエフェクトは使いつつ、つぎのようなロジックを加えてください。
リクエストの重複排除。
レスポンスのキャッシュ。
ネットワークのウォーターフォール回避。
データのプリロードやルーティングへのデータ要求の引き上げ。
如果这些方法都不符合目标的话,我们可以在效果内直接获取数据。
将分析日志发送
假设以下代码在访问网页时发送分析事件。
useEffect(() => {
logVisit(url); // POSTリクエストの送信
}, [url]);
在开发环境中,每个URL对应的logVisit都会被调用两次。你可能想要修复这个问题。让我们保持这段代码不变。就像之前的代码示例一样,从用户的角度来看,执行一次或者两次都不会改变行为。从实践的角度来看,在开发时logVisit可能什么都不会做。因为不希望开发机器的日志影响到生产环境的测量结果。而且,组件会在每次保存文件时重新加载。无论如何,在开发时都会记录额外的访问记录。
在生产环境中,不会出现重复的访问日志。
调试正在发送的分析事件有以下几种方法。
-
アプリケーションをステージング環境にデプロイする。本番モードでの実行。
一時的にStrictModeを外して、開発環境専用の再マウントチェックを止める。
アナリティクスログを、エフェクトでなく、ルート変更のイベントハンドラから送る。
交差オブザーバーを用いてより正確に分析する。
どのコンポーネントがビューポートにあり、どれだけの時間表示されているか追跡できる。
如果不是特效的情况下:应用程序初始化。
当应用程序启动时,可能还有一些只需要执行一次的逻辑。请将这些代码放在组件外部。
// ブラウザで実行されているかを確かめる
if (typeof window !== 'undefined') {
checkAuthToken();
loadDataFromLocalStorage();
}
export default function App() {
// ...
}
如果不是特效的情况下:购买商品。
即使编写了清理函数,对于用户而言,无法防止二次执行效果时产生的影响。举个例子,例如当效果发送POST请求,比如购买商品时。
useEffect(() => {
// ? NG: エフェクトが開発時には2度実行され、コードに問題が生じる
fetch('/api/buy', { method: 'POST' });
}, []);
我不想购买相同的商品两次。因此,这个逻辑不能加入到效果中。举个例子,如果用户切换到其他页面后再按返回按钮,效果会被重新执行。用户购买产品的时机并不是在访问页面时,而是在点击购买按钮时。
购买不是由渲染引起的,而是基于特定用户操作。换句话说,只有用户按下按钮时才执行购买。请将/api/buy请求从效果中排除,移至购买按钮的事件处理程序中。
const handleClick = () => {
// ✅ OK: 購入は特定のユーザー操作にもとづくのでイベント
fetch('/api/buy', { method: 'POST' });
}
换言之,如果重新加载时逻辑出现错误,那么通常情况下会显露出存在bug的问题。从用户的角度来看,这两种情况不应该有明显的差别。
-
ページを訪れること。
訪れたページでリンクをクリックし、移った別のページから戻るボタンを押すこと。
在开发过程中,React会重新装载组件,以确保其遵循此原则。
通过感觉来确认特效的实际运作。
让我们通过感觉来确认一下效果是如何实际工作的。
在下面的代码示例中,当点击[Mount the component]按钮时,文本输入字段将出现。设置效果的是setTimeout函数,它将在3秒后将输入字段中的文本(text)输出到控制台。清理函数将取消等待中的超时操作(clearTimeout)。
export const Playground: FC = () => {
const [text, setText] = useState('a');
useEffect(() => {
const onTimeout = () => {
console.log('⏰ ' + text);
};
console.log('? Schedule "' + text + '" log');
const timeoutId = setTimeout(onTimeout, 3000);
return () => {
console.log('? Cancel "' + text + '" log');
clearTimeout(timeoutId);
};
}, [text]);
};
首先显示的是三个输出。 ” Schedule “和 ” Cancel “额外出现的原因是,React在开发时重新加载组件一次。通过确保清理正确实施,可以确保这一点。
?安排”A”记录
?取消”A”记录
?安排”A”记录
然后,3秒后显示的是超时结束的下一个输出。
⏰ 一个
请在输入字段中迅速添加文本”bc”。一旦”ab”被”Schedule”后立即被”Cancel”,”abc”的”Schedule”将会重新设置。React在执行下一个渲染效果之前进行清理的是上一次渲染的效果。因此,在以上代码示例中,即使快速输入文本字段,最多只能同时设置一个超时。
? 安排”ab”日志
? 取消”ab”日志
? 安排”abc”日志
⏰ abc
当您在输入框中键入任何文本时,请立即点击[卸载组件]按钮。通过卸载组件,最后的渲染效果将被清除。如果在超时结束前取消卸载,将不会输出到控制台。
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Unmount' : 'Mount'} the component
</button>
{show && <hr />}
{show && <Playground />}
</>
);
}
最后我们来确认一下如果删除清除函数的情况。请像[2]一样在文本框中快速输入“bc”。
export const Playground: FC = () => {
useEffect(() => {
/* return () => {
console.log('? Cancel "' + text + '" log');
clearTimeout(timeoutId);
}; */
}, [text]);
};
控制台的显示会是怎样的呢?当然,并不会进行清理。输出如下所示。
? 安排 “ab” 日志
? 安排 “abc” 日志
⏰ ab
⏰ abc
当超时结束时,你可能预计会同时输出两个”⏰ abc”。但是,每个特效都会”捕获”对应渲染的文本值。即使文本状态发生改变,特效仍然会一直参考渲染时的文本值。换句话说,特效是根据渲染而独立存在的。如果想更详细了解这个原理,”闭包”可能会对你有所帮助。
每个渲染都有不同的特效。
让我们以ChatRoom组件中的效果为例来理解useEffect作为与渲染输出“相关”的行为之一。
export const ChatRoom: FC<Props> = ({ roomId }) => {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to {roomId}!</h1>;
};
房间的初始值设为’general’。
export default function App() {
const [roomId, setRoomId] = useState('general');
return (
<>
<ChatRoom roomId={roomId} />
</>
);
}
最初的渲染
用户访问roomId=”general”的
// 初期レンダーのエフェクト(roomId = 'general')
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// 初期レンダーの依存(roomId = 'general')
['general']
以相同的依赖值进行重新渲染。
假设我们将使用相同的roomId=”general”重新渲染。JSX的输出与上次相同。React会确保渲染输出未发生变化,因此不会更新DOM。
// 再レンダーのJSXは初期レンダーと同じ(roomId="general")
return <h1>Welcome to general!</h1>;
再渲染的效果与初始渲染相同。React会比较上次和本次渲染的依赖数组[‘general’]。依赖数组没有变化。因此,React会省略在重新渲染中执行效果。换句话说,效果不会被调用。
以不同的依赖值重新渲染
假设用户切换到roomId=”travel”的
// 再レンダーのJSXが変わる(roomId="travel")
return <h1>Welcome to travel!</h1>;
效果也修改了roomId的状态变量值。React会将本次渲染的依赖数组[‘travel’]与上次的[‘general’]进行比较。Object.is(‘travel’, ‘general’)的结果为false。由于依赖不同,效果无法省略。
// 異なる依存値で再レンダーしたエフェクト(roomId = 'travel')
() => {
const connection = createConnection('travel');
connection.connect();
return () => connection.disconnect();
},
// 再レンダーで変わった依存(roomId = 'travel')
['travel']
在应用此次渲染效果之前,React需要执行清理上次执行的效果。上次重新渲染的效果被省略了。React需要清理的是初始渲染的效果。清理的代码调用了createConnection(‘general’)创建的连接,然后调用了disconnect()。这样,应用就从名为’general’的聊天室中断开连接了。
// 初期レンダーのエフェクト(roomId = 'general')
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
['general']
然后,React会执行这次渲染的效果。新连接的是聊天室“travel”。
卸下
当用户离开页面时,
只针对开发环境的操作
在StrictMode模式下的行为是这样的。React会先将所有组件挂载一次,然后再进行重新挂载(状态和DOM会保留)。这样一来,可以明确需要清理的效果,并且也更容易发现类似竞态条件的错误。另外,在开发环境中,每次保存文件时,React都会重新挂载效果。这些只发生在开发时的行为。
总结
在这篇文章中,我们对以下项目进行了说明。
-
エフェクトは、レンダリングそのものから引き起こされます。イベントのように特定のユーザー操作にはもとづきません。
エフェクトにより、コンポーネントを外部システム(サードパーティAPI、ネットワークなど)と同期できます。
デフォルトでは、エフェクトの実行は(初期も含む)毎レンダーのあとです。
エフェクトのすべての依存が前回のレンダー時と同じ値であったら、Reactは実行を省きます。
依存値は勝手に「選択」できません。エフェクト内のコードによって決まります。
空の依存配列([])でエフェクトが実行されるのは、コンポーネントの「マウント時」です。つまり、画面に追加されたときを意味します。StrictModeでは、Reactが各コンポーネントを初期マウントするのはそれぞれ2回ずつで(開発環境のみ)、エフェクトのストレステストのためです。
再マウントでエフェクトが破綻するときは、クリーンアップ関数を実装してください。
Reactがクリーンアップ関数を呼び出すのは、つぎのエフェクトが実行される前とアンマウント時です。