翻译: 卷帘依旧
react
hook
useEffect
useState
你可能已经阅读了很多有关React hooks
的文章了,但是有时候知道如何不使用与知道如何使用同样重要。
在这篇文章中,我会列举一些React hooks
的使用错误以及如何修复这些错误。
1. 不要改变钩子的调用顺序
在写这篇文章的前几天,我正在编写一个根据id
获取游戏数据的组件,以下是FetchGame
组件的简单实现:
function FetchGame({ id }) {
if (!id) {
return 'Please select a game to fetch';
}
const [game, setGame] = useState({
name: '',
description: ''
});
useEffect(() => {
const fetchGame = async () => {
const response = await fetch(`/api/game/${id}`);
const fetchedGame = await response.json();
setGame(fetchedGame);
};
fetchGame();
}, [id]);
return (
<div>
<div>Name: {game.name}</div>
<div>Description: {game.description}</div>
</div>
);
}
组件FecthGame
接收一个propid
-要获取的游戏id
,useEffect()
通过await fetch(/game/${id})
获取到了游戏信息并将其保存到变量game
中。
打开演示示例并加载几个游戏。组件能够正确地获取游戏,并且根据获取到的数据更新状态,但是Problems
tab下有警告-Eslint
对hooks的错误执行顺序给出了警告信息:
问题出现在最先的退出语句中:
function FetchGame({ id }) {
if (!id) {
return 'Please select a game to fetch';
}
// ...
}
如果id
为空,组件就会渲染出'Please select a game to fetch'
并退出。没有hooks被调用。但是如果id
不为空(比如id==='1'
),那么useState()
和useEffect()
就会被调用。
条件语句执行hooks会导致意料不到的问题,而且很难调试。React hooks
的内部工作原理要求组件在每次渲染的时候总是按相同的顺序执行hooks。
这正是hooks的第一条规则:不要在循环、条件或嵌套函数中调用Hooks
解决hooks的错误顺序问题只需要将return
语句移到hooks调用语句之后:
function FetchGame({ id }) {
const [game, setGame] = useState({
name: '',
description: ''
});
useEffect(() => {
const fetchGame = async () => {
const response = await fetch(`/api/game/${id}`);
const fetchedGame = await response.json();
setGame(fetchedGame);
};
if (id) {
fetchGame();
}
}, [id]);
if (!id) {
return 'Please select a game to fetch';
}
return (
<div>
<div>Name: {game.name}</div>
<div>Description: {game.description}</div>
</div>
);
}
现在,无论id
是否为空,useState()
和useEffect()
hook总是会按照相同的顺序被调用。
这是一个帮助避免条件渲染hook的实用性建议:
"在组件主体的顶部执行hooks,逻辑渲染语句移到组件底部。"
eslint-plugin-react-hooks同样能够帮助你强制纠正hooks的执行顺序。
2. 不要使用陈旧的状态
下面的组件MyIncreaser
在点击按钮之后增加状态变量count
:
function MyIncreaser() {
const [count, setCount] = useState(0);
const increase = useCallback(() => {
setCount(count + 1);
}, [count]);
const handleClick = () {
increase();
increase();
increase();
};
return (
<>
<button onClick={handleClick}>Increase</button>
<div>Counter: {count}</div>
</>
);
}
有趣的部分是handleClick
调用了3次状态更新语句。
现在,在打开演示示例之前,我想问你一个问题:如果你点击一次Increase
按钮,计数器count
会增加3
吗?
好的,打开示例并点击Increase
按钮一次。
然而,尽管increase()
在handleClick()
内部被调用了3
次,count
也仅仅增加了1
...
问题在于setCount(count + 1)
状态更新器,当按钮点击之后,React
调用了setCount(count + 1)
3次:
const handleClick = () => {
increase();
increase();
increase();
};
// same as:
const handleClick = () => {
setCount(count + 1);
// count variable is now stale
setCount(count + 1);
setCount(count + 1);
};
第一次调用setCount(count + 1)
能够正确更新计数器count + 1 = 0 + 1 = 1
,然而,接下来的两次调用setCount(count + 1)
也将count
设置为1
,因为它们都使用了陈旧的状态。
陈旧状态的问题可以通过使用函数的方式更新状态来解决。不使用setCount(count + 1)
,现在最好使用setCount(count => count + 1)
:
function MyIncreaser() {
const [count, setCount] = useState(0);
const increase = useCallback(() => {
setCount(count => count + 1);
}, []);
const handleClick = () {
increase();
increase();
increase();
};
return (
<>
<button onClick={handleClick}>Increase</button>
<div>Counter: {count}</div>
</>
);
}
通过使用更新器函数count => count + 1
, React
会返回最新的真实的状态值。
打开修复好的示例。现在点击Increase
按钮更新count
为3,结果正如预期。
这是一条避免陈旧状态变量的规则:
如果你需要使用当前状态去计算下一个状态,总是使用函数的方式去更新状态:
setValue(prevValue => prevValue + someResult)
3. 不要创建旧状态闭包
React hooks
严重依赖闭包的概念,依赖闭包是因为它们丰富的表现性。
快速提醒一下,JavaScript
中的闭包是从其词法范围捕获变量的函数。无论闭包在哪里执行,它总是可以从定义它的地方访问变量。
当使用接收回调作为参数的hooks(比如useEffect(callback, deps)
, useCallback(callback, deps)
)时,你可能创建了陈旧的闭包-捕获过时状态或 prop变量的闭包。
我们一起来看一个使用useEffect(callback, deps)
时创建陈旧闭包的例子。
在<WatchCount>
组件内部,useEffect()
hook每2
秒打印一次count
的值:
function WatchCount() {
const [count, setCount] = useState(0);
useEffect(function() {
setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
}, []);
const handleClick = () => setCount(count => count + 1);
return (
<>
<button onClick={handleClick}>Increase</button>
<div>Counter: {count}</div>
</>
);
}
打开demo点击Increase按钮,然后观察控制台-无论count
的实际值是什么,控制台每隔两秒打印的是Count is : 0
为什么会这个亚子
首次渲染,闭包log
捕获的count
值是0
.
然后,当按钮点击之后,count
增加,setInterval
仍然调用旧的闭包log
,此时闭包捕获的是初始渲染的值0
。log
是陈旧闭包因为它捕获了陈旧状态(过时的)变量count
。
解决方法是告诉useEffect()
闭包log
依赖count
进而正确地重置计时器:
function WatchCount() {
const [count, setCount] = useState(0);
useEffect(function() {
const id = setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
return () => clearInterval(id);
}, [count]);
const handleClick = () => setCount(count => count + 1);
return (
<>
<button onClick={handleClick}>Increase</button>
<div>Counter: {count}</div>
</>
);
}
当依赖正确设置的时候,useEffect()
在count
发生变化之后就能更新setInterval()
的闭包。
打开示例点击几次增加按钮,控制台会按照count
的实际值打印内容。
为了防止闭包捕获旧值:
"确保在回调函数内部用到的state或prop都被设置为依赖"
selint-plugin-react-hooks能够帮助你记得设置正确的hook依赖。
4. 不要使用基础数据的状态
想象这样一种需求场景:我只需要在状态更新时调用副作用,但是不需要在首次渲染时调用。useEffect(callback, deps)
总是会在组件挂载之后调用callback
: 所以我想避免这种情况。
令我意外的是,我发现了以下解决方法:
function MyComponent() {
const [isFirst, setIsFirst] = useState(true);
const [count, setCount] = useState(0);
useEffect(() => {
if (isFirst) {
setIsFirst(false);
return;
}
console.log('The counter increased!');
}, [count]);
return (
<button onClick={() => setCount(count => count + 1)}>
Increase
</button>
);
}
状态变量isFirst
标识组件是否为首次渲染。将这样的信息保存在状态中是一个问题-一旦你更新了setIsFirst(false)
,就会触发重新渲染-没有原因。
是否为首次渲染的信息不应该被保存在状态中。基础设施的数据,比如渲染周期的细节(是否首次渲染,渲染次数),计时器id(setTimeout()
, setInterval()
),对DOM
元素的直接引用,等等,应该使用引用useRef()
来保存和更新。
我们将首次渲染的信息保存在引用isFirstRef
中:
function MyComponent() {
const isFirstRef = useRef(true);
const [count, setCount] = useState(0);
useEffect(() => {
if (isFirstRef.current) {
isFirstRef.current = false;
return;
}
console.log('The counter increased!');
}, [count]);
return (
<button onClick={() => setCounter(count => count + 1)}>
Increase
</button>
);
}
isFirstRef
是一个引用,用于保存组件是否为首次渲染,isFirstRef.current
属性用于访问和更新引用的值。
重要的是:更新isFirstRef.current = false
不会触发重新渲染。
5. 不要忘记清理副作用
很多副作用,比如发送请求或使用计时器,比如setTimeout()
,都是异步的。
在组件卸载时或不需要副作用的执行结果时,不要忘记清理副作用。
比如,如果你开启了一个计时器,确保在组件卸载时停止计时器。
以下组件有个“开始增加”按钮,当按钮点击之后,计数器每秒钟加1:
function DelayedIncreaser() {
const [count, setCount] = useState(0);
const [increase, setShouldIncrease] = useState(false);
useEffect(() => {
if (increase) {
setInterval(() => {
setCount(count => count + 1)
}, 1000);
}
}, [increase]);
return (
<>
<button onClick={() => setShouldIncrease(true)}>
Start increasing
</button>
<div>Count: {count}</div>
</>
);
}
打开示例,点击开始增加按钮,正如所料,计数器变量每秒钟增1.
然而,在计数器增加的过程中,点击卸载计数器按钮来卸载组件,React
会在控制台发出关于更新已卸载组件的状态警告。
修复DelayedIncreaser
组件非常简单:只需要在useEffect()
的回调中使用清理函数去停止计时器就可以了:
function DelayedIncreaser() {
// ...
useEffect(() => {
if (increase) {
const id = setInterval(() => {
setCount(count => count + 1)
}, 1000);
return () => clearInterval(id);
}
}, [increase]);
// ...
}
打开示例。点击开始增加按钮,检查计数器是如何增加的,然后点击卸载计数器按钮:幸亏() => clearInterval(id)
清理函数清除了计时器。React
没有报错。
也就是说,每次对副作用编码,都要反问一下自己,是否需要清理。计时器,繁重的请求(比如上传文件),sockets-都需要被清理。
6. 总结
开始使用React hooks
的最好方法是学习如何使用它们。
但是,您可能会遇到无法理解为什么hooks的行为与预期不同的情况。知道如何使用 React hooks是不够的:你还应该知道如何不使用它们。
第一个不要做的事情就是条件渲染hook或改变hooks的调用顺序。React
期望的是,无论props或state是什么,组件总是能够按相同的顺序调用hooks
避免陈旧状态值的方法是使用函数的方式去更新状态。
不要忘记指明接收回调作为参数hooks用到的依赖:比如useEffect(callback, deps)
,useCallback(callback, deps)
,这能够解决陈旧闭包问题。
不要将基础数据(比如组件渲染周期信息,setTimeout()
或setInterval()
的id)存储到状态中,经验法则是将这些数据存储到引用中。
不要忘记清理副作用,如果用到的话。
使用useEffect()
可能会遇到的另一个可能的陷阱是无限循环,可以查看useEffect中的无限循环陷阱
useful links
我打破了React Hook必须按顺序、不能在条件语句中调用的枷锁
欢迎评论区留言
,点赞
小小的赞
,大大的动力❤️❤️❤️