文章目录
- [一、React 进阶笔记:useEffect 深度解析](#一、React 进阶笔记:useEffect 深度解析)
-
- [1. 核心思维模型:从"生命周期"到"同步"](#1. 核心思维模型:从“生命周期”到“同步”)
-
- [A. 执行时机](#A. 执行时机)
- [2. 依赖项数组(Dependencies)的规则](#2. 依赖项数组(Dependencies)的规则)
- [3. 清理函数(Cleanup Function)](#3. 清理函数(Cleanup Function))
- [二、useLayoutEffect 深度解析](#二、useLayoutEffect 深度解析)
-
- [1. 核心差异:执行时机](#1. 核心差异:执行时机)
- [2. 为什么需要 useLayoutEffect?(解决闪烁问题)](#2. 为什么需要 useLayoutEffect?(解决闪烁问题))
- [3. 代码示例:测量元素位置](#3. 代码示例:测量元素位置)
- [4. 使用原则与建议](#4. 使用原则与建议)
-
- [A. 优先使用 `useEffect`](#A. 优先使用
useEffect) - [B. 仅在以下情况使用 `useLayoutEffect`](#B. 仅在以下情况使用
useLayoutEffect) - [C. 服务端渲染(SSR)警告](#C. 服务端渲染(SSR)警告)
- [A. 优先使用 `useEffect`](#A. 优先使用
- [三、useEffect 正确处理异步请求,和避免竞态条件?](#三、useEffect 正确处理异步请求,和避免竞态条件?)
-
- [1. 为什么不能直接使用 `async`?](#1. 为什么不能直接使用
async?) - [2. 什么是竞态条件(Race Condition)?](#2. 什么是竞态条件(Race Condition)?)
- [3. 如何避免竞态条件?](#3. 如何避免竞态条件?)
-
- [方案 1:使用 Boolean 标志位(推荐)](#方案 1:使用 Boolean 标志位(推荐))
- [方案 2:使用 AbortController(原生撤回)](#方案 2:使用 AbortController(原生撤回))
- [1. 为什么不能直接使用 `async`?](#1. 为什么不能直接使用
一、React 进阶笔记:useEffect 深度解析
useEffect 是 React 中最强大也最容易被误用的 Hook。它的核心目的不是"生命周期钩子",而是**"同步"**:将组件内部的状态与外部系统(API、DOM、订阅等)同步。
1. 核心思维模型:从"生命周期"到"同步"
不要用类组件的 componentDidMount 等生命周期去套用 useEffect。你应该思考的是:
"每当状态 [Dependency] 改变时,我需要重新运行这段副作用逻辑。"
A. 执行时机
- 渲染后执行 :
useEffect会在浏览器完成布局与绘制(Paint)之后异步执行,因此不会阻塞屏幕更新。 - 闭包陷阱 :每次渲染都有自己的 Props 和 State,
useEffect捕获的是对应那次渲染中的值。
2. 依赖项数组(Dependencies)的规则
依赖项决定了 Effect 是否重新执行,是调优性能和逻辑正确性的关键。
| 依赖项写法 | 执行时机 | 适用场景 |
|---|---|---|
useEffect(() => { ... }) |
每次渲染后都执行 | 极少使用,通常会导致性能问题 |
useEffect(() => { ... }, []) |
仅在挂载时执行一次 | 初始化数据、绑定全局事件、建立 WebSocket |
useEffect(() => { ... }, [a, b]) |
a 或 b 改变时执行 | 搜索过滤、根据 ID 获取数据、响应状态变化 |
3. 清理函数(Cleanup Function)
当 Effect 返回一个函数时,React 会在组件卸载 以及下一次 Effect 执行之前调用它。
javascript
useEffect(() => {
const timer = setInterval(() => console.log('Tick'), 1000);
// 清理逻辑
return () => {
clearInterval(timer);
console.log('Cleanup: 计时器已销毁');
};
}, []);
二、useLayoutEffect 深度解析
useLayoutEffect 是 useEffect 的一个特殊版本。它的签名(参数)与 useEffect 完全一致,但它的执行逻辑是同步的,会阻塞浏览器的渲染。
1. 核心差异:执行时机
理解 useLayoutEffect 的关键在于它在浏览器渲染流水线中所处的位置。
| 特性 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制(Paint)完成之后 | 浏览器绘制之前,DOM 更新之后 |
| 执行方式 | 异步(不会阻塞页面渲染) | 同步(会阻塞页面渲染,直到逻辑运行完) |
| 性能影响 | 极小,不会导致视觉卡顿 | 如果逻辑复杂,会导致明显的页面白屏或掉帧 |
| 典型场景 | API 请求、事件绑定、日志统计 | 测量 DOM、防止 UI 闪烁 |
2. 为什么需要 useLayoutEffect?(解决闪烁问题)
当你需要根据 DOM 的实际尺寸、位置来更新 UI 时,如果使用 useEffect:
- 浏览器渲染初始 UI。
useEffect执行,修改状态,导致重新渲染。- 浏览器渲染更新后的 UI。
结果:用户会看到 UI 在一瞬间发生了跳动或闪烁。
使用 useLayoutEffect:
- React 计算出 DOM 变化。
useLayoutEffect立即执行,测量 DOM 并进行同步修改。- 浏览器一次性绘制最终的结果。
结果:用户只看到最终正确的状态,没有任何闪烁。
3. 代码示例:测量元素位置
javascript
import React, { useState, useLayoutEffect, useRef } from 'react';
function Tooltip() {
const [position, setPosition] = useState(0);
const divRef = useRef();
useLayoutEffect(() => {
// 此时 DOM 已经挂载,但浏览器还没画到屏幕上
const { bottom } = divRef.current.getBoundingClientRect();
// 同步更新状态,React 会确保这一步和初始渲染合并
setPosition(bottom + 10);
}, []);
return (
<div ref={divRef} style={{ marginTop: '50px' }}>
目标元素,Tooltip 位置:{position}px
</div>
);
}
4. 使用原则与建议
A. 优先使用 useEffect
在 99% 的场景下,你应该首选 useEffect。因为其异步执行的特性不会阻塞浏览器的屏幕渲染,能为用户提供更流畅的交互体验。
B. 仅在以下情况使用 useLayoutEffect
- 测量布局 :需要获取
width,height,scrollPosition等实时 DOM 属性。 - 防止闪烁:当状态更新会立刻改变 DOM 样式(例如:下拉菜单的定位校准、动画起始位修正)。
- 同步操作:需要在浏览器重绘(Repaint)之前,确保某些逻辑必须串行完成。
C. 服务端渲染(SSR)警告
在 Next.js 或 Remix 等 SSR 环境中,直接使用 useLayoutEffect 会收到控制台警告。
- 原因:服务端环境没有真实的 DOM 树,无法执行同步的布局测量。
- 解决方案 :
- 优先改用
useEffect。 - 判断环境:使用"同构"自定义 Hook:const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
- 优先改用
一句话总结
-
useEffect 是"渲染后通知我"。
-
useLayoutEffect 是"渲染前拦住我,等我改完再画"。
三、useEffect 正确处理异步请求,和避免竞态条件?
在 React 中处理异步请求时,最容易犯的错误就是直接在 useEffect 回调函数上加 async。以下是处理异步请求的标准模式,以及解决"竞态条件"的深度方案。
1. 为什么不能直接使用 async?
你可能想写 useEffect(async () => { ... }, []),但这是错误的。
- 原因 :
useEffect必须返回一个 清理函数(Cleanup Function) 或者什么都不返回(undefined)。 - 后果 :由于
async函数隐式返回一个Promise,这会让 React 感到困惑,导致它无法在组件卸载或重新执行 Effect 时正确识别并执行清理逻辑。 - 正确做法:在 Effect 内部定义一个异步函数并立即调用它。
javascript
useEffect(() => {
const fetchData = async () => {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
};
fetchData();
}, []);
2. 什么是竞态条件(Race Condition)?
假设你有一个搜索框,用户快速输入了 "React":
- 发起请求 A(关键词 "Re")。
- 发起请求 B(关键词 "React")。
- 请求 B 响应较快,先返回了,页面显示了 "React" 的结果。
- 请求 A 因为网络波动后返回了,页面数据被覆盖成了 "Re" 的结果(旧数据)。
此时,用户明明输入的是 "React",看到的却是 "Re" 的结果。这就是典型的竞态条件。
3. 如何避免竞态条件?
解决的核心思路是:在下一次 Effect 执行前,废弃掉(或忽略)上一次未完成的请求。
方案 1:使用 Boolean 标志位(推荐)
这是最简单、最稳健的方法,利用了闭包和 useEffect 的清理函数。
javascript
useEffect(() => {
let isCancelled = false; // 1. 定义一个标志位
const fetchData = async () => {
const result = await fetch(`/api/search?q=${query}`);
const data = await result.json();
// 2. 在更新状态前,检查当前 Effect 轮次是否已被废弃
if (!isCancelled) {
setData(data);
}
};
fetchData();
// 3. 清理函数:当 query 改变或组件卸载时执行
return () => {
isCancelled = true;
};
}, [query]);
方案 2:使用 AbortController(原生撤回)
这种方法不仅能从逻辑上忽略数据,还能真正中断网络传输,从而节省带宽。
javascript
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchData = async () => {
try {
const response = await fetch(`/api/data?q=${query}`, { signal }); // 传入 signal
const data = await response.json();
setData(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求已被取消,忽略状态更新');
} else {
// 处理真实的业务错误
console.error('请求出错:', error);
}
}
};
fetchData();
return () => {
controller.abort(); // 撤回未完成的请求
};
}, [query]);