React中函数组件的主体只应该用来返回组件的 HTML 代码,所有的其他操作(副效应)都必须通过钩子引入。由于副效应非常多,所以钩子有许多种。
React 为许多常见的操作(副效应),都提供了专用的钩子。如useState()
:保存状态,useContext()
:保存上下文useRef()
:保存引用。
上面这些钩子,都是引入某种特定的副效应,而 useEffect()
是通用的副效应钩子 。找不到对应的钩子时,就可以用它。本文主要就是来说说这个副效应(side effect)。
Effect
Effect 在 React 中是专有定义——由渲染引起的副作用。
// 语法
useEffect(() => {
// 在这里写你的副作用逻辑
}, [dependencies]); // 依赖项数组
基础使用
每当组件渲染时,React 将更新屏幕,然后运行 useEffect 中的代码。换句话说,useEffect 会把这段代码放到屏幕更新渲染之后执行。
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// 每次渲染后都会执行此处的代码
});
return <div />;
}
一般来说,我们并不需要每次渲染的时候都执行 Effect,所以最好这样写,第二个参数加一个空数组
注意,每次setState都会导致组件重新渲染
useEffect(() => {
// 这里的代码只会在组件挂载后执行
}, []);
指定依赖
我们可以指定依赖项,是Effect在指定的时候运行,例如,下面这段代码的意思是,当isPlaying改变时,才运行 Effect
useEffect(() => {
if (isPlaying) { // isPlaying 在此处使用……
// ...
} else {
// ...
}
}, [isPlaying]); // ……所以它必须在此处声明!
依赖性可以有多个
useEffect(() => {
//这里的代码只会在每次渲染后,并且 a 或 b 的值与上次渲染不一致时执行
}, [a, b]);
清理函数
可以在 Effect 中返回一个 清理(cleanup) 函数,每次重新执行 Effect 之前,React 都会调用清理函数;组件被卸载时,也会调用清理函数。
import { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
function onTick() {
setCount(c => c + 1);
}
const intervalId = setInterval(onTick, 1000);
return () => { // 如果没有使用清理函数,你可以看到计数器不是每秒递增一次,而是两次。
clearInterval(intervalId)
};
}, []);
return <h1>{count}</h1>;
}
React 总是在执行下一轮渲染的 Effect 之前清理上一轮渲染的 Effect。
不推荐使用Effect的情况
特定操作
应该由用户处理的一些操作
// ❌ 错误:购买接口不应该放在这里执行,应该要由用户点击购买按钮后执行。
useEffect(() => {
fetch('/api/buy', { method: 'POST' });
}, []);
// ✅ 购买商品应当在事件中执行,因为这是由特定的操作引起的。
function handleClick() {
fetch('/api/buy', { method: 'POST' });
}
怎么一些逻辑是放入事件处理函数还是 Effect ?可以这样分析
- 如果这个逻辑是由某个特定的交互引起的,则放在相应的事件处理函数中。
- 如果是由用户在屏幕上 看到 组件时引起的,则放在 Effect 中。
计算结果
如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。
例如:
有两个 state 变量,firstName 和 lastName。现在想通过把它们计算出 fullName。每当 firstName 和 lastName 变化时, fullName 都能更新。
// 🔴 Bad
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 多余的 state 和不必要的 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
// ✅ Good
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 在渲染期间进行计算
const fullName = firstName + ' ' + lastName;
// ...
}
因为每次setFirstName,或setLastName时,Form组件都会重写渲染,所以fullName直接使用普通变量即可
重置全部state
避免当 prop 变化时,在 Effect 中重置 state,例如:
// ❌ Bad
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 避免这样做,这是非常低效的,因为 ProfilePage 和它的子组件首先会用旧值渲染,然后再用新值重新渲染。
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
可以将上面的组件拆分为两个组件,并从外部的组件传递一个 key 属性给内部的组件。
每当 key(这里是 userId)变化时,React 将重新创建 DOM,并 重置 Profile 组件和它的所有子组件的 state。
// ✅ Good
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// 当 key 变化时,该组件内的 comment 或其他 state 会自动被重置
const [comment, setComment] = useState('');
// ...
}
重置部分state
当 prop 变化时,想重置或调整部分 state ,而不是所有 state。
例如:
List 组件接收一个 items 列表,然后用 state 变量 selection 来保持已选中的项。当 items 接收到一个不同的数组时,想将 selection 重置为 null
// ❌ Bad
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 避免当 prop 变化时,在 Effect 中调整 state
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
上面这样写,
- 每当 items 变化时,List 及其子组件会先使用旧的 selection 值渲染。
- 然后 React 会更新 DOM 并执行 Effect。
- 最后,调用 setSelection(null) 将导致 List 及其子组件重新渲染,重新启动整个流程
// ✅ 优化1
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 在渲染期间调整 state,虽然这种方式比 Effect 更高效,但是会让人不好理解
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
// ✅ 优化2,存储已选中的 item ID 而不是存储(并重置)已选中的 item,然后通过计算实现相同效果
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// 在渲染期间计算所需内容
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
共享逻辑
有的时候,为了减少代码重复,可能会想把共享逻辑放在Effect。
例如:
假设产品页面上有两个按钮(购买和付款),当用户点击这两个按钮时,都想显示一个通知。但是在两个按钮的 click 事件处理函数中都调用 showNotification() 感觉有点重复,所以可能会这样写
// ❌ Bad 这个 Effect 是多余的,而且很可能会导致问题
function ProductPage({ product, addToCart }) {
// 避免在 Effect 中处理属于事件特定的逻辑
useEffect(() => {
if (product.isInCart) {
showNotification(`已添加 ${product.name} 进购物车!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
更好的方式应该是把共享的逻辑封装到新的函数中
// ✅ Good
function ProductPage({ product, addToCart }) {
// 事件特定的逻辑在事件处理函数中处理
function buyProduct() {
addToCart(product);
showNotification(`已添加 ${product.name} 进购物车!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
初始化应用
有些逻辑只需要在应用加载时执行一次。比如,验证登陆状态和加载本地程序数据。
// ❌ Bad
function App() {
// 避免:把只需要执行一次的逻辑放在 Effect 中
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
你可以将其放在组件之外,而不是Effect中
// ✅ Good
if (typeof window !== 'undefined') { // 检查是否在浏览器中运行
checkAuthToken();
loadDataFromLocalStorage();
}
// 这里面时组件
function App() {
// ……
}
顶层代码会在组件被导入时执行一次,即使它最终并没有被渲染。所以应用级别的初始化逻辑还是应该保留在像 App.js 这样的根组件模块或你的应用入口中。
注意
这样写会出现死循环
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
因为每次渲染结束都会执行 Effect;而更新 state 会触发重新渲染。但是新一轮渲染时又会再次执行 Effect,然后 Effect 再次更新 state……如此周而复始,从而陷入死循环。
使用 ref 操作 DOM
基本使用
示例1):使文本框输入获得焦点
// 1️⃣ 需要访问由 React 管理的 DOM 元素,首先,引入 useRef Hook
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null); // 2️⃣ useRef Hook 返回一个对象
function handleClick() {
inputRef.current.focus(); // 3️⃣ useRef Hook 对象有一个名为 current 的属性,可以通过这个属性使用任意浏览器 API
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>
聚焦输入框
</button>
</>
);
}
最初,inputRef.current 是 null。
当 React 为这个
<input>
创建一个 DOM 节点时,React 会把对该节点的引用放入 inputRef.current。然后,你可以从 事件处理器 访问此 DOM 节点,并使用在其上定义的内置浏览器 API。
多个ref
一个组件中可以有多个 ref,例如:
// 部分代码
...
const firstCatRef = useRef(null);
const secondCatRef = useRef(null);
const thirdCatRef = useRef(null);
function handleScrollToFirstCat() {
firstCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
function handleScrollToSecondCat() {
secondCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
function handleScrollToThirdCat() {
thirdCatRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
return (
<>
...
<img
src="https://placekitten.com/g/200/200"
alt="Tom"
ref={firstCatRef}
/>
<img
src="https://placekitten.com/g/300/200"
alt="Maru"
ref={secondCatRef}
/>
<img
src="https://placekitten.com/g/250/200"
alt="Jellylorum"
ref={thirdCatRef}
/>
...
</>
);
如果有多个ref,但是不确定数量和名字怎么办?
// ❌.错误,因为 Hook 只能在组件的顶层被调用。所以不能在循环语句、条件语句或 map() 函数中调用 useRef
<ul>
{items.map((item) => {
// 行不通!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
解决方案,将函数传递给 ref 属性,完整代码
<li
key={cat.id}
ref={node => {
const map = getMap();
if (node) {
// 添加到 Map
map.set(cat.id, node);
} else {
// 从 Map 删除
map.delete(cat.id);
}
}}
>
访问另一个组件的 DOM 节点
如果你试图将 ref 放在 你自己的 组件上,例如 <MyInput />
,默认情况下你会得到 null
import { useRef } from 'react';
function MyInput(props) {
return <input {...props} />;
}
export default function MyForm() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus(); // 无法正常访问,React 不允许组件访问其他组件的 DOM 节点,自己的子组件也不行
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
聚焦输入框
</button>
</>
);
}
改进,需要使用forwardRef
将父组件的 ref “转发”给子组件
import { forwardRef, useRef } from 'react';
// MyInput 组件将自己接收到的 ref 传递给它内部的 <input>
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
···
🎨【点赞】【关注】不迷路,更多前端干货等你解锁
往期推荐