认识
一、认识
useEffect
是 React
为函数组件提供的一个 Hook
,用于管理副作用(side effects
),例如数据获取、订阅、DOM
操作、定时器等。它让函数组件也能模拟类组件中的生命周期行为,确保在组件挂载、更新和卸载时执行合适的操作。useEffect
: 初始渲染或者状态更新之后, 在 commit
阶段完成以后异步执行,所以 effect
回调函数不会阻塞浏览器绘制视图。useEffect
执行是在浏览器绘制视图之后,如果修改 DOM
布局放在 useEffect
,那 useEffect
执行是在浏览器绘制视图之后,接下来又改 DOM
,就可能会导致浏览器再次回流和重绘。而且由于两次绘制,视图上可能会造成闪现突兀的效果。除修改 DOM
,改变布局的场景外,其他情况都用 useEffect
。
useEffect
在 commit
阶段之后异步执行, 不会阻塞浏览器的渲染。useEffect
初始化渲染之后执行一次。如果不传入依赖项, 每次组件渲染后都会执行 effect
回调, 适用于那些不依赖外部变量的副作用,但会导致每次渲染都执行,可能引起性能问题。如果传入的依赖项是空数组, useEffect
只会在初始化渲染之后执行一次, 在组件卸载时执行清理函数。这是模拟 componentDidMount
和 componentWillUnmount
的常见模式。如果传入的依赖项有状态, 当传入依赖数组且其中的某个依赖发生变化时, React
遵循的原则就是 先清理后创建, React
会在执行新的 effect
回调前,先执行上一次 effect
返回的清理函数(destroy
回调)。这种 先清理后创建 策略可以防止旧副作用干扰新逻辑, 避免潜在的内存泄漏或其他副作用问题。
对于不同的 useEffect
钩子, 父子组件的执行顺序是: 先子后父。为什么呢? 本质上 commit
阶段处理的事情和 dom
元素有关系,commit
阶段生命周期是可以改变真实 dom
元素的状态的,所以如果在子组件生命周期内改变 dom
状态,并且想要在父组件的生命周期中同步状态,就需要确保父组件的生命周期执行时机要晚于子组件。
二、语法
useEffect(() => { doSomething },[a,b]);
-
第一个参数: 初始渲染后触发
doSomething
: 组件重新渲染后根据 [a,b] 决定是否触发 doSomething 副作用。(注意副作用是在渲染完成之后执行)- doSomething 返回函数:组件销毁时触发 doSomething 返回函数; 组件重新渲染后根据 [a,b] 决定是否触发 doSomething 返回函数,这时候先执行 doSomething 返回函数,后执行 doSomething 副作用。
-
[a,b]
:-
如果为
[a,b]
,只有a,b
更新引起的组件重新渲染后才会重新调用doSomething
,以及doSomething
返回函数 -
如果为
[]
, 无论组件是否重新渲染,都不会重新调用doSomething
, 以及doSomething
返回函数 -
如果没有
[a,b]
或者[]
时,组件每次重新渲染都会调用doSomething
包括doSomething
的返回函数
-
三、用法
3.1 useEffect 无限循环
- 如果在
useEffect
中修改状态,且不加任何条件,那么循环调用useEffect
,进入死循环
import React, { useState, useEffect } from "react";
function App() {
const [number, setNumber] = useState(0);
useEffect(() => {
setNumber((state) => {
return state + 1;
});
});
return <div>{number}</div>;
}
export default App;
3.2 useEffect 条件执行
useEffect
如果要限定只是初始渲染执行一次或者根据某个状态变化来执行,需要限制条件
[]
空数组: 控制 useEffect 只在初始化时执行
import React, { useState, useEffect } from "react";
function App() {
const [number, setNumber] = useState(0);
useEffect(() => {
setNumber((state) => {
return state + 1;
});
},[]);
return <div>{number}</div>;
}
export default App;
[x状态,y状态,……]
: 控制 useEffect 只在初始化、x状态、y状态等发生变化时执行
import React, { useState, useEffect } from "react";
function App() {
const [number, setNumber] = useState(0);
useEffect(()=>{
console.log('哈哈');
},[number]);
return <div>{number}</div>;
}
export default App;
3.3 useEffect 销毁副作用
通过return ()=>{}
来销毁副作用
import React, { useState, useEffect } from "react";
function App() {
const [number, setNumber] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
setNumber((state) => {
return state + 1;
});
});
return ()=>{
clearTimeout(timer);
}
}, []);
return <div>{number}</div>;
}
export default App;
四、问题
4.1 useEffect 实现原理?
useEffect
是 React
为函数组件提供的一个 Hook
,用于管理副作用(side effects
),例如数据获取、订阅、DOM
操作、定时器等。它让函数组件也能模拟类组件中的生命周期行为,确保在组件挂载、更新和卸载时执行合适的操作。
useEffect
初始化渲染之后执行一次。如果不传入依赖项, 每次组件渲染后都会执行 effect
回调, 适用于那些不依赖外部变量的副作用,但会导致每次渲染都执行,可能引起性能问题。如果传入的依赖项是空数组, useEffect
只会在初始化渲染之后执行一次, 在组件卸载时执行清理函数。这是模拟 componentDidMount
和 componentWillUnmount
的常见模式。如果传入的依赖项有状态, 当传入依赖数组且其中的某个依赖发生变化时, React
遵循的原则就是 先清理后创建, React
会在执行新的 effect
回调前,先执行上一次 effect
返回的清理函数(destroy
回调)。这种 先清理后创建 策略可以防止旧副作用干扰新逻辑, 避免潜在的内存泄漏或其他副作用问题。对于不同的 useEffect
钩子, 父子组件的执行顺序是: 先子后父。
具体实现如下:
useEffect
可以让你在函数中使用副作用,执行时机在浏览器绘制之后。对于 useEffect
执行, React
处理逻辑是采用异步调用 ,对于每一个 effect
的 callback
, React
会向 setTimeout
回调函数一样,放入任务队列,等到主线程任务完成,DOM
更新,js
执行完成,视图绘制完毕,才执行。所以 effect
回调函数不会阻塞浏览器绘制视图。对于不同的 effect
钩子, 父子组件的执行顺序是: 先子后父。
Mount
阶段: 调用 mountEffect
, 创建一个 Hook
对象, 存储在 当前处理的 fiber.memoizedState
中。Hook
对象中也有一个 memoizedState
, 用于存储 effect
对象。另外还有 next
、baseQueue
、baseState
、updateQueue
。此时的 Hook.memoizedState
为包含 useEffect
回调函数、依赖项等的链表数据结构 effect
。effect
中有 deps
依赖项、create
回调函数、destroy
销毁回调、tag
用来表示表本次更新是否存在副作用、以及 next
。在 Mount
初始阶段, 将当前 Fiber.flags
标记为 PassiveEffect
, 将 effect.tag
标记为 Passive
, 标记当前 Fiber
的当前 useEffect Hook
本次更新存在副作用。
Update
阶段: 从当前的 Fiber
中取出 Hook
链表, 依次循环遍历执行, 执行到 useEffect
时, 更新通过 Object.is
循环对比依赖项数组, 如果依赖项中的每一项依赖都没有发生变化, 则不做任何处理。如果发生变化, 则将当前 Fiber.flags
标记为 PassiveEffect
, 将 effect.tag
标记为 Passive
, 标记当前 Fiber
的当前 useEffect Hook
本次更新存在副作用。
在 Render CompleteWork
归阶段, 将子 fiber.flags
冒泡到父 fiber.flags
中, 最终会冒泡到 root.finishedWork.flags
或者 root.finishedWork.subtreeFlags
在 Commit
阶段:
-
调度副作用: 判断
root.finishedWork.flags
或者root.finishedWork.subtreeFlags
是否存在PassiveMask
标记, 如果存在, 则通过scheduleCallback
调度useEffect
副作用 -
收集副作用: 通过
root.pendingPassiveEffects
来存储update
和unmount
两种情况的副作用-
在
commit mutation
挂载阶段: 如果root.finishedWork.flags
存在PassiveEffect
, 开始收集update
副作用回调, 存储到root.pendingPassiveEffects
的update
中。 -
在
commit mutation Deletion
删除阶段: 如果root.finishedWork.flags
存在PassiveEffect
, 开始收集unmount
副作用回调, 存储到root.pendingPassiveEffects
的unmount
中。
-
-
执行副作用:
React 18
中的useEffect
底层调度机制遵循的原则就是 先清理后创建,确保在执行新的副作用(create
回调)之前,所有旧的副作用(destroy
回调)都已经被清理完毕。-
3.1 卸载阶段 (
Unmount
) 的清理: 当组件即将卸载时,React
会将所有useEffect
中注册的destroy
回调存入root.pendingPassiveEffects.unmount
队列中。随后,React
遍历该队列,依次执行每个destroy
回调。这确保了组件卸载时能够正确清除所有副作用,避免内存泄漏或其他潜在问题。 -
3.2 更新阶段 (
Update
) 的清理: 在组件更新时,如果某个useEffect
的依赖发生了变化,React
会将上一次执行该effect
时返回的destroy
回调存入root.pendingPassiveEffects.update
队列中。接着,在本次更新的create
回调执行前,React
会先遍历这个队列并执行所有的destroy
回调,确保上一次副作用的清理已经完成。 -
3.3 更新阶段 (
Update
) 的副作用创建: 在所有旧副作用的destroy
回调执行完毕后,React
再次遍历root.pendingPassiveEffects.update
队列,这时只处理那些带有副作用标记的create
回调。只有在清理工作完成后,新的create
回调才会被执行,从而设置新的副作用。
useEffect
通过先清理后创建, 确保新的副作用不会与未清理的旧副作用发生冲突,避免出现数据竞态或副作用残留问题。通过分别维护unmount
和update
队列,React
能够精确地控制副作用的清理和创建顺序,使得整个更新流程更加可靠。清理旧副作用有助于释放占用的资源,比如事件监听、定时器等,防止潜在的内存泄漏问题。 -
-
处理更新流程: 在
useEffect
的create
回调中, 可能会有setState
等触发新的更新的操作, 所以等destroy
、create
回调执行完毕后继续处理更新流程。
理解
useEffect(() => {
console.log("执行 create 回调"); // 初始渲染后或者组件中任意状态更新, 都会执行 create 回调
return () => {
console.log("执行 destroy 回调"); // 组件中任意状态更新或者组件卸载, 都会执行 destroy 回调, 且 destroy 先执行
};
});
useEffect(() => {
console.log("执行 create 回调"); // 初始渲染, 会执行 create 回调
return () => {
console.log("执行 destroy 回调"); // 组件卸载, 会执行 destroy 回调
};
}, []);
useEffect(() => {
console.log("执行 create 回调"); // 初始渲染后或者每次 count 更新后, 都会执行 create 回调
return () => {
console.log("执行 destroy 回调"); // 每次 count 更新后或者组件卸载, 都会执行 destroy 回调, 且 destroy 先执行。
};
}, [count]);
4.2 为什么每次更新的时候都要运行 useEffect ?
在 React
类组件中, 实现这样一个场景: 初始渲染完成后, 根据状态来订阅, 状态更新后, 需要取消之前的订阅, 重新订阅, 在组件卸载后取消全部订阅。这样一个场景, 需要 componentDidMount
、componentDidUpdate
、componentWillUnmount
, 而且, 在 componentDidUpdate
需要重复实现 componentWillUnmount
的取消订阅逻辑 和 componentDidMount
的订阅逻辑。这样, 才能实现一个完整订阅逻辑, 而且没有内存泄漏的风险。因此, 在实现 useEffect
的时候, React
想让开发者更明确、灵活的控制 useEffect
副作用的执行, 通过依赖项数组来定制执副作用的执行频率, 这样有效解决了 类组件 的问题。如果没有依赖项, 初始渲染后以及每次组件更新时,都会执行 useEffect
的副作用, 如果提供依赖项, 只有在初始渲染和依赖项发生变化时才会执行 useEffect
的副作用。如果依赖项为空数组, 则只有初始渲染后才会执行副作用。React 18
内部对于 useEffect
的调度机制主要为先清理, 后创建。
一、卸载阶段 (Unmount
) 的清理: 当组件即将卸载时,React
会将所有 useEffect
中注册的 destroy
回调存入 root.pendingPassiveEffects.unmount
队列中。随后,React
遍历该队列,依次执行每个 destroy
回调。这确保了组件卸载时能够正确清除所有副作用,避免内存泄漏或其他潜在问题。
二、更新阶段 (Update
) 的清理: 在组件更新时,如果某个 useEffect
的依赖发生了变化,React
会将上一次执行该 effect
时返回的 destroy
回调存入 root.pendingPassiveEffects.update
队列中。接着,在本次更新的 create
回调执行前,React
会先遍历这个队列并执行所有的 destroy
回调,确保上一次副作用的清理已经完成。
三、更新阶段 (Update
) 的副作用创建: 在所有旧副作用的 destroy
回调执行完毕后,React
再次遍历 root.pendingPassiveEffects.update
队列,这时只处理那些带有副作用标记的 create
回调。只有在清理工作完成后,新的 create
回调才会被执行,从而设置新的副作用。
useEffect
这样的实现在解决类组件问题同时, 通过先清理后创建, 确保新的副作用不会与未清理的旧副作用发生冲突,避免出现数据竞态或副作用残留问题。通过分别维护 unmount
和 update
队列,React
能够精确地控制副作用的清理和创建顺序,使得整个更新流程更加可靠。清理旧副作用有助于释放占用的资源,比如事件监听、定时器等,防止潜在的内存泄漏问题。
理解
useEffect(() => {
console.log("执行 create 回调"); // 初始渲染后或者组件中任意状态更新, 都会执行 create 回调
return () => {
console.log("执行 destroy 回调"); // 组件中任意状态更新或者组件卸载, 都会执行 destroy 回调, 且 destroy 先执行
};
});
useEffect(() => {
console.log("执行 create 回调"); // 初始渲染, 会执行 create 回调
return () => {
console.log("执行 destroy 回调"); // 组件卸载, 会执行 destroy 回调
};
}, []);
useEffect(() => {
console.log("执行 create 回调"); // 初始渲染后或者每次 count 更新后, 都会执行 create 回调
return () => {
console.log("执行 destroy 回调"); // 每次 count 更新后或者组件卸载, 都会执行 destroy 回调, 且 destroy 先执行。
};
}, [count]);
4.3 useEffect 与 componentDidMount 的区别?
componentDidMount
是类组件中的一个生命周期, 在组件初始渲染后执行一次。而 useEffect
是函数组件 Hook
, 初始化渲染之后执行一次。如果不传入依赖项, 每次组件渲染后都会执行 effect
回调, 适用于那些不依赖外部变量的副作用,但会导致每次渲染都执行,可能引起性能问题。如果传入的依赖项是空数组, useEffect
只会在初始化渲染之后执行一次, 在组件卸载时执行清理函数。这是模拟 componentDidMount
和 componentWillUnmount
的常见模式。如果传入的依赖项有状态, 当传入依赖数组且其中的某个依赖发生变化时, React
遵循的原则就是 先清理后创建, React
会在执行新的 effect
回调前,先执行上一次 effect
返回的清理函数(destroy
回调)。这种 先清理后创建 策略可以防止旧副作用干扰新逻辑, 避免潜在的内存泄漏或其他副作用问题。
useEffect
与 componentDidMount
都遵循 先子后父 的顺序, 从最深层的子组件开始一次调用各个组件的 useEffect
或者 componentDidMount
。确保在父组件的 useEffect
或者 componentDidMount
执行时,所有子组件已经挂载完毕。为什么呢? 本质上 commit
阶段处理的事情和 dom
元素有关系,commit
阶段生命周期是可以改变真实 dom
元素的状态的,所以如果在子组件生命周期内改变 dom
状态,并且想要在父组件的生命周期中同步状态,就需要确保父组件的生命周期执行时机要晚于子组件。
但是, useEffect
在 commit
阶段之后异步执行, 不会阻塞浏览器的渲染。而 componentDidMount
是在 commit
阶段同步完成的。
4.4 useLayoutEffect 与 useEffect 的区别? 什么时候用 useLayoutEffect, 什么时候用 useEffect?
useLayoutEffect
: 初始渲染或者状态更新之后, 在 commit
阶段中同步执行, 所以 useLayoutEffect callback
中代码执行会阻塞浏览器绘制。
useLayoutEffect
是在 DOM
更新之后,浏览器绘制之前,这样可以方便修改 DOM
,获取 DOM
信息,这样浏览器只会绘制一次。修改 DOM
,改变布局就用 useLayoutEffect
useEffect
: 初始渲染或者状态更新之后, 在 commit
阶段完成以后异步执行,所以 effect
回调函数不会阻塞浏览器绘制视图。useEffect
执行是在浏览器绘制视图之后,如果修改 DOM
布局放在 useEffect
,那 useEffect
执行是在浏览器绘制视图之后,接下来又改 DOM
,就可能会导致浏览器再次回流和重绘。而且由于两次绘制,视图上可能会造成闪现突兀的效果。除修改 DOM
,改变布局的场景外,其他情况都用 useEffect
useLayoutEffect
同 useEffect
调用顺序一致, 都遵循 先子后父。为什么呢? 本质上 commit
阶段处理的事情和 dom
元素有关系,commit
阶段生命周期是可以改变真实 dom
元素的状态的,所以如果在子组件生命周期内改变 dom
状态,并且想要在父组件的生命周期中同步状态,就需要确保父组件的生命周期执行时机要晚于子组件。
4.5 如何在 React 函数组件区分 mounted 和 updated(比如需要在 updated 时候做一个操作,但是 mounted 时候不需要)
思路: 可以通过配合 useEffect
与 useRef
来区分组件的挂载(mounted
)与更新(updated
)。基本思路是利用一个 ref
保存组件是否已经挂载的状态,初始值为 false
,在第一次执行 useEffect
时不执行更新逻辑,而是将该值设为 true
。后续当依赖变化时,再执行更新时的操作。
import { useRef, useEffect } from "react";
function useUpdateEffect(effect, deps) {
const isMounted = useRef(false);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
} else {
return effect();
}
}, [deps]);
}
export default useUpdateEffect;
4.6 有遇到过 React Hooks 中的闭包问题吗? 如何解决?
什么是 React Hooks
中的闭包陷阱?: useMemo
、useEffect
、useCallback
在 React
组件函数首次执行时创建的函数作用域, 会捕获当时渲染的 props
或 state
,而后续再次调用它们取决于依赖数组更新与否。若依赖数组为空 ([]
) 或未发生变化, React
不重新执行,这些 Hook
捕获的闭包变量值永远是旧的。若依赖数组变化, 会重新执行, 捕获最新的闭包变量。解决方案如下:
-
使用
useRef
保存最新值, 有时你不希望频繁重渲染,可以使用useRef
来存储最新的状态或props
,并在回调中引用ref.current
。 -
通过正确设置依赖项, 确保函数重新捕获最新的值, 在
useEffect
、useCallback
或useMemo
中,要确保依赖数组中包含所有在回调中使用到的状态或props
,这样React
会在依赖变化时重新创建回调函数,避免闭包问题。
4.7 useEffect 对应类组件的哪些生命周期?
useEffect
对应类组件的 componentDidMount
、componentDidUpdate
、component
、componentWillUnmount
生命周期。
useEffect
初始化渲染之后执行一次。如果不传入依赖项, 每次组件渲染后都会执行 effect
回调, 适用于那些不依赖外部变量的副作用,但会导致每次渲染都执行,可能引起性能问题。如果传入的依赖项是空数组, useEffect
只会在初始化渲染之后执行一次, 在组件卸载时执行清理函数。这是模拟 componentDidMount
和 componentWillUnmount
的常见模式。如果传入的依赖项有状态, 当传入依赖数组且其中的某个依赖发生变化时, React
遵循的原则就是 先清理后创建, React
会在执行新的 effect
回调前,先执行上一次 effect
返回的清理函数(destroy
回调)。这种 先清理后创建 策略可以防止旧副作用干扰新逻辑, 避免潜在的内存泄漏或其他副作用问题。
4.8 如何让 useEffect 支持 async/await?
因为 React
的 useEffect
不能直接接受 async
函数(async
函数返回 Promise
, 而 useEffect
需要返回清理函数或 void
),因此我们通常的做法是:
-
在
useEffect
内部定义一个异步函数并立即调用。 -
或封装自定义
Hook
,使代码更整洁、更易维护。 -
如果需要清理异步请求(如
fetch
),则使用AbortController
和挂载状态标记。
常见错误示范, 直接传入 async
是错误的:
// 错误用法 ❌
useEffect(async () => {
const data = await fetchData(); // 会导致 React 警告
}, []);
标准模式, 在 useEffect
内部创建一个立即执行的异步函数
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
};
fetchData();
}, []); // 依赖项数组
return <div>My Component</div>;
}
最佳实践, 为了复用和保持组件整洁,建议封装为一个自定义 Hook
import { useEffect, useState } from 'react';
// 自定义 Hook:封装异步操作
function useAsyncEffect(asyncCallback, dependencies) {
useEffect(() => {
asyncCallback();
// 无需返回清理函数时,直接调用即可
}, dependencies);
}
// 用法
function MyComponent() {
const [data, setData] = useState(null);
useAsyncEffect(async () => {
try {
const response = await fetch('https://api.example.com/data');
const result = await response.json();
setData(result);
} catch (err) {
console.error(err);
}
}, []); // 依赖项根据需要设置
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}
进阶用法, 如果需要支持清理函数(例如取消请求),可以
import { useEffect, useState } from 'react';
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
let isMounted = true; // 标记组件是否还挂载中
const controller = new AbortController(); // 可取消 fetch 请求
const fetchData = async () => {
try {
const res = await fetch('https://api.example.com/data', {
signal: controller.signal,
});
const result = await res.json();
if (isMounted) setData(result);
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err);
}
}
};
fetchData();
return () => {
isMounted = false; // 组件卸载时取消状态更新
controller.abort(); // 中止请求
};
}, []);
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}