阿里妹导读
useEffect是React提供给我们的一个“逃生舱”,是React 的纯函数式世界通往命令式世界的“逃生通道”,选择合适的时机使用useEffect会让我们的代码既优雅又高效,反之会造成不必要的负担。> 本文95%来自官方文档,均为官方推荐的写法或者做法。
你希望状态name变化后,发起一次请求,于是你用了useEffect,这简直不要太适合:
useEffect(()=>{
query();
},[name])
随着迭代,需求变更,name也会被其他地方给修改,但是他只是临时修改,并不需要发起一次请求。
幸运的是,你发现了这个问题,因为之前name相关的修改地方太多了,所以不敢动,于是你用if处理:
useEffect(()=>{
if(isReFresh){
query();
}
},[name])
很好,没毛病,不会重复发起请求了。
后面需求又改了,现在age改变也要重新请求,很好,这不就是依赖数组的用处吗,加进去就好了:
useEffect(()=>{
if(isReFresh){
query();
}
},[name,age])
随着时间推移,你逐渐发现:
根本分不清到底什么时候会发送请求,一直在堆屎...Effect的生命周期
https://react.dev/learn/lifecycle-of-reactive-effectsEffect和其他的组件生命周期不太一样,组件的生命周期通常是:
这是设计组件生命周期的好办法,但是并不适用于Effect。Effect只能做两件事情:
如果你的Effect取决于props和state的变化,那么这个循环可能会发生多次。React也提供了相应的linter规则来检测你是否正确的指定了Effect的依赖,以保证你的Effect可以正确的同步最新的props和state。为什么要少用Effect?
修改数据以更新渲染时:当你更新状态的时候,React会首先调用你的组件函数来计算屏幕上应该显示什么,然后React会将这些变化“提交”到DOM去渲染更新屏幕。接着React会执行你的Effects,如果你的Effects也马上更新了State,此时也将从头开始重新启动整个过程。用户事件:当某一个数据变更时,在Effect运行时你可能无法准确的得知用户做了什么事情,只知道数据发生了变更。(依赖项变化->useEffect->if判断 ==> 开头提到的死循环问题)在React官方文档的ESCAPE HATCHES章节中,我们发现effect占据了一半的文档,这个章节翻译成中文是逃生舱口,只有在危险的时候才会进入逃生舱口,effect亦是如此,只有在不得已的时候将其作为最后的逃生舱。
Don’t rush to add Effects to your components. Keep in mind that Effects are typically used to “step out” of your React code and synchronize with some external system. This includes browser APIs, third-party widgets, network, and so on. If your Effect only adjusts some state based on other state, you might not need an Effect.不要急于向组件添加Effect。请记住,Effects 通常用于“跳出”React 代码并与某些外部系统同步,你可以把 Effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。这包括浏览器 API、第三方小部件、网络等。如果您的效果仅根据其他状态调整某些状态,则您可能不需要Effect。
从Effect-Usage的第二个小节开始,React官方就在说Effect只是一个逃生舱,你可能不需要用它,和Effect相关的文章,我感觉官方每时每刻都在重复强调少用,别用,换一个方法,就怕你滥用....所以,大家可以从以下几个case开始考虑,是不是不要用useEffect了,但是阅读之前请注意,不要根据标题来断章取义的认为标题的这种情况就完全不适合用useEffect,可能不适合用useEffect的仅在内容描述的某一些case下,而其他的情况还需要再分析考虑。
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// Avoid:冗余的状态和不必要的Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
没必要这么写,既复杂又低效。这种情况就命中了我前面说的他会从头开始重新渲染整个页面。
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: 重渲染期间计算
const fullName = firstName + ' ' + lastName;
// ...
}
当你需要计算某些值,而恰好计算的参数都是可以从Props或者State中获取的,不要把他放在State中,相对应的,可以在渲染期间计算它。这使得:
非state数据是否可以?
答:不可以
这个就涉及到React本身的更新渲染机制,也就是我们为什么要用useState来set数据。如果不用useState,是不会触发React本身的更新的。
如何判断本次计算是否昂贵?一般而言,除非您要创建或遍历数千个对象,否则它可能并不昂贵。
如果记录的总时间加起来很大(比如 1 毫秒或更多),那么记住该计算可能是有意义的。
可以利用如下的方式来测算:
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// Avoid: 冗余的状态和不必要的Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
与前面的示例一样,这既不必要又低效。
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ Good:当getFilteredTodos执行的不慢的时候,这个写法很好
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
通常情况下,这种写法已经是很优秀的写法了!但是如果getFilteredTodos很慢,或者你有很多其他的待办事项会导致props或者state变化的非常的频繁。在这种情况下,如果你不想重新计算的话,可以将昂贵的计算包裹在useMemo的Hook中来缓存。
注意:useMemo 不会让第一次渲染更快。它只会帮助您跳过不必要的更新工作。
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ Good:除非 todos 或 filter 改变了,否则不会重新执行这个方法
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
这个 ProfilePage 组件接收一个 userId。该页面包含评论输入,您使用评论状态变量来保存其值。当您从一个配置文件导航到另一个配置文件时,评论状态不会重置。因此,很容易不小心在错误的用户个人资料上发表评论。所以你考虑用useEffect来清空state:
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// Avoid: prop 改变时用useEffect来重置state
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
其实大家都可以感受得到,这个写法非常的低效且一看就不是正确的解法。如果你有多个state,每多一个state你都要加一次清空。而且ProfilePage 及其子项将首先使用过时值呈现,然后再次呈现清空后的值。并且如果这个组件是嵌套的,你也需要一个一个清除嵌套组件的state,显然不是最优解。相反的,你可以用key来标识每一个组件,来告诉React,每一个用户的个人资料在概念上是不同的:
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ Good: comment包括其他的state在key改变时会自动的重置
const [comment, setComment] = useState('');
// ...
}
通常,React在同一组件的同一个地方渲染时,React会保留状态。四、当props改变时重置部分state
可能也有一些场景,比如说你只希望在props改变时,只修改部分的state,你可能会想到用useEffect来清除部分,如下:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Avoid: prop 改变时用useEffect来重置state
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
这里的问题和第三点说的一致,而且setSelection(null)调用将导致再次重新渲染 List 及其子组件,再次重新启动整个过程。第一步还是删除useEffect,考虑在渲染期间直接调整状态:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Better: 渲染改变时调整state
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
带着一些疑问来看一下这个写法:
Storing information from previous renders [2]
即使这种写法比useEffect更好,但是大部分组件也不会这么写,不管你怎么做,基于属性或其他状态调整状态都会使你的数据流更难理解和调试。仍然不建议用这个写法。遇到这类问题,我们还是优先考虑将这个问题归到其余的两个解法中来:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Best: 在渲染期间计算一切
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
这种写法更好,因为它的结果是计算出来的,每次渲染都会重新计算一次结果。对大多数项目遇到这种情况来说,这个显然是更优解。
假设你有一个带有两个按钮(购买和结帐)的产品页面,这两个按钮都可以让你购买该产品。你希望在用户将产品放入购物车时显示通知。在两个按钮的点击处理程序中调用 showNotification() 感觉是重复的,因此你可能想将此逻辑放在副作用中:
function ProductPage({ product, addToCart }) {
// Avoid: 将特定事件逻辑放在Effect中
useEffect(() => {
if (product.isInCart) {
// 展示通知
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
在这里用useEffect是没必要的,而且可能会导致一些bug,例如,假设你的应用程序在页面重新加载之间“记住”了购物车。如果你将产品添加到购物车一次并刷新页面,通知将再次出现。每次你刷新该产品的页面时,它都会继续出现。这是因为 product.isInCart在页面加载时已经是 true,所以上面的effect将调用 showNotification()。当您不确定某些代码是应该在 Effect 中还是在事件处理程序中时,问问自己为什么这段代码需要运行。只有当代码应该在组件被展示给用户再运行时,才应该使用 useEffect**。**在这个例子中,通知应该出现是因为用户按下了按钮,而不是因为页面被显示了!正确的应该是将对应的时间逻辑放在特定事件被处理时才触发:
function ProductPage({ product, addToCart }) {
// ✅ Good: 特定事件逻辑只有在事件被处理时才触发
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
useEffect会发起两次请求
useEffect(() => {
logVisit(url); // Sends a POST request
}, [url]);
在dev环境中,logVisit 将针对每个 url 调用两次,因此你可能想尝试修复它。 我们建议保持此代码不变。 与前面的示例一样,运行一次和运行两次之间没有用户可见的行为差异。从实际的角度来看,logVisit 不应在开发中执行任何操作,因为你不希望来自开发机器的日志影响生产指标。每次你保存其文件时,你的组件都会remounts,因此无论如何它都会记录开发中的额外访问。
在production环境中,不会访问两次。
假设你有一个Form组件,会发送2组请求。一组是在组件mount的时候发起,另一种是在点击提交按钮时发起:
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Good: This logic should run because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// Avoid: Event-specific logic inside an Effect
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
我们可以分析一下这两个:
function handleSubmit(e) {
e.preventDefault();
// ✅ Good: Event-specific logic is in the event handler
post('/api/register', { firstName, lastName });
}
有时你可能会想链接副作用,每个副作用都根据其他状态调整一个状态:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// Avoid: Chains of Effects that adjust the state solely to trigger each other
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('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
}
如果用useEffect来处理的话,会有两个问题:
在这种情况下,最好在渲染期间计算你能做什么,并在事件处理程序中调整状态:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ Calculate what you can during rendering
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ Calculate all the next state in the event handler
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
}
这样效率更高。此外,如果打算做一个查看游戏历史的方法,现在能够将每个状态变量设置为过去的值,而不会触发调整每个其他值的Effect链。如果您需要在多个事件处理程序之间重用逻辑,您可以提取一个函数并从这些处理程序中调用它。
在事件处理内部,状态表现的会像快照。举个例子,在你调用setRound(round + 1)时,round值会同步的更新给用户,但是如果你想要在方法里面拿到更新后的值,需要像这样const nextRound = round + 1手动定义它。说到这里,你如果觉得如果涉及到计算链,就先摒弃useEffect,那就错了。并不是所有的计算链都不适合在useEffect中。举个例子,如果说你有多个dropdown,且下一个dropdown的内容依赖于上一个dropdown选择的内容,此时用useEffect来处理计算链是合适的,因为此时我们的数据是和后端交互的,从服务端中获取数据,在这种情况下,由于获取到的数据是异步的,需要等待请求结果,所以用effect反而可以保证数据的同步性。这样做的好处是,无论哪一个下拉框的选项发生了变化,都会触发相应的 Effect 钩子函数,保证了整个表单的数据同步性。
一些逻辑应该只在应用程序加载时运行一次你可能想将它放在顶层组件的副作用中:
function App() {
// Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
我想如果前面你有认真读这篇文章,你应该知道问题。前面提到了,在dev环境下,useEffect会触发两次。这就可能会导致问题,就以这个代码为例,可能会导致身份令牌验证失效,因为可能这个方法就是用来调用一次的,再次请求可能就拿不到身份信息了。
虽然这个问题只在dev环境中会被触发,但是还是要尽量保证一致性,便于调试和重用。如果某些逻辑必须在每次应用加载时运行一次而不是每次组件安装时运行一次,请添加一个顶级变量来跟踪它是否已经执行:
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Only runs once per app load
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
您还可以在模块初始化期间和应用程序呈现之前运行它:
if (typeof window !== 'undefined') { // Check if we're running in the browser.
// ✅ Only runs once per app load
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
尽量慎用此模式,即使这个组件最终没被渲染,当你的组件被导入时就会执行一次。应尽量将应用程序范围的初始化逻辑保留到根组件模块(如 App.js)或应用程序的入口点。
假设你正在编写一个具有内部 isOn 状态的 Toggle 组件,该状态可以是 true 或 false。有几种不同的方式来切换它(通过单击或拖动)。你希望在 Toggle 内部状态发生变化时通知父级,因此你公开了一个 onChange 事件并从副作用中调用它:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// Avoid: The onChange handler runs too late
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
和之前一样,这个做法并不理想,Toggle更新状态->React更新屏幕->React运行Effect->调用onChange->父组件更新状态->重渲染(这里要启动另一个渲染通道)。如果可以的话,最好一次性完成所有事情。
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
// ...
}
通过这种方法,Toggle 组件及其父组件都会在事件期间更新其状态。React 将来自不同组件的更新一起批量处理,因此只有一个渲染通道。这个做法也叫“状态提升”,将状态提升到父组件中,让父组件通过切换状态来完全控制Toggle,这意味着父组件将必须包含更多逻辑,但总体上需要担心的状态会更少。每当您尝试保持两个不同的状态变量同步时,请尝试提升状态!
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// Avoid: Passing data to the parent in an Effect
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
这个操作是可以实现数据传递,不存在写法问题。但是在React中,数据是从父组件传递到子组件的单向数据流动。当你发现错误时,可以沿着组件链追踪信息的来源,直到你发现某个组件有错误属性或者状态。当子组件在副作用中更新其父组件的状态时,数据流变得很难追踪。由于子级和父组件都需要相同的数据,因此让父级获取该数据,然后将其传递给子级才是正确的做法:
function Parent() {
const data = useSomeAPI();
// ...
// ✅ Good: Passing data down to the child
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
有时,你的组件可能需要订阅 React 状态之外的一些数据。此数据可能来自第三方库或内置浏览器 API。由于此数据可能会在 React 不知情的情况下发生变化,因此你需要手动为你的组件订阅它。这通常通过useEffect完成,例如:
function useOnlineStatus() {
// Not ideal: Manual store subscription in an Effect
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();
// ...
}
在这里,该组件订阅了一个外部数据存储(在本例中为浏览器 navigator.onLine API)。由于此 API 在服务器上不存在(因此它不能用于初始 HTML),因此最初将状态设置为 true。每当该数据存储的值在浏览器中发生变化时,组件都会更新其状态。
这么用其实也很正常,一般也不会挑什么毛病。但是,我们有更好的解决方案,React中有一个专门的用户订阅外部存储的钩子,useSyncExternalStore:
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ Good: Subscribing to an external store with a built-in Hook
return useSyncExternalStore(
subscribe, // React won't resubscribe for as long as you pass the same function
() => navigator.onLine, // How to get the value on the client
() => true // How to get the value on the server
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
与使用副作用手动将可变数据同步到 React 状态相比,这种方法更不容易出错。通常,你会像上面的 useOnlineStatus()一样编写一个自定义钩子,这样你就不需要在各个组件中重复此代码。
前面说了这么多可以不用的useEffect,我就当大家都认可了,但是大家心里肯定有一个是觉得一定要放在useEffect里面的,那就是获取数据> 事先说明,并不是所有获取数据都不可以直接在useEffect中,请看下文分析,部分场景是不适合直接在useEffect中...
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// Avoid:没有清除逻辑的获取数据
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
肯定有人会质疑,这有啥问题?请求参数(query)或者页码(page)改变发起一次请求,不就该放在useEffect里面吗?但是我现在要说的是,你不需要把这个逻辑放在事件处理函数中。
这可能看起来与之前需要将逻辑放入事件处理函数中的示例相矛盾!但是,请考虑到打字事件并不是获取数据的主要原因。
搜索输入框的值经常从 URL 中预填充,用户可以在不关心输入框的情况下导航到后退和前进页面。page 和query 的来源其实并不重要。只要该组件可见,你就需要通过当前 page 和 query 的值,保持 results 和网络数据的同步。这就是为什么这里是一个 Effect 的原因。然而,大家在写这类请求的时候一定会碰到一个问题,那就是用户快速输入,会频繁发起请求,但是无法保证返回的数据顺序。
由于setState是最后调用的,将会错误地显示结果。这种情况被称为 “竞态条件”:两个不同的请求 “相互竞争”,并以与你预期不符的顺序返回。你可能会考虑加上debounce,或者加上清理函数来忽略较早的返回结果。
这确保了当你在 Effect 中获取数据时,除了最后一次请求的所有返回结果都将被忽略。处理竞态条件并不是实现数据获取的唯一难点。你可能还需要考虑:
如果你不使用框架(也不想开发自己的框架),但希望使从 Effect 中获取数据更符合人类直觉,请考虑像这个例子一样,将获取逻辑提取到一个自定义 Hook 中:
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
一般来说,当你不得不编写 Effect 时,请留意是否可以将某段功能提取到专门的内置 API 或一个更具声明性的自定义 Hook 中,比如上面的 useData。你会发现组件中的原始 useEffect 调用越少,维护应用将变得更加容易。
https://react.dev/reference/react/useEffect#usage
内容太多,大家可以作为后续自己学习的内容...
当你希望name变化之后发起请求,你首先应该问自己:「状态name变化,接下来需要发起请求」还是「某个用户行为需要发起请求,请求依赖状态name作为参数」如果是后者,我们应当将这个请求和用户的操作关联,而不是放在useEffect当中。
<Button onClick={(e)=>{query(e)}}>点击发起请求</Button>
经过这样修改,「状态name变化」与「发送请求」之间不再有因果关系,后续对状态name的修改不会再有「无意间触发请求」的顾虑。诸如此类的问题,大家期望使用useEffect的时候,都应该考虑一下,是否在前面提到的十二个case其中之一,是否有更好的写法。
useEffect是React提供给我们的一个“逃生舱”,是React 的纯函数式世界通往命令式世界的“逃生通道”,选择合适的时机使用useEffect会让我们的代码既优雅又高效,反之会造成不必要的负担。以下情况可能考虑不要使用useEffect
Copyright© 2013-2019