前言
本文来自React官方文档You Might Not Need an Effect,是我近几天读了n遍之后自己的理解,感觉受益匪浅,这里小记一下跟大家分享。
本小白R的水平一直停留在会用React写业务,讲究能跑就行的程度,最近尝试学习一些关于React的最佳实践,感兴趣的朋友一起上车吧!!
useEffect痛点概述
useEffect
的回调是异步宏任务,在React根据当前状态更新视图之后,下一轮事件循环里才会执行useEffect
的回调,一旦useEffect
回调的逻辑中存在状态修改等操作,就会触发渲染的重新执行(FC函数体重新运行,渲染视图),不光存在一定的性能损耗,而且因为前后两次渲染的数据不同,可能造成用户视角下视图的闪动,所以在开发过程中应该避免滥用useEffect
。
如何移除不必要的 Effect
-
对于渲染所需的数据,如果可以用组件内状态(
props
、state
)转换而来,转换操作避免放在Effect
中,而应该直接放在FC函数体中。如果转换计算的消耗比较大,可以用
useMemo
进行缓存。 -
对于一些用户行为引起数据变化,其后续的逻辑不应该放在
Effect
中,而是在事件处理函数中执行逻辑即可。比如点击按钮会使组件内
count
加一,我们希望count
变化后执行某些逻辑,那么就没必要把代码写成:jsxfunction Counter() { const [count, setCount] = useState(0); function handleClick() { setCount(prev => prev + 1); } useEffect(() => { // count改变后的逻辑... }, [count]) // ... }
上面的demo大家肯定也看出来了,直接把
Effect
中的逻辑移动到事件处理函数中即可。
根据props
或state
来更新state
(类似于vue中的计算属性)
如下Form
组件中fullName
由firstName
与lastName
计算(简单拼接)而来,错误使用Effect
:
jsx
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 避免:多余的 state 和不必要的 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
分析一下,按照上面的写法,如果firstName
或者lastName
改变之后,首先根据新的firstName
与lastName
与旧的fullName
进行渲染,然后才是useEffect
回调的执行,最后根据最新的fullName
再次渲染视图。
我们要做的是尽可能把渲染的效果进行统一(同步fullName
与两个组成state的新旧),并且减少渲染的次数:
jsx
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ 非常好:在渲染期间进行计算
const fullName = firstName + ' ' + lastName;
// ...
}
缓存昂贵的计算
基于上面的经验,我们如果遇到比较复杂的计算逻辑,把它放在FC函数体中可能性能消耗较大,可以使用useMemo
进行缓存,如下,visibleTodos
这个数据由todos
与filter
两个props
数据计算而得,并且计算消耗较大:
jsx
import { useMemo } from 'react';
function TodoList({ todos, filter }) {
// ✅ 除非 todos 或 filter 发生变化,否则不会重新执行 getFilteredTodos()
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
当 props 变化时重置所有 state
比如一个ProfilePage
组件,它接收一个userId
代表当前正在操作的用户,里面有一个评论输入框,用一个state来记录输入框中的内容。我们为了防止切换用户后,原用户输入的内容被当前的用户发出这种误操作,有必要在userId
改变时置空state,包括ProfilePage
组件的所有子组件中的评论state
。
错误操作:
jsx
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 避免:当 prop 变化时,在 Effect 中重置 state
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
为什么避免上诉情况,本质还是避免Effect
的痛点,我们可以利用组件**key
不同将会完全重新渲染**的特点解决这个问题,只需要在父组件中给这个组件传递一个与props
同步的key
值即可:
jsx
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ 当 key 变化时,该组件内的 comment 或其他 state 会自动被重置
const [comment, setComment] = useState('');
// ...
}
当 prop 变化时调整部分 state
其实说白了还是上面的基于props
和state
来计算其它所需state
的逻辑,如下List
组件,当传入的items
改变时希望同步selection
(被选中的数据),那么我们直接在渲染阶段计算所需内容就好了:
jsx
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ 非常好:在渲染期间计算所需内容
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
在事件处理函数中共享逻辑
比如两种用户操作都可以修改某个数据,然后针对数据修改有相应的逻辑处理,这时候有一种错误(不好)的代码逻辑:事件回调------>修改state------>state修改触发Effect------>Effect中执行后续逻辑。
我们不应该多此一举的添加一个Effect,这个Effect就类似于数据改变的监听器一样,完全是多余的,我们只需要在数据改变之后接着写后续的逻辑就好了!!
如下,用户的购买与检查两种行为都可以触发addToCart
的逻辑,进而修改product
这个数据,然后可能触发后续逻辑showNotification
:
jsx
function ProductPage({ product, addToCart }) {
// 🔴 避免:在 Effect 中处理属于事件特定的逻辑
useEffect(() => {
if (product.isInCart) {
showNotification(`已添加 ${product.name} 进购物车!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
我们把Effect
中的逻辑提取出来放到事件处理函数中就好了:
jsx
function ProductPage({ product, addToCart }) {
// ✅ 非常好:事件特定的逻辑在事件处理函数中处理
function buyProduct() {
addToCart(product);
showNotification(`已添加 ${product.name} 进购物车!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
发送 POST 请求
也有一些典型的需要使用Effect
的情景,比如有些数据、逻辑是页面初次渲染,因为组件的呈现而需要的,而不是后续交互触发的,比如异步数据的获取,我们就可以写一个依赖数组为[]
的Effect
。
如下Form
组件,页面加载之际就需要发送一个分析请求,这个行为与后续交互无关,是因为页面的呈现就需要执行的逻辑,所以放在Effect
中,而表单提交的行为触发的网络请求,我们直接放在事件回调中即可。
切忌再多写一个state
和一个Effect
,然后把一部分逻辑写在Effect
里面,比如下面handleSubmit
中修改firstName
与lastName
,然后多写一个Effect
监听这两个数据发送网络请求,这就是上面我们一直纠正的问题,我就不放代码了。
jsx
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ 非常好:这个逻辑应该在组件显示时执行
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// ✅ 非常好:事件特定的逻辑在事件处理函数中处理
post('/api/register', { firstName, lastName });
}
// ...
}
链式计算
避免通过state将Effect
变成链式调用,如下Game
组件中,类似于一个卡牌合成游戏,card
改变可能触发goldCardCount
的改变,goldCardCount
的改变可能触发round
的改变,最终round
的改变可能触发isGameOver
的改变,试想如果某次card
改变,从而正好所有条件都依次满足,最后isGameOver
改变,setCard
→ 渲染 → setGoldCardCount
→ 渲染 → setRound
→ 渲染 → setIsGameOver
→ 渲染,有三次不必要的重新渲染!!
jsx
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 避免:链接多个 Effect 仅仅为了相互触发调整 state
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('游戏结束!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('游戏已经结束了。');
} else {
setCard(nextCard);
}
}
// ...
因为Game
中所有state
改变之后的行为都是可以预测的,也就是说某个卡牌数据变了,后续要不要继续合成更高级的卡牌,或者游戏结束等等这些逻辑都是完全明确的,所以直接把数据修改的逻辑放在同一个事件回调中即可,然后根据入参判断是哪种卡牌然后进行后续的操作即可:
jsx
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ 尽可能在渲染期间进行计算
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('游戏已经结束了。');
}
// ✅ 在事件处理函数中计算剩下的所有 state
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('游戏结束!');
}
}
}
}
// ...
初始化应用
因为React严格模式&开发模式下:
jsx
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
组件的渲染会执行两次,包括依赖为[]
的Effect
,据说是作者故意而为之,总之可能会造成一些问题,我们可以用一个全局变量来保证即使在React严格模式&开发模式下也只执行一次Effect
的回调:
jsx
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 只在每次应用加载时执行一次
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
通知父组件有关 state 变化的信息
最佳实践的本质还是我们刚刚一直强调的:减少Effect的使用,可以归并到回调函数中的逻辑就不要放在Effect
中。
如下,假设我们正在编写一个有具有内部 state isOn
的 Toggle
组件,该 state 可以是 true
或 false
,希望在 Toggle
的 state 变化时通知父组件。
错误示范:
(事件回调只负责修改 state, Effect中执行通知父组件的逻辑)
jsx
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 避免:onChange 处理函数执行的时间太晚了
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
删除Effect
:
jsx
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// ✅ 事件回调中直接通知父组件即可
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}
将数据传递给父组件
避免在 Effect
中传递数据给父组件,这样会造成数据流的混乱。我们应该考虑把获取数据的逻辑提取到父组件中,然后通过props
将数据传递给子组件:
错误示范:
jsx
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 避免:在 Effect 中传递数据给父组件
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
理想情况:
jsx
function Parent() {
const data = useSomeAPI();
// ...
// ✅ 非常好:向子组件传递数据
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
订阅外部 store
说白了就是React给我们提供了一个专门的hook用来绑定外部数据(所谓外部数据,就是一些环境运行环境里的数据,比如window.xxx
)
我们曾经常用的做法是在Effect
中编写事件监听的逻辑:
jsx
function useOnlineStatus() {
// 不理想:在 Effect 中手动订阅 store
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
这里可以换成useSyncExternalStore
这个hook,关于这个hook,还是有一点理解成本的,我的基于useSyncExternalStore封装一个自己的React状态管理模型吧这篇文章里有详细的解释,下面直接放绑定外部数据最佳实践的代码了:
jsx
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ 非常好:用内置的 Hook 订阅外部 store
return useSyncExternalStore(
subscribe, // 只要传递的是同一个函数,React 不会重新订阅
() => navigator.onLine, // 如何在客户端获取值
() => true // 如何在服务端获取值
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
获取异步数据
比如组件内根据props
参数query
与一个组件内状态page
来实时获取异步数据,下面组件获取异步数据的逻辑之所以没有写在事件回调中,是因为首屏即使用户没有触发数据修改,我们也需要主动发出数据请求(类似于首屏数据获取),总之因为业务场景需求吧,我们把请求逻辑放在一个Effect
中:
jsx
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 避免:没有清除逻辑的获取数据
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
上面代码的问题在于,由于每次网络请求的不可预测性,我们不能保证请求结果是根据当前最新的组件状态获取的,也即是所谓的竞态条件:两个不同的请求 "相互竞争",并以与你预期不符的顺序返回。
所以可以给我们的Effect
添加一个清理函数,来忽略较早的返回结果, 如下,说白了用一个变量ignore
来控制这个Effect
回调的"有效性",只要是执行了下一个Effect
回调,上一个Effect
里的ignore
置反,也就是让回调的核心逻辑失效,保证了只有最后执行的Effect
回调是"有效"的:
jsx
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 说白了用一个ignore变量来控制这个Effect回调的"有效性",
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}