注:本文是对useEffect完整指南的总结
摘要
-
useEffect执行时机:使用useEffect去调度的任务,任务执行时机是在DOM渲染完成后;这里包含了两个意思:
- 当前渲染的副作用函数的执行是在当前的DOM渲染完毕之后;
- 清除上一次的副作用的函数是在当前副作用函数执行之前,但在当前的DOM渲染之后;
- 清除操作在组件卸载时也会执行;
graph TD 本次DOM渲染完成 --> 上一次的副作用清除函数执行 --> 本次副作用函数执行
React的核心思想是DOM渲染优先,防止UI阻塞,副作用函数滞后DOM渲染;
-
useEffect捕获当前渲染的props和state:由于函数的闭包特性,副作用函数内部访问的props和state是该次渲染的props和state,每次渲染都有自己的props和state,以及对应的副作用函数;
-
useEffect的依赖数组,如果副作用函数使用了函数组件内部的数据,则要“如实”地写在依赖数组里:不要对React撒谎,副作用函数使用了函数组件内的数据,却没有写在依赖数组里,这样会造成某些意料之外的问题;
副作用的清理
以前一直以为副作用的清理是在下一次渲染DOM之前。但实际上,上一次副作用的清理函数是在DOM渲染之后; 例如以下代码
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只会在浏览器绘制后运行effects。这样UI才不会被阻塞,而大多数effects并不会阻塞屏幕的更新。
Effect的清除同样会被延迟,直到DOM重新渲染完成才会执行清除操作;
那么正确的执行流程是这样的:
- React 渲染
{id: 20}
的UI。 - 浏览器绘制。我们在屏幕上看到
{id: 20}
的UI。 - React 清除
{id: 10}
的effect。 - React 运行
{id: 20}
的effect。
每次渲染都有自己的...所有(props、state、事件监听函数、副作用函数等等)
这里的每次渲染指的是react组件的渲染,而非DOM渲染;
由于函数闭包的特性,每次函数式组件的渲染,都会得到一个新的组件视图描述(虚拟DOM),包含了数据流(props和state以及其他函数内部的变量值)和函数(事件监听函数、effect、定时器、其他API调用);每个函数都会捕获当前渲染的数据流;
如果effect中想要访问最新的props或者state(而非捕获的props和state),最简单的方法是使用refs;refs在函数式组件调用前后会一直保持不变,不会生成新的对象,在refs中保持对数据流的引用,从而可以得到最新的props和state;
- 通常访问最新的props和state的场景,是在effect函数中执行异步任务,异步任务中需要访问最新的state和props,如下代码
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); });
effect同步props和state给React Tree之外的东西
effect根据当前函数组件中的数据流(props和state)会同步更新React Tree以外的事物,也就是说,除了React Tree之外的事物都属于effect范畴,都由effect去同步;
effect的依赖数组要准确,不要撒谎
每次数据的更新都会运行effect,但很多场景下,可能effect内部并没有使用这些数据,因此没必要每次更新都去运行effect,所以我们给effect指定了依赖数组,告诉effect如果依赖没变,则不需要执行effect函数;
如果设置了依赖项,effect中用到的所有组件内的值都要包含在依赖中。 这包括props,state,函数 — 组件内的任何东西。
只想在挂载的时候运行effect
这可能是我们对依赖数组撒谎最常见的场景了。 这种思想来自于类组件的生命周期,因此即使effect中使用了props或者state,依赖数组里也只是提供了一个空数组,用来表示只在挂载时运行一次effect。 虽然在很多场景下,这样做不会引起问题,但是在某些场景下,可能会有潜在的问题;
例如计数器场景下 按照生命周期和定时器直觉逻辑,在挂载时启动定时器,卸载时清除定时器,可能会写如下的代码:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
实际上,先渲染count=0
,然后执行副作用,定时器执行一次,count=1
渲染,执行副作用清除(取消定时器),副作用函数没有再次执行,count只会停留在1;
两种添加依赖的方式
在依赖数组中包含所有effect要用到的变量
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
修改effect代码,让依赖的变量更少
修改的思想在于,要在effect中使用最少的状态信息,即让effect尽量与状态解耦。
这样可以做到代码逻辑正确且避免effect多次调用。
例如使用回调函数的方式,而不是直接引用count
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
但最佳的方案是使用useReducer,通过组件发生了什么(actions)与状态的响应和更新分开表述,做到解耦;原因在于:
React会保证dispatch
在组件的生命周期内保持不变,类似于refs机制,因此dispatch可以从依赖数组中去除也没问题,React会保证在组件生命周期里dispatch是静态的。
例如step是count递增的变量,step为2,则每次增加2;
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' }); // Instead of setCount(c => c + step); }, 1000);
return () => clearInterval(id);
}, [dispatch]);
对应的reducer:
const initialState = {
count: 0,
step: 1,
};
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();
}
}
如何将函数作为依赖
在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?
// ...
对于函数型依赖,由于函数在每次渲染都是新的,因此即使是将函数作为依赖,并不会改变effect多余的调用行为;
两个原则
如果函数只有effect调用,则将该函数在effect内部定义
function SearchResults() {
// ...
useEffect(() => {
// We moved these functions inside!
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=react';
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
fetchData();
}, []); // ✅ Deps are OK
// ...
}
如果函数除了effect外,还有其他地方使用
有两个原则:
-
如果函数没有访问组件的数据,将函数定义在组件之外,这样函数就会是静态的;
// ✅ Not affected by the data flowfunction getFetchUrl(query) { return 'https://hn.algolia.com/api/v1/search?query=' + query;} function SearchResults() { useEffect(() => { const url = getFetchUrl('react'); // ... Fetch data and do something ... }, []); // ✅ Deps are OK useEffect(() => { const url = getFetchUrl('redux'); // ... Fetch data and do something ... }, []); // ✅ Deps are OK // ... }
-
或者使用useCallback来包装函数;
function SearchResults() { // ✅ Preserves identity when its own deps are the same const getFetchUrl = useCallback((query) => { return 'https://hn.algolia.com/api/v1/search?query=' + query; }, []); // ✅ Callback deps are OK useEffect(() => { const url = getFetchUrl('react'); // ... Fetch data and do something ... }, [getFetchUrl]); // ✅ Effect deps are OK useEffect(() => { const url = getFetchUrl('redux'); // ... Fetch data and do something ... }, [getFetchUrl]); // ✅ Effect deps are OK // ... }