共计 15755 个字符,预计需要花费 40 分钟才能阅读完成。
原文链接: https://www.robinwieruch.de/r…
在本教程中,我想通过 state 和 effect hook 来像你展示如何用 React Hooks 来获取数据。我将会使用 Hacker News 的 API 来获取热门的技术文章。你将会实现一个属于你自己的自定义 hook 来在你程序的任何地方复用,或者是作为一个 npm 包发布出来。
如果你还不知道这个 React 的新特性,那么点击 React Hooks 介绍,如果你想直接查看最后的实现效果,请点击这个 github 仓库。
注意:在未来,React Hooks 将不会用于 React 的数据获取,一个叫做 Suspense 的特性将会去负责它。但下面的教程仍会让你去更多的了解关于 React 中的 state 和 effect hook。
用 React Hooks 去获取数据
如果你对在 React 中获取数据还不熟悉,可以查看我其他的 React 获取数据的文章。它将会引导你通过使用 React 的 class 组件来获取数据,并且还可以和 render props 或者高阶组件一起使用,以及结合错误处理和加载状态。在这篇文章中,我将会在 function 组件中使用 React Hooks 来展示这些功能。
import React, {useState} from ‘react’;
function App() {
const [data, setData] = useState({hits: [] });
return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}
export default App;
这个 App 组件展示了一个包含很多项的 list(hits = Hacker News 文章)。state 和 state 的更新函数来自于 state hook 中 useState 的调用,它负责管理我们用来渲染 list 数据的本地状态,初始状态是一个空数组,此时还没有为其设置任何的状态。
我们将使用 axios 来获取数据,当然你也可以使用其他的库或者 fetch API,如果你还没安装 axios,你可以在命令行使用 npm install axios 来安装它。然后来实现用于数据获取的 effect hook:
import React, {useState, useEffect} from ‘react’;
import axios from ‘axios’;
function App() {
const [data, setData] = useState({hits: [] });
useEffect(async () => {
const result = await axios(
‘http://hn.algolia.com/api/v1/search?query=redux’,
);
setData(result.data);
});
return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}
export default App;
通过 axios 在 useEffect 中获取数据,然后通过 setData 将数据放到组件本地的 state 中,并通过 async/await 来处理 Promise。
然而当你运行程序的时候,你应该会遇到一个讨厌的循环。effect hook 不仅在组件 mount 的时候也会在 update 的时候运行。因为我们在每一次的数据获取之后,会去通过 setState 设置状态,这时候组件 update 然后 effect 就会运行一遍,这就造成了数据一次又一次的获取。我们仅仅是想要在组件 mount 的时候来获取一次数据,这就是为什么我们需要在 useEffect 的第二个参数提供一个空数组,从而实现只在 mount 的时候触发数据获取而不是每一次 update。
import React, {useState, useEffect} from ‘react’;
import axios from ‘axios’;
function App() {
const [data, setData] = useState({hits: [] });
useEffect(async () => {
const result = await axios(
‘http://hn.algolia.com/api/v1/search?query=redux’,
);
setData(result.data);
}, []);
return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}
export default App;
第二个参数可以定义 hooks 所依赖的变量(在一个数组中去分配),如果一个变量改变了,hooks 将会执行一次,如果是一个空数组的话,hooks 将不会在组件更新的时候执行,因为它没有监听到任何的变量。
这里还有一个陷阱,在代码中,我们使用 async/await 从第三方的 API 中获取数据,根据文档,每一个 async 函数都将返回一个 promise,async 函数声明定义了一个异步函数,它返回一个 asyncFunction 对象,异步函数是通过事件循环异步操作的函数,使用隐式 Promise 返回其结果。但是,effect hook 应该不返回任何内容或清除功能,这就是为什么你会在控制台看到以下警告:07:41:22.910 index.js:1452 Warning: useEffect function must return a cleanup function or nothing. Promises and useEffect(async () => …) are not supported, but you can call an async function inside an effect.. 这就是为什么不允许在 useEffect 函数中直接使用 async 的原因。让我们通过在 effect 内部使用异步函数来实现它的解决方案。
import React, {useState, useEffect} from ‘react’;
import axios from ‘axios’;
function App() {
const [data, setData] = useState({hits: [] });
useEffect(() => {
const fetchData = async () => {
const result = await axios(
‘http://hn.algolia.com/api/v1/search?query=redux’,
);
setData(result.data);
};
fetchData();
}, []);
return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}
export default App;
简而言之,这就是用 React Hooks 获取数据。但是,如果你对错误处理、加载提示、如何从表单中触发数据获取以及如何实现可重用的数据获取 hook 感兴趣,请继续阅读。
如何通过编程方式 / 手动方式触发 hook?
好的,我们在 mount 后获取了一次数据,但是,如果使用 input 的字段来告诉 API 哪一个话题是我们感兴趣的呢?“Redux”可以作为我们的默认查询,如果是关于“React”的呢?让我们实现一个 input 元素,使某人能够获取“Redux”以外的话题。因此,为 input 元素引入一个新的状态。
import React, {Fragment, useState, useEffect} from ‘react’;
import axios from ‘axios’;
function App() {
const [data, setData] = useState({hits: [] });
const [query, setQuery] = useState(‘redux’);
useEffect(() => {
const fetchData = async () => {
const result = await axios(
‘http://hn.algolia.com/api/v1/search?query=redux’,
);
setData(result.data);
};
fetchData();
}, []);
return (
<Fragment>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
</Fragment>
);
}
export default App;
目前,这两个状态彼此独立,但现在希望将它们耦合起来,以获取由 input 中的输入来查询指定的项目。通过下面的更改,组件应该在挂载之后通过查询词获取所有数据。
function App() {
const [data, setData] = useState({hits: [] });
const [query, setQuery] = useState(‘redux’);
useEffect(() => {
const fetchData = async () => {
const result = await axios(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
setData(result.data);
};
fetchData();
}, []);
return (
…
);
}
export default App;
还差一部分:当你尝试在 input 中输入一些内容时,在 mount 之后就不会再获取任何数据了,这是因为我们提供了空数组作为第二个参数,effect 没有依赖任何变量,因此只会在 mount 的时候触发,但是现在的 effect 应该依赖 query,每当 query 改变的时候,就应该触发数据的获取。
function App() {
const [data, setData] = useState({hits: [] });
const [query, setQuery] = useState(‘redux’);
useEffect(() => {
const fetchData = async () => {
const result = await axios(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
setData(result.data);
};
fetchData();
}, [query]);
return (
…
);
}
export default App;
现在每当 input 的值更新的时候就可以重新获取数据了。但这又导致了另一个问题:对于 input 中键入的每个字符,都会触发该效果,并执行一个数据提取请求。如何提供一个按钮来触发请求,从而手动 hook 呢?
function App() {
const [data, setData] = useState({hits: [] });
const [query, setQuery] = useState(‘redux’);
const [search, setSearch] = useState(”);
useEffect(() => {
const fetchData = async () => {
const result = await axios(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
setData(result.data);
};
fetchData();
}, [query]);
return (
<Fragment>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type=”button” onClick={() => setSearch(query)}>
Search
</button>
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
</Fragment>
);
}
现在,effect 依赖于于 search,而不是随输入字段中变化的 query。一旦用户点击按钮,新的 search 就会被设置,并且应该手动触发 effect hook。
function App() {
const [data, setData] = useState({hits: [] });
const [query, setQuery] = useState(‘redux’);
const [search, setSearch] = useState(‘redux’);
useEffect(() => {
const fetchData = async () => {
const result = await axios(
`http://hn.algolia.com/api/v1/search?query=${search}`,
);
setData(result.data);
};
fetchData();
}, [search]);
return (
…
);
}
export default App;
此外,search 的初始值也设置为与 query 相同,因为组件也在 mount 时获取数据,因此结果应反映输入字段中的值。但是,具有类似的 query 和 search 状态有点令人困惑。为什么不将实际的 URL 设置为状态而来代替 search?
function App() {
const [data, setData] = useState({hits: [] });
const [query, setQuery] = useState(‘redux’);
const [url, setUrl] = useState(
‘http://hn.algolia.com/api/v1/search?query=redux’,
);
useEffect(() => {
const fetchData = async () => {
const result = await axios(url);
setData(result.data);
};
fetchData();
}, [url]);
return (
<Fragment>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button
type=”button”
onClick={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
Search
</button>
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
</Fragment>
);
}
这就是使用 effect hook 获取隐式编程数据的情况。你可以决定 effect 依赖于哪个状态。一旦在点击或其他 effect 中设置此状态,此 effect 将再次运行。在这种情况下,如果 URL 状态发生变化,effect 将再次运行以从 API 获取数据。
React Hooks 和 loading
让我们为数据获取引入一个加载提示。它只是另一个由 state hook 管理的状态。loading 被用于在组件中渲染一个 loading 提示。
import React, {Fragment, useState, useEffect} from ‘react’;
import axios from ‘axios’;
function App() {
const [data, setData] = useState({hits: [] });
const [query, setQuery] = useState(‘redux’);
const [url, setUrl] = useState(
‘http://hn.algolia.com/api/v1/search?query=redux’,
);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
const result = await axios(url);
setData(result.data);
setIsLoading(false);
};
fetchData();
}, [url]);
return (
<Fragment>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button
type=”button”
onClick={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
Search
</button>
{isLoading ? (
<div>Loading …</div>
) : (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</Fragment>
);
}
export default App;
一旦调用该 effect 进行数据获取(当组件 mount 或 URL 状态更改时发生),加载状态将设置为 true。一旦请求完成,加载状态将再次设置为 false。
React Hooks 和错误处理
如果在 React Hooks 中加上错误处理呢,错误只是用 state hook 初始化的另一个状态。一旦出现错误状态,应用程序组件就可以为用户提供反馈。使用 async/await 时,通常使用 try/catch 块进行错误处理。你可以在 effect 内做到:
import React, {Fragment, useState, useEffect} from ‘react’;
import axios from ‘axios’;
function App() {
const [data, setData] = useState({hits: [] });
const [query, setQuery] = useState(‘redux’);
const [url, setUrl] = useState(
‘http://hn.algolia.com/api/v1/search?query=redux’,
);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const result = await axios(url);
setData(result.data);
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [url]);
return (
<Fragment>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button
type=”button”
onClick={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
Search
</button>
{isError && <div>Something went wrong …</div>}
{isLoading ? (
<div>Loading …</div>
) : (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</Fragment>
);
}
export default App;
React 在表单中获取数据
到目前为止,我们只有 input 和按钮的组合。一旦引入更多的输入元素,您可能需要用一个表单元素包装它们。此外,表单还可以通过键盘上的“enter”来触发。
function App() {
…
return (
<Fragment>
<form
onSubmit={() =>
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
}
>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type=”submit”>Search</button>
</form>
{isError && <div>Something went wrong …</div>}
…
</Fragment>
);
}
但是现在浏览器在单击提交按钮时页面会重新加载,因为这是浏览器在提交表单时的固有行为。为了防止默认行为,我们可以通过 event.preventDefault() 取消默认行为。这也是在 React 类组件中实现的方法。
function App() {
…
const doFetch = () => {
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`);
};
return (
<Fragment>
<form onSubmit={event => {
doFetch();
event.preventDefault();
}}>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type=”submit”>Search</button>
</form>
{isError && <div>Something went wrong …</div>}
…
</Fragment>
);
}
现在,当你单击提交按钮时,浏览器不会再重新加载。它和以前一样工作,但这次使用的是表单,而不是简单的 input 和按钮组合。你也可以按键盘上的“回车”键。
自定义数据获取 hook
为了提取用于数据获取的自定义 hook,请将属于数据获取的所有内容,移动到一个自己的函数中。还要确保能够返回 App 组件所需要的全部变量。
const useHackerNewsApi = () => {
const [data, setData] = useState({hits: [] });
const [url, setUrl] = useState(
‘http://hn.algolia.com/api/v1/search?query=redux’,
);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const result = await axios(url);
setData(result.data);
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [url]);
const doFetch = () => {
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`);
};
return {data, isLoading, isError, doFetch};
}
现在,你可以在 App 组件中使用新的 hook 了。
function App() {
const [query, setQuery] = useState(‘redux’);
const {data, isLoading, isError, doFetch} = useHackerNewsApi();
return (
<Fragment>
…
</Fragment>
);
}
接下来,从 dofetch 函数外部传递 URL 状态:
const useHackerNewsApi = () => {
…
useEffect(
…
);
const doFetch = url => {
setUrl(url);
};
return {data, isLoading, isError, doFetch};
};
function App() {
const [query, setQuery] = useState(‘redux’);
const {data, isLoading, isError, doFetch} = useHackerNewsApi();
return (
<Fragment>
<form
onSubmit={event => {
doFetch(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
event.preventDefault();
}}
>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type=”submit”>Search</button>
</form>
…
</Fragment>
);
}
初始状态也可以变为通用状态。把它简单地传递给新的自定义 hook:
import React, {Fragment, useState, useEffect} from ‘react’;
import axios from ‘axios’;
const useDataApi = (initialUrl, initialData) => {
const [data, setData] = useState(initialData);
const [url, setUrl] = useState(initialUrl);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const result = await axios(url);
setData(result.data);
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [url]);
const doFetch = url => {
setUrl(url);
};
return {data, isLoading, isError, doFetch};
};
function App() {
const [query, setQuery] = useState(‘redux’);
const {data, isLoading, isError, doFetch} = useDataApi(
‘http://hn.algolia.com/api/v1/search?query=redux’,
{hits: [] },
);
return (
<Fragment>
<form
onSubmit={event => {
doFetch(
`http://hn.algolia.com/api/v1/search?query=${query}`,
);
event.preventDefault();
}}
>
<input
type=”text”
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type=”submit”>Search</button>
</form>
{isError && <div>Something went wrong …</div>}
{isLoading ? (
<div>Loading …</div>
) : (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</Fragment>
);
}
export default App;
这就是使用自定义 hook 获取数据的方法。hook 本身对 API 一无所知。它从外部接收所有参数,只管理必要的状态,如数据、加载和错误状态。它执行请求并将数据作为自定义数据获取 hook 返回给组件。
Reducer 的数据获取 hook
reducer hook 返回一个状态对象和一个改变状态对象的函数。dispatch 函数接收 type 和可选的 payload。所有这些信息都在实际的 reducer 函数中使用,从以前的状态、包含可选 payload 和 type 的 action 中提取新的状态。让我们看看这在代码中是如何工作的:
import React, {
Fragment,
useState,
useEffect,
useReducer,
} from ‘react’;
import axios from ‘axios’;
const dataFetchReducer = (state, action) => {
…
};
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
…
};
Reducer Hook 接受 reducer 函数和一个初始化的状态对象作为参数,在我们的例子中,数据、加载和错误状态的初始状态的参数没有改变,但是它们被聚合到由一个 reducer hook 管理的一个状态对象,而不是单个 state hook。
const dataFetchReducer = (state, action) => {
…
};
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
useEffect(() => {
const fetchData = async () => {
dispatch({type: ‘FETCH_INIT’});
try {
const result = await axios(url);
dispatch({type: ‘FETCH_SUCCESS’, payload: result.data});
} catch (error) {
dispatch({type: ‘FETCH_FAILURE’});
}
};
fetchData();
}, [url]);
…
};
现在,在获取数据时,可以使用 dispatch 向 reducer 函数发送信息。dispatch 函数发送的对象包括一个必填的 type 属性和可选的 payload。type 告诉 Reducer 函数需要应用哪个状态转换,并且 Reducer 还可以使用 payload 来提取新状态。毕竟,我们只有三种状态转换:初始化获取过程,通知成功的数据获取结果,以及通知错误的数据获取结果。
在自定义 hook 的最后,状态像以前一样返回,但是因为我们有一个状态对象,而不再是独立状态,所以需要用扩展运算符返回 state。这样,调用 useDataApi 自定义 hook 的用户仍然可以访问 data、isloading 和 isError:
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
…
const doFetch = url => {
setUrl(url);
};
return {…state, doFetch};
};
最后,还缺少了 reducer 函数的实现。它需要处理三种不同的状态转换,即 FETCH_INIT、FETCH_SUCCESS 和 FETCH_FAILURE。每个状态转换都需要返回一个新的状态对象。让我们看看如何用 switch case 语句实现这一点:
const dataFetchReducer = (state, action) => {
switch (action.type) {
case ‘FETCH_INIT’:
return {…state};
case ‘FETCH_SUCCESS’:
return {…state};
case ‘FETCH_FAILURE’:
return {…state};
default:
throw new Error();
}
};
reducer 函数可以通过其参数访问当前状态和 action。到目前为止,switch case 语句中的每个状态转换只会返回原来的状态。… 语句用于保持状态对象不变(意味着状态永远不会直接改变),现在,让我们重写一些当前状态返回的属性,以便在每次状态转换时更改状态:
const dataFetchReducer = (state, action) => {
switch (action.type) {
case ‘FETCH_INIT’:
return {
…state,
isLoading: true,
isError: false
};
case ‘FETCH_SUCCESS’:
return {
…state,
isLoading: false,
isError: false,
data: action.payload,
};
case ‘FETCH_FAILURE’:
return {
…state,
isLoading: false,
isError: true,
};
default:
throw new Error();
}
};
现在,每个状态转换(由操作的 type 决定)都将基于先前的状态和可选的 payload 返回一个新的状态。例如,在成功请求的情况下,payload 用于设置新状态对象的数据。
总之,reducer hook 确保状态管理的这一部分是用自己的逻辑封装的。通过提供 type 和可选 payload,你将始终已一个可预测的状态结束。此外,你将永远不会进入无效状态。例如,以前可能会意外地将 isloading 和 isError 状态设置为 true。在这个案例的用户界面中应该显示什么?现在,reducer 函数定义的每个状态转换都会导致一个有效的状态对象。
在 effect hook 中禁止数据获取
即使组件已经卸载(例如,由于使用 react 路由器导航而离开),设置组件状态也是 react 中的一个常见问题。我以前在这里写过这个问题,它描述了如何防止在各种场景中为 unmount 的组件设置状态。让我们看看如何防止在自定义 hook 中为数据获取设置状态:
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
dispatch({type: ‘FETCH_INIT’});
try {
const result = await axios(url);
if (!didCancel) {
dispatch({type: ‘FETCH_SUCCESS’, payload: result.data});
}
} catch (error) {
if (!didCancel) {
dispatch({type: ‘FETCH_FAILURE’});
}
}
};
fetchData();
return () => {
didCancel = true;
};
}, [url]);
const doFetch = url => {
setUrl(url);
};
return {…state, doFetch};
};
每个 effect hook 都有一个 clean 功能,在组件卸载时运行。clean 函数是从 hook 返回的一个函数。在我们的例子中,我们使用一个名为 didCancel 的布尔标志,让我们的数据获取逻辑知道组件的状态(已装载 / 未装载)。如果组件已卸载,则标志应设置为“tree”,这将导致在最终异步解决数据提取后无法设置组件状态。
注意:事实上,数据获取不会中止——这可以通过 axios 的 Cancellation 实现——但是对于未安装的组件,状态转换会不再执行。因为在我看来,axios 的 Cancellation 并不是最好的 API,所以这个防止设置状态的布尔标志也能起到作用。
你已经了解了在 React 中 state 和 effect hook 如何用于获取数据。如果您对使用 render props 和高阶组件在类组件(和函数组件)中获取数据很感兴趣,请从一开始就去我的另一篇文章。否则,我希望本文对您了解 react hook 以及如何在现实场景中使用它们非常有用。