前言
我还依稀记得在我还比较小的时候,一位乡村医生(我爷爷)曾这样告诉我这样一句话:"是药皆有三分毒",所以自从听到了这句话过后,每次生病拿药过后,感觉自己快要好了,就不会再谨遵医嘱把药全部吃完了。这里所说"三分毒"就是用药后的副作用。
我们都知道在 React 中,首次记载或属性状态值发生变化时,组件树都会渲染或重渲染,前面我们已经学习过状态,属性的管理,以及组件渲染过程,那如果我们要在组件渲染完成后,获取 DOM 或者 做一些其他的操作的时候该怎么来处理呢?本节我们就又多学习一个 React 钩子------useEffect。本节内容还是很简单的理解为:useEffect 钩子最主要的作用就是监听组件渲染完成后的一个回调函数。我们暂且先带着这个理解一点点的去学习这个钩子函数。
useEffect 钩子
单词 effect:效应,影响,效果的意思,我们再对效应,影响,效果进行分析,这几个词是不是都是表达,在某一件事情之后,即一件事情的后续,预知后续如何,请听下回分解...哈哈哈,我们继续。
我们先来看这样一个例子,新建一个今日待办的组件,我们来尝试一下,打印出勾选的状态:
js
import { useState } from "react";
function TodoItem () {
const [checked, setChecked] = useState(false);
alert(`checked:${checked}`);
return(
<div className="check-item">
<label htmlFor="input">今日待办:</label>
<input
type="checkbox"
checked={checked}
value={checked}
style={{marginLeft: '6px'}}
onChange={(e) => { setChecked(checked => !checked); }}
/>
</div>
)
}
export default TodoItem;
我们知道,alert 方法会阻塞代码执行,也就是在 alert 弹出提示的时候,组件还没有进行渲染,相当于就是我们在渲染组件之前就已经去获取了选择状态,试想一下,我们怎么才能在组件渲染之后进行状态的打印呢?放在 return 方法之后行不行,我们知道函数中 return 会直接跳出函数,之后的代码就不会再执行了。所以,怎么在我完成了今日待办后给出提示?React 给我们提供的 useEffect 钩子这个时候就派上用场了。
我们来看一下,添加了 useEffect 钩子是什么样的呢?
js
import { useState, useEffect } from "react";
function TodoItem () {
const [checked, setChecked] = useState(false);
useEffect(() => {
console.log(`checked:${checked}`);
});
return(
<div className="check-item">
<label htmlFor="input">今日待办:</label>
<input
type="checkbox"
checked={checked}
value={checked}
style={{marginLeft: '6px'}}
onChange={(e) => { setChecked(checked => !checked); }}
/>
</div>
)
}
export default TodoItem;
从代码中我们可以看到,只是在原来的基础上加了一个 useEffect 钩子,加了这个钩子过后可以使得打印结果是在组件渲染完成之后。
写过 vue 的小伙伴应该知道,每当我们要获取 DOM 时,是不是需要等到组件渲染完成过后再去获取,才能拿得到,所以当我们在 React 中获取 DOM 时,同理,至少得等到组件渲染完成过后吧。当状态,属性发生变化后,组件是否需要重新渲染,我们要想拿到新的状态,属性,那 useEffect 钩子就起到了大作用了。
开发环境下 useEffect 初始化二次触发的问题
我们还是以最简单的待办案例来进行举例,我们将新建一个生产待办的组件。
js
import { useEffect, useState } from "react";
function AddItem() {
const [value, setValue] = useState("");
const [items, setItems] = useState([]);
const createItem = () => {
setItems([...items, {id: new Date().valueOf(), text: value}]);
setValue("");
}
useEffect(() => {
console.log(`input: ${value}`);
});
useEffect(() => {
const itemsStr = items.map(item => item.text).join(",");
console.log(`itemsStr: ${itemsStr}`);
});
return(
<>
<div style={{margin: "60px 10px"}}>
<label>待办计划:</label>
<input value={value} onChange={(e) => {setValue(e.target.value)}} />
<button style={{marginLeft: "6px"}} onClick={createItem}>生成待办</button>
</div>
<ul style={{margin: "60px 0" }}>
{items.map(item => (
<li key={item.id}>{`${item.id} - ${item.text}`}</li>
))}
</ul>
</>
)
};
export default AddItem;
让我们来看一下这个组件初始化过后的效果。
从控制控制台的打印结果我们可以知道,对于定义的多个 useEffect 钩子,在组件渲染完成后,按顺序进行调用触发 。这里我们可以考虑一下,初始化完一个组件,讲道理应该只会触发一次 useEffect 钩子吧,这怎么就触发了两次呢?这是因为 useEffect 是 React18 的新特性,只有在开发模式下且使用严格模式("Strict Mode")下才会触发两次,这两次都执行了啥?在开发模式下,模拟了组件卸载以及重新挂载的过程,才导致了 useEffect 被触发两次的问题。所以,在生产环境,测试环境下还是正常的触发一次,所以我们在开发模式下,要想只触发一次,需要去掉严格模式。所以我们只需要把 index.js 中的 <React.StrictMode>
标签去掉就可以了。正常情况可以不用去管它,但是我们得知道有这么一回事,这样在调试的时候才不会被这种情况影响。
js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import AddItem from './components/AddItem'
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
// 去掉 <React.StrictMode> 就不再是严格模式了
<React.StrictMode>
<AddItem/>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
依赖数组
上述组件代码中,有两个状态变量 value,items 分别表示输入框的值与生成的待办列表。当我们在输入框输入 "react 学习" 这个字符串,然后点击生成待办按钮,我们来看一下 useEffect 触发打印的情况:
我们可以看,每输入一个字符,就会触发组件重新渲染一次,直到我们点击生成待办按钮后,输入框的值被重置为 "",但是,小伙伴们有没有发现一个问题,当我们还没有触发点击生成待办按钮时,打印 itemsStr useEffect 钩子依旧在每次重新渲染的时候再触发,我们很清楚的知道,这些触发我们是不需要的,不需要每次重渲染都产生效应,所以我们这时候就可以应用依赖数组(dependency array)来监控某个特定属性状态或多个属性状态发生变化来触发 useEffect 钩子产生效应。所以我们来看看依赖数组怎么使用:
js
import { useEffect, useState } from "react";
function AddItem() {
const [value, setValue] = useState("");
const [items, setItems] = useState([]);
const createItem = () => {
setItems([...items, {id: new Date().valueOf(), text: value}]);
setValue("");
}
useEffect(() => {
console.log(`input: ${value}`);
});
useEffect(() => {
const itemsStr = items.map(item => item.text).join(",");
console.log(`itemsStr: ${itemsStr}`);
}, [items]);
return(
<>
<div style={{margin: "60px 10px"}}>
<label>待办计划:</label>
<input value={value} onChange={(e) => {setValue(e.target.value)}} />
<button style={{marginLeft: "6px"}} onClick={createItem}>生成待办</button>
</div>
<ul style={{margin: "60px 0" }}>
{items.map(item => (
<li key={item.id}>{`${item.id} - ${item.text}`}</li>
))}
</ul>
</>
)
};
export default AddItem;
为了方便对比,我还是把重复的代码,唯一改变的位置就是组件内的第二个 useEffect 钩子中添加了一个参数 [items],这样我们知道了 useEffect 钩子是接受两个参数的,第一个参数为渲染触发后的回调函数,第二次参数为依赖数组。为了对比明显,我们再次重复同样的输入与点击过程。再来看看效果:
通过执行打印结果,介入依赖数组后,我们就可以控制组件渲染后的回调了,从上面的结果来看,我们就只执行了一次依赖于待办列表 items 的 useEffect 钩子的回调。
深入依赖
针对于依赖数组,归根到底它是一个数组,数组的话就可能出现没有元素,单个元素,多个元素。带着好奇我们一一来看一下会是什么样的情况:
空数组
何时使用空数组
当我们对useEffect 添加了一个 空的依赖数组时,我们再键入 "react" 时,控制台不会再打印 value 的值了,只是在组件初始化的时候进行一次(开发+严格模式两次)触发 useEffect 。但是我们可以看到代码中有一个ESlint 的警告⚠️:
css
React Hook useEffect has a missing dependency: 'value'. Either include it or remove the dependency array.eslint(react-hooks/exhaustive-deps)
因为我们在副作用中使用了 value,但是我们却在使用的时候提供了空数组依赖,但是 ESlint 规范提出警告,明明说可以使用空数组作为依赖,为啥还要警告。警告证明我们使用是不规范的,容易导致问题的产生,虽然我们达到了只有初始化执行的效果,但是的确是不规范的。我们看一下空数组依赖使用的原则:**在副作用中没有使用任何依赖,只是单纯的做没有赋值或调用接口的的操作,可以使用空数组。**所以我们在使用时尽可能的规范使用,我们这里只是为了说明这样使用,我将把这个依赖数组改为正确的的使用方式。
eslint
useEffect(() => {
console.log(`input: ${value}`);
}, [value]);
移除副作用
我们之前的知识已经知道了,数组中没有依赖时,这个效应在首次渲染时进行调用,所以当我们进行初始化的时候,这个效应就特别适用了。当然,useEffect 钩子还有个功能也是相当实用,那就是 如果 useEffect 钩子的返回一个函数,那么这个函数会在组件从组件树上移出时进行调用,所以当我们需要处理这里功能时特别适用,比如清除定时器,监听器等。
js
useEffect(() => {
let timer = setInterval(() => {
console.log("1s 就这么浪费了!")
}, 1000);
return () => {
if (timer) {
clearInterval(timer);
timer = null;
}
}
}, []);
组件强制渲染
这里还有一种特别的操作,即使没有依赖,也能让组件强制渲染。我们先来看代码:
js
const [, forceRender] = useState();
useEffect(() => {
console.log(`component rendered`);
return () => console.log(`component removed`)
});
useEffect(() => {
window.addEventListener("resize", forceRender);
return () => window.removeEventListener("resize", forceRender);
}, []);
我们可以看到在第二个 useEffect 效应中,我们监听了浏览器窗口的变换,所以我们在改变窗口大小后会调用 forceRender 函数,使得组件渲染。由于我们根本不关心状态,所以 useState 的析构只需要满足 forceRender 存在就可以了。由于我们的监听只需要调取一次就可以了,所以,我们用了一个无依赖的数组,第一个效应的作用就是为了方便我们查看改变窗口后组件是否进行了渲染,结果是,在我们改变窗口大小时,控制台在不断的打印这对应的信息,由此证明,我们这个强制渲染组件是成功的。
多元素数组(包括单个)
这里我们就一起说了,之前的代码中我们传递了 items 的依赖数组,我们从这里例子可以知道,只有当 items 发生变化引起组件重新渲染后触发副作用,那么当有多个依赖的时候,是怎么样的呢?只要有一个依赖发生了变化就会触发副作用。所以也就不再过多赘述了。
理解依赖的变化
我们看看下面的示例:
js
console.log("XiaoChen" === "XiaoChen"); // true
console.log([1, 2, 3] === [1, 2, 3]); // false
var arr = [1, 2, 3];
var arr2 = arr;
console.log(arr === arr2); // true;
通过这简单的示例,我们是不是回想起一些我们常常遇到的知识点,JavaScript 类型的比较,这就好办了,我们都知道 JavaScript 原始类型如字符串、布尔值、数字等都是可以直接比较的。但是,对象,数组,函数等的比较就不同了,当然,我们的依赖变化归根到底也是这么判断他们的变化的。我们来看这样一个例子。
js
import { useEffect, useState } from "react";
const outer_users = ["炭烤", "小橙", "微辣吧"];
function AddItem() {
const [value, setValue] = useState("");
const [items, setItems] = useState([]);
const createItem = () => {
setItems([...items, {id: new Date().valueOf(), text: value}]);
setValue("");
}
const user = "炭烤小橙微辣哟";
useEffect(() => {
console.log(`${user}:`, "组件刷新了");
}, [user]);
const inner_users = ["炭烤", "小橙", "微辣哦"];
useEffect(() => {
console.log(`${inner_users.toString()}`, '组件刷新了');
console.log("user总是在变化");
}, [inner_users]);
useEffect(() => {
console.log(`${outer_users.toString()}`, '组件刷新了');
}, [outer_users]);
return(
<>
<div style={{margin: "60px 10px"}}>
<label>待办计划:</label>
<input value={value} onChange={(e) => {setValue(e.target.value)}} />
<button style={{marginLeft: "6px"}} onClick={createItem}>生成待办</button>
</div>
<ul style={{margin: "60px 0" }}>
{items.map(item => (
<li key={item.id}>{`${item.id} - ${item.text}`}</li>
))}
</ul>
</>
)
};
export default AddItem;
![截屏2023-11-02 下午5.06.02](/Users/ex-liubangqiang001/Desktop/截屏2023-11-02 下午5.06.02.png)
我们先来看看输入 "kkkk" 后打印结果,[inner_users]
的依赖的效应是在每次按键后进行了调用,也就是,可以认为每次组件渲染后,依赖 inner_users 发生了变化,从代码上来看,我们感觉是没有发生变化的,实际上是因为按键后,组件重新渲染,都新声明了一个数组,所以依赖发生了改变,导致会每次都执行效应。我们再看 user 与 outer_users 依赖就没有此情况,那是因为user 为一个字符串变量,而 outer_users 为一个组件外变量,所以不管组件如何渲染,都不会发生变化,所以在页面重新渲染时,不会产生副作用。
总结
- useEffect 钩子最主要的作用就是监听组件渲染完成后的一个回调函数。
- 开发者环境下 useEffect 二次触发 (开发模式 + 严格模式),解决办法(去掉严格模式)。
- 依赖数组:
- 空数组(无依赖):非开发模式下,初始化只执行一次。
- 多元数组:任何元素发生变化,组件都将重新渲染,产生副作用。
- 副作用返回一个函数,那么这个函数会在组件从组件树上移出时进行调用。
- useEffect 中定义的状态,如果调用了使状态发生改变的函数(不关心状态的变化的结果),那么将产生对应的副作用。
- 依赖数组中添加原始类型(字符串、数字、布尔值等)时,这个效应只被调用一次。
- 依赖为组件内的数组、对象等,这被视为可以出发重新渲染的变动。
- 依赖为组件外的数组、对象等,这个效应只被调用一次。