使用 React Hooks 和 Typescript 获取数据
加载初始数据
最后的想法
在 React 中复用逻辑一直很复杂,像 HOC 和 Render Props 这样的模式试图解决这个问题。随着最近 Hooks 的加入,复用逻辑变得更加容易。在本文中,我将展示一种使用 HooksuseEffect
并useState
从 Web 服务加载数据的简单方法(我在示例中使用swapi.co加载星球大战飞船),以及如何轻松管理加载状态。作为额外奖励,我使用了 Typescript。我将构建一个简单的应用程序来买卖星球大战飞船,您可以在这里看到最终结果:https://camilosw.github.io/react-hooks-services
加载初始数据
在 React Hooks 发布之前,从 Web 服务加载初始数据的最简单方法是componentDidMount
:
class Starships extends React.Component {
state = {
starships: [],
loading: true,
error: false
}
componentDidMount () {
fetch('https://swapi.co/api/starships')
.then(response => response.json())
.then(response => this.setState({
starships: response.results,
loading: false
}))
.catch(error => this.setState({
loading: false,
error: true
}));
}
render () {
const { starships, loading, error } = this.state;
return (
<div>
{loading && <div>Loading...</div>}
{!loading && !error &&
starships.map(starship => (
<div key={starship.name}>
{starship.name}
</div>
))
}
{error && <div>Error message</div>}
</div>
);
}
};
但是,复用这些代码很困难,因为在 React 16.8 之前版本中,你无法从组件中提取行为。目前流行的选择是使用高阶组件或渲染 props,但这些方法存在一些缺点,如 React Hooks 文档https://reactjs.org/docs/hooks-intro.html#its-hard-to-reuse-stateful-logic-between-components中所述。
使用 Hooks,我们可以将行为提取到自定义 Hook 中,以便轻松地在任何组件中复用它。如果您不知道如何创建自定义 Hook,请先阅读文档:https://reactjs.org/docs/hooks-custom.html。
因为我们使用的是 Typescript,所以首先我们需要定义期望从 Web 服务接收的数据的形状,所以我定义了接口Starship
:
export interface Starship {
name: string;
crew: string;
passengers: string;
cost_in_credits?: string;
url: string;
}
由于我们将要处理具有多个状态的 Web 服务,因此我为每个状态定义了一个接口。最后,我将Service
这些接口定义为一个联合类型:
interface ServiceInit {
status: 'init';
}
interface ServiceLoading {
status: 'loading';
}
interface ServiceLoaded<T> {
status: 'loaded';
payload: T;
}
interface ServiceError {
status: 'error';
error: Error;
}
export type Service<T> =
| ServiceInit
| ServiceLoading
| ServiceLoaded<T>
| ServiceError;
ServiceInit
和ServiceLoading
分别定义执行任何操作之前和加载期间 Web 服务的状态。具有存储从 Web 服务加载的数据的属性(请注意,我在这里使用泛型,因此我可以将该接口与任何数据类型一起用于有效负载)。ServiceLoaded
具有存储可能发生的任何错误的属性。 使用这种联合类型,如果我们在属性中设置字符串并尝试将某些内容分配给或属性,Typescript 将会失败,因为我们没有定义允许类型以及名为或的属性的接口。 如果没有 Typescript 或任何其他类型检查,如果您犯了这个错误,您的代码只会在运行时失败。payload
ServiceError
error
'loading'
status
payload
error
status
'loading'
payload
error
定义了类型Service
和接口后Starship
,我们现在可以创建自定义 Hook usePostStarshipService
:
import { useEffect, useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';
export interface Starships {
results: Starship[];
}
const usePostStarshipService = () => {
const [result, setResult] = useState<Service<Starships>>({
status: 'loading'
});
useEffect(() => {
fetch('https://swapi.co/api/starships')
.then(response => response.json())
.then(response => setResult({ status: 'loaded', payload: response }))
.catch(error => setResult({ status: 'error', error }));
}, []);
return result;
};
export default usePostStarshipService;
上述代码中发生了以下情况:
- 因为 SWAPI 返回数组内的星舰数组
results
,所以我定义了一个新接口Starships
,其中包含该属性results
作为数组Starship
。 - 自定义 Hook
usePostStarshipService
只是一个函数,以use
React Hooks 文档中推荐的单词开头:https://reactjs.org/docs/hooks-custom.html#extracting-a-custom-hook。 - 在该函数中,我使用 Hook
useState
来管理 Web 服务的状态。需要注意的是,我需要定义状态result
传递的泛型所管理的数据类型。我使用联合类型的<Service<Starship>>
接口初始化 Hook ,因此唯一允许的属性是字符串。ServiceInit
Service
status
'loading'
- 我还使用了一个 Hook
useEffect
,它的第一个参数是一个回调函数,用于从 Web 服务获取数据,第二个参数是一个空数组。第二个参数指定了useEffect
运行回调函数的条件。由于我们传递了一个空数组,因此回调函数只会被调用一次(useEffect
如果您不熟悉这个 Hook,请阅读更多相关信息,请访问https://reactjs.org/docs/hooks-effect.html)。 - 最后,我返回
result
。该对象包含状态以及调用 Web 服务所产生的任何有效负载或错误。这正是我们在组件中需要的,以便向用户显示 Web 服务的状态以及检索到的数据。
请注意,我在上一个示例中使用的方法fetch
非常简单,但对于生产代码来说还不够。例如, catch 只会捕获网络错误,而不会捕获 4xx 或 5xx 错误。在您自己的代码中,最好创建另一个函数来包装fetch
处理错误、标头等。
现在,我们可以使用 Hook 来检索星舰列表并向用户显示它们:
import React from 'react';
import useStarshipsService from '../services/useStarshipsService';
const Starships: React.FC<{}> = () => {
const service = useStarshipsService();
return (
<div>
{service.status === 'loading' && <div>Loading...</div>}
{service.status === 'loaded' &&
service.payload.results.map(starship => (
<div key={starship.url}>{starship.name}</div>
))}
{service.status === 'error' && (
<div>Error, the backend moved to the dark side.</div>
)}
</div>
);
};
export default Starships;
这次,我们的自定义 HookuseStarshipService
将管理状态,因此我们只需要根据status
返回service
对象的属性有条件地进行渲染。
payload
请注意,如果您尝试在状态为 时访问'loading'
,TypeScript 将失败,因为payload
仅存在于ServiceLoaded
界面中,而不存在于界面中ServiceLoading
:
TypeScript 足够智能,它知道如果status
属性和字符串之间的比较'loading'
为真,则相应的接口为真,ServiceLoaded
并且在这种情况下starships
对象没有payload
属性。
状态改变时加载内容
在我们的示例中,如果用户点击任何一艘星舰,我们就会改变组件的状态来设置所选的星舰,并使用与该星舰对应的 URL 调用 Web 服务(请注意,https: //swapi.co/api/starships会加载每艘星舰的所有数据,因此无需再次加载该数据。我这样做只是为了演示目的。)
传统上,我们使用 componentDidUpdate 来检测状态变化并采取相应的措施:
class Starship extends React.Component {
...
componentDidUpdate(prevProps) {
if (prevProps.starship.url !== this.props.starship.url) {
fetch(this.props.starship.url)
.then(response => response.json())
.then(response => this.setState({
starship: response,
loading: false
}))
.catch(error => this.setState({
loading: false,
error: true
}));
}
}
...
};
如果我们需要在不同的 props 和 state 属性发生变化时执行不同的操作,componentDidUpdate
很快就会变得一团糟。使用 Hooks,我们可以将这些操作封装在单独的自定义 Hook 中。在本例中,我们将创建一个自定义 Hook 来提取其中的行为,componentDidUpdate
就像我们之前做的那样:
import { useEffect, useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';
const useStarshipByUrlService = (url: string) => {
const [result, setResult] = useState<Service<Starship>>({
status: 'loading'
});
useEffect(() => {
if (url) {
setResult({ status: 'loading' });
fetch(url)
.then(response => response.json())
.then(response => setResult({ status: 'loaded', payload: response }))
.catch(error => setResult({ status: 'error', error }));
}
}, [url]);
return result;
};
export default useStarshipByUrlService;
这次,我们的自定义 Hook 接收 url 作为参数,并将其用作 Hook 的第二个参数useEffect
。这样,每次 url 发生变化时,useEffect
都会调用内部的回调函数来检索新星舰的数据。
请注意,在回调函数内部,我调用了setResult
设置status
为'loading'
。这是因为回调函数会被多次调用,所以我们需要在开始获取数据之前重置状态。
在我们的Starship
组件中,我们接收 url 作为 prop,并将其传递给自定义 Hook useStarshipByUrlService
。每次父组件中的 url 发生变化时,自定义 Hook 都会再次调用 Web 服务并为我们管理状态:
import React from 'react';
import useStarshipByUrlService from '../services/useStarshipByUrlService';
export interface Props {
url: string;
}
const Starship: React.FC<Props> = ({ url }) => {
const service = useStarshipByUrlService(url);
return (
<div>
{service.status === 'loading' && <div>Loading...</div>}
{service.status === 'loaded' && (
<div>
<h2>{service.payload.name}</h2>
...
</div>
)}
{service.status === 'error' && <div>Error message</div>}
</div>
);
};
export default Starship;
发送内容
发送内容看起来与状态改变时加载内容类似。在第一种情况下,我们向自定义 Hook 传递了一个 URL,现在我们可以传递一个包含要发送数据的对象。如果我们尝试这样做,代码将如下所示:
const usePostStarshipService = (starship: Starship) => {
const [result, setResult] = useState<Service<Starship>>({
status: 'init'
});
useEffect(() => {
setResult({ status: 'loading' });
fetch('https://swapi.co/api/starships', {
method: 'POST',
body: JSON.stringify(starship)
})
.then(response => response.json())
.then(response => {
setResult({ status: 'loaded', payload: response });
})
.catch(error => {
setResult({ status: 'error', error });
});
}, [starship]);
return result;
};
const CreateStarship: React.FC<{}> = () => {
const initialStarshipState: Starship = {
name: '',
crew: '',
passengers: '',
cost_in_credits: ''
};
const [starship, setStarship] = useState<PostStarship>(initialStarshipState);
const [submit, setSubmit] = useState(false);
const service = usePostStarshipService(starship);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.persist();
setStarship(prevStarship => ({
...prevStarship,
[event.target.name]: event.target.value
}));
};
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmit(true);
};
useEffect(() => {
if (submit && service.status === 'loaded') {
setSubmit(false);
setStarship(initialStarshipState);
}
}, [submit]);
return (
<form onSubmit={handleFormSubmit}>
<input
type="text"
name="name"
value={starship.name}
onChange={handleChange}
/>
...
</form>
)
}
但是之前的代码存在一些问题:
- 我们将该
starship
对象传递给自定义 Hook,并将其作为useEffect
Hook 的第二个参数。由于 onChange 处理程序会starship
在每次按键时更改对象,因此每次用户输入时都会调用我们的 Web 服务。 - 我们需要使用 Hook
useState
创建布尔状态,submit
以便知道何时可以清理表单。我们可以将这个布尔值用作第二个参数来usePostStarshipService
解决上一个问题,但这会使代码变得复杂。 - 布尔状态
submit
为我们的组件添加了逻辑,该逻辑必须在重用我们的自定义 Hook 的其他组件上复制usePostStarshipService
。
有一个更好的方法,这次不需要useEffect
Hook:
import { useState } from 'react';
import { Service } from '../types/Service';
import { Starship } from '../types/Starship';
export type PostStarship = Pick<
Starship,
'name' | 'crew' | 'passengers' | 'cost_in_credits'
>;
const usePostStarshipService = () => {
const [service, setService] = useState<Service<PostStarship>>({
status: 'init'
});
const publishStarship = (starship: PostStarship) => {
setService({ status: 'loading' });
const headers = new Headers();
headers.append('Content-Type', 'application/json; charset=utf-8');
return new Promise((resolve, reject) => {
fetch('https://swapi.co/api/starships', {
method: 'POST',
body: JSON.stringify(starship),
headers
})
.then(response => response.json())
.then(response => {
setService({ status: 'loaded', payload: response });
resolve(response);
})
.catch(error => {
setService({ status: 'error', error });
reject(error);
});
});
};
return {
service,
publishStarship
};
};
export default usePostStarshipService;
首先,我们创建了一个PostStarship
派生自 的新类型Starship
,并选取了将要发送到 Web 服务的属性。在自定义钩子中,我们使用'init'
属性中的字符串初始化了 Web 服务status
,因为usePostStarshipService
在调用时不会对 Web 服务执行任何操作。useEffect
这次,我们创建了一个函数来代替钩子,该函数将接收要发送到 Web 服务的表单数据并返回一个 Promise。最后,我们返回一个包含该service
对象和负责调用 Web 服务的函数的对象。
注意:我本可以在自定义 Hook 中返回一个数组而不是对象,使其像useState
Hook 一样工作,这样组件中的名称就可以任意命名。我决定返回一个对象,因为我认为没有必要重命名它们。如果您愿意,也可以返回一个数组。
CreateStarship
这次我们的组件会更简单:
import React, { useState } from 'react';
import usePostStarshipService, {
PostStarship
} from '../services/usePostStarshipService';
import Loader from './Loader';
const CreateStarship: React.FC<{}> = () => {
const initialStarshipState: PostStarship = {
name: '',
crew: '',
passengers: '',
cost_in_credits: ''
};
const [starship, setStarship] = useState<PostStarship>(
initialStarshipState
);
const { service, publishStarship } = usePostStarshipService();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.persist();
setStarship(prevStarship => ({
...prevStarship,
[event.target.name]: event.target.value
}));
};
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
publishStarship(starship).then(() => setStarship(initialStarshipState));
};
return (
<div>
<form onSubmit={handleFormSubmit}>
<input
type="text"
name="name"
value={starship.name}
onChange={handleChange}
/>
...
</form>
{service.status === 'loading' && <div>Sending...</div>}
{service.status === 'loaded' && <div>Starship submitted</div>}
{service.status === 'error' && <div>Error message</div>}
</div>
);
};
export default CreateStarship;
我使用useState
Hook 来管理表单的状态,但其行为与在类组件中handleChange
使用时相同。除了返回初始状态的对象并返回 publishStarship 方法以调用 Web 服务之外,我们什么也不做。当表单提交并被调用时,我们会使用表单数据进行调用。现在,我们的对象开始管理 Web 服务状态的变化。如果返回的 Promise 成功,我们会调用来清理表单。this.state
usePostStarshipService
service
handleFormSubmit
publishStarship
service
setStarship
initialStarshipState
就这样,我们创建了三个自定义 Hook,分别用于检索初始数据、检索单个项目以及发布数据。完整项目可以在这里查看:https://github.com/camilosw/react-hooks-services
最后的想法
React Hooks 是一个很好的补充,但是当有更简单和完善的解决方案时,不要过度使用它们,比如 Promise,而不是useEffect
我们的发送内容示例。
使用 Hooks 还有另一个好处。如果你仔细观察,就会发现我们的组件基本上变成了展示型的,因为我们把状态逻辑移到了自定义的 Hooks 中。有一种成熟的模式可以将逻辑与展示分离,称为容器/展示模式,即把逻辑放在父组件中,把展示放在子组件中。这种模式最初是由 Dan Abramov 提出的,但现在有了 Hooks,Dan Abramov 建议少用这种模式,多用 Hooks:https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
你可能讨厌用字符串来命名状态,并因此责怪我这么做。但如果你使用 Typescript,那就没问题了,因为即使状态名称拼写错误,Typescript 也会失败,而且 VS Code(以及其他编辑器)的自动补全功能也完全免费。总之,如果你愿意,也可以使用布尔值。
鏂囩珷鏉ユ簮锛�https://dev.to/camilomejia/fetch-data-with-react-hooks-and-typescript-390c