前端筆記
TypeScript
created: 2024/08/12
在新的工作中前輩提到 DTO(Data Transfer Object)還有 service 的專案架構觀念,所以花點時間找相關的文章學習。
但其實我是認為在拆分元件時,被 wrapper 元件拆出的元件應該也是要有自己一層(尤其是當這個元件描繪的 UI 在專案中於不同地方使用),實際的標準我目前也不算是非常明白 XD。
但反正不要讓元件和 API response 有太直接的綁定就是。
// 因為是筆記所以先寫在一起,實際上會切分檔案夾
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 ...
)
}
// 因為是筆記所以先寫在一起,實際上會切分檔案夾
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,但架構層面還有許多優化地空間。
最不好的方式就是在元件內直接「裸露地」call API:
// 以 axios 為例子:
useEffect(() => {
axios
.get<UserResponse>(`/api/user/${handle}`)
.then((response) => setUser(response.data))
.catch(() => setHasError(true));
}, [handle])
以 UI 層面來看:
/api/
=> /apiV2/
)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)
}
});
}
}
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(Data Transfer Object)可以想像是從 source(像是 DB)取得資料後透過程式進行欄位變更及轉換得到的新結果。
以後端為例:DB(Entity) => 拿取需要的資料給前端(DTO)
但是以前端的角度是相反的 => DTO(後端回傳的資料)=> 前端再自行加工(Entity)
導入 DTO = 在取得 API response 後經過一層,並在該層處理資料成前端需要的物件格式:
以文章的範例來看,原先直接在元件直接操作 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>
);
}
關鍵字:一個函示只做一件事情
原先我直覺性也是覺得在「打 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 能夠解耦。