React Hooks 深度解析:useEffect
与 useLayoutEffect
在 React Hooks 中,useEffect
是我们最常用的副作用 Hook,用于处理组件渲染后的各种操作,比如数据请求、订阅事件、DOM 操作等。然而,React 还提供了一个鲜为人知的兄弟 Hook:useLayoutEffect
。虽然它们的名字相似,但执行时机却大相径庭,理解它们之间的区别对于编写高性能和无视觉闪烁的 React 应用至关重要。
useEffect
: 最常用的副作用 Hook
useEffect
处理组件渲染后的各种操作,例如数据请求、订阅事件、DOM 操作等。
useEffect
的执行时机
useEffect
的回调函数是在 浏览器完成 DOM 更新和屏幕绘制之后 才异步执行的。这种异步执行的特性使得 useEffect
不会阻塞浏览器的渲染进程,从而保证了应用的响应性。
useEffect
基本用法
useEffect
接收两个参数:
- 一个回调函数(effect 函数) :这是你实际实现副作用的地方。
- 一个可选的依赖项数组(dependency array) :这个数组控制着回调函数的执行时机。
JavaScript
scss
useEffect(() => {
// 副作用代码
// 例如:数据获取、事件监听、DOM 操作
return () => {
// 可选的清理函数
// 在组件卸载或回调函数重新执行前运行
};
}, [d1, d2]); // 依赖项数组
依赖项数组控制回调函数执行时机:
- 没有依赖项数组:每次组件渲染完成后都执行。
- 空数组
[]
:只在组件首次挂载时执行一次。 - 包含依赖项的数组
[dep1, dep2, ...]
:当依赖项数组中包含一个或多个变量时,函数会在组件首次挂载时执行一次 ,并且在数组中的任何一个依赖项发生变化时重新执行。
清理函数:告别内存泄漏
useEffect
的回调函数可以选择性地返回一个函数 。这个返回的函数就是清理函数(cleanup function) 。
清理函数会在以下两种情况下执行:
- 组件卸载时 :当组件从 DOM 中被移除时,清理函数会执行,以清除
effect
订阅的资源。 - 下一次
effect
执行前 :如果effect
会因为依赖项的变化而重新运行,那么在执行新的effect
之前,上一次effect
返回的清理函数会先执行。这确保了在新的订阅或操作开始之前,旧的资源已被正确清理。
useLayoutEffect
useLayoutEffect
是一个与 useEffect
签名相同 的 Hook,但它在 DOM 更新之后,浏览器执行绘制 (Paint) 之前同步执行。
简单来说:
useEffect
在 渲染内容绘制到屏幕之后 异步执行。useLayoutEffect
在 DOM 更新完成后,但浏览器还没来得及绘制到屏幕上 同步执行。
useLayoutEffect
的执行时机
为了更好地理解 useLayoutEffect
,我们来对比一下 React 组件生命周期中相关 Hook 的执行顺序:
- React 更新 DOM:这是 React 计算出新的 DOM 树并将其应用到实际浏览器 DOM 的阶段。
useLayoutEffect
回调执行 :此时 DOM 已经更新完毕,但屏幕还没有重新绘制。useLayoutEffect
的回调函数会在这里同步执行。如果你的操作会直接影响到布局或需要读取 DOM 元素的精确尺寸,那么这里是最好的时机。- 浏览器绘制 (Paint) :浏览器将更新后的 DOM 内容渲染到屏幕上。
useEffect
回调执行 :在浏览器完成绘制之后,useEffect
的回调才会被异步执行。
这个同步执行的特性,意味着 useLayoutEffect
的回调函数会阻塞浏览器绘制 。如果 useLayoutEffect
中的操作耗时过长,可能会导致用户看到明显的卡顿或页面闪烁。
什么时候使用 useLayoutEffect
?
useLayoutEffect
的主要应用场景是当你需要在 DOM 更新后立即读取布局信息 或同步修改 DOM 样式以避免视觉上的闪烁时。
以下是几个常见的应用场景:
-
测量 DOM 元素尺寸或位置并立即基于此进行布局调整: 例如,你需要在组件渲染后获取一个元素的宽度,然后根据这个宽度调整另一个元素的位置,以确保它们精确对齐。如果使用
useEffect
,可能会出现元素先以旧位置显示,然后瞬间跳到新位置的"闪烁"现象。JavaScript
javascriptimport React, { useRef, useLayoutEffect, useState } from 'react'; function Tooltip({ children, text }) { const targetRef = useRef(null); const tooltipRef = useRef(null); const [tooltipStyle, setTooltipStyle] = useState({}); useLayoutEffect(() => { if (targetRef.current && tooltipRef.current) { const targetRect = targetRef.current.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); // 计算 Tooltip 的位置,使其居中显示在目标元素上方 // 并向上偏移一些,确保在 target 元素上方可见 setTooltipStyle({ left: targetRect.left + targetRect.width / 2 - tooltipRect.width / 2, top: targetRect.top - tooltipRect.height - 10, position: 'absolute', }); } }, [children]); // 依赖 children 变化时重新计算 return ( <span ref={targetRef} style={{ position: 'relative', display: 'inline-block' }}> {children} <div ref={tooltipRef} style={{ ...tooltipStyle, background: 'black', color: 'white', padding: '5px', borderRadius: '3px' }}> {text} </div> </span> ); } function App() { const [showMore, setShowMore] = useState(false); return ( <div style={{ padding: '100px', display: 'flex', flexDirection: 'column', gap: '20px' }}> <Tooltip text="这是一个提示">悬停在我上面</Tooltip> <p> 这是一个很长的段落,用于演示当内容动态变化时,`useLayoutEffect` 如何避免闪烁。 {showMore && ( <span> <br /> 更多内容在这里展开。如果这里的内容长度发生变化,Tooltip 的位置可能需要重新计算。 使用 `useLayoutEffect` 可以确保 Tooltip 在新布局绘制到屏幕之前就处于正确位置。 </span> )} </p> <button onClick={() => setShowMore(!showMore)}> {showMore ? '收起' : '展开更多'} </button> </div> ); }
避免页面"闪烁"的例子:
在上面的
Tooltip
组件示例中,我们使用useLayoutEffect
来精确计算并设置 Tooltip 的位置。想象一下,如果children
(例如"悬停在我上面"的文本)的宽度动态改变,或者父组件的布局发生变化导致targetRef
元素的位置改变了。-
如果使用
useEffect
:- React 更新 DOM,
targetRef
元素的新位置或尺寸被计算。 - 浏览器绘制屏幕,用户可能短暂地看到 Tooltip 还在旧位置。
useEffect
回调执行,计算出 Tooltip 的新位置并更新tooltipStyle
。- React 再次渲染,Tooltip 跳到新位置。 这种 "先显示旧位置,再跳到新位置" 的过程就会导致视觉上的**"闪烁"**。
- React 更新 DOM,
-
使用
useLayoutEffect
:- React 更新 DOM,
targetRef
元素的新位置或尺寸被计算。 useLayoutEffect
回调立即同步执行 ,它能够读取到targetRef
的最新准确位置和尺寸。setTooltipStyle
同步更新状态,导致 React 立即安排新的渲染。- 浏览器进行绘制时,Tooltip 已经根据最新的计算结果处于正确的位置,用户从一开始就看到 Tooltip 在正确的位置,从而避免了任何视觉上的"闪烁" 。
- React 更新 DOM,
-
什么时候不 使用 useLayoutEffect
?
由于 useLayoutEffect
是同步执行并阻塞浏览器绘制的,因此应该谨慎使用。
- 大多数情况下,你应该优先使用
useEffect
。 如果你的副作用操作不需要在 DOM 绘制前立即执行,或者不涉及读取 DOM 布局信息,那么useEffect
是更好的选择,因为它不会阻塞浏览器绘制,从而避免影响用户体验。 - 避免在
useLayoutEffect
中执行耗时操作。 任何长时间运行的同步操作都会导致页面卡顿。如果你的操作耗时,考虑将其放到useEffect
中,或者使用requestAnimationFrame
进行优化。 - 不涉及 DOM 操作或布局读取的操作: 例如,数据请求、设置订阅、日志记录等,这些都应该放在
useEffect
中。
useLayoutEffect
与 useEffect
的对比总结
特性 | useEffect |
useLayoutEffect |
---|---|---|
执行时机 | DOM 更新并绘制到屏幕后 (异步) | DOM 更新后,绘制到屏幕前 (同步) |
是否阻塞绘制 | 否 | 是 |
可见性闪烁 | 可能导致 (如果依赖于布局并需立即更新) | 避免 (在绘制前完成所有布局调整) |
优先级 | 推荐在大多数副作用场景使用 | 仅在需要同步读取/修改布局时使用 |
性能影响 | 较低 (不阻塞主线程) | 较高 (可能阻塞主线程,导致卡顿) |