2023年了~useEffect你真的会用嘛
前言
这篇文章是我根据a-complete-guide-to-useeffect自己总结出来的一些重点~
按照顺序依次如下:
- React单向数据流的渲染
- Effect的执行时机
- 不要对Effect撒谎:依赖数组要怎么设置
- 使用
setState
和useReducer
将上报行为和状态更新解耦 - 函数是否可以作为Effect的依赖呢?
同时我们可以在阅读的时候时刻问自己以下问题:
- 如何用useEffect模拟componentDidMount生命周期?
- 如何正确地在useEffect里请求数据?[]又是什么?
- 我应该把函数当做effect的依赖吗?
- 为什么有时候会出现无限重复请求的问题?
- 为什么有时候在effect里拿到的是旧的state或prop?
每一次渲染都有它自己的props和state
function Counter() {
const [count, setCount] = useState(0);
return (
You clicked {count} times
+
);
}
上述例子中,对于 You clicked {count} times
中的count
,可能react的初学者会认为其是响应式的,也就是会自动监听并且更新渲染。
但实际上,它只是一个普通的变量count
,没有任何的data bounding
。我们在每一次调用setCount
的时候,实际上是React使用新的count
值来反复渲染组件。第一次是0,第二次是1,以此类推。。。
除了变量,那么事件处理函数呢?
function Example() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
return (
You clicked {count} times
);
}
我们想象一下:
- 首先连续点击 Click me 两次
- 再点击一次 Show alert
- 继续点击 Click me
在点击alert的3秒过后,其显示的值会是什么呢?
答案是2!!,这也证明了我们上述的结论。即事件处理函数跟变量一样,也是独立属于当前的渲染。
每次渲染都有它独立的Effect
我们来看看官网上的例子:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
You clicked {count} times
);
}
这里根据之前的学习我们知道,并不是「不变的Effect中count
的值发现了变化,而是Effect每一次渲染都不相同,每一次Effect都捕获了当前渲染的count
值」
useEffect
每一次渲染都有它自己的......所有
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
});
return (
You clicked {count} times
);
}
我们来思考一下上述代码,我们给每一次渲染都添加一个延时函数。当我们疯狂点击Click me的时候,会发生什么呢?
答案是:「顺序打印0,1,2,3...」
当然,你可能会想,如果我就是想要获取最新的值要怎么办的?使用useRef
!
function Example() {
const [count, setCount] = useState(0);
const latestCount = useRef(count);
useEffect(() => {
// Set the mutable latest value
latestCount.current = count;
setTimeout(() => {
// Read the mutable latest value
console.log(`You clicked ${latestCount.current} times`);
}, 3000);
});
// ...
此时当我们疯狂点击后,最终打印出来的全都是最新的count
值。
Effect中的清理又是怎么样的?
思考官方的案例:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
假设第一次渲染的时候props是{id: 10}
,第二次渲染的时候是{id: 20}
。你可能会认为发生了下面的这些事:
- React 清除了
{id: 10}
的effect。 - React 渲染
{id: 20}
的UI。 - React 运行
{id: 20}
的effect。
事实上并不是这样的。React只会在浏览器渲染完毕后才会去执行Effect,这并不会阻塞渲染使得你的应用更加流畅。
Effect的清理实际上也被延后了。上一次Effecet的清理会在UI渲染结束后被执行。
- React 渲染
{id: 20}
的UI。 - 浏览器绘制 我们在屏幕上看到
{id: 20}
的UI。 - React 清除
{id: 10}
的effect。 - React 运行
{id: 20}
的effect。
告诉react如何去比对useEffect
简单来说,你的Effect可能会因为其他状态变化了而造成了不必要的调用。
为了解决这个问题,可以通过依赖数组来告诉React
如果当前渲染中的这些依赖项和上一次运行这个effect的时候值一样,因为没有什么需要同步,React会自动跳过这次effect。
如果依赖项设置错误会怎么样呢?
举个例子,我们来写一个每秒递增的计数器。在Class组件中,我们的直觉是:“开启一次定时器,清除也是一次”。
当我们理所当然地把它用useEffect
的方式翻译,直觉上我们会设置依赖为[]
。因为“我只想运行一次effect“。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return {count}
;
}
在这个例子中,·count
的值永远都不会超过1
。因为定时器只会设置一次,而此时的count
永远都是0,即永远都在执行setCount(0+1)
我们必须改变我们的想法。所谓「依赖数组」是我们用来告诉React该Effect用到了什么状态。在这个例子中,我们对React撒谎了,告诉React说没有用到任何组件内的值。但实际上依赖了count
。
要诚实的告诉React我们正确的依赖的两种办法
1. 将所有Effect用到的依赖都告诉给React
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
+ }, [count]);
现在依赖正确了,但是会有一个问题,就是每一次执行setCount
的时候,都会导致计数器的清除和新建。这并不符合我们的初衷。
2. 修改effect内部的代码以确保它包含的值只会在需要的时候发生变更。
我们不想告知错误的依赖 - 我们只是修改effect使得依赖更少。
继续看会上面的示例(将count
传入依赖数组)。我们需要思考,我们为什么需要用到count
?我们想要通过setCount
来更新count
的值。
但其实在这个场景,我们并不需要获取count
的值,我们只需要获取上一次的状态进行处理即可。因此可以写成
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
函数式更新
对于Effect来说,我们可以用「同步」的思想去理解。
以上面这个例子来说,同步的一个有趣的地方就是将同步信息和状态解耦。类似于setCount(c => c + 1)
这样的更新形式比setCount(count + 1)
传递了更少的信息,因为它不再被当前的count
值“污染”。它只是表达了一种行为(“递增”)。
但是,setCount(c => c + 1)
并不完美,例如我们有两个状态互相依赖,即下一次状态不仅仅依赖上一次状态,它就不能做到了。
这时候,useReducer
大哥就要出场了。
useReducer
我们对上面例子做一些修改:
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [step]);
return (
<>
{count}
setStep(Number(e.target.value))} />
>
);
}
现在count
的值不仅仅依赖自己上一次的值,还依赖step
状态。
相同的,目前我们并没有对React说谎,因此它可以正确运行。但问题在于每一次改变step
后,计时器都会被销毁重建。
当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer
去替换它们。
当你写类似setSomething(something => ...)
这种代码的时候,也许就是考虑使用reducer的契机。reducer可以让你把组件内发生了什么(actions)和状态如何响应并更新分开表述。
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' }); // 更新count
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return (
<>
{count}
{
// 更新step
dispatch({
type: 'step',
step: Number(e.target.value)
});
}} />
>
);
}
function reducer(state, action) {
const { count, step } = state;
if (action.type === 'tick') {
return { count: count + step, step };
} else if (action.type === 'step') {
return { count, step: action.step };
} else {
throw new Error();
}
}
使用useReducer
可以完美解决上述问题。这是为什么呢?
答案在于「React会保证dispatch在组件的声明周期内保持不变」。所以上面例子中不再需要重新订阅定时器。
(你可以从依赖中去除dispatch
,setState
, 和useRef
包裹的值因为React会确保它们是静态的。不过你设置了它们作为依赖也没什么问题。)
与其在Effect中去获取状态,不如只是dispatch一个action来描述行为,这使得Effect与状态解耦,Effect再也不用关心具体的状态了~
使用useRuducer真的是在作弊
当我们的step
是props传进来的又会怎样呢?我们还是可以使用useReducer
,但这种情况下我们的reducer
函数就得写进组件中,因为要获取props。
function Counter({ step }) {
const [count, dispatch] = useReducer(reducer, 0);
function reducer(state, action) {
if (action.type === 'tick') {
return state + step;
} else {
throw new Error();
}
}
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' });
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return {count}
;
}
有的小伙伴可能会问:上一次渲染的reducer
怎么能获取到最新的props呢?
答案是:Effect只是dispatch了行为 - 它会在下一次渲染中去调用reducer,这是就可以获取到最新的props了。注意:「reducer不是在Effect中调用的」
永远要记住useEffect的执行时机
useEffect的执行时机在Dom渲染完毕后。而且它是异步执行的,不会阻塞的。因此在下面例子中,会出现页面闪动的效果。(0 -> 12 -> 2)
export default function FuncCom () {
const [counter, setCounter] = useState(0);
useEffect(() => {
if (counter === 12) {
// 为了演示,这里同步设置一个延时函数 500ms
delay()
setCounter(2)
}
});
return (
setCounter(12)}>{counter}
)
}
换成了 useLayoutEffect
后,屏幕上只会出现 0 和 2,这是因为 useLayoutEffect
的同步特性,会在浏览器渲染之前同步更新react DOM 数据,哪怕是多次的操作,也会在渲染前一次性处理完,再交给浏览器绘制。这样不会导致闪屏现象发生。
将函数放进Effect中
首先抛出问题:函数真的不应该成为依赖项嘛?
我们来看一个许多程序员都会写的一个案例:
function SearchResults() {
const [data, setData] = useState({ hits: [] });
async function fetchData() {
const result = await axios(
'https://hn.algolia.com/api/v1/search?query=react',
);
setData(result.data);
}
useEffect(() => {
fetchData();
}, []); // Is this okay?
// ...
}
该代码是可以正常运行的,但是它的拓展性非常差。试想一下,在项目代码不断增长下,我们可能会分离出很多个很长的函数,可能会有其他的依赖:
function SearchResults() {
const [query, setQuery] = useState('react');
// Imagine this function is also long
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
// Imagine this function is also long
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
如果我们忘记更新Effect的依赖数组的话,这不仅仅欺骗了React,而且导致Effect不会同步所依赖的state和props。
一个最简单的解决方案是,如果某些函数仅在effect中调用,你可以把它们的定义移到effect中:。这样的话程序员不需要考虑这些“间接依赖”,而且在添加query
状态的时候,意识到Effect依赖于它。
function SearchResults() {
const [query, setQuery] = useState('react');
useEffect(() => {
// ❇️ get together
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
fetchData();
}, [query]); // ✅ Deps are OK
// ...
}
我们必须意识到:·useEffect
设计意图就是要强迫你去关注数据流的变化,然后决定怎么去让Effect同步!!
但我不能将函数放进Effect中
举个例子,比如某函数被多个Effect使用到了。
首先我们要明确一点,在组件中定义的函数每一次渲染都是变化的,但这个事实也给我们带来了问题。比如:
function SearchResults() {
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
useEffect(() => {
const url = getFetchUrl('react');
// ... Fetch data and do something ...
}, []); // Missing dep: getFetchUrl
useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, []); // Missing dep: getFetchUrl
// ...
}
一方面我们不想在每一个Effect中都复制相同的代码,但这又是对React的不诚实~,另一方面假设我们将getFetchUrl
当作依赖项,但它每一次渲染都在变,我们的依赖数组就没有发挥用处了。
这里有两个解决办法:
- 如果函数并没有用到组件内的值,我们可以大胆地将其移到组件外部!
- 使用
useCallBack
hook包裹。
useCallBack
本质上只是加了一层依赖检查,使得函数本身只有在需要时才改变,这样我们也不需要去掉函数依赖。
当函数用到了组件内的值,比如query
可以通过输入框由用户去改变,我们可以这样做:
function SearchResults() {
const [query, setQuery] = useState('');
const getFetchUrl = useCallback(() => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, [query]); // ✅ Deps are OK
useEffect(() => {
const url = getFetchUrl();
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Deps are OK
useEffect(() => {
const url = getFetchUrl();
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Deps are OK
// ...
}
这才是拥抱React数据流和同步思维的最终结果。query
改变 -> getFetchUrl
改变 -> Effect重新执行。反之,如果query
不变,则Effect将不会重新执行
函数是数据流的一部分吗?
有趣的是,这种模式在class组件中是行不通的。
class Parent extends Component {
state = {
query: 'react'
};
fetchData = () => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
// ... Fetch data and do something ...
};
render() {
return ;
}
}
class Child extends Component {
state = {
data: null
};
componentDidMount() {
this.props.fetchData();
}
componentDidUpdate(prevProps) {
// This condition will never be true
if (this.props.fetchData !== prevProps.fetchData) {
this.props.fetchData();
}
}
render() {
// ...
}
}
在class组件中,类方法this.fetchData
是永远不会变的
为了解决这个问题,我们必须将query
参数传给Child
组件即使它并没有直接使用到query
:
class Child extends Component {
componentDidUpdate(prevProps) {
// This condition will never be true
if (this.props.query !== prevProps.query) {
this.props.fetchData();
}
}
在class组件中,函数属性本身并不是数据流的一部分。组件的方法中包含了可变的this变量导致我们不能确定无疑地认为它是不变的。因此,即使我们只需要一个函数,我们也必须把一堆数据传递下去仅仅是为了做“diff”。我们无法知道传入的this.props.fetchData
是否依赖状态,并且不知道它依赖的状态是否改变了。
但是感谢useCallback
,它让函数本身也参与进了React数据流,我们可以说函数的输入变了,那么函数本身就变了。(函数的输入需要我们来定义)
需要强调的是:当我们需要将函数传递给子组件并且在子组件的Effect中运用的话,最好使用useCallback
将其包裹。
关于竞态
下面是一个class组件发请求的案例
class Article extends Component {
state = {
article: null
};
componentDidMount() {
this.fetchData(this.props.id);
}
async fetchData(id) {
const article = await API.fetchArticle(id);
this.setState({ article });
}
// ...
}
心细的小伙伴可能发现了,上面代码并没有考虑到id更新的情况,于是:
componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id) {
this.fetchData(this.props.id);
}
}
但是这真的可以完美解决所有问题嘛?
我们可以试想一下,比如我先请求 {id: 10}
,然后更新到{id: 20}
,但{id: 20}
的请求更先返回。请求更早但返回更晚的情况会错误地覆盖状态值。这就是「竞态」
最好的权宜之计就是利用一个boolean来去记录当前请求状态。
更多相关知识可以看这篇文章
结尾
我是「盐焗乳鸽还要香锅」,喜欢我的文章欢迎关注噢
- github 链接https://github.com/1360151219
- 博客链接是 strk2.cn
- 掘金账号、知乎账号、简书《盐焗乳鸽还要香锅》
- 思否账号《天天摸鱼真的爽》
本文由mdnice多平台发布