跳到主要内容

认识

2023年02月22日
柏拉文
越努力,越幸运

一、认识


useEffectReact 为函数组件提供的一个 Hook,用于管理副作用(side effects),例如数据获取、订阅、DOM 操作、定时器等。它让函数组件也能模拟类组件中的生命周期行为,确保在组件挂载、更新和卸载时执行合适的操作。useEffect: 初始渲染或者状态更新之后, 在 commit 阶段完成以后异步执行,所以 effect 回调函数不会阻塞浏览器绘制视图。useEffect 执行是在浏览器绘制视图之后,如果修改 DOM 布局放在 useEffect ,那 useEffect 执行是在浏览器绘制视图之后,接下来又改 DOM ,就可能会导致浏览器再次回流和重绘。而且由于两次绘制,视图上可能会造成闪现突兀的效果。除修改 DOM ,改变布局的场景外,其他情况都用 useEffect

useEffectcommit 阶段之后异步执行, 不会阻塞浏览器的渲染。useEffect 初始化渲染之后执行一次。如果不传入依赖项, 每次组件渲染后都会执行 effect 回调, 适用于那些不依赖外部变量的副作用,但会导致每次渲染都执行,可能引起性能问题。如果传入的依赖项是空数组, useEffect 只会在初始化渲染之后执行一次, 在组件卸载时执行清理函数。这是模拟 componentDidMountcomponentWillUnmount 的常见模式。如果传入的依赖项有状态, 当传入依赖数组且其中的某个依赖发生变化时, 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 无限循环

细节
  1. 如果在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 条件执行

细节
  1. 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 实现原理?

useEffectReact 为函数组件提供的一个 Hook,用于管理副作用(side effects),例如数据获取、订阅、DOM 操作、定时器等。它让函数组件也能模拟类组件中的生命周期行为,确保在组件挂载、更新和卸载时执行合适的操作。

useEffect 初始化渲染之后执行一次。如果不传入依赖项, 每次组件渲染后都会执行 effect 回调, 适用于那些不依赖外部变量的副作用,但会导致每次渲染都执行,可能引起性能问题。如果传入的依赖项是空数组, useEffect 只会在初始化渲染之后执行一次, 在组件卸载时执行清理函数。这是模拟 componentDidMountcomponentWillUnmount 的常见模式。如果传入的依赖项有状态, 当传入依赖数组且其中的某个依赖发生变化时, React 遵循的原则就是 先清理后创建, React 会在执行新的 effect 回调前,先执行上一次 effect 返回的清理函数(destroy 回调)。这种 先清理后创建 策略可以防止旧副作用干扰新逻辑, 避免潜在的内存泄漏或其他副作用问题。对于不同的 useEffect 钩子, 父子组件的执行顺序是: 先子后父

具体实现如下:

useEffect 可以让你在函数中使用副作用,执行时机在浏览器绘制之后。对于 useEffect 执行, React 处理逻辑是采用异步调用 ,对于每一个 effectcallbackReact 会向 setTimeout 回调函数一样,放入任务队列,等到主线程任务完成,DOM 更新,js 执行完成,视图绘制完毕,才执行。所以 effect 回调函数不会阻塞浏览器绘制视图对于不同的 effect 钩子, 父子组件的执行顺序是: 先子后父

Mount 阶段: 调用 mountEffect, 创建一个 Hook 对象, 存储在 当前处理的 fiber.memoizedState 中。Hook 对象中也有一个 memoizedState, 用于存储 effect 对象。另外还有 nextbaseQueuebaseStateupdateQueue。此时的 Hook.memoizedState 为包含 useEffect 回调函数、依赖项等的链表数据结构 effecteffect 中有 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 阶段:

  1. 调度副作用: 判断 root.finishedWork.flags 或者 root.finishedWork.subtreeFlags 是否存在 PassiveMask 标记, 如果存在, 则通过 scheduleCallback 调度 useEffect 副作用

  2. 收集副作用: 通过 root.pendingPassiveEffects 来存储 updateunmount 两种情况的副作用

    1. commit mutation 挂载阶段: 如果 root.finishedWork.flags 存在 PassiveEffect, 开始收集 update 副作用回调, 存储到 root.pendingPassiveEffectsupdate 中。

    2. commit mutation Deletion 删除阶段: 如果 root.finishedWork.flags 存在 PassiveEffect, 开始收集 unmount 副作用回调, 存储到 root.pendingPassiveEffectsunmount 中。

  3. 执行副作用: 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 通过先清理后创建, 确保新的副作用不会与未清理的旧副作用发生冲突,避免出现数据竞态或副作用残留问题。通过分别维护 unmountupdate 队列,React 能够精确地控制副作用的清理和创建顺序,使得整个更新流程更加可靠。清理旧副作用有助于释放占用的资源,比如事件监听、定时器等,防止潜在的内存泄漏问题。

  4. 处理更新流程: 在 useEffectcreate 回调中, 可能会有 setState 等触发新的更新的操作, 所以等 destroycreate 回调执行完毕后继续处理更新流程。

理解

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 类组件中, 实现这样一个场景: 初始渲染完成后, 根据状态来订阅, 状态更新后, 需要取消之前的订阅, 重新订阅, 在组件卸载后取消全部订阅。这样一个场景, 需要 componentDidMountcomponentDidUpdatecomponentWillUnmount, 而且, 在 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 这样的实现在解决类组件问题同时, 通过先清理后创建, 确保新的副作用不会与未清理的旧副作用发生冲突,避免出现数据竞态或副作用残留问题。通过分别维护 unmountupdate 队列,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 只会在初始化渲染之后执行一次, 在组件卸载时执行清理函数。这是模拟 componentDidMountcomponentWillUnmount 的常见模式。如果传入的依赖项有状态, 当传入依赖数组且其中的某个依赖发生变化时, React 遵循的原则就是 先清理后创建, React 会在执行新的 effect 回调前,先执行上一次 effect 返回的清理函数(destroy 回调)。这种 先清理后创建 策略可以防止旧副作用干扰新逻辑, 避免潜在的内存泄漏或其他副作用问题。

useEffectcomponentDidMount 都遵循 先子后父 的顺序, 从最深层的子组件开始一次调用各个组件的 useEffect 或者 componentDidMount。确保在父组件的 useEffect 或者 componentDidMount 执行时,所有子组件已经挂载完毕。为什么呢? 本质上 commit 阶段处理的事情和 dom 元素有关系,commit 阶段生命周期是可以改变真实 dom 元素的状态的,所以如果在子组件生命周期内改变 dom 状态,并且想要在父组件的生命周期中同步状态,就需要确保父组件的生命周期执行时机要晚于子组件。

但是, useEffectcommit 阶段之后异步执行, 不会阻塞浏览器的渲染。而 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

useLayoutEffectuseEffect 调用顺序一致, 都遵循 先子后父。为什么呢? 本质上 commit 阶段处理的事情和 dom 元素有关系,commit 阶段生命周期是可以改变真实 dom 元素的状态的,所以如果在子组件生命周期内改变 dom 状态,并且想要在父组件的生命周期中同步状态,就需要确保父组件的生命周期执行时机要晚于子组件。

4.5 如何在 React 函数组件区分 mounted 和 updated(比如需要在 updated 时候做一个操作,但是 mounted 时候不需要)

思路: 可以通过配合 useEffectuseRef 来区分组件的挂载(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 中的闭包陷阱?: useMemouseEffectuseCallbackReact 组件函数首次执行时创建的函数作用域, 会捕获当时渲染的 propsstate,而后续再次调用它们取决于依赖数组更新与否。若依赖数组为空 ([]) 或未发生变化, React 不重新执行,这些 Hook 捕获的闭包变量值永远是旧的。若依赖数组变化, 会重新执行, 捕获最新的闭包变量。解决方案如下:

  1. 使用 useRef 保存最新值, 有时你不希望频繁重渲染,可以使用 useRef 来存储最新的状态或 props,并在回调中引用 ref.current

  2. 通过正确设置依赖项, 确保函数重新捕获最新的值, 在 useEffectuseCallbackuseMemo 中,要确保依赖数组中包含所有在回调中使用到的状态或 props,这样 React 会在依赖变化时重新创建回调函数,避免闭包问题。

4.7 useEffect 对应类组件的哪些生命周期?

useEffect 对应类组件的 componentDidMountcomponentDidUpdatecomponentcomponentWillUnmount 生命周期。

useEffect 初始化渲染之后执行一次。如果不传入依赖项, 每次组件渲染后都会执行 effect 回调, 适用于那些不依赖外部变量的副作用,但会导致每次渲染都执行,可能引起性能问题。如果传入的依赖项是空数组, useEffect 只会在初始化渲染之后执行一次, 在组件卸载时执行清理函数。这是模拟 componentDidMountcomponentWillUnmount 的常见模式。如果传入的依赖项有状态, 当传入依赖数组且其中的某个依赖发生变化时, React 遵循的原则就是 先清理后创建, React 会在执行新的 effect 回调前,先执行上一次 effect 返回的清理函数(destroy 回调)。这种 先清理后创建 策略可以防止旧副作用干扰新逻辑, 避免潜在的内存泄漏或其他副作用问题。

4.8 如何让 useEffect 支持 async/await?

因为 ReactuseEffect 不能直接接受 async 函数(async 函数返回 Promise, 而 useEffect 需要返回清理函数或 void,因此我们通常的做法是:

  1. useEffect 内部定义一个异步函数并立即调用。

  2. 或封装自定义 Hook,使代码更整洁、更易维护。

  3. 如果需要清理异步请求(如 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>;
}