easycodesniper

blog by chen qiyi

useEffect

Effect在React中被专门定义为:由渲染引起的副作用。它允许你指定由渲染本身,而不是特定事件引起的副作用

useEffect被设计用于将组件和外部系统同步,例如数据获取、服务器的链接、设置订阅等不受React控制的系统

作为一个React Hook,它只能在组件的顶层调用,如果你需要在循环或判断的逻辑中调用,可以将这部分逻辑抽离出去成为一个组件。

语法

useEffect(setup, dependencies?)

  • setup: 处理Effect的函数,可以选择性的返回一个清理(cleanup)函数。当 组件被添加到DOM时 和 每次依赖项发生变化重新渲染后,React会首先利用旧值运行cleanup函数(如果你设置了该函数),然后使用新值运行setup函数。在组件从DOM中移除后,React将最后一次运行cleanup函数
  • dependencies(可选): setup函数中引用的所有响应式值的列表。
    • React会使用Object.is来比较每个依赖项和它先前的值,只要有依赖项和先前的值不同时,将会重新运行setup函数。
    • 如果忽略此参数,则在每次重新渲染组件之后,将重新执行setup函数
    • 如果此参数为空数组[],则只在初次渲染组件的时候执行setup函数

useEffect的运行过程:

  1. 将组件挂载到页面时,将运行setup函数
  2. 重新渲染依赖项变更的组件后:
  • 首先,使用旧值运行cleanup函数
  • 然后,使用新值运行setup函数
  1. 当组件从页面卸载后,cleanup函数将运行最后一次

开发环境下,React在运行setup之前会额外运行一次setupcleanup,这是一种压力测试,来验证Effect逻辑是否正确实现

使用示例:模拟一个连接服务器的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useEffect, useState } from "react";
function App() {
const [serverUrl, setServerUrl] = useState('easycodesniper.top')

useEffect(() => {
const connection = createConnection(serverUrl) //模拟连接服务器
connection.connect()
return () => { //在销毁组件时断开连接
connection.disconnect()
}
}, [serverUrl]) //依赖项,当服务器路径改变时重新执行setup

return (
<>
<h1>欢迎来到{serverUrl}</h1>
</>
)
}
export default App;

useEffect执行时机

每当你的组件渲染时,React将更新视图,然后运行useEffect中的代码。换句话说,**useEffect会把这段代码放到视图更新渲染之后执行**

实践导向

在Effect中根据先前的state更新state

当想在Effect中根据先前的state更新state时,会遇到问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useEffect, useState } from "react";
function App() {
const [cnt, setCnt] = useState(0)

useEffect(() => {
const timer = setInterval(() => {
setCnt(cnt + 1) //每秒将cnt加1
}, 1000)
return () => clearInterval(timer)
}, [cnt]) //将cnt作为依赖项

return (
<>
<spsn>{cnt}</span>
</>
)
}
export default App;

因为cnt是响应式数据,所以必须在依赖项列表中指定它,这就会导致Effect在每次cnt变化之后都要执行cleanupsetup

解决方法:在setCnt中不是直接传入修改的值,而是传入c => c + 1状态更新器,这样做的目的是:将cnt从依赖项中移除

1
2
3
4
5
6
7
8
const [cnt, setCnt] = useState(0)

useEffect(() => {
const timer = setInterval(() => {
setCnt(c => c + 1) //每秒将cnt加1
}, 1000)
return () => clearInterval(timer)
}, [])

Effect依赖于对象或函数

如果你的Effect依赖于渲染期间创建的对象或函数,则它可能会频繁运行

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useEffect, useState } from "react";
function App() {
function createOptions(serverUrl) {
return {
serverUrl,
// ... others
}
}
useEffect(() => {
const options = createOptions('easycodesniper.top') //在Effect中被使用
const connection = createConnection(options)
connection.connect()
return () => {
connection.disconnect()
}
}, [createOptions])

return (
<>
<h1>欢迎来到{serverUrl}</h1>
</>
)
}
export default App;

createOptions函数本身的封装并没有问题。

首先,对象或函数都是引用类型的值,判断他们是否相同是通过是否指向同一块内存地址

然后,每次组件重新渲染,都会从头创建一个createOptions函数,那这个函数的地址和之前的地址肯定不同,也就意味着这两者不是相同的(即依赖项发生了改变),会导致Effect在每次重新渲染之后再次重新执行

所以,避免使用在渲染期间创建的函数作为依赖项请在Effect内部声明它

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
import { useEffect, useState } from "react";
function App() {

const [serverUrl, setServerUrl] = useState('easycodesniper.top')

useEffect(() => {

// 在Effect内部创建它
function createOptions(serverUrl) {
return {
serverUrl,
// ... others
}
}

const options = createOptions(serverUrl) //在Effect中被使用
const connection = createConnection(options)
connection.connect()
return () => {
connection.disconnect()
}
}, [serverUrl])

return (
<>
<h1>欢迎来到{serverUrl}</h1>
</>
)
}
export default App;

通过在Effect内部定义createOption函数,这样Effect只依赖于serverUrl字符串,字符串作为基础类型值,除非你将它设置为其他值,否则它不会改变

你不需要Effect

Effect是React范式中的一种脱围机制。它让你可以使组件和一些外部的系统同步。如果没有涉及到外部系统(例如只是像根据props或state的变化更新一个组件的state),你就不应该使用Effect

常见的情况:

  1. 不必使用Effect来转换渲染所需的数据。例如,想在展示一个列表之前先做筛选,你可能会写一个当列表变化时更新state的Effect。然而,这是低效的。
  • 当你更新state,React首先会调用组件来渲染视图
  • 然后React会执行你的Effect,如果你的Effect也立即更新了这个state,将会重新执行整个组件

所以,你应该在组件的顶层转换数据

  1. 根据props或state来更新state。例如,你又一个包含了两个state变量的组件:firstNamelastName。你想通过他们计算出fullName。你可能会写一个当firstName或者lastName变化时更新fullNameEffect
1
2
3
4
5
6
7
8
9
10
function App() {
const [firstName, setFirstName] = useState('easycode');
const [lastName, setLastName] = useState('sniper');

const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}

这样做会导致:

  • 首先,React会调用组件,使用fullName的旧值执行整个渲染流程
  • 然后,React会执行你的Effect,用更新后的值又重新渲染了一遍

所以,你应该直接计算这个值const fullName = firstName + ' ' + lastName

初始化应用

有些逻辑只需要在应用加载时执行一次,你可能会将它放到一顶层组件的Effect

1
2
3
4
5
function App() {
useEffect(() => {
loadFunction()
}, [])
}

这会遇到一些问题:在开发环境它会被执行两次,这可能会导致潜在的问题

解决方法:

添加一个顶层变量来记录它是否已经执行过了

1
2
3
4
5
6
7
8
9
10
11
12
13
let didInit = false;

function App() {
useEffect(() => {

if(!didInit) {
didInit = true
// 只在每次应用加载时执行一次
loadFunction()
}

}, [])
}

获取数据

很多场景都需要使用Effect来发起数据请求,例如:

1
2
3
4
5
6
7
8
9
10
11
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);

useEffect(() => {
// 模拟发送数据请求
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
}

然而上面的代码有一个问题,假如你快速的输入hello,那么query会从h变成he,hel,hell最后是hello,这会触发一连串不同的数据请求,但无法保证返回顺序。例如,hell的响应可能在hello的响应之后返回,这将会显示错误的搜索结果

为了修复这个问题,需要添加一个清理函数来忽略较早的返回结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
}

这可以确保在Effect中获取数据时,除了最后一次请求的所有返回结果都将被忽略

源码分析