Try   HackMD

[Project Structure] React 專案架構學習

tags 前端筆記 TypeScript

created: 2024/08/12

前言

在新的工作中前輩提到 DTO(Data Transfer Object)還有 service 的專案架構觀念,所以花點時間找相關的文章學習。

目標

  1. 讓元件盡可能只保留繪製 UI 的處理及邏輯(state)
  2. 元件不要與 API response 有太直接的綁定,最好中間額外插一層(插入的中間層可以處理前端自己需要的物件 => 若使用 TypeScript 則可以額外處理型別)

但其實我是認為在拆分元件時,被 wrapper 元件拆出的元件應該也是要有自己一層(尤其是當這個元件描繪的 UI 在專案中於不同地方使用),實際的標準我目前也不算是非常明白 XD。

但反正不要讓元件和 API response 有太直接的綁定就是。

未知道 DTO 等概念之前,我之前都是以類似下列的方式處理:

1. Bad(太直接綁定):

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

// 因為是筆記所以先寫在一起,實際上會切分檔案夾 type User = { name: string type: string userId: string createAT: number relationships: { replies: string[] } } function WrapperComponent () { const [user, setUser] = useState<User>(); useEffect(() => { /* * 假設這個 API 回傳的欄位如下 * { * name: 'Lun', * type: 'person', * userId: '1713455132695', * createAT: 1713455132695 * relationships: { * replies: [ * "shout-3" * ] * } * * */ apiClient .get<UserResponse>(`/user .then((response) => setUser(response.data)) .catch(() => setHasError(true)); } return ( {user ? <SomeComponent user={user.data} /> : <></>} ) } // 與 API 回傳的型別相同,這樣子會讓此元件與 API response 直接綁定 function SomeComponent (props: User) { return ( // render UI ... ) }

2. Better(元件跟 API response 有插一層)

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

// 因為是筆記所以先寫在一起,實際上會切分檔案夾 type User = { name: string type: string userId: string createAT: number relationships: { replies: string[] } } function WrapperComponent () { const [user, setUser] = useState<User>(); useEffect(() => { /* * 假設這個 API 回傳的欄位如下 * { * name: 'Lun', * type: 'person', * userId: '1713455132695', * createAT: 1713455132695 * relationships: { * replies: [ * "shout-3" * ] * } * * */ apiClient .get<UserResponse>(`/user .then((response) => { setUser(response.data) }) .catch(() => setHasError(true)); } // 這裡多轉一層,為 SomeComponent 多處理的 const formattedUser = { userName: user.name, isPerson: user.type === 'person' } return ( {user ? <SomeComponent user={formattedUser} /> : <></>} ) } // 這個 type 是專門給 SomeComponent 使用 type Props = { userName: string isPerson: boolean } // 與 API 回傳的型別相同,這樣子會讓此元件與 API response return ( // render UI ... ) }

雖然用 formattedUser 處理傳遞子層元件的 props,但架構層面還有許多優化地空間。

DTO 之前可以先好好管理叫用 API

最不好的方式就是在元件內直接「裸露地」call API:

// 以 axios 為例子: useEffect(() => { axios .get<UserResponse>(`/api/user/${handle}`) .then((response) => setUser(response.data)) .catch(() => setHasError(true)); }, [handle])

為什麼這樣子不好?

以 UI 層面來看:

  1. UI 層不需要知道我使用 axios 或者 fetch 去處理 API
  2. 若指定的 API domain 更改,個別裸露使用 = 都需要個別修改 domain(比如說 domain 需要從 /api/ => /apiV2/

若使用 axios 的話就可以用 axios.create 建立實例

import axios from "axios"; export const apiClient = axios.create({ baseURL: "/api/v2", });

當然也是可以像之前在工作中學到的,用 class

type RequestInput = { endpoint: string options?: RequestInit } type BasicGetRequestInput = {} & RequestInput type BasicPostRequestInput<TBody> = { body: TBody } & RequestInput type BasicHttpError = { status: number response: unknown } & Error const BASE_URL = { V3: 'https://api.accupass.com/v3' } as const; class BasedFetch { constructor (baseUrl: string) { this.baseUrl = baseUrl; } private baseUrl: string; private async request ({ endpoint, options }: RequestInput) { const response = await fetch(`${this.baseUrl}${endpoint}`, options); if (!response.ok) { const error = new Error('HTTP error') as BasicHttpError; error.status = response.status; error.response = await response.json(); throw error; } return response.json(); } get<TResult> ({ endpoint, options }: BasicGetRequestInput): Promise<TResult> { return this.request({ endpoint , options: { ...options, method: 'GET', headers: { ...options?.headers, 'Content-Type': 'application/json', } } }); } post<TBody> ({ endpoint, options, body }: BasicPostRequestInput<TBody>) { return this.request({ endpoint, options: { ...options, method: 'POST', headers: { ...options?.headers, 'Content-Type': 'application/json', }, body: JSON.stringify(body) } }); } }

建立 API 統一管理的實例後就可以將 call API 的行為封裝,元件僅需處理叫用 + 帶入動態資訊

async function getOrganizerDetailRequest (organizerId: string) { // fetch API const result = await BasedFetchInstance.get<OrganizationDTO>({ endpoint: `${ENDPOINT}/${organizerId}` }); return result; } function SomeWrapperComponent () { const { organizerId } = useParams() const [detail, setDetail] = useState<DetailType>() useEffect(() => { // 元件僅負責處理叫用(執行)+ 帶入動態資訊 // 僅練習用所以靠 useEffect 打 API async function processGetOrganizerDetailRequest () { const result = await getOrganizerDetailRequest(organizerId) setDetail(result) } processGetOrganizerDetailRequest() }, [organizerId]) }

導入 DTO(元件視角)

DTO(Data Transfer Object)可以想像是從 source(像是 DB)取得資料後透過程式進行欄位變更及轉換得到的新結果。
以後端為例:DB(Entity) => 拿取需要的資料給前端(DTO)
但是以前端的角度是相反的 => DTO(後端回傳的資料)=> 前端再自行加工(Entity)

導入 DTO = 在取得 API response 後經過一層,並在該層處理資料成前端需要的物件格式:

截圖 2024-08-13 12.01.33

這樣子有什麼好處?

  1. 有時候後 API response 取得的資料結構很複雜巢狀,直接拿來使用會很麻煩
  2. 可以根據 API response 額外推導前端需要的欄位
  3. 若日後後端更改 API response 前端只需要再指定層數修正,讓修改範圍能夠與 UI 分開(或者修改範圍能夠更限縮)

以文章的範例來看,原先直接在元件直接操作 API response:

// src/pages/user-profile.tsx // ref. [Path To A Clean(er) React Architecture - Domain Entities & DTOs](https://profy.dev/article/react-architecture-domain-entities-and-dtos) import UserApi from "@/api/user"; import { User } from "@/types"; type UserProfileProps = { handle: string; } export function UserProfile({ handle }: UserProfileProps) { const [user, setUser] = useState<User>(); useEffect(() => { UserApi.getUser(handle) .then((user) => setUser(user)); }, [handle]); return ( <section> <img src={user.attributes.avatar} alt={`${user.attributes.handle}'s avatar`} /> /* 這裡因為 API response 就是回傳這麼巢狀,所以直接在元件內操作就很複雜 */ <h1>@{user.attributes.handle}</h1> <p>({user.relationships.followerIds.length} follower)</p> <p>{user.attributes.info || "Shush! I'm a ghost."}</p> </section> ); }

插入中間層,並將 API response 轉換成前端好用的格式:

// 因為筆記所以寫在一起,在實務上就會分檔案 // ref. [Path To A Clean(er) React Architecture - Domain Entities & DTOs](https://profy.dev/article/react-architecture-domain-entities-and-dtos) /* 這個 type 是定義 API response 的 type => 並不會直接進入前端專案使用 */ export interface UserDto { id: string; type: "user"; attributes: { handle: string; avatar: string; info?: string; }; relationships: { followerIds: string[]; }; } /* 這個 type 是前端在 API response 及 UI 額外插入一層要使用的 type => 會直接在前端專案使用 */ export interface User { id: string; handle: string; avatar: string; info?: string; followerIds: string[]; } // 在元件中使用 import UserApi from "@/api/user"; import { User } from "@/domain"; type UserProfileProps = { handle: string; } export function UserProfile({ handle }: UserProfileProps) { const [user, setUser] = useState<User>(); useEffect(() => { UserApi.getUser(handle) .then((user) => setUser(user)); }, [handle]); return ( /* 透過中間層讓實際使用可以更符合前端需求 */ <section> <img src={user.avatar} alt={`${user.handle}'s avatar`} /> <h1>@{user.handle}</h1> <p>({user.followerIds.length} follower)</p> <p>{user.info || "Shush! I'm a ghost."}</p> </section> ); }

實作 DTO 處理(API response => 前端專案需要的物件)

關鍵字:一個函示只做一件事情

原先我直覺性也是覺得在「打 API」的函示額外處理 response 轉換:

// 類似像是下面的方式 async function getUser(handle: string) { const response = await SomeApi(handle) const responseDTO = response // 直接在這個函示叫用轉換 return dtoToSomeEntity(responseDTO); }

但是教學文章內有提到這樣子會讓負責發出請求的函示有其他任務參雜,應該要讓這個函示回歸到只做發送請求而已。

教學文章建立一個 class 稱作 service,並把這個 service 當作一個 wrapper function(當作「叫用」打 API 以及轉換的 callback):

// ref. [Path To A Clean(er) React Architecture (Part 5) - Infrastructure Services & Dependency Injection For Testability](https://profy.dev/article/react-architecture-infrastructure-services-and-dependency-injection) 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); } } // 若要使用時則直接 new 建立其實例 => 因為 constructor 會先執行,為實例塞入屬性 const mediaService = new MediaService(mediaApi);

但是我實際看作者在 github 的 repo 則是未使用 class,而是單純用一個 wrapper function:

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

有什麼好處?

雖然前端很多 UI 是由 API 回傳的資料決定內容,但是有時候後端回傳的東西是非常一大包,前端有時候只需要裡面一些屬性,為了避免前端取得欄位時與後端 API response 有太過緊密的關聯,所以前端可以多做一層抽象化,讓 UI 跟 API 能夠解耦。

  1. 有時後前端會因為 UI 的需求額外轉換 API response(新增欄位、調整欄位名稱、重新建立一個全站的物件或者刪除欄位等),這個時候前端可以在專案自己管理
  2. 就有點像是之前我額外建立 component 的 type,收到 API response 後重新修改 response,改成該 component props 的屬性 => 讓元件更獨立、彈性及更有語意化

Recap

  1. 可以用 wrapper function 多做一層,負責叫用各個單點的 function
  2. 元件層儘量維持與元件層相關的東西,非元件層需要知道的部分可以拆出並封裝讓元件層觸發
  3. 透過 DTO 的 pattern,讓元件層可以與 API response 不強制綁定,若 API response 有變更時便可以限縮要修改的範圍。

參考資料

  1. Nice way to abstract your data fetching logic in React with TypeScript
  2. A Shared API Client
  3. API Layer & Fetch Functions
  4. API Layer & Data Transformations
  5. Domain Entities & DTOs
  6. Infrastructure Services & Dependency Injection For Testability
  7. Business Logic Separation
  8. Domain Logic
  9. jkettmann/react-architecture