使

使用 React Hooks 和 Typescript 获取数据加载初始数据最后的想法

2025-06-08

使用 React Hooks 和 Typescript 获取数据

加载初始数据

最后的想法

在 React 中复用逻辑一直很复杂,像 HOC 和 Render Props 这样的模式试图解决这个问题。随着最近 Hooks 的加入,复用逻辑变得更加容易。在本文中,我将展示一种使用 HooksuseEffectuseState从 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>
    );
  }
};



Enter fullscreen mode Exit fullscreen mode

但是,复用这些代码很困难,因为在 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;
}


Enter fullscreen mode Exit fullscreen mode

由于我们将要处理具有多个状态的 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;


Enter fullscreen mode Exit fullscreen mode

ServiceInitServiceLoading分别定义执行任何操作之前和加载期间 Web 服务的状态。具有存储从 Web 服务加载的数据的属性(请注意,我在这里使用泛型,因此我可以将该接口与任何数据类型一起用于有效负载)。ServiceLoaded具有存储可能发生的任何错误的属性。 使用这种联合类型,如果我们在属性中设置字符串并尝试将某些内容分配给属性,Typescript 将会失败,因为我们没有定义允许类型以及名为或的属性的接口。 如果没有 Typescript 或任何其他类型检查,如果您犯了这个错误,您的代码只会在运行时失败。payloadServiceErrorerror'loading'statuspayloaderrorstatus'loading'payloaderror

定义了类型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;


Enter fullscreen mode Exit fullscreen mode

上述代码中发生了以下情况:

  • 因为 SWAPI 返回数组内的星舰数组results,所以我定义了一个新接口Starships,其中包含该属性results作为数组Starship
  • 自定义 HookusePostStarshipService只是一个函数,以useReact Hooks 文档中推荐的单词开头:https://reactjs.org/docs/hooks-custom.html#extracting-a-custom-hook
  • 在该函数中,我使用 HookuseState来管理 Web 服务的状态。需要注意的是,我需要定义状态result传递的泛型所管理的数据类型。我使用联合类型的<Service<Starship>>接口初始化 Hook ,因此唯一允许的属性是字符串ServiceInitServicestatus'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;



Enter fullscreen mode Exit fullscreen mode

这次,我们的自定义 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 
        }));
    }
  }

  ...
};


Enter fullscreen mode Exit fullscreen mode

如果我们需要在不同的 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;



Enter fullscreen mode Exit fullscreen mode

这次,我们的自定义 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;



Enter fullscreen mode Exit fullscreen mode

发送内容

发送内容看起来与状态改变时加载内容类似。在第一种情况下,我们向自定义 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>
  )
}


Enter fullscreen mode Exit fullscreen mode

但是之前的代码存在一些问题:

  • 我们将该starship对象传递给自定义 Hook,并将其作为useEffectHook 的第二个参数。由于 onChange 处理程序会starship在每次按键时更改对象,因此每次用户输入时都会调用我们的 Web 服务。
  • 我们需要使用 HookuseState创建布尔状态,submit以便知道何时可以清理表单。我们可以将这个布尔值用作第二个参数来usePostStarshipService解决上一个问题,但这会使代码变得复杂。
  • 布尔状态submit为我们的组件添加了逻辑,该逻辑必须在重用我们的自定义 Hook 的其他组件上复制usePostStarshipService

有一个更好的方法,这次不需要useEffectHook:



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;



Enter fullscreen mode Exit fullscreen mode

首先,我们创建了一个PostStarship派生自 的新类型Starship,并选取了将要发送到 Web 服务的属性。在自定义钩子中,我们使用'init'属性中的字符串初始化了 Web 服务status,因为usePostStarshipService在调用时不会对 Web 服务执行任何操作。useEffect这次,我们创建了一个函数来代替钩子,该函数将接收要发送到 Web 服务的表单数据并返回一个 Promise。最后,我们返回一个包含该service对象和负责调用 Web 服务的函数的对象。

注意:我本可以在自定义 Hook 中返回一个数组而不是对象,使其像useStateHook 一样工作,这样组件中的名称就可以任意命名。我决定返回一个对象,因为我认为没有必要重命名它们。如果您愿意,也可以返回一个数组。

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;



Enter fullscreen mode Exit fullscreen mode

我使用useStateHook 来管理表单的状态,但其行为与在类组件中handleChange使用时相同。除了返回初始状态的对象并返回 publishStarship 方法以调用 Web 服务之外,我们什么也不做。当表单提交并被调用时,我们会使用表单数据进行调用。现在,我们的对象开始管理 Web 服务状态的变化。如果返回的 Promise 成功,我们会调用清理表单。this.stateusePostStarshipServiceservicehandleFormSubmitpublishStarshipservicesetStarshipinitialStarshipState

就这样,我们创建了三个自定义 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
PREV
构建 SEO 友好 URL 的初学者指南 构建博客文章 URL 的初学者指南
NEXT
#DevOps 新手指南 - Kubernetes 中的请求 x 限制