useEffect 是 React 中的一个 Hook,它能够在函数组件中执行副作用函数。useEffect 可以被认为是 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个生命周期方法的组合。

副作用函数

副作用函数通常是开发者定义的在组件渲染过程中需要执行的那些操作,这些操作会影响组件之外的系统,例如发起API请求、执行手动DOM操作、设置定时器、记录日志、订阅外部数据源等。这些副作用不能在组件的渲染函数中直接执行,因为这样做可能会导致不可预测的行为和性能问题。

在React中,副作用函数通常是指传递给 useEffect Hook 的函数

useEffect的基本使用

无依赖执行

每次渲染都会执行,等价于componentDidMount和componentDidUpdate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*
* @Description:
* @Author: xiuji
* @Date: 2024-01-23 09:10:48
* @LastEditTime: 2024-01-23 10:06:08
* @LastEditors: Do not edit
*/
import { useState, useEffect } from "react"
const EffectDemo = () => {
let [count, setCount] = useState(0),
[flag, setFlag] = useState(true);

// @1 无依赖执行
// 每次渲染都会执行,等价于componentDidMount和componentDidUpdate
useEffect(() => {
// console.log('useEffect无依赖执行');
})

const handle = () => {
setCount(count + 1);
}

return (
<div>
<h1>{count}</h1>
<h1>{flag.toString()}</h1>
<button onClick={handle}>count+1</button>
<button onClick={() => setFlag(!flag)}>flag</button>
</div>
)
}

export default EffectDemo;

空数组依赖执行

  • 只在第一次渲染完毕后执行
  • 每一次视图更新完毕后callback不再执行,类似于componentDidMount
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { useState, useEffect } from "react"
const EffectDemo = () => {
let [count, setCount] = useState(0),
[flag, setFlag] = useState(true);

// 空数组依赖执行
// 只在第一次渲染完毕后执行
// 每一次视图更新完毕后callback不再执行,类似于componentDidMount
useEffect(() => {
console.log('useEffect空数组依赖执行');
}, [])

const handle = () => {
setCount(count + 1);
}

return (
<div>
<h1>{count}</h1>
<h1>{flag.toString()}</h1>
<button onClick={handle}>count+1</button>
<button onClick={() => setFlag(!flag)}>flag</button>
</div>
)
}

export default EffectDemo;

有依赖执行

  • 第一次渲染完毕会执行
  • 当依赖的状态值(或多个依赖状态中的一个)发生改变时会触发callback执行
  • 但是如果依赖的状态值没有发生改变,callback不会执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { useState, useEffect } from "react"
const EffectDemo = () => {
let [count, setCount] = useState(0),
[flag, setFlag] = useState(true);

// 第一次渲染完毕会执行
// 当依赖的状态值(或多个依赖状态中的一个)发生改变时会触发callback执行
// 但是如果依赖的状态值没有发生改变,callback不会执行
useEffect(() => {
console.log('useEffect有依赖执行', count); // count、flag改变时执行
}, [count, flag])

const handle = () => {
setCount(count + 1);
}

return (
<div>
<h1>{count}</h1>
<h1>{flag.toString()}</h1>
<button onClick={handle}>count+1</button>
<button onClick={() => setFlag(!flag)}>flag</button>
</div>
)
}

export default EffectDemo;

返回一个清理函数

  • 在组件将要卸载时执行
  • 返回的函数中拿到的状态值是上一次渲染的状态值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useState, useEffect } from "react"
const EffectDemo = () => {
let [count, setCount] = useState(0),
[flag, setFlag] = useState(true);

// 在组件卸载时执行
// 返回的函数中拿到的状态值是上一次渲染的状态值
useEffect(() => {
return () => {
console.log('useEffect返回函数执行', count);
}
})

const handle = () => {
setCount(count + 1);
}

return (
<div>
<h1>{count}</h1>
<h1>{flag.toString()}</h1>
<button onClick={handle}>count+1</button>
<button onClick={() => setFlag(!flag)}>flag</button>
</div>
)
}

export default EffectDemo;

useEffect底层机制

首次渲染(挂载阶段)

  1. 初始化渲染: React 准备渲染组件,设置初始状态(使用 useState(或者 useReducer)这样的 Hooks 在组件内部创建和管理本地状态)。

  2. 渲染组件: React 处理 JSX,确定 DOM 的更新操作,并准备好所有的 Hooks。在这个过程中,所有的 useEffect 被收集起来,但不会立即执行。

  3. 完成工作但尚未提交: 此时,React 已经知道了哪些 DOM 更新是必要的,但还没有真正更新 DOM。

    完成工作但尚未提交对应React的渲染流程的Pre-commit阶段

    在这个阶段,React已经完成了所有准备工作,并且即将开始对DOM进行实际的修改。这意味着React可以安全地读取DOM信息,因为它还没有被更改,但它还没有开始执行实质性的DOM更新。这个阶段是React的渲染流程中一个临时的“快照”状态,是介于Render阶段和Commit阶段之间的。

  4. 挂载Effect: 此刻,React 会逐一执行所有收集到的 useEffect 中的副作用函数。这些函数被放到一个队列中(这个队列是 React 内部的机制,并不是开发者可见的),按照它们在组件中声明的顺序执行。

  5. 清理阶段: 对于那些返回了清理函数的 useEffect,这些清理函数会被存储起来,以便在副作用的依赖项改变时执行,或者在组件卸载时调用。

状态更新(更新阶段)

  1. 触发更新: 当状态通过 setState 函数变化时(例如 setCountsetFlag 被调用),组件被标记为需要更新。
  2. 重渲染组件: React 重新执行组件函数来获取新的 JSX,并计算出需要进行的 DOM 更新。
  3. 对比依赖项: 对于每一个 useEffect,React 会检查当前的依赖项与上次渲染时的依赖项是否相同。如果依赖项数组与上一次渲染时相同,该 useEffect 的效果函数将被跳过。
  4. 准备Effect: 对于那些依赖项发生变化的 useEffect,或者那些没有提供依赖项的 useEffect(每次渲染后都会执行的),它们的副作用函数被放到更新队列中。
  5. 执行清理函数: 在执行新的副作用函数之前,如果存在上一次渲染留下的清理函数,这些函数会被调用,以清理上一个副作用可能创建的资源。
  6. 执行副作用: 接下来,在 DOM 更新之后,React 会从更新队列中取出副作用函数并执行。

此过程确保了 useEffect 能够在正确的时间点执行,以及它们的执行顺序与它们在组件中的声明顺序保持一致。这也意味着副作用的执行是异步的,发生在浏览器绘制屏幕之后,防止了可能的性能问题。

useEffect使用细节处理

useEffect必须写在函数的最外层,不能写在if/else/for等语句中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/*
* @Description:
* @Author: xiuji
* @Date: 2024-01-25 10:25:04
* @LastEditTime: 2024-01-25 10:36:04
* @LastEditors: Do not edit
*/
import { useState, useEffect } from "react";

const EffectDemo = () => {
let [count, setCount] = useState(0);

// 报错 React Hook "useEffect" is called conditionally. React Hooks must be called in the exact same order in every component render.
// if (count > 5) {
// useEffect(() => {
// console.log('count>5');
// })
// }

// 合理的语法
useEffect(() => {
if(count > 5) {
console.log('count>5');
}
},[count])

const handle = () => {
setCount(count + 1);
}

return (
<div>
<h1>{count}</h1>
<button onClick={handle}>count+1</button>
</div>
)
}

export default EffectDemo;

通过useEffect回调函数异步获取服务器数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/*
* @Description:
* @Author: xiuji
* @Date: 2024-01-25 10:25:04
* @LastEditTime: 2024-01-25 10:36:04
* @LastEditors: Do not edit
*/
import { useState, useEffect } from "react";

// 模拟服务器异步请求
const getData = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('data');
}, 1000);
})
}

const EffectDemo = () => {
let [count, setCount] = useState(0);

// 视图渲染完毕后获取数据
// 不能使用async/await,因为useEffect返回的函数是清理函数,不能是async函数
// useEffect(() => {
// getData().then(res => {
// console.log(res);
// })
// })

// 使用async/await
useEffect(() => {
(async () => {
const res = await getData();
console.log(res);
})()
}, [])

const handle = () => {
setCount(count + 1);
}

return (
<div>
<h1>{count}</h1>
<button onClick={handle}>count+1</button>
</div>
)
}

export default EffectDemo;

useLayoutEffect与useEffect

useEffect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
* @Description:
* @Author: xiuji
* @Date: 2024-01-25 10:25:04
* @LastEditTime: 2024-01-25 16:26:05
* @LastEditors: Do not edit
*/
import { useState, useEffect, useLayoutEffect } from "react";

const EffectDemo = () => {
let [count, setCount] = useState(0);

useEffect(() => {
if (count === 0) {
setCount(10)
}
}, [count])

return (
<div>
<h1 style={{
backgroundColor: count === 0 ? 'red' : 'green'
}}>{count}</h1>
<button onClick={() => setCount(0)}>count change</button>
</div>
)
}

export default EffectDemo;

上述示例在状态改变时会有明显的闪动现象:

useEffect

useLayoutEffect

1
2
3
4
5
useLayoutEffect(() => {
if (count === 0) {
setCount(10)
}
}, [count])

useLayoutEffect

使用useLayoutEffect不会有闪动现象。

useEffectuseLayoutEffect 都可以用来处理副作用,但它们在组件渲染的生命周期中被调用的时间点不同:

  1. useEffect 在所有DOM变更之后执行,执行时机是浏览器绘制之后,因此它不会阻塞DOM的更新。这意味着在屏幕上看到的是DOM的更新(比如背景颜色的改变)后,useEffect 中的代码才会运行。
  2. useLayoutEffect 在DOM变更之后、浏览器绘制之前执行,因此它会阻塞浏览器的绘制。这意味着在useLayoutEffect 中的代码执行(和可能的DOM更新)完成前,用户是看不到任何变化的。

在上述代码示例中,当 count 为0时,useLayoutEffect 中的更新会同步地执行,这将在浏览器绘制之前将 count 设置为10。因为这发生在浏览器得到绘制的机会之前,用户将不会看到 count 为0时的红色背景,而只会看到更新后的绿色背景。

相反,如果使用 useEffect,当 count 为0时设置的状态更新将会在浏览器绘制了红色背景后执行,然后在下一个渲染周期中,将 count 设置为10。因为这个更新是在浏览器绘制之后执行的,用户会先看到红色背景,然后屏幕上的内容会在下一次渲染时更新为绿色背景。这个过程会导致用户短暂看到一个背景色从红色切换到绿色的闪烁效果。

总结来说,useEffect 导致背景色闪动是因为它允许浏览器绘制了一个状态(红色背景),然后在绘制后才进行状态更新,而 useLayoutEffect 通过在浏览器绘制之前同步更新状态,避免了这种闪烁现象。