如何用 TypeScript 编写正确的 API 客户端

2025-06-10

如何用 TypeScript 编写正确的 API 客户端

在本文中,我将详细讨论如何用 TypeScript 实现客户端 API,以便与第三方 API 以及我自己的 API 协同工作。该客户端可以与公共和受保护的端点协同工作,并且不受特定框架的约束,因此非常适合在 React、Vue、Svelte 和其他框架中使用。

创建应用程序比创建待办事项列表更复杂,我们通常需要与存储在服务器上的一些数据进行交互。这些数据可能包括由第三方 API 处理的天气预报,也可能包括客户的数据,例如他们的登录名和密码,或是商店中的购物清单。使用 SPA(单页应用程序)应用程序时,我们需要从客户端接收、修改和发送这些数据。因此,您需要某种层来负责与服务器交互。在本文中,我们将考虑使用 React 库的 API 客户端,尽管它可以安全地用于 Vue、Svelte 等框架。

为什么不在使用查询的组件中注册所有查询?

很简单:如果你更改了正在使用的 API 接口,就必须遍历所有代码,找出所有受影响的变更点。既然我们现在正在讨论这个问题,你可以尝试将这种逻辑放入 React hooks 中,但这种解决方案无法在其他使用其他框架的项目中应用。

TypeScript 实现

首先,我们将 API 所在的域放在与文件一起使用的配置中.env

REACT_APP_API_BASE_URL="http://localhost:8083"
Enter fullscreen mode Exit fullscreen mode
export default {
    get apiBaseUrl(): string {
        return process.env.REACT_APP_API_BASE_URL || "";
    },
}
Enter fullscreen mode Exit fullscreen mode

然后,我们将编写一个抽象客户端本身,它不与该领域绑定。它需要axiosaxios-extensions库才能工作。

客户端代码:

import axios, {AxiosInstance, AxiosRequestConfig} from "axios";
import {
    Forbidden,
    HttpError,
    Unauthorized
} from '../errors';
import {Headers} from "../types";

export class ApiClient {
    constructor(
        private readonly baseUrl: string,
        private readonly headers: Headers,
        private readonly authToken: string = ""
    ) {}

    public async get(endpoint: string = "", params?: any, signal?: AbortSignal): Promise<any> {
        try {
            const client = this.createClient(params);
            const response = await client.get(endpoint, { signal });
            return response.data;
        } catch (error: any) {
            this.handleError(error);
        }
    }

    public async post(endpoint: string = "", data?: any, signal?: AbortSignal): Promise<any> {
        try {
            const client = this.createClient();
            const response = await client.post(endpoint, data, { signal });
            return response.data;
        } catch (error) {
            this.handleError(error);
        }
    }

    public async uploadFile(endpoint: string = "", formData: FormData): Promise<any> {
        try {
            const client = this.createClient();
            const response = await client.post(endpoint, formData, {
                headers: {
                    "Content-Type": "multipart/form-data",
                }
            })
            return response.data;
        } catch (error) {
            this.handleError(error);
        }
    }

    private createClient(params: object = {}): AxiosInstance {
        const config: AxiosRequestConfig = {
            baseURL: this.baseUrl,
            headers: this.headers,
            params: params
        }
        if (this.authToken) {
            config.headers = {
                Authorization: `Bearer ${this.authToken}`,
            }
        }
        return axios.create(config);
    }

    private handleError(error: any): never {
        if (!error.response) {
            throw new HttpError(error.message)
        } else if (error.response.status === 401) {
            throw new Unauthorized(error.response.data);
        } else if (error.response.status === 403) {
            throw new Forbidden(error.response.data);
        } else {
            throw error
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

客户端使用自定义类型,例如Headers,实际上只是一个字典[key: string]: string,以及继承全局Error类的各种错误(Unauthorized、Forbidden、HTTPError),以便将来更容易理解导致它们的原因。

该类只有三个公共方法,每次使用时都会生成一个 axios 客户端。该客户端可以通过添加带有 Bearer 令牌的标头来处理公共 API 端点和受保护的端点。客户端如何接收此令牌将在稍后讨论。get 和 post 方法都使用可选abort Signal参数,该参数允许您根据用户的操作中断请求的发送。

在向服务器发送任何文件的情况下,客户端使用该uploadFile()方法,向服务器发送带有Content-Type: multipart/form-data标头的请求。

为了封装创建这些客户端的逻辑,我们将编写一个工厂。

工厂代码:

import {Headers} from "../../types";
import {ApiClient} from "../../clients";

export class ApiClientFactory {
    constructor(
        private readonly baseUrl: string,
        private readonly headers: Headers = {}
    ) {}

    public createClient(): ApiClient {
        return new ApiClient(this.baseUrl, this.headers);
    }

    public createAuthorizedClient(authToken: string): ApiClient {
        return new ApiClient(this.baseUrl, this.headers, authToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

它不会做任何复杂的事情:它只是创建一个常规客户端或授权客户端,并将令牌传递给构造函数。

具体实现

现在我们需要将这个抽象客户端适配到某个特定的端点。例如,让我们创建一个管理器,从服务器接收用户配置文件的最新状态:

import {ApiClientInterface} from "./clients";
import {Profile} from "./models";

export class ProfileManager {
    constructor(private readonly apiClient: ApiClientInterface) {}

    public async get(): Promise<Profile> {
        return this.apiClient.get("");
    }
}
Enter fullscreen mode Exit fullscreen mode

在这个例子中,我们不关心配置文件使用的模型。我们只需假设它与服务器传输的值兼容即可。

管理器类本身使用组合并将客户端对象存储在其状态中,以便将所有 API 请求转发给它,并且如果需要,它可以向接收的值添加一些自己的逻辑(执行验证、创建自己的端点等)。

通常,API 会通过在其端点上添加特定前缀来对域逻辑进行分组。也存在 API 从一个版本迁移到新版本的情况。为了满足所有这些需求,我们将为这个特定的管理器创建一个工厂。

工厂代码:

import {ApiClientFactory} from "./clients";
import {Headers} from "../types";
import {ProfileManager} from "../ProfileManager";

export class ProfileManagerFactory {
    private readonly apiClientFactory: ApiClientFactory;

    constructor(baseUrl: string, headers: Headers) {
        this.apiClientFactory = new ApiClientFactory(
            `${baseUrl}/api/v1/profile`,
            headers
        );
    }

    public createProfileManager(authToken: string): ProfileManager {
        return new ProfileManager(
            this.apiClientFactory.createAuthorizedClient(authToken)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

创建此工厂时,会将域 URL 和请求标头传递给构造函数。然后,这些参数会传递给客户端 API 工厂构造函数,并在传递的 URL 后添加 API 版本和相同的前缀(表示域逻辑的一部分)。创建用户配置文件管理器时需要授权,因此会将令牌传递给该方法,并在此基础上创建带有授权标头的客户端。

依赖注入

现在剩下的就是编写一个函数,负责在代码的任何部分(无论是 React 组件还是独立的 TypeScript 类)提供可用的配置文件管理器。它看起来会像这样:

export async function createProfileManager(): Promise<apiClient.ProfileManager> {
    const factory = new apiClient.ProfileManagerFactory(apiClientConfig.apiBaseUrl, getBaseHeaders());
    return factory.createProfileManager(await getAuthToken());
}
Enter fullscreen mode Exit fullscreen mode

首先,在内部创建这些管理器的工厂,将服务器域和基本头转移到该工厂,如下所示:

function getBaseHeaders(): apiClient.Headers {
    return {
        "Accept-Language": "en"
    }
}
Enter fullscreen mode Exit fullscreen mode

如果需要,您可以在管理器创建功能级别添加任何您自己的标题。

getAuthToken()我不会在本文中讨论获取 API 令牌的方法和函数的操作,因为这个主题值得单独发表。

async function getAuthToken(): Promise<string> {
    // ЗThere would be a token receipt code here, but for now...
    return localStorage.getItem("auth-token");
}
Enter fullscreen mode Exit fullscreen mode

在组件中使用

配置文件管理器的示例如下所示:

useEffect(() => {
        (async () => {
            try {
                await initProfile();
            } catch (error: any) {
                await handleError(error);
            } finally {
                setLoading(false);
            }
        })()
    }, []);

    const initProfile = async () => {
        const manager = await createProfileManager();
        const profile = await manager.get();
        await dispatch(set(profile));
    }
Enter fullscreen mode Exit fullscreen mode

当函数在useEffect钩子中运行时,会异步创建一个配置文件管理器,然后它会从服务器请求用户配置文件的当前状态。在本例中,我们只需将接收到的状态写入 Redux 存储,这样我们就可以处理此配置文件,而无需每次都从服务器重新请求。如果客户端发生错误,handleError()则会启动该函数,并根据错误类型(如我之前提到的)执行相应的操作。

结果

此实现独立于您正在使用的框架,甚至可以在原生 JS(TS)上使用。您可以对其进行许多改进,例如,添加“构建器”模式来创建 API 客户端并向其传递参数、中止信号和其他内容,或者通过 JWT 令牌构建一个可变的身份验证系统。一切都由您自行决定。在下一篇文章中,我将向您介绍在客户端获取和使用 API 令牌的方法。

在Github上关注我<3

鏂囩珷鏉ユ簮锛�https://dev.to/ra1nbow1/how-to-write-the-right-api-client-in-typescript-38g3
PREV
2025 年的 Web 开发:您准备好迎接未来了吗?
NEXT
7 种现代数据库类型:用途、优点和缺点 关系型 SQL 数据库 面向文档的数据库 内存数据库 宽列数据库 列式数据库 搜索引擎 图形数据库 结论