beio Logobeio

【App 工程師的前端轉生術 03】資料層的防護罩:用 Zod 與 Repository 打造原生級安全感

發布於

01. 前言:為什麼 console.log 裡全是 undefined

在上一篇,我們建立了對 Next.js Runtime 的認知。今天我們要深入 「資料層(Data Layer)」

作為 iOS/Android 開發者,我們對「資料解析」這件事是非常嚴肅的。

  • iOS: 你會寫 struct User: Codable,如果 JSON 欄位對不上,JSONDecoder 會直接拋出 Error,你絕不會拿到一個爛掉的 Model。
  • Android: 你會用 data class User(...) 搭配 Moshi 或 Kotlin Serialization,確保 Null Safety 在解析時就生效。

但在前端,TypeScript 的型別檢查只存在於 「編譯期(Compile Time)」。 當你的 API 回傳了錯誤的 JSON(例如後端無預警把 user_name 改成 username),TypeScript 完全不會報錯。直到你的 User 在 UI 上點擊某個按鈕,程式崩潰,噴出著名的 Cannot read properties of undefined

這對 App 工程師來說是不可接受的技術債。今天我們要導入 ZodRepository Pattern,把這種不確定性在邊界層就擋下來。

02. 定義 Model:TypeScript Interface 還不夠

在 App 開發中,我們習慣定義 Data Model。

Mobile 思維

  • Android (Kotlin):
    @Serializable
    data class User(val id: String, val name: String, val email: String?)
    
  • iOS (Swift):
    struct User: Codable {
        let id: String
        let name: String
        let email: String?
    }
    

前端現狀與問題

你可能會直接在 TypeScript 寫:

// ❌ 這只是給編譯器看的,執行時它不存在
interface User {
  id: string;
  name: string;
  email?: string;
}

然後直接 const user = await res.json() as User;這是一個謊言! as User (Type Assertion) 只是你強迫編譯器閉嘴。如果 API 回傳 { "id": 123 } (數字而非字串),你的 App 在執行時照樣會掛掉。

解法:Zod (Runtime Schema Validation)

我們需要一個東西能在「執行期」驗證資料,就像 JSONDecoderMoshi 做的事。Zod 就是這個標準答案。

請先安裝:npm install zod

// src/domain/models/user.ts
import { z } from 'zod';

// 1. 定義 Schema (這是執行期要跑的程式碼)
export const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email().optional(), // 支援格式驗證
});

// 2. 導出 TypeScript Type (這是給編譯器看的)
// z.infer 會自動根據上面的 Schema 產生 interface,不用寫兩次!
export type User = z.infer<typeof UserSchema>;

現在,我們擁有了跟 Native App 一樣強壯的 Model 定義:既有靜態型別檢查,又有執行期資料驗證。

03. 資料獲取:別再 Component 裡寫 fetch

這是一個最典型的前端壞習慣:在 View (React Component) 裡面直接呼叫 API。

錯誤示範 (Anti-Pattern):

// ❌ 這是把網路層邏輯寫死在 View 裡 (Violation of SRP)
export default function UserPage() {
  const [user, setUser] = useState<User>();
  
  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data)); // 沒驗證資料就直接塞進 State
  }, []);

  return <div>{user?.name}</div>;
}

App 開發者視角: 這就像是你把 URLSession.shared.dataTask 寫在 SwiftUI.View.onAppear 裡,或者是把 OkHttp 請求寫在 Android 的 Compose 函數裡。這是架構上的大忌,導致難以測試且無法重用。

04. 架構設計:Repository Pattern

我們要建立一個乾淨的資料層,負責處理「資料從哪裡來」以及「資料是否合法」。

架構層級

  1. API Client: 封裝底層 HTTP Client (axios/fetch)。
  2. Repository: 定義業務邏輯需要的資料接口,並負責將 API 資料透過 Zod 轉成 Domain Model。

Step 1: 建立 API Client (類似 Retrofit / Moya)

// src/infrastructure/api/apiClient.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;

export const apiClient = {
  get: async (url: string) => {
    const res = await fetch(`${BASE_URL}${url}`);
    if (!res.ok) throw new Error(`Network error: ${res.status}`);
    return res.json();
  }
};

Step 2: 實作 Repository (核心邏輯)

這裡我們將 Zod 派上用場。這段程式碼對應到 Android 的 Repository Implementation 或 iOS 的 Service Class

// src/infrastructure/repositories/userRepository.ts
import { apiClient } from '../api/apiClient';
import { User, UserSchema } from '@/domain/models/user';

// 定義介面 (Protocol / Interface)
// 這是為了以後寫單元測試時可以輕鬆 Mock
export interface IUserRepository {
  getUserProfile(id: string): Promise<User>;
}

export const userRepository: IUserRepository = {
  getUserProfile: async (id: string): Promise<User> => {
    // 1. 獲取原始資料 (Unknown type)
    const data = await apiClient.get(`/users/${id}`);
    
    // 2. 驗證與轉換 (Parsing)
    // 如果後端欄位錯了,這裡會直接噴 error,而不是讓 UI 顯示錯誤
    const result = UserSchema.safeParse(data);
    
    if (!result.success) {
      // 可以在這裡送 Log 給 Sentry / Firebase Crashlytics
      console.error("Data Parsing Error:", result.error);
      throw new Error("Invalid User Data Structure");
    }
    
    // 3. 回傳乾淨的 Domain Model
    return result.data;
  }
};

App 對照表

概念Android (Kotlin)iOS (Swift)Next.js (TS)
Model 定義data classstructtype (via Zod)
JSON 解析Moshi / GsonCodable (JSONDecoder)Zod Schema
網路層Retrofit / KtorAlamofire / Moyafetch / axios
資料倉庫RepositoryService / ManagerRepository

05. 整合:在 Next.js 中使用

現在我們的資料層已經與 UI 無關了。我們可以在 Server Component (SSR) 中直接使用它。

// app/user/[id]/page.tsx
// 這是一個 Server Component (還記得 Part 2 說的嗎?)
import { userRepository } from '@/infrastructure/repositories/userRepository';

export default async function UserPage({ params }: { params: { id: string } }) {
  // 直接呼叫 Repository,就像在 Android ViewModel 呼叫 Repo 一樣
  // 這裡是在伺服器端執行的,所以連 Network Latency 都很低
  const user = await userRepository.getUserProfile(params.id);

  return (
    <main>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </main>
  );
}

看到差異了嗎?我們的 Page Component 變得極度乾淨。 它不需要處理 fetch,不需要擔心 json 格式錯誤,它拿到的 user 物件是經過 Zod 嚴格驗證的。如果資料錯誤,錯誤會在 userRepository 拋出,並由 Next.js 的 error.tsx 捕獲(這就像是 App 的全域 Error Handler)。

06. 小結:找回強型別的安全感

在這篇設計篇中,我們解決了 App 工程師轉職前端時最大的恐懼——不可控的資料

  1. Zod 是你的 Codable / Serializable,它保證了執行期的資料型別安全。
  2. Repository Pattern 隔離了 API 細節,讓你的 UI 層不需要知道 fetch 的存在。

現在我們有了強壯的 骨架 (Next.js Structure)血液 (Data Layer)。 但在前端開發中,最複雜的往往不是獲取資料,而是 管理互動狀態(比如:點擊編輯按鈕後切換 UI、表單輸入驗證、Loading 狀態切換)。

在 App 開發中,我們會用 ViewModel 來處理這些邏輯。 在下一篇 【Part 4】,我們將探討如何用 Custom Hooks 來實作 ViewModel,這將是真正釋放 React 潛力的關鍵一步。


Ken Huang

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner