React最佳实践之“你可能不需要 Effect”
点击关注公众号,回复”福利”即可参与文末抽奖
前言本文思想来自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变化后执行某些逻辑,那么就没必要把代码写成:
functionCounter(){
const[count,setCount]=useState(0);
functionhandleClick(){
setCount(prev=prev+1);
}
useEffect(()={
//count改变后的逻辑...
},[count])
//...
}
上面的demo大家肯定也看出来了,直接把Effect中的逻辑移动到事件处理函数中即可。
根据props或state来更新state(类似于vue中的计算属性)如下Form组件中fullName由firstName与lastName计算(简单拼接)而来,错误使用Effect:
functionForm(){
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的新旧),并且减少渲染的次数:
functionForm(){
const[firstName,setFirstName]=useState('Taylor');
const[lastName,setLastName]=useState('Swift');
//?非常好:在渲染期间进行计算
constfullName=firstName+''+lastName;
//...
}
缓存昂贵的计算基于上面的经验,我们如果遇到比较复杂的计算逻辑,把它放在FC函数体中可能性能消耗较大,可以使用useMemo进行缓存,如下,visibleTodos这个数据由todos与filter两个props数据计算而得,并且计算消耗较大:
import{useMemo}from'react';
functionTodoList({todos,filter}){
//?除非todos或filter发生变化,否则不会重新执行getFilteredTodos()
constvisibleTodos=useMemo(()=getFilteredTodos(todos,filter),[todos,filter]);
//...
}
当 props 变化时重置所有 state比如一个ProfilePage组件,它接收一个userId代表当前正在操作的用户,里面有一个评论输入框,用一个state来记录输入框中的内容。我们为了防止切换用户后,原用户输入的内容被当前的用户发出这种误操作,有必要在userId改变时置空state,包括ProfilePage组件的所有子组件中的评论state。
错误操作:
exportdefaultfunctionProfilePage({userId}){
const[comment,setComment]=useState('');
//??避免:当prop变化时,在Effect中重置state
useEffect(()={
setComment('');
},[userId]);
//...
}
为什么避免上诉情况,本质还是避免Effect的痛点,我们可以利用组件**key不同将会完全重新渲染**的特点解决这个问题,只需要在父组件中给这个组件传递一个与props同步的key值即可:
exportdefaultfunctionProfilePage({userId}){
return(
Profile
userId={userId}
key={userId}
/
}
functionProfile({userId}){
//?当key变化时,该组件内的comment或其他state会自动被重置
const[comment,setComment]=useState('');
//...
}
当 prop 变化时调整部分 state其实说白了还是上面的基于props和state来计算其它所需state的逻辑,如下List组件,当传入的items改变时希望同步selection(被选中的数据),那么我们直接在渲染阶段计算所需内容就好了:
functionList({items}){
const[isReverse,setIsReverse]=useState(false);
const[selectedId,setSelectedId]=useState(null);
//?非常好:在渲染期间计算所需内容
constselection=items.find(item=item.id===selectedId)??null;
//...
}
在事件处理函数中共享逻辑比如两种用户操作都可以修改某个数据,然后针对数据修改有相应的逻辑处理,这时候有一种错误(不好)的代码逻辑:事件回调——修改state——state修改触发Effect——Effect中执行后续逻辑。
我们不应该多此一举的添加一个Effect,这个Effect就类似于数据改变的监听器一样,完全是多余的,我们只需要在数据改变之后接着写后续的逻辑就好了!!
如下,用户的购买与检查两种行为都可以触发addToCart的逻辑,进而修改product这个数据,然后可能触发后续逻辑showNotification:
functionProductPage({product,addToCart}){
//??避免:在Effect中处理属于事件特定的逻辑
useEffect(()={
if(product.isInCart){
showNotification(`已添加${product.name}进购物车!`);
}
},[product]);
functionhandleBuyClick(){
addToCart(product);
}
functionhandleCheckoutClick(){
addToCart(product);
navigateTo('/checkout');
}
//...
}
我们把Effect中的逻辑提取出来放到事件处理函数中就好了:
functionProductPage({product,addToCart}){
//?非常好:事件特定的逻辑在事件处理函数中处理
functionbuyProduct(){
addToCart(product);
showNotification(`已添加${product.name}进购物车!`);
}
functionhandleBuyClick(){
buyProduct();
}
functionhandleCheckoutClick(){
buyProduct();
navigateTo('/checkout');
}
//...
}
发送 POST 请求也有一些典型的需要使用Effect的情景,比如有些数据、逻辑是页面初次渲染,因为组件的呈现而需要的,而不是后续交互触发的,比如异步数据的获取,我们就可以写一个依赖数组为[]的Effect。
如下Form组件,页面加载之际就需要发送一个分析请求,这个行为与后续交互无关,是因为页面的呈现就需要执行的逻辑,所以放在Effect中,而表单提交的行为触发的网络请求,我们直接放在事件回调中即可。
切忌再多写一个state和一个Effect,然后把一部分逻辑写在Effect里面,比如下面handleSubmit中修改firstName与lastName,然后多写一个Effect监听这两个数据发送网络请求,这就是上面我们一直纠正的问题,我就不放代码了。
functionForm(){
const[firstName,setFirstName]=useState('');
const[lastName,setLastName]=useState('');
//?非常好:这个逻辑应该在组件显示时执行
useEffect(()={
post('/analytics/event',{eventName:'visit_form'
},
functionhandleSubmit(e){
e.preventDefault();
//?非常好:事件特定的逻辑在事件处理函数中处理
post('/api/register',{firstName,lastName
}
//...
}
链式计算避免通过state将Effect变成链式调用,如下Game组件中,类似于一个卡牌合成游戏,card改变可能触发goldCardCount的改变,goldCardCount的改变可能触发round的改变,最终round的改变可能触发isGameOver的改变,试想如果某次card改变,从而正好所有条件都依次满足,最后isGameOver改变,setCard → 渲染 → setGoldCardCount → 渲染 → setRound → 渲染 → setIsGameOver → 渲染,有三次不必要的重新渲染!!
functionGame(){
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!==nullcard.gold){
setGoldCardCount(c=c+1);
}
},[card]);
useEffect(()={
if(goldCardCount3){
setRound(r=r+1)
setGoldCardCount(0);
}
},[goldCardCount]);
useEffect(()={
if(round5){
setIsGameOver(true);
}
},[round]);
useEffect(()={
alert('游戏结束!');
},[isGameOver]);
functionhandlePlaceCard(nextCard){
if(isGameOver){
throwError('游戏已经结束了。');
}else{
setCard(nextCard);
}
}
//...
因为Game中所有state改变之后的行为都是可以预测的,也就是说某个卡牌数据变了,后续要不要继续合成更高级的卡牌,或者游戏结束等等这些逻辑都是完全明确的,所以直接把数据修改的逻辑放在同一个事件回调中即可,然后根据入参判断是哪种卡牌然后进行后续的操作即可:
functionGame(){
const[card,setCard]=useState(null);
const[goldCardCount,setGoldCardCount]=useState(0);
const[round,setRound]=useState(1);
//?尽可能在渲染期间进行计算
constisGameOver=round5;
functionhandlePlaceCard(nextCard){
if(isGameOver){
throwError('游戏已经结束了。');
}
//?在事件处理函数中计算剩下的所有state
setCard(nextCard);
if(nextCard.gold){
if(goldCardCount=3){
setGoldCardCount(goldCardCount+1);
}else{
setGoldCardCount(0);
setRound(round+1);
if(round===5){
alert('游戏结束!');
}
}
}
}
//...
初始化应用因为React严格模式&开发模式下:
ReactDOM.createRoot(document.getElementById('root')).render(
React.StrictMode
App/
/React.StrictMode,
)
组件的渲染会执行两次(挂载+卸载+挂载),包括依赖为[]的Effect同样会执行两次,这是React作者为了提醒开发者 cleanup 有意而设计之的(比如一些需要手动清除的原生事件如果没写清除逻辑,事件触发时就会执行两次回调从而引起注意),所以执行两次的逻辑可能会造成一些逻辑问题,我们可以用一个全局变量来保证即使在React严格模式&开发模式下也只执行一次Effect的回调:
letdidInit=false;
functionApp(){
useEffect(()={
if(!didInit){
didInit=true;
//?只在每次应用加载时执行一次
loadDataFromLocalStorage();
checkAuthToken();
}
},
//...
}
通知父组件有关 state 变化的信息最佳实践的本质还是我们刚刚一直强调的:减少Effect的使用,可以归并到回调函数中的逻辑就不要放在Effect中。
如下,假设我们正在编写一个有具有内部 state isOn 的 Toggle 组件,该 state 可以是 true 或 false,希望在 Toggle 的 state 变化时通知父组件。
错误示范:
(事件回调只负责修改 state, Effect中执行通知父组件的逻辑)
functionToggle({onChange}){
const[isOn,setIsOn]=useState(false);
//??避免:onChange处理函数执行的时间太晚了
useEffect(()={
onChange(isOn);
},[isOn,onChange])
functionhandleClick(){
setIsOn(!isOn);
}
functionhandleDragEnd(e){
if(isCloserToRightEdge(e)){
setIsOn(true);
}else{
setIsOn(false);
}
}
//...
}
删除Effect:
functionToggle({onChange}){
const[isOn,setIsOn]=useState(false);
functionupdateToggle(nextIsOn){
//?事件回调中直接通知父组件即可
setIsOn(nextIsOn);
onChange(nextIsOn);
}
functionhandleClick(){
updateToggle(!isOn);
}
functionhandleDragEnd(e){
if(isCloserToRightEdge(e)){
updateToggle(true);
}else{
updateToggle(false);
}
}
//...
}
将数据传递给父组件避免在 Effect 中传递数据给父组件,这样会造成数据流的混乱。我们应该考虑把获取数据的逻辑提取到父组件中,然后通过props将数据传递给子组件:
错误示范:
functionParent(){
const[data,setData]=useState(null);
//...
returnChildonFetched={setData}/;
}
functionChild({onFetched}){
constdata=useSomeAPI();
//??避免:在Effect中传递数据给父组件
useEffect(()={
if(data){
onFetched(data);
}
},[onFetched,data]);
//...
}
理想情况:
functionParent(){
constdata=useSomeAPI();
//...
//?非常好:向子组件传递数据
returnChilddata={data}/;
}
functionChild({data}){
//...
}
订阅外部 store说白了就是React给我们提供了一个专门的hook用来绑定外部数据(所谓外部数据,就是一些环境运行环境里的数据,比如window.xxx)
我们曾经常用的做法是在Effect中编写事件监听的逻辑:
functionuseOnlineStatus(){
//不理想:在Effect中手动订阅store
const[isOnline,setIsOnline]=useState(true);
useEffect(()={
functionupdateState(){
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online',updateState);
window.addEventListener('offline',updateState);
return()={
window.removeEventListener('online',updateState);
window.removeEventListener('offline',updateState);
},
returnisOnline;
}
functionChatIndicator(){
constisOnline=useOnlineStatus();
//...
}
这里可以换成useSyncExternalStore这个hook,关于这个hook,还是有一点理解成本的,我的基于useSyncExternalStore封装一个自己的React状态管理模型吧这篇文章里有详细的解释,下面直接放绑定外部数据最佳实践的代码了:
functionsubscribe(callback){
window.addEventListener('online',callback);
window.addEventListener('offline',callback);
return()={
window.removeEventListener('online',callback);
window.removeEventListener('offline',callback);
}
functionuseOnlineStatus(){
//?非常好:用内置的Hook订阅外部store
returnuseSyncExternalStore(
subscribe,//只要传递的是同一个函数,React不会重新订阅
()=navigator.onLine,//如何在客户端获取值
()=true//如何在服务端获取值
}
functionChatIndicator(){
constisOnline=useOnlineStatus();
//...
}
获取异步数据比如组件内根据props参数query与一个组件内状态page来实时获取异步数据,下面组件获取异步数据的逻辑之所以没有写在事件回调中,是因为首屏即使用户没有触发数据修改,我们也需要主动发出数据请求(类似于首屏数据获取),总之因为业务场景需求吧,我们把请求逻辑放在一个Effect中:
functionSearchResults({query}){
const[results,setResults]=useState([]);
const[page,setPage]=useState(1);
useEffect(()={
//??避免:没有清除逻辑的获取数据
fetchResults(query,page).then(json={
setResults(json);
},[query,page]);
functionhandleNextPageClick(){
setPage(page+1);
}
//...
}
上面代码的问题在于,由于每次网络请求的不可预测性,我们不能保证请求结果是根据当前最新的组件状态获取的,也即是所谓的「竞态条件:两个不同的请求 “相互竞争”,并以与你预期不符的顺序返回。」
「所以可以给我们的Effect添加一个清理函数,来忽略较早的返回结果,」 如下,说白了用一个变量ignore来控制这个Effect回调的"有效性",只要是执行了下一个Effect回调,上一个Effect里的ignore置反,也就是让回调的核心逻辑失效,保证了只有最后执行的Effect回调是“有效”的:
functionSearchResults({query}){
const[results,setResults]=useState([]);
const[page,setPage]=useState(1);
useEffect(()={
//说白了用一个ignore变量来控制这个Effect回调的"有效性",
letignore=false;
fetchResults(query,page).then(json={
if(!ignore){
setResults(json);
}
return()={
ignore=true;
},[query,page]);
functionhandleNextPageClick(){
setPage(page+1);
}
//...
}点击小卡片,参与粉丝专属福利!!如果文章对你有帮助的话欢迎「关注+点赞+收藏」
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线