斜体样式本文大致的翻译了这篇文章
本文将向你展示如何通过 react 的 state 和 effect hook 获取数据。本文将使用 Hacker News API 获取科技类的热门文章,带你实现自定义的 react hook 来获取数据。
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 组件做的一件事就是显示文章列表(hits = Hacker News 文章)。useState 为函数组件提供状态和状态更新功能,它负责为 App 组件管理数据的局部状态。初始状态是 {hits: []},hits 为空数组。此时没有任何地方修改这个状态。
接下来使用 axios 这个库来获取数据,如果你还未安装,可以通过 npm install axios -S
安装一下。
import React, { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [data, setData] = useState({ hits: [] });
useEffect(async () => {
const result = await axios(
'https://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;
在 effect hook useEffect 里通过 axios 请求数据,并通过 state hook 的更新函数 setData 来将请求到的数据设置到组件的局部状态 data 里。
但是这时运行起来的代码应该会陷入死循环,因为组件挂载后会运行 useEffect 里的 axios 代码获取数据,然后更新状态 data,继而引发组件更新,而组件更新后 useEffect 又会再次运行,再次获取数据,再次触发组件更新。如此反复,进入死循环。我们只希望在组件挂载时请求一次数据,后续不再触发。我们可以给 useEffect 的第二个参数传入一个空数组,如下:
import React, { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [data, setData] = useState({ hits: [] });
useEffect(async () => {
const result = await axios(
'https://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;
第二个参数是一个数组,用来定义该 useEffect 依赖的变量,只要依赖的变量发生变化,这个 hook 就会再次运行。而如果这个数组是一个空数组,那么这个 hook 不会再组件更新时运行,因为它不依赖任何变量。
到这里还有最后一个问题,我们用了 async / await 来获取数据,按照标准 async 函数会返回一个隐式的 promise。然而,effect hook 不能返回任何任何东西,也不能返回函数。所以你会看到一个提示 Warning: An effect function must not return anything besides a function, which is used for clean-up.
所以直接在 useEffect 里使用 async 函数是不允许的,改下代码,如下:
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(
'https://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 hook 里获取数据的简单示例。如果你对错误处理、loading 状态以及如何在 form 表单中获取数据和封装一个可复用的数据获取 hook 感兴趣的话,可以继续阅读下去。
现在通过这个组件已经能获取数据了,可是如何通过数据关键词来搜索想要的文章呢。我们添加一个输入框,用于输入搜索关键词,为此需要引入新的状态:
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(
'https://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;
此时两个状态是相互独立的,但是我们想把他们结合起来,搜索想要的文章,做如下改动:
...
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;
这样组件启动就会获取 redux
的文章,接下来给 useEffect 第二个参数传入变量 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;
这里虽然实现了输入搜索功能,不过打字过程会频繁修改 query 的值,导致频繁的发出接口请求。再加一个 按钮来手动触发请求可能会好点,如下:
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>
);
}
现在让 useEffect
依赖 search
状态而不是输入框里频繁变化的值。一旦用户点击按钮,就会更新 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;
同样,为了在组件加载时就请求 redux 文章,search
state 也被初始化为 redux
。不过,这样 query
和 search
这两个 state 会让人有点困惑,所以我们直接把 url 设置给 search
state 吧。
function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('redux');
const [url, setUrl] = useState(
'https://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>
);
}
让我们为数据请求加一个 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(
'https://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 hook(组件挂载或 url 改变时),loading state 会被设置为 true,等请求结束设置为 false。
错误处理也是同样通过 state hook 来实现,一旦有 error state,组件就会将其反馈给用户。使用 async / await 的时候,一般都会用 try / catch 来捕获异常。
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(
'https://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;
每次 effect hook 重新运行都会重置 error state,这在用户发现报错后再次尝试请求数据是很有用。
上面只用了一个 input 和一个 button,如果想加入更多表单控件,可以考虑用一个 form 表单来包起来。而且,一个 form 表单使得可以通过回车来触发 button 发送请求。
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>
);
}
上面的代码有个问题,回车后页面会刷新,因为这是浏览器在表单提交时的默认行为。可以在 onSubmit
里阻止浏览器的默认行为。
function App() {
...
return (
<Fragment>
<form onSubmit={event => {
setUrl(`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>}
...
</Fragment>
);
}
现在点击提交按钮,浏览器不会再刷新了。
为了提取出自定义的 reach hook,将所有除了 query state
之外属于数据获取的代码(包括 loading 指示器和错误处理)移入一个函数内。
const useHackerNewsApi = () => {
const [data, setData] = useState({ hits: [] });
const [url, setUrl] = useState(
'https://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 [{ data, isLoading, isError }, setUrl];
}
然后再 App 组件中使用这个 hook:
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]);
return [{ data, isLoading, isError }, setUrl];
};
function App() {
const [query, setQuery] = useState('redux');
const [{ data, isLoading, isError }, doFetch] = useDataApi(
'https://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 本身对 API 一无所知。它从外部接收所有参数,并且仅管理必要的状态,例如数据,加载和错误状态。它执行请求,并将数据用作自定义数据获取 hook,将数据返回给组件。
到目前为止,我们已经使用各种状态钩子来管理数据获取,包括数据状态,加载状态和错误状态。所有这些状态,虽然各自管理各自的状态,但是因为它们关心相同的原因可以归为一类。如你所见,它们都在用在同个数据获取函数中。可以用来判断它们是否可以归为一类的一个很好的指标,就是它们一个接一个的调用(例如 setIsError,setIsLoading)。让我们将它们三个用一个 Reducer Hook 结合起来使用。
Reducer Hook 返回一个状态对象和一个更改状态对象的函数。这个函数是 dispatch 函数,接收一个 action 参数,action 参数的属性有 type 操作类型和一个可选的 payload。所有这些信息都将在实际的 reducer 函数中使用,以从上一个状态,action 的可选 payload 和 type 中提取新状态。让我们看看这在代码中是如何工作的:
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 函数 and 一个初始的 state 对象作为参数。在这里,作为初始 state 参数的 data, loading,error 状态没有改变,只是合并到一个状态对象里,并由一个 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 函数。信息包含一个必选属性 type 和一个可选属性 payload。type 告诉 reducer 函数使用哪个状态转换,还可以使用额外的 payload 来生成新的状态。
在自定义的钩子函数里,还是像之前一样返回 state。因为我们有一个 state 对象而不是多个独立的 state。这样一来,调用 useDataApi
还是能得到 data, isLoading 和 isError。
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
});
...
return [state, setUrl];
};
最后,还需要实现 reducer 函数,它需要实现三个动作来实现三种状态转换,包括 FETCH_INIT, FETCH_SUCCESS
和 FETCH_FAILURE
。每个状态转换都返回一个新的 state。
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 语句中,每个状态转换仅返回上一个状态,通过解构语法,保证 state 对象不被修改(意味着 state 对象永远不会被直接修改,强制实施最佳实践)。现在,在状态转换中,覆盖掉当前状态的部分属性来修改当前状态。
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();
}
};
现在,由 action 类型决定的每个状态转换都将基于先前的状态和可选的 payload 返回一个新的状态。例如,在成功请求的情况下,payload 用于设置新状态对象的数据。
总之,Reducer Hook 确保状态管理的这一部分使用其自己的逻辑进行封装。通过提供 action 类型和可选的 payload,你始终能得到一个可预测的状态。此外,您将永远不会得到无效状态。例如,以前可能会意外地将 isLoading 和 isError 状态都设置为true。在这种情况下,UI 中应显示什么?现在,由 reducer 函数定义的每个状态转换都会得到一个有效的状态对象。
在 React 中,一个常见的问题是,即使组件已经卸载,组件的状态也会被设置(例如路由切换)。来看看如何解决:
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]);
return [state, setUrl];
};
每个 effect hook 都带有清除函数,该函数在组件卸载时运行。清除函数是从 effect hook 返回的一个函数。在我们的例子中,使用一个布尔标志 didCancel 来使数据获取逻辑知道组件的状态(挂载/卸载)。如果确实卸载了组件,则应设置该标志为 true,以免接口异步请求返回数据之后设置组件状态。
注意:实际上,数据获取不会中止——这可以通过 Axios Cancellation 来实现——但是,已卸载的组件不再执行状态转换。Axios Cancellation 在我眼中并不是最好的 API,这个防止设置状态的布尔值标记也可以完成此工作了。