深入详解 useLayoutEffect
的实现原理与源码分析(最完整版)
一、概述
useLayoutEffect
是 React 提供的一个 Hook,用于在DOM 更新之后但浏览器绘制之前同步执行副作用。它与 useEffect
的最大区别在于:
特性 | useEffect | useLayoutEffect |
---|---|---|
执行时机 | 在 DOM 更新后异步执行 | 在 DOM 更新后、页面渲染前同步执行 |
是否阻塞页面渲染 | 不会 | 会 |
是否适合测量 DOM 布局 | ❌ 不推荐 | ✅ 推荐 |
🧠 适用场景:
- 需要测量或操作 DOM 布局;
- 动画初始计算;
- 避免视觉闪烁(如位置调整);
二、历史发展与设计背景
2.1 类组件时代:生命周期函数的局限
在类组件中,开发者通常使用以下生命周期方法进行布局相关操作:
componentDidMount
componentDidUpdate
这些方法虽然可以访问真实 DOM,但无法控制其执行顺序和性能。
2.2 Hooks 引入:useEffect 与 useLayoutEffect 分离职责
React v16.8 引入了 Hooks,并将副作用分为两类:
Hook | 执行时机 | 是否同步 | 用途 |
---|---|---|---|
useEffect | Layout 之后,异步执行 | 否 | 数据获取、订阅等不影响布局的操作 |
useLayoutEffect | Layout 之后,绘制之前,同步执行 | ✅ 是 | 测量、动画、布局调整等影响视觉的操作 |
2.3 版本发布时间线
版本 | 时间 | 内容 |
---|---|---|
React 16.8 | 2019 年 2 月 | 正式发布 useLayoutEffect Hook |
三、源码文件定位(React v18.2)
以下是 useLayoutEffect
在 React 源码中的核心位置:
文件名 | 路径 | 功能 |
---|---|---|
ReactFiberHooks.js | packages/react-reconciler/src/ReactFiberHooks.js | 定义 useLayoutEffect 的 Hook 实现逻辑 |
ReactFiberBeginWork.js | packages/react-reconciler/src/ReactFiberBeginWork.js | 处理 Hook 初始化和更新 |
ReactFiberCommitWork.js | packages/react-reconciler/src/ReactFiberCommitWork.js | 在 commit 阶段执行 layout effect |
ReactFiberFlags.js | packages/react-reconciler/src/ReactFiberFlags.js | 标记 Hook 副作用类型 |
ReactFiberWorkLoop.js | packages/react-reconciler/src/ReactFiberWorkLoop.js | 控制副作用调度机制 |
四、useLayoutEffect 的语法结构
useLayoutEffect(() => {
// 同步执行的副作用逻辑
return () => {
// 清理逻辑(可选)
};
}, [deps]);
参数 | 描述 |
---|---|
第一个参数 | 一个回调函数,包含副作用逻辑 |
第二个参数 | 依赖项数组,决定是否重新执行副作用 |
返回值 | 可选的清理函数,在组件卸载或依赖变化时调用 |
五、算法设计思路与步骤详解
✅ 总体思想:
useLayoutEffect
是 React 中用于处理同步副作用的核心 Hook,其执行发生在:
- DOM 已更新;
- 页面尚未重绘;
- 同步执行副作用逻辑;
- 可能修改 DOM 布局以避免视觉问题;
📌 关键流程图解:
六、底层实现原理详解
6.1 Hook 初始化阶段(简化源码模拟)
// 挂载阶段的 useLayoutEffect 实现(简化版)
function mountLayoutEffect(create, deps) {
// 1. 创建/获取当前正在处理的 WorkInProgress Hook
const hook = mountWorkInProgressHook();
// 2. 处理依赖项:将 undefined 转为 null(React 内部统一格式)
const nextDeps = deps === undefined ? null : deps;
// 3. 初始化副作用状态(存储创建函数和依赖项)
hook.memoizedState = {
create, // 用户传入的副作用创建函数
deps: nextDeps, // 依赖数组(可能为 null)
};
// 4. 标记当前 Fiber 需要执行 Layout Effect
// LayoutMask 表示该组件有 Layout 阶段的副作用需要同步执行
currentlyRenderingFiber.flags |= LayoutMask;
/* 完整执行流程:
1. 组件渲染时创建 Hook 并记录副作用
2. 通过 flags 标记需要执行 Layout Effect
3. 在提交阶段(Commit Phase)的 Layout 阶段:
- 执行所有带 LayoutMask 标记的副作用
- 同步执行 create 函数
- 捕获执行过程中的错误(通过 Error Boundary)
*/
}
注释说明:
hook.memoizedState
:保存副作用函数和依赖项;currentlyRenderingFiber.flags |= LayoutMask
:标记该 Hook 为 layout effect,需在 commit 阶段执行;
6.2 更新阶段(判断依赖变化)
// 更新阶段的 useLayoutEffect 实现(简化版)
function updateLayoutEffect(create, nextDeps) {
// 1. 获取当前正在处理的 WorkInProgress Hook(更新阶段的 Hook)
const hook = updateWorkInProgressHook();
// 2. 读取之前挂载阶段保存的副作用对象(包含 create 函数和依赖项)
const prevEffect = hook.memoizedState;
// 3. 依赖项对比:浅比较新旧依赖数组
if (areHookInputsEqual(prevEffect.deps, nextDeps)) {
// 依赖未变化时直接返回,不执行任何操作(性能优化)
return;
}
// 4. 依赖变化时创建新的副作用对象
const effect = {
create, // 用户传入的副作用创建函数(可能已更新)
deps: nextDeps, // 新的依赖数组
};
// 5. 更新 Hook 的 memoizedState 为新副作用对象
hook.memoizedState = effect;
// 6. 标记当前 Fiber 需要执行 Layout Effect(同步执行)
// LayoutMask 表示该组件有 Layout 阶段的副作用需要执行
currentlyRenderingFiber.flags |= LayoutMask;
/* 完整执行流程:
1. 组件更新时检测到 useLayoutEffect 调用
2. 通过 updateWorkInProgressHook 获取当前 Hook
3. 对比新旧依赖项(浅比较)
4. 依赖变化时:
- 创建新副作用对象
- 更新 Hook 状态
- 标记 Fiber 需执行 Layout Effect
5. 在提交阶段(Commit Phase)的 Layout 阶段:
- 执行所有带 LayoutMask 标记的副作用
- 同步执行 create 函数
- 自动捕获执行过程中的错误(通过 Error Boundary)
*/
}
注释说明:
areHookInputsEqual()
:比较依赖项是否变化;- 若依赖变化,更新 memoizedState;
- 继续标记为 layout effect,等待 commit 阶段执行;
6.3 commit 阶段执行副作用
// 提交阶段处理 Layout 效应的入口函数(简化版)
function commitLayoutEffects(finishedWork) {
// 获取当前 fiber 的对应 current 树节点(可能为 null,表示首次挂载)
const current = finishedWork.alternate;
// 检查该 fiber 是否有 Layout 阶段的副作用需要执行
if ((finishedWork.flags & LayoutMask) !== NoFlags) {
try {
// 执行具体的 Layout 效应逻辑
commitLayoutEffectOnFiber(current, finishedWork);
} catch (error) {
// 捕获提交阶段的错误(通过 Error Boundary 处理)
captureCommitPhaseError(finishedWork, error);
}
}
}
// 执行具体 Layout 效应的函数(简化版)
function commitLayoutEffectOnFiber(current, fiber) {
// 获取组件实例(类组件)或 null(函数组件)
const instance = fiber.stateNode;
// 获取当前 Hook 的 memoizedState(即保存的副作用对象)
const hook = fiber.memoizedState;
if (hook !== null) {
// 获取副作用对象(实际应为环形链表结构,此处简化)
const effect = hook;
// 执行副作用创建函数(用户传入的 create 函数)
const destroy = effect.create();
// 保存清理函数(实际 React 会保留之前的 destroy 用于卸载)
if (typeof destroy === 'function') {
effect.destroy = destroy; // ⚠️ 简化实现:实际需处理更新时的清理逻辑
}
}
}
注释说明:
commitLayoutEffects()
:在 commit 阶段执行所有 layout effect;commitLayoutEffectOnFiber()
:具体执行某个 fiber 上的 layout effect;effect.create()
:执行用户传入的副作用函数;effect.destroy
:保存清理函数供后续卸载时调用;
七、完整代码实现与注释(模拟版)
7.1 自定义 useLayoutEffect
Hook 模拟实现
// 定义全局变量追踪当前 Hook 和挂载状态(⚠️ 实际 React 使用 Fiber 链表管理)
let currentHook = null; // 当前处理的 Hook 节点
let isMounting = true; // 挂载阶段标记
function useMyLayoutEffect(create, deps) {
// 创建当前 Hook 的副作用对象(⚠️ 实际 React 使用环形链表存储)
const hook = {
create, // 用户传入的副作用创建函数(如:() => { ... })
deps, // 当前依赖数组(用于对比是否需要更新)
destroy: undefined, // 清理函数(由 create 返回,用于卸载时清理资源)
};
if (isMounting) {
// 组件初次挂载时执行
hook.destroy = create(); // 立即执行副作用函数并保存清理函数
} else {
// 组件更新阶段:对比新旧依赖
const prevDeps = currentHook.deps; // 获取前一次依赖
// 浅比较依赖数组(⚠️ 实际 React 使用 areHookInputsEqual)
const hasChanged = !prevDeps || !areDepsEqual(deps, prevDeps);
if (hasChanged) {
// 依赖变化:执行新副作用并保存清理函数
hook.destroy = create();
} else {
// 依赖未变:复用前一次的清理函数(⚠️ 实际需先执行旧清理再创建新副作用)
hook.destroy = currentHook.destroy;
}
}
currentHook = hook; // 更新当前 Hook 节点(⚠️ 实际 React 使用链表遍历)
isMounting = false; // 标记为更新阶段(⚠️ 组件卸载后重新挂载时需重置)
}
// 模拟依赖数组的浅比较函数
function areDepsEqual(nextDeps, prevDeps) {
// 遍历依赖数组进行严格相等比较(⚠️ 实际 React 使用 Object.is)
for (let i = 0; i < nextDeps.length; i++) {
if (!Object.is(nextDeps[i], prevDeps[i])) {
return false; // 发现不匹配项立即返回 false
}
}
return true; // 所有依赖项均匹配
}
7.2 使用示例:同步测量 DOM 尺寸
import { useRef, useLayoutEffect } from 'react';
function MeasureBox() {
const boxRef = useRef(null);
useLayoutEffect(() => {
const node = boxRef.current;
const rect = node.getBoundingClientRect();
console.log('盒子尺寸:', rect.width, rect.height);
}, []);
return (
<div ref={boxRef} style={{ width: '200px', height: '100px', background: 'lightblue' }}>
我是一个盒子
</div>
);
}
八、最佳实践与注意事项
✅ 推荐做法:
- 用于需要同步执行的 DOM 操作;
- 配合
ref
获取真实 DOM; - 避免在其中执行耗时任务,以免阻塞渲染;
- 配合
useEffect
构建完整的副作用管理方案;
❌ 不推荐做法:
- 不要在其中执行网络请求;
- 不要频繁修改 state 导致多次触发;
- 不要滥用,优先考虑
useEffect
; - 不要忘记清理副作用;
九、源码分析:ReactFiberHooks.js 中的实现
以下为 React 源码中 useLayoutEffect
的核心部分(简化):
// 挂载阶段的 useLayoutEffect 实现(React 内部核心逻辑)
function mountLayoutEffect(create, deps) {
// 1. 创建新 Hook 节点(WorkInProgress 阶段专用)
const hook = mountWorkInProgressHook();
// 2. 统一依赖项格式:将 undefined 转为 null(React 内部规范)
const nextDeps = deps === undefined ? null : deps;
// 3. 创建副作用描述符并挂载到 Hook 状态
hook.memoizedState = pushEffect(
// 副作用标志组合:Layout 阶段 + HasEffect(表示需要执行)
Layout | HasEffect,
// 用户传入的副作用创建函数(如:() => { ...副作用逻辑... })
create,
// 清理函数(首次挂载时无旧副作用,故为 undefined)
undefined,
// 处理后的依赖数组(可能为 null)
nextDeps
);
}
// 更新阶段的 useLayoutEffect 实现(React 内部核心逻辑)
function updateLayoutEffect(create, deps) {
// 1. 获取当前正在处理的 WorkInProgress Hook 节点
const hook = updateWorkInProgressHook();
// 2. 统一依赖项格式(保持与挂载阶段一致的处理逻辑)
const nextDeps = deps === undefined ? null : deps;
// 3. 读取之前挂载阶段保存的副作用描述符
const effect = hook.memoizedState;
// 4. 浅比较新旧依赖数组(React 内部优化关键步骤)
if (areHookInputsEqual(effect.deps, nextDeps)) {
// 5. 依赖未变化:创建无 HasEffect 的副作用描述符
hook.memoizedState = pushEffect(
Layout, // 仅保留 Layout 阶段标志(跳过执行)
create, // 用户传入的副作用创建函数
effect.destroy, // 复用之前挂载阶段的清理函数
nextDeps // 新依赖数组(可能未变)
);
} else {
// 6. 依赖变化:创建带 HasEffect 的副作用描述符
hook.memoizedState = pushEffect(
Layout | HasEffect, // 标记需要执行 Layout 副作用
create, // 用户传入的副作用创建函数
effect.destroy, // 复用之前挂载阶段的清理函数
nextDeps // 新依赖数组(已变化)
);
}
}
/* 关键概念说明:
1. Layout 阶段:
- 发生在 DOM 更新后、浏览器重绘前
- 适合需要立即操作 DOM 的副作用(如:读取 DOM 尺寸)
2. HasEffect 标志:
- 控制副作用是否需要执行
- 挂载阶段必然设置
- 更新阶段依赖变化时设置
3. pushEffect 函数:
- 将副作用描述符加入副作用链表
- 返回新的副作用描述符(供 Hook 状态存储)
4. areHookInputsEqual:
- 浅比较依赖数组(Object.is)
- 用于优化性能(跳过未变化的副作用)
*/
十、常见面试题(高频)
题目 | 答案概要 |
---|---|
1. useLayoutEffect 和 useEffect 有什么区别? | useLayoutEffect 同步执行,影响布局;useEffect 异步执行,不影响渲染。 |
2. useLayoutEffect 会在什么时候执行? | 在 DOM 更新后、页面绘制前同步执行。 |
3. useLayoutEffect 是否会影响性能? | 会,因为它同步执行,可能阻塞页面渲染。 |
4. useLayoutEffect 是否可以返回清除函数? | 可以,用于清理副作用。 |
5. useLayoutEffect 是否可以在服务端渲染(SSR)中使用? | 可以,但要注意 window 对象不存在。 |
6. useLayoutEffect 是否可以在类组件中使用? | 不支持,只能用于函数组件。 |
7. useLayoutEffect 是否可以用于动画? | 可以,常用于动画初始化计算。 |
8. useLayoutEffect 是否可以替代 window.addEventListener('resize') ? | 可以结合使用,用于监听 DOM 布局变化。 |
9. useLayoutEffect 是否可以用于表单验证? | 不推荐,应使用 useEffect 或其他状态管理方式。 |
10. useLayoutEffect 是否必须提供依赖项? | 不是必须,但建议提供以避免不必要的重复执行。 |
十一、结语
通过对 useLayoutEffect
的深入剖析,我们了解到它是 React 中处理同步副作用的关键 Hook,适用于 DOM 布局测量、动画初始化等场景。
掌握它的原理、使用方式以及最佳实践,有助于你:
- 更好地理解 React 的副作用系统;
- 构建更稳定、流畅的 UI;
- 应对高级前端面试和技术挑战;
如果你希望进一步研究,老曹建议阅读官方源码中的如下部分:
ReactFiberHooks.js
:查看useLayoutEffect
的完整实现;ReactFiberBeginWork.js
:观察 Hook 初始化流程;ReactFiberCommitWork.js
:查看 commit 阶段如何执行 layout effect;ReactFiberFlags.js
:了解副作用标记机制;ReactFiberWorkLoop.js
:学习 React 的副作用调度机制。