译:使用 React Query 和 Zod 使 REST API 类型安全

原文:https://noahflk.com/blog/typesafe-rest-api
作者:Noah
译者:ChatGPT 4 Turbo

编者注:tRPC 方案的缺点是,前后端都必须用 TypeScript 编写,这不适用于大部分项目。而只用 TypeScript 声明类型的问题是,无法对 response 做校验,这是一种虚假的安全感。然后补几个想法,1)需要注意的是,zod 验证不应用于生产,2)zod 可以考虑和工程化结合,基于接口约定生成 zod schema。

长久以来,我一直是使用 tRPC 构建端到端类型安全 API 的倡导者。这是一个令人惊叹的库,它允许你直接从前端调用后端函数,并完全保证类型安全。它能自动推断类型,因此你不必担心像使用 GraphQL 那样的代码生成。此外,它还封装了 React Query。这意味着你将享受到 React Query 提供的所有状态管理的好处。

一个很大的缺点是,tRPC 不仅要求你的前端,而且还要求你的后端都用 TypeScript 编写。即便如此,你还需要把所有东西放入一个单体仓库中,以便在前端和后端之间进行类型推断。对许多项目来说,这是一个无法启动的条件,因为你可能已经用完全不同的技术定义了一个完整的 REST API。所以,假设我们正在使用一个用 Django 编写的 REST API,并且我们要从用 TypeScript 编写的 React 前端调用它。我们怎样才能获得最佳的开发体验?

备注:这不会是一个完整的教程,展示如何在你的代码库中配置这些库。官方文档可以帮你解决这个问题。相反,请将这视为一个概念性的概述,说明如何将这些技术结合起来获得最佳可能的开发体验。

使用 React Query 进行数据获取

React Query,也被称为 TanStack Query,因为它已经被移植到不同的网络框架上,理所当然地成为了最受欢迎的 React 库之一。在我参与的每个项目中,我都直接或通过 tRPC 来使用它。一个常见的误解是 React Query 是一个数据获取库,但它不是。它是一个使异步状态同步化的工具。所以你仍然需要一个数据获取工具。内置的 fetch 浏览器 API 是默认的选择。React Query 所做的是包装一个异步的 fetch 函数,并通过一个 React 钩子将其作为同步数据返回给你。让我们看一个例子:

在 React 组件中获取数据的传统方式如下:

  • 为数据初始化空的 useState
  • useEffect 中挂载时触发数据获取
  • 使用 setState 存储结果数据
  • 状态中保存的数据触发重新渲染显示数据

代码形式如下:

function DataFetchingComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('https://api.example.com/data');
      const result = await response.json();
      setData(result);
    };

    fetchData();
  }, []);

  if (!data) {
    return <div>加载中...</div>;
  }

  return (
    <div>
      <pre>{JSON.stringify(data)}</pre>
    </div>
  );
}

使用 React Query,我们可以简化为:

function DataFetchingComponent() {
  const fetchData = async () => {
    const response = await fetch('https://api.example.com/data');
    return response.json();
  };

  const { data, isLoading } = useQuery('data', fetchData);

  if (!isLoading) {
    return <div>加载中...</div>;
  }

  return (
    <div>
      <pre>{JSON.stringify(data)}</pre>
    </div>
  );
}

更加简洁,不是吗?但这还不是全部。现在想象一下,你需要编写自定义代码来处理以下事情:

  • 在多个组件中访问同一数据而不重复获取
  • 在变更过程中刷新数据
  • 缓存和缓存失效
  • 乐观更新
  • 后台重新获取

React Query 为你处理了所有这些!所以它成为我项目中的核心技术并不令人惊讶。你会注意到的是,两个例子都没有关联任何 TypeScript 类型。那么让我们解决这个问题。

手动 API 类型安全

之前的代码示例没有办法知道类型。我们从常规 HTTP 端点获取数据,不论是否使用 React Query,端点都不提供任何类型信息。一种常见的使其类型安全的方法是设置一个 TypeScript 类型。一些项目通过代码生成来做到这一点,意味着有一个脚本从后端确定类型,然后在你的前端生成一个类型定义文件。这是一个有效的策略,但它需要复杂的设置和构建过程。因此,许多项目选择手动设置类型。

// 示例数据,可以是任何东西
interface FetchedData {
  id: number;
  name: string;
}

function DataFetchingComponent() {
  const fetchData = async (): Promise<FetchedData> => {
    const response = await fetch('https://api.example.com/data');
    return response.json();
  };

  const { data, isLoading } = useQuery('data', fetchData);

  if (isLoading) {
    return <div>加载中...</div>;
  }

  return (
    <div>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

这样工作得很好。你的 data 对象现在拥有了定义的类型 FetchedData但存在一个大问题:你无法保证响应与你的类型之间的一致性。TypeScript 给你一种虚假的安全感,以为某个属性总会存在。如果 API 突然不再包含这个属性在响应中,你无从知晓。

Zod 验证

为了保证一致性,我们需要一种验证 API 响应的方法。这里,Zod 派上用场。你定义了一个描述 API 响应应该是什么样的模式:

import { z } from 'zod';

const FetchedDataSchema = z.object({
  id: z.number(),
  name: z.string(),
});

到目前为止,这看起来与我们的类型定义类似,但具有稍微不同的语法。但最好的部分是,我们可以基于这个模式自动生成一个类型定义。

type FetchedData = z.infer<typeof FetchedDataSchema>;

现在我们可以使用这个来验证 API 响应:

const FetchedDataSchema = z.object({
  id: z.number(),
  name: z.string(),
});

type FetchedData = z.infer<typeof FetchedDataSchema>;

function DataFetchingComponent() {
  const fetchData = async (): Promise<FetchedData> => {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    return FetchedDataSchema.parse(data);
  };

  const { data, isLoading } = useQuery('data', fetchData);

  if (isLoading) {
    return <div>加载中...</div>;
  }

  return (
    <div>
      <pre>{JSON.stringify(data)}</pre>
    </div>
  );
}

parse(data) 函数如果响应不符合模式,将会引发一个错误。然后,你可以按照你喜欢的方式来处理那个错误。现在我们已经有了如何从 React 使用 React Query 和 Zod 以类型安全的方式调用 REST API 的完整工作流。在生产应用中,你会将模式和获取函数移动到单犬的文件中,这样它们可以在你的应用程序中被重复使用。但除此之外,我们已经拥有了可以扩展到大型应用的完整设置。