书接上文:juejin.cn/post/739520... 继续翻译 Dan Abramov的文章 overreacted.io/a-complete-... 文章采用意译,融合了我自己的理解,欢迎大家留言讨论。
🤔 如何在 useEffect 中正确地获取数据?什么是 []?
这篇文章是关于使用 useEffect 进行数据获取的好入门指南。一定要读到最后!它没有这篇文章长。[] 表示该 effect 不使用任何参与 React 数据流的值,因此可以安全地只应用一次。依赖项要如实加入,不能对 React 撒谎。 两种减少依赖项的方法:
-
setCount(c => c + 1)
-
使用 useReducer
🤔 问题:我需要将函数作为 effect 的依赖项指定吗?
-
建议是将不需要 props 或 state 的函数提升到组件外部。
-
将只在 effect 内使用的函数移到该 effect 内。
-
effect 使用了渲染作用域中的函数(包括从 props 中传递的函数),使用 useCallback 包裹
告诉 React 区分 Effects
当我们更新代码,从:
js
<h1 className="Greeting"> Hello, Dan</h1>
变为:
js
<h1 className="Greeting"> Hello, Yuzhi</h1>
React 会比较如下两个对象(虚拟DOM):
js
const oldProps = {className: 'Greeting', children: 'Hello, Dan'};
const newProps = {className: 'Greeting', children: 'Hello, Yuzhi'};
它会遍历每个属性,确定 children
发生了变化,需要更新 DOM,但 className
没有变化。所以它只需要执行:
js
domNode.innerText = 'Hello, Yuzhi';
// 不需要触碰 domNode.className
那么,effects
也可以实现类似的效果吗?如果 effects
没有改变,则没有必要重新执行它们。
例如,我们的组件可能因为状态变化而重新渲染:
js
function Greeting({ name }) {
const [counter, setCounter] = useState(0);
useEffect(() => {
document.title = 'Hello, ' + name;
});
return (
<h1 className="Greeting">
Hello, {name}
<button onClick={() => setCounter(counter + 1)}>
Increment
</button>
</h1>
);
}
这里effects
没有使用 counter
状态。在 effects
中,我们只是同步 document.title
和 name
属性,但 name
属性是相同的。每次 counter
变化时重新赋值 document.title
显然是没必要的。
那么,React 能不能区分 Effects
呢?
js
let oldEffect = () => { document.title = 'Hello, Dan'; };
let newEffect = () => { document.title = 'Hello, Dan'; };
// React 能不能看到这些函数做了同样的事情?
并不能。React 无法在不调用函数的情况下猜测函数会做什么。(源代码并不真正包含具体值,它只是闭包了 name
属性。)
如果我们想想避免不必要地重新运行效果,可以给 useEffect
提供一个依赖数组(也叫"deps")参数:
js
useEffect(() => {
document.title = 'Hello, ' + name;
}, [name]); // 依赖数组
就像我们告诉 React:"嘿,我知道你看不见这个函数内部,但我保证它只使用 name
,而且没有其他的渲染作用域的变量。"
如果这些值每次效果运行时都是相同的,那么就没有什么需要同步的,React 可以跳过这个效果:
js
const oldEffect = () => { document.title = 'Hello, Dan'; };
const oldDeps = ['Dan'];
const newEffect = () => { document.title = 'Hello, Dan'; };
const newDeps = ['Dan'];
// React 无法窥探函数内部,但它可以比较依赖项。
// 因为所有依赖项都是相同的,所以它不需要运行新的效果。
如果依赖数组中的任一值在渲染之间不同,我们就知道不能跳过运行 effects
,需要同步所有的东西!
依赖项不要对 Rect 撒谎
例如,我们正在写一个每秒递增的计数器。对于一个类组件,我们的直觉是:"设置一次 interval 并销毁一次"。当我们用 useEffect
翻译这段代码时,我们本能地在依赖项中添加 []
。"我想让它运行一次",对吗? 文章 juejin.cn/post/693020... 详细说明了该问题。
js
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
然而,这么写 count
只递增一次!
如果你的思维模式是"依赖项让我指定何时重新触发 effects",则这个例子会反应这种思维模式的问题。
依赖项的用途是告诉 React,effects
使用了哪些依赖。并且,我们的代码不应该对 React 撒谎。
在第一次渲染中,count
是 0。因此,在第一次渲染的效果中的 setCount(count + 1)
意味着 setCount(0 + 1)
。 []
依赖项使得 effects
并不会重新执行,它将每秒调用 setCount(0 + 1)
:
js
// 第一次渲染,状态是 0
function Counter() {
// ...
useEffect(
// 第一次渲染的效果
() => {
const id = setInterval(() => {
setCount(0 + 1); // 总是 setCount(1)
}, 1000);
return () => clearInterval(id);
},
[] // 永不重新运行
);
// ...
}
// 每次后续渲染,状态是 1
function Counter() {
// ...
useEffect(
// 这个效果总是被忽略,因为
// 我们对 React 撒谎了,使用了空依赖项。
() => {
const id = setInterval(() => {
setCount(1 + 1);
}, 1000);
return () => clearInterval(id);
},
[]
);
// ...
}
我们对 React 撒谎,我们的 effects
依赖了组件内的值 count
,但是我们并没有加入到依赖项 []
中。
解决方法:提供了一个 lint 规则强制执行这一点:始终诚实地指定 effects
依赖项,并指定所有依赖项。
两种方法对依赖项保持诚实
第一种方法是:把 effects
用到组件内部的值都放到依赖项中。
js
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
依赖数组正确了。虽然可能不是最理想的,但是我们解决了刚说的问题。现在对 count
的更改将重新运行 effect
,每个下一个间隔引用 setCount(count + 1)
中的 count
:
js
// 第一次渲染,状态是 0
function Counter() {
// ...
useEffect(
// 第一次渲染的 effect
() => {
const id = setInterval(() => {
setCount(0 + 1); // setCount(count + 1)
}, 1000);
return () => clearInterval(id);
},
[0] // [count]
);
// ...
}
// 第二次渲染,状态是 1
function Counter() {
// ...
useEffect(
// 第二次渲染的 effect
() => {
const id = setInterval(() => {
setCount(1 + 1); // setCount(count + 1)
}, 1000);
return () => clearInterval(id);
},
[1] // [count]
);
// ...
}
这可以解决问题,但我们的定时器 interval 会在 count 变化时被清除并重新设置。这是不理想的:
第二种策略是修改我们的 effect
代码,使其减少依赖项。我们不能对依赖项撒谎------我们只是希望通过减少依赖来改变我们的 effect。
几种常见的减少依赖的方法如下:
让 Effects 自给自足
我们想要去掉 effect 中对 count 的依赖。
js
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
为此,我们需要问自己:我们使用 count 是为了什么?看起来我们只是在 setCount 调用中使用了它。在这种情况下,我们实际上不需要在作用域中使用 count。当我们想要根据之前的状态更新状态时,可以使用 setState 的函数更新形式:
js
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
我喜欢把这些情况看作"伪依赖"。是的,count 是一个必要的依赖,因为我们在 effect 内部写了 setCount(count + 1)。但是,我们实际上只需要 count 将其转换为 count + 1 并"传回"给 React。但 React 已经知道当前的 count。我们只需要告诉 React 增加状态------无论它现在是什么。
这正是 setCount(c => c + 1) 所做的。你可以把它看作是"发送一个指令"给 React,告诉它状态应该如何变化。这种"更新器形式"在其他情况下也有帮助,比如当你批量进行多个更新时。
请注意,我们实际上做了移除依赖的工作。我们没有作弊。我们的 effect 不再从渲染作用域读取计数器值:
即使这个 effect 只运行一次,属于第一次渲染的间隔回调完全能够在每次间隔触发时发送 c => c + 1 更新指令。它不再需要知道当前的计数器状态。React 已经知道它。
从 Actions
中解耦操作
修改之前的例子,使用两个状态变量:count 和 step。定时器 interval 将根据 step 输入的值增加 count:
js
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 (
<>
<h1>{count}</h1>
<input value={step} onChange={e => setStep(Number(e.target.value))} />
</>
);
}
请注意,我们没有作弊。在 effect
中使用 step
后,所以将其添加到了依赖项中。这就是代码运行正确的原因。
当前示例中的行为是修改 step
会导致重新启动定时器 interval
,因为它是依赖项之一。
但是,假设我们希望更改 step
定时器 interval
不会重置。我们怎么才能从 effect 中移除 step 依赖项?
当设置一个状态变量依赖于另一个状态变量的当前值时,你可能想尝试用 useReducer
替换它们。
当你发现自己在编写 setSomething(something => ...)
时,考虑使用 reducer
可能是个好时机。reducer
让你能够将组件中发生的 "操作" 与响应这些操作的状态更新解耦。
让我们在 effect
中用 dispatch
依赖项替换 step
依赖项:
js
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' }); // 替代 setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
你可能会问:"这有什么更好的呢?"答案是 React 保证 dispatch 函数在组件生命周期中是恒定的。所以上述示例永远不需要重新订阅定时器 interval。
我们解决了问题!
(你可以省略 dispatch
、setState
和 useRef
容器值的依赖项,因为 React 保证它们是静态的。但指定它们也无妨。)
代替在 effect 中读取状态,它会分派一个编码了发生了什么信息的操作。这使得我们的 effect 与 step 状态解耦。我们的 effect 不关心我们如何更新状态,它只告诉我们发生了什么。reducer 集中更新逻辑:
js
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();
}
}
demo 地址: codesandbox.io/p/sandbox/x...
使用 useReducer
我们已经学会了当一个 effect 需要基于一个状态来设置另一个状态时,如何去除依赖项。但是,如果我们需要根据 props 计算下一个状态呢 ?例如,我们的 API 可能是 <Counter step={1} />
。在这种情况下,我们肯定无法避免将 props.step
作为依赖项吗?
实际上,我们可以通过将 reducer 放在组件内部来读取 props:
js
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 <h1>{count}</h1>;
}
这种情况下,dispatch
的身份在重新渲染之间仍然是稳定的。我们也可以省略 dispatch
作为 effect 的依赖项。这不会导致 effect 重新运行。
你可能会想:这怎么可能工作?在属于另一个渲染的 effect 内部调用时,reducer 如何"知道" props?答案是,当你 dispatch 时,React 只是记住了 action,但它将在下次渲染期间调用你的 reducer。届时,新鲜的 props 将在作用域内,而不在 effect 内部。
使用 useReducer
能够将更新逻辑与描述发生的事情分离开来。同时去除 effect 中不必要的依赖项,并避免不必要地重新运行它们。
将函数移到 Effect 内部
一个常见的错误是认为函数不应该作为依赖项。例如,这段代码看起来好像是可以工作的:
js
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();
}, []); // 这样可以吗?
// ...
}
这个例子改编自 Robin Wieruch 的一篇优秀文章------可以去看看!www.robinwieruch.de/react-hooks...
明确地说,这段代码确实能工作。但简单地省略本地函数的问题在于,随着组件的增长,很难判断我们是否处理了所有情况!
假设我们的代码是这样分割的,并且每个函数都比现在长五倍:
js
function SearchResults() {
// 假设这个函数很长
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=react';
}
// 假设这个函数也很长
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
现在假设我们后来在这些函数中使用了一些状态或 prop:
js
function SearchResults() {
const [query, setQuery] = useState('react');
// 假设这个函数也很长
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
// 假设这个函数也很长
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
如果我们忘记更新调用这些函数的任意 effect 的依赖项(可能通过其他函数调用),我们的 effect 将无法同步来自我们的 props 和状态的更改。
幸运的是,有一个简单的解决方案。如果你只在 effect 内部使用某些函数,请直接将它们移到该 effect 中:
js
function SearchResults() {
// ...
useEffect(() => {
// 我们将这些函数移到了内部!
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 依赖项中:
js
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]); // ✅ 依赖项是正确的
// ...
}
通过添加这个依赖项,当 query
更改时重新获取数据是有意义的。useEffect
的设计迫使你注意到数据流中的变化,并选择我们的 effect 应该如何同步它------而不是忽略它,直到我们的产品用户遇到 bug。
感谢 eslint-plugin-react-hooks
插件中的 exhaustive-deps
规则,你可以在编辑器中键入代码时分析 effect 并接收有关缺失依赖项的建议。换句话说,机器可以告诉你组件未正确处理的数据流更改。
当函数不能移到 Effect 内部时
有时你可能不想将函数移到 effect 内。例如,同一个组件中的多个 effect 可能会调用相同的函数,而你不想复制和粘贴它的逻辑。或者它可能是一个 prop。
你应该在 effect 的依赖项中跳过这样的函数吗?我认为不应该。再次强调,effect 不应该对它们的依赖项撒谎。通常有更好的解决方案。一个常见的误解是"一个函数永远不会改变"。实际上,在组件内定义的函数在每次渲染时都会改变!
这本身就带来了一个问题。假设两个 effect 调用了 getFetchUrl
:
js
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
// ...
}
在这种情况下,你可能不想将 getFetchUrl
移到任何一个 effect 中,因为你不能共享逻辑。
另一方面,如果你对 effect 的依赖项保持"诚实",你可能会遇到问题。由于我们的两个 effect 都依赖于 getFetchUrl
(每次渲染时都不同),我们的依赖数组变得无用:
js
function SearchResults() {
// 🔴 每次渲染都会重新触发所有的 effect
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]); // 🚧 依赖项是正确的,但它们变化太频繁
// ...
}
一个诱人的解决方案是跳过依赖列表中的 getFetchUrl
函数。然而,我认为这不是一个好的解决方案。这使得我们难以注意到我们添加了需要由 effect 处理的数据流更改。这导致了我们之前看到的"从不更新的定时器"这类 bug。
相反,有两个更简单的解决方案。
方案一:如果一个函数不使用组件范围内的任何内容,你可以将其提升到组件外部,然后在你的 effect 内部自由使用它:
js
// ✅ 不受数据流的影响
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 ...
}, []); // ✅ 依赖项是正确的
// ...
}
没有必要在依赖项中指定它,因为它不在渲染范围内,并且不会受到数据流的影响。它不会意外地依赖于 props 或 state。
方案二 :将函数包装到 useCallback
Hook 中:
js
function SearchResults() {
// ✅ 当自身的依赖项相同时保留标识
const getFetchUrl = useCallback((query) => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, []); // ✅ Callback 依赖项是正确的
useEffect(() => {
const url = getFetchUrl('react');
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Effect 依赖项是正确的
useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Effect 依赖项是正确的
// ...
}
useCallback
本质上就像添加了另一层依赖检查。它解决了另一个的问题------我们不是避免函数依赖,而是让函数本身只在必要时变化。 之前,我们的例子展示了两个搜索结果('react' 和 'redux' 搜索查询)。但是假设我们想添加一个输入,以便你可以搜索任意查询。因此,getFetchUrl
将不再接受查询作为参数,而是从本地状态中读取它。
我们会立即看到它缺少一个 query
依赖项:
js
function SearchResults() {
const [query, setQuery] = useState('react');
const getFetchUrl = useCallback(() => { // 没有 query 参数
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, []); // 🔴 缺少依赖:query
// ...
}
直接修复 useCallback
的依赖项以包括 query
,那么任何包含 getFetchUrl
的 effect 依赖项都会在 query
更改时重新运行:
如果 query
是相同的,getFetchUrl
也会保持相同,并且我们的 effect 不会重新运行。但是如果 query
发生变化,getFetchUrl
也会变化,我们将重新获取数据。这很像当你更改 Excel 电子表格中的某个单元格时,使用它的其他单元格会自动重新计算。
这只是拥抱数据流和同步思维的结果。同样的解决方案也适用于从父组件传递的函数 prop:
js
function Parent() {
const [query, setQuery] = useState('react');
// ✅ 在 query 更改之前保持标识
const fetchData = useCallback(() => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + query;
// ... Fetch data and return it ...
}, [query]); // ✅ Callback 依赖项是正确的
return <Child fetchData={fetchData} />
}
function Child({ fetchData }) {
let [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [fetchData]); // ✅ Effect 依赖项是正确的
// ...
}
由于 fetchData
只在 Parent
的 query
状态变化时变化,所以我们的 Child
不会重新获取数据,除非对于应用程序来说确实有必要。
关于竞争条件
一个经典的类组件数据获取示例可能看起来像这样:
js
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 });
}
// ...
}
如你所知,这段代码是有问题的。它没有处理更新。因此,你在网上可能找到的第二个经典示例是这样的:
js
class Article extends Component {
state = {
article: null
};
componentDidMount() {
this.fetchData(this.props.id);
}
componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id) {
this.fetchData(this.props.id);
}
}
async fetchData(id) {
const article = await API.fetchArticle(id);
this.setState({ article });
}
// ...
}
这确实更好!但它仍然有问题。问题在于请求可能会乱序。如果我正在获取 {id: 10}
,切换到 {id: 20}
,但 {id: 20}
的请求先到,那么早先开始但后来完成的请求将错误地覆盖我的状态。
这就是所谓的竞争条件,在混合了 async/await
(假设某些东西在等待结果)和自上而下的数据流(props 或 state 在我们进行异步函数中间时可能会改变)的代码中很常见。
effect 并不能神奇地解决这个问题,虽然它会在你尝试将异步函数直接传递给 effect 时发出警告。
如果你使用的异步方法支持取消,那很好!你可以在清理函数中取消异步请求。
或者,最简单的临时解决方法是用一个布尔值来跟踪:
js
function Article({ id }) {
const [article, setArticle] = useState(null);
useEffect(() => {
let didCancel = false;
async function fetchData() {
const article = await API.fetchArticle(id);
if (!didCancel) {
setArticle(article);
}
}
fetchData();
return () => {
didCancel = true;
};
}, [id]);
// ...
}
这篇文章详细介绍了如何处理错误和加载状态,以及如何将这些逻辑提取到自定义 Hook 中。如果你对使用 Hooks 进行数据获取感兴趣,我推荐你去看看。 www.robinwieruch.de/react-hooks...
提高标准
跟生命周期心智模型不一样,副作用的行为与渲染输出不同。渲染 UI 是由 props 和 state 驱动的,并且保证与它们一致,但副作用不是。这是一个常见的 bug 来源。
useEffect
的心智模型是,事情默认是同步的。副作用成为 React 数据流的一部分。对于每个 useEffect
调用,一旦你做对了,你的组件会更好地处理边缘情况。
然而,做对的前期成本更高。这可能会令人恼火。编写处理边缘情况良好的同步代码本质上比触发与渲染不一致的一次性副作用更困难。
如果 useEffect
是你大部分时间使用的工具,这可能会令人担忧。然而,它是一个低级构建块。对于 Hooks 来说,现在是一个早期阶段,所以每个人总是使用低级 Hooks,特别是在教程中。但在实践中,随着好的 API 获得关注,社区可能会开始转向高级 Hooks。
我看到不同的应用程序创建了自己的 Hooks,如 useFetch
,它封装了一些应用程序的身份验证逻辑,或者 useTheme
使用主题上下文。一旦你有了这些工具箱,你就不常使用 useEffect
了。但它带来的弹性使每个构建在其上的 Hook 受益。
到目前为止,useEffect
最常用于数据获取。但数据获取并不是一个同步问题。这尤其明显,因为我们的依赖项通常是 []
。我们在同步什么?
从长远来看,数据获取的 Suspense 将允许第三方库拥有一种一流的方法来告诉 React 暂停渲染,直到某些异步内容(任何东西:代码、数据、图像)准备就绪。
随着 Suspense 逐渐覆盖更多的数据获取用例,我预计 useEffect
将退居背景,作为一种在你实际想将 props 和 state 同步到某个副作用时的高级用户工具。与数据获取不同,它自然地处理这种情况,因为它是为此设计的。但在那之前,如本文所示的自定义 Hooks 是重用数据获取逻辑的好方法。