系列文章:

1. 共享的API客户端

API请求配置不要使用硬编码,应该在所有请求之间共享基础配置,实现一个单独的API层,这样的好处在于

  • 更容易重构,例如更改全局API地址
  • 基础配置共享、权限验证等
  • 便于实现API与UI层的分离

2. 单独的API层与数据转换

UI层应该与数据请求进行分离,尽量不要混合在一起,对UI组件来说,数据的获取方式并不重要,不管数据是GET请求还是POST请求获取,组件应该只关系传入的 Props 数据。
UI组件应该有自己的数据 Schema定义,这个定义不应该与API接口有关系。
对于API接口,应该进行统一的数据转换、清洗,这一步骤应该放在API层,不要在代码中每个使用API 的地方单独处理,例如
这种情况
image.png

理想的方式是在 API层的数据获取函数中进行数据转换处理

export interface UserResponse {
  data: User;
}
 
async function getUser(handle: string) {
  const response = await apiClient.get<{ data: User }>(`/user/${handle}`);
  // first `.data` comes from axios
  const user = response.data.data;
  return user;
}
 
export interface UserShoutsResponse {
  data: Shout[];
  included: Image[];
}
 
async function getUserShouts(handle: string) {
  const response = await apiClient.get<UserShoutsResponse>(
    `/user/${handle}/shouts`
  );
 
  const shouts = response.data.data;
  const images = response.data.included;
  return { shouts, images };
}

糟糕的代码: 在UI组件中进行数据转换处理

3. 领域实体与数据传输对象

UI组件不应该与服务器API耦合在一起,UI层需要有自己的实体对象,而服务器层的API DTO 数据模型应该只在API层使用,例如 API 的 DTO模型是这样的

export interface UserDto {
  id: string;
  type: "user";
  attributes: {
    handle: string;
    avatar: string;
    info?: string;
  };
 
  relationships: {
    followerIds: string[];
  };
}

这个 DTO 目前只是一个简单的二层嵌套对象,但是有可能会变成三层甚至四层,UI组件中不应该这样使用

user.relationshaips.followerIds[0]
user.attributes.info?.xxx

将 DTO 模型暴露给 UI组件会增加不必要的复杂性,通过多层的对象结构来访问数据,会使代码库变得膨胀、混乱和难以维护。

因此需要定义前端 UI 组件所需要的实体对象

export interface User {
  id: string;
  handle: string;
  avatar: string;
  info?: string;
  followerIds: string[];
}

这个实体对象面向前端,在 API 层,将服务端 DTO对象转换为前端实体对象,这样代码逻辑会更清晰

4. 基础设施服务与依赖注入

API 层一个功能请求函数:

import { apiClient } from "../client";
 
import { ImageDto } from "./dto";
import { dtoToImage } from "./transform";
 
async function uploadImage(file: File) {
  const formData = new FormData();
  formData.append("image", file);
 
  const response = await apiClient.post<{ data: ImageDto }>("/image", formData);
  const imageDto = response.data.data;
  return dtoToImage(imageDto);
}
 
export default { uploadImage };

在组件中的使用方式

async function handleSubmit(event: React.FormEvent<ReplyForm>) {
	...
	await MediaApi.uploadImage(files[0]);
	...
}

这段代码存在的问题:

  • 非单一职责函数: uploadImage 函数中包含了 数据转换与API请求
  • 不容易测试,因为必须模拟一个 APIClient 或者设置一个模拟服务器

优化方案:使用 Service 和依赖注入

  1. uploadImage 函数分离为 Service和 API 客户端
  2. 将其封装在单例类中
  3. 使用依赖注提高可测试性

分离 Service 和 API

API: uploadImage 仅用于 API 请求。

import { apiClient } from "../client";
 
import { ImageDto } from "./dto";
 
async function uploadImage(formData: FormData) {
  const response = await apiClient.post<{ data: ImageDto }>("/image", formData);
  return response.data;
}
 
export default { uploadImage };

Service: 专注与数据转换

import MediaApi from "./api";
import { dtoToImage } from "./transform";
 
async function saveImage(file: File) {
  const formData = new FormData();
  formData.append("image", file);
  
  const { data: imageDto } = await MediaApi.uploadImage(formData);
  
  return dtoToImage(imageDto);
}
 
export default { saveImage };

依赖注入和控制反转

实现反转控制:将服务函数 saveImage 包转在一个类汇总,并让它在构造函数中接收 MediaAPI 作为参数, 关键点在于 Service 将 API 实例存储为私有变量,然后在 saveImage 函数中使用这个变量来调用 uploadImage 函数。

MediaApi interface:

import { ImageDto } from "./dto";
 
export interface MediaApi {
 
  uploadImage(formData: FormData): Promise<{ data: ImageDto }>;
 
}

MediaService:

// src/infrastructure/media/service.ts
 
import { MediaApi } from "./interfaces";
import { dtoToImage } from "./transform";
 
export class MediaService {
  constructor(private api: MediaApi) {
    this.api = api;
  }
 
  async saveImage(file: File) {
    const formData = new FormData();
    formData.append("image", file);
    
    const { data: imageDto } = await this.api.uploadImage(formData);
    
    return dtoToImage(imageDto);
  }
}

这样实现当我们在进行单元测试时,不需要任何的模拟服务,只需要创建一个简单的 MediaApi 接口的模拟实现,并将其传递给 MediaService 可以通过依赖注入实现一个不错的小单元测试

5. 业务逻辑分离

业务逻辑的定义:

数据验证和对协调对服务或者 Rest API 的调用,这就是业务逻辑,可以与 UI 框架隔离开

优化这种方式点依然在于 提取业务逻辑并使用依赖注入

第一步:首先将业务逻辑提取到单独的文件中,这个文件的代码不应该包含与 UI 或者 React相关的代码,即使后续需要迁移到另外一个UI框架,这里的代码也可以在不做任何更改的情况下迁移

第二步:用于依赖注入的自定义钩子

import { useCallback } from "react";
 
import MediaService from "@/infrastructure/media";
import ShoutService from "@/infrastructure/shout";
import UserService from "@/infrastructure/user";
 
...
 
// 我们使用服务将依赖项创建为单独的对象。
// 这样它已经有了类型,我们不需要另一个TS接口。
const dependencies = {
  getMe: UserService.getMe,
  getUser: UserService.getUser,
  saveImage: MediaService.saveImage,
  createShout: ShoutService.createShout,
  createReply: ShoutService.createReply,
};
 
// replyToShout函数接受依赖项作为第二个参数。
// 现在调用此函数的代码决定提供什么作为例如getMe。
// 这被称为控制反转,有助于单元测试。
export async function replyToShout(
  { recipientHandle, shoutId, message, files }: ReplyToShoutInput,
  { getMe, getUser, saveImage, createReply, createShout }: typeof dependencies
) {
  const me = await getMe();
  if (me.numShoutsPastDay >= 5) {
    return { error: ErrorMessages.TooManyShouts };
  }
 
  const recipient = await getUser(recipientHandle);
  if (!recipient) {
    return { error: ErrorMessages.RecipientNotFound };
  }
  if (recipient.blockedUserIds.includes(me.id)) {
    return { error: ErrorMessages.AuthorBlockedByRecipient };
  }
 
  try {
    let image;
    if (files?.length) {
      image = await saveImage(files[0]);
    }
 
    const newShout = await createShout({
      message,
      imageId: image?.id,
    });
 
    await createReply({
      shoutId,
      replyId: newShout.id,
    });
 
    return { error: undefined };
  } catch {
    return { error: ErrorMessages.UnknownError };
  }
}
 
// 这个钩子只是一个注入依赖项的机制。一个组件可以使用这个钩子而不必关心提供依赖项。
export function useReplyToShout() {
  return useCallback(
    (input: ReplyToShoutInput) => replyToShout(input, dependencies),
    []
  );
}

通过依赖注入的方式将 replyToShout 函数所依赖的服务抽离出去,作为参数传递进来,这样的好处在于

  • 将业务逻辑与UI界面分离,使其独立于用户界面框架
  • 使用依赖注入使得测试逻辑变得简单
  • 从组件中移除逻辑使它们能够专注于自己的职责: 用户界面
  • 此外,可以将业务逻辑与工具函数和自定义钩子分开

6. 将逻辑提取到领域层

领域逻辑是对领域模型(如用户对象)进行操作的代码,听起来有些抽象,他的目标是讲这些逻辑与我们的 UI组件隔离,将其移动到代码库的特定位置,并进行单元测试

案例一

问题代码

// src/components/shout-list/shout-list.tsx
 
import { Shout } from "@/components/shout";
import { Image } from "@/domain/media";
import { Shout as IShout } from "@/domain/shout";
import { User } from "@/domain/user";
 
interface ShoutListProps {
  shouts: IShout[];
  images: Image[];
  users: User[];
}
 
export function ShoutList({ shouts, users, images }: ShoutListProps) {
  return (
    <ul className="flex flex-col gap-4 items-center">
      {shouts.map((shout) => {
        const author = users.find((u) => u.id === shout.authorId);
        const image = shout.imageId
          ? images.find((i) => i.id === shout.imageId)
          : undefined;
          
        return (
          <li key={shout.id} className="max-w-sm w-full">
            <Shout shout={shout} author={author} image={image} />
          </li>
        );
      })}
    </ul>
  );
}

存在的问题:这段代码中对 User 和 Image 实体进行了操作,具体来说,这段代码定义了我们应用程序中用户和图像查找的工作方式。
此外, Shout 可能没有图像,因此我们需要一个三元表达式,这不是最优美或者可读的代码

第一步:在领域层创建函数

// src/domain/user/user.ts
 
export interface User {
  id: string;
  handle: string;
  avatar: string;
  info?: string;
  blockedUserIds: string[];
  followerIds: string[];
}
 
export function getUserById(users?: User[], userId?: string) {
  if (!userId || !users) return;
  return users.find((u) => u.id === userId);
}

通过将两个参数设为可选,使函数比所需的更灵活, 同样对图像做类似的事情

// src/domain/media/media.ts
 
export interface Image {
  id: string;
  url: string;
}
 
export function getImageById(images?: Image[], imageId?: string) {
  if (!imageId || !images) return;
  return images.find((i) => i.id === imageId);
}

在组件中使用

// src/components/shout-list/shout-list.tsx
 
import { Shout } from "@/components/shout";
import { Image, getImageById } from "@/domain/media";
import { Shout as IShout } from "@/domain/shout";
import { User, getUserById } from "@/domain/user";
 
...
 
export function ShoutList({ shouts, users, images }: ShoutListProps) {
  return (
    <ul className="flex flex-col gap-4 items-center">
      {shouts.map((shout) => (
        <li key={shout.id} className="max-w-sm w-full">
          <Shout
            shout={shout}
            author={getUserById(users, shout.authorId)}
            image={getImageById(images, shout.imageId)}
          />
        </li>
      ))}
    </ul>
  );
}

提取领域逻辑的利弊

优势

  • 较少的工具函数:没有领域层时,通常不清楚将上述逻辑放在哪里。这些逻辑通常分散在各个组件中,或者可以在工具文件中找到。然而,工具文件可能会变得问题重重,因为它们很容易变成各种共享代码的倾倒场所。
  • 可读性:虽然这不是很多,users.find(({ id }) => id === userId) 需要一些认知负担来将其转换为 ID 查找。相反,阅读 getUserById(users, userId) 更具描述性。如果你有很多这样的行聚集在一起(例如,在组件的顶部),这尤其有效。
  • 可测试性:你通常会发现使用 if/switch 语句或三元运算符的代码。每一个这些都意味着需要覆盖多个测试分支。为所有边界情况编写单元测试可能会容易得多,并将集成测试的数量减少到严格必要的测试。
  • 可重用性:这些小逻辑片段往往看起来不值得提取到单独的函数中。于是它们在代码中被无限重复。但需求的小变化很容易导致更大的重构。

缺点

  • 并不是每个开发者都习惯于以不同的逻辑思考。因此,如果您希望保持代码库的一致性,可能需要文档和培训。
  • 开销:正如您在上面的一个示例中看到的,我们使某些域函数比特定组件所需的更灵活(在这里,我们将usersuserId参数的getUserById函数设为可选)。由于我们还通过单元测试覆盖了这些情况,因此引入了比所需更多的代码,导致维护工作量增加。同时,如果您突然遇到来自服务器的意外响应数据,这可以为您节省麻烦。