这篇文章会假设你对useEffectAPI有一定程度的了解。
在我们讨论 effects 之前,我们需要先讨论一下渲染,当我们更新 state 的时候,React会重新渲染组件。每一次渲染都能拿到独立的 state,这个状态值是函数中的一个常量。
这里关键的点在于任意一次渲染中的常量都不会随着时间改变。渲染输出会变是因为我们的组件被一次次调用,而每一次调用引起的渲染中,它包含的值独立于其他渲染。
如果 props 和 state 在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。它们都“属于”一次特定的渲染。即便是事件处理中的异步函数调用“看到”的也是这次渲染中的值。
让我们先看向官网的 useEffect 的例子:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
You clicked {count} times
);
}
effect是如何读取到最新的count
状态值的呢?
也许,是某种 watching 机制类似 vue 中的数据响应式使得能够在 effect 函数内更新?也或许是一个可变的值,React 会在我们组件内部修改它以使我们的 effect 函数总能拿到最新的值?
都不是。
我们已经知道是某个特定渲染中的常量。事件处理函数“看到”的是属于它那次特定渲染中的状态值。对于 effects 也同样如此:
并不是count
的值在“不变”的 effect 中发生了改变,而是 effect 函数本身在每一次渲染中都不相同。
React 会记住你提供的 effect 函数,并且会在每次更改作用于DOM并让浏览器绘制屏幕后去调用它。
所以虽然我们说的是一个 effect(这里指更新document的title),但其实每次渲染都是一个不同的函数— 并且每个 effect 函数看到的 props 和 state 都来自于它属于的那次特定渲染。
现在只需要记住:如果你设置了依赖项,effect 中用到的所有组件内的值都要包含在依赖中。这包括props,state,函数组件内的任何东西。
在下面这个组件中,我们的直觉是:“开启一次定时器,清除也是一次”。直觉上我们会设置依赖为 '[]'。“我只想运行一次 effect ”,但是这样对吗?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return {count};
}
我们以为他会一直递增下去,但实际上他只会递增一次,你想要触发一次因为它是定时器 ,但为什么会有问题?
在第一次渲染中我们执行了setCount(0 + 1)
。但是我们设置了[]
依赖,effect不会再重新运行,它后面每一秒都会调用setCount(0 + 1)。
我们对 React 撒谎说我们的 effect 不依赖组件内的任何值,可实际上我们的 effect 有依赖。
第一种策略是在依赖中包含所有 effect 中用到的组件内的值。让我们在依赖中包含:count
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
这在我们大部分初级开发者的眼中都没有什么问题,并且程序确实不会出任何 bug,现在,每次修改都会重新运行 effect,这能解决问题但是我们的定时器会在每一次改变后清除和重新设定。这肯定不是我们想要的结果。
第二种策略是修改 effect 内部的代码以确保它包含的值只会在需要的时候发生变更。
在这个场景中,我们其实并不需要在effect中使用 count。当我们想要根据前一个状态更新状态的时候,我们可以使用的函数形式:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
我们需要告知React的仅仅是去递增状态 - 不管它现在具体是什么值。注意我们做到了移除依赖,并且没有撒谎。我们的 effect 不再读取渲染中的count
值。
如果我们有两个互相依赖的状态,或者我们想基于一个 prop 来计算下一次的 state,setCount(c => c + 1)
它并不能做到。幸运的是,有一个更强大的姐妹模式,它的名字叫useReducer。
我们先来修改上面的例子让它包含两个状态:count 和 step 。我们的定时器会每次在 count 上增加一个 step 值:
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))} />
>
);
}
注意我们没有撒谎。既然我们在 effect 里使用了step
,我们就把它加到依赖里。所以这也是为什么代码能运行正确。
这个例子目前的行为是修改会重启定时器 - 因为它是依赖项之一。在大多数场景下,这正是你所需要的。清除上一次的effect然后重新运行新的effect并没有任何错。不过,假如我们不想在改变后重启定时器,我们该如何从effect中移除对的依赖呢?
下面这句话我希望你作为一名 react 开发人员要记下来:
当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer
去替换它们。
reducer 可以让你把组件内发生了什么(actions)和状态如何响应并更新分开表述。
我们用一个dispatch
依赖去替换 effect 的依赖 step
:
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' });
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return (
<>
{count}
{
dispatch({
type: 'step',
step: Number(e.target.value)
});
}} />
>
);
}
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();
}
}
你可能会问:“这怎么就更好了?”答案是React会保证dispatch
在组件的声明周期内保持不变。所以上面例子中不再需要重新订阅定时器。
相比于直接在 effect 里面读取状态,它 dispatch 了一个action来描述发生了什么。这使得我们的 effect 和状态解耦。我们的 effect 不再关心怎么更新状态,它只负责告诉我们发生了什么。更新的逻辑全都交由 reducer 去统一处理。
一个典型的误解是认为函数不应该成为依赖。举个例子,下面的代码看上去可以运行正常:
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();
}, []);
// ...
需要明确的是,上面的代码可以正常工作。但这样做在组件日渐复杂的迭代过程中我们很难确保它在各种情况下还能正常运行。
如果我们在某些函数内使用了某些 state 或者 prop:
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();
}, []);
// ...
}
如果我们忘记去更新使用这些函数(很可能通过其他函数调用)的effects的依赖,我们的effects就不会同步props和state带来的变更。这当然不是我们想要的。
如果某些函数仅在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();
}, []);
}
这么做有什么好处呢?我们不再需要去考虑这些“间接依赖”。我们的依赖数组也不再撒谎:在我们的 effect 中确实没有再使用组件范围内的任何东西。
如果我们后面修改getFetchUrl
去使用状态 query
,我们更可能会意识到我们正在effect里面编辑它因此,我们需要把 query
添加到effect的依赖里:
function SearchResults() {
const [query, setQuery] = useState('react');
useEffect(() => {
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]);
}
有时候你可能不想把函数移入 effect 里。比如,组件内有几个 effect 使用了相同的函数,你不想在每个 effect 里复制黏贴一遍这个逻辑。也或许这个函数是一个 prop。
在这种情况下你应该忽略对函数的依赖吗?这么做是不对的。再次强调,effects不应该对它的依赖撒谎。通常我们还有更好的解决办法。一个常见的误解是,“函数从来不会改变”。但是这篇文章你读到现在,你知道这显然不是事实。实际上,在组件内定义的函数每一次渲染都在变。
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 ...
}, []);
useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, []);
}
在这个例子中,你可能不想把getFetchUrl
移到 effects 中,因为你想复用逻辑。
另一方面,如果你对依赖很“诚实”,你可能会掉到陷阱里。我们的两个 effects 都依赖 getFetchUrl
,而它每次渲染都不同,所以我们的依赖数组会变得无用:
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 ...
}, [getFetchUrl]);
useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, [getFetchUrl]);
// ...
}
我们有两个更简单的解决办法。
第一个, 如果一个函数没有使用组件内的任何值,你应该把它提到组件外面去定义,然后就可以自由地在 effects 中使用:
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
function SearchResults() {
useEffect(() => {
const url = getFetchUrl('react');
// ... Fetch data and do something ...
}, []);
useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, []);
// ...
}
你不再需要把它设为依赖,因为它们不在渲染范围内,因此不会被数据流影响。
或者, 你也可以把它包装成useCallback Hook:
function SearchResults() {
const getFetchUrl = useCallback((query) => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, []);
useEffect(() => {
const url = getFetchUrl('react');
// ... Fetch data and do something ...
}, [getFetchUrl]);
useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, [getFetchUrl]);
// ...
}
我们用 useCallback 对 getFetchUrl 做了一层缓存,现在只有当依赖项变化的时候,才会重新执行 useCallback 来返回新的函数,依赖项没有变化的时候就算组件 rerender 了,这个函数也不会重新执行,这样我们把 getFetchUrl 作为 useEffect 的依赖就没问题了。
不同于传递参数的方式,现在我们从状态中读取 query:
function SearchResults() {
const [query, setQuery] = useState('react');
const getFetchUrl = useCallback(() => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, [query]);
useEffect(() => {
const url = getFetchUrl();
// ... Fetch data and do something ...
}, [getFetchUrl]);
// ...
}
如果query
保持不变,useCallback
也会保持不变,我们的 effect 也不会重新运行。但是如果修改了 query,useCallback 也会随之改变,因此会重新请求数据。这就像你在Excel里修改了一个单元格的值,另一个使用它的单元格会自动重新计算一样。