FE Day 16 專案結構與程式碼組織
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第十六篇。前十五天你學會了元件、狀態、Hook、樣式、測試,這些東西散落在幾個檔案時還能跑;專案長大到幾十個元件、數百個檔案時,沒有結構的程式會變得「找檔案比寫程式還累」。今天把後端工程師熟悉的「分層」思惟搬到 React 上,建立一套可以撐到數百個檔案也穩定的資料夾結構,並把路徑別名、Lint 規則、共用型別、Barrel Export 的取捨一次講清楚。整篇的範例都在本機 CPU 跑得起來,不依賴任何雲端服務。
引言
寫後端的工程師對「分層」一點都不陌生:route 層、service 層、repository 層、model 層,每層的職責清楚,跨層呼叫的方向固定,CI 跑測試時可以一層一層驗證。React 專案長大之後也需要類似的分層,新手最常把所有東西塞進 src/components/,結果一個資料夾裡同時混著 Button、useBooking、formatDate、api.ts,找檔案變成「捲動加猜測」,新成員 onboarding 變成考古。
本系列從 Day 5 開始的範例都是「小到能放在一個檔案」的程度,結構問題不明顯。但預約管理系統從 Day 31 開始要長到數十個元件、上百個檔案,如果現在不把骨架蓋好,後面每一天都會被「這段程式該放哪」這個問題打斷。今天的目標是把一個小型 React 專案重構成「以角色為主」的分層結構,讓檔案位置能反映程式碼的職責,並說明怎麼用 Lint 把不一致擋在 PR 階段。
版本基準延續前幾天:Node.js 22 LTS/24 LTS、TypeScript 5.9、React 19.x、Vite 7、Vitest 3.x、ESLint 9 flat config;明天 Day 17 才會切到 Next.js 15/16 的 App Router,所以今天的結構以「純 React 加 Vite」為主,但分層觀念完全通用,搬到 Next.js 也直接適用。讀完之後你應該能在 30 秒內決定任何一段新程式碼該放哪個資料夾,並且能用 Lint 把違反結構的程式碼擋下。
為什麼「全部塞進 components」會爆炸
新手最常見的起手式是「在 src/components/ 底下建一個資料夾裝所有東西」:UI 元件、Hook、工具函式、API 呼叫、TypeScript 型別全部混在一起。一個 components/ 資料夾裡同時出現 useBooking.ts、formatDate.ts、api.ts 是很常見的狀況,git log 看下去每次 commit 都會動到這個資料夾的三四個檔案,PR review 也難以聚焦。
這個結構在前兩週不會出問題,檔案少、邏輯短、IDE 的全域搜尋找得到。但長大到 50 個檔案以上時,三個症狀會同時出現。第一是「找檔案變難」:要改日期格式,得猜它是 components/formatDate.ts、components/utils/formatDate.ts、還是 lib/format.ts。第二是「互相依賴」(circular dependency):UI 元件需要某個型別,型別又需要某個 Hook,Hook 又用到某個工具函式,TypeScript 編譯器抱怨「circular reference」、ESLint 跳出紅字。第三是「測試不好切」:想單獨測 formatDate 卻發現它跟 fetch 寫在同一個檔案,必須 mock 一堆不相干的東西。
對照後端經驗,這就像把 controller、service、repository、model 全塞進 controllers/——能跑,但每次改一個地方都得擔心會不會打到別人。後端用「資料夾等於層」來切,React 也一樣。最直接的分法是「以程式碼的角色為單位」:元件、Hook、工具函式、型別、API 各自有家。下面幾個小節會把這個結構的每一層拆開來看。
以角色為主的資料夾分層
React 專案最常見、也最不容易後悔的分層方式是「按程式碼的角色切資料夾」。一個可以撐到數百個檔案的最小結構長這樣:
src/
├── app/ # 應用程式入口(Vite main.tsx、root 元件、Provider)
├── components/ # 純 UI 元件:Button、Card、Modal、Field
├── features/ # 以業務功能切:booking/、customer/、report/
├── hooks/ # 共用 Hook:useToggle、useDebounce、useFetch
├── lib/ # 純函式工具:formatDate、validatePhone、currency
├── api/ # 與後端溝通:booking.ts、customer.ts、client.ts
├── types/ # 共用型別:Booking、Customer、Role
├── styles/ # 全域樣式:tailwind.css、reset.css、tokens.css
└── test/ # 測試輔助:render.tsx、msw handlers、fixtures
每個資料夾的角色都很單純:components/ 放可以被多個頁面共用的純展示元件;features/ 放「業務功能」相關的程式碼(一個 feature 內部可以有自己的元件、Hook、型別,全部 co-locate);hooks/ 放跨功能共用的狀態邏輯;lib/ 放沒有 React 依賴的純函式;api/ 集中所有對後端的 HTTP 呼叫;types/ 放跨功能共用的 TypeScript 型別。這個對應關係很直覺,新成員只要花半天就能掌握。
「features/ 內部可以有自己的元件」這條很重要。它解決了「元件太多、全部塞 components/ 又太雜」的矛盾:純展示的 Button、Field 放 components/;跟「預約」業務邏輯綁定的 BookingCard、BookingForm 放 features/booking/components/。這個分法在 Day 13「元件設計原則」提過,叫 feature-sliced design,能讓大型專案在維持秩序的同時允許業務獨立演進。
另一個關鍵字是「co-locate(同位置)」:一個 feature 用到的元件、Hook、型別、測試盡量放在一起。例如 features/booking/ 底下可能長這樣:
features/booking/
├── components/
│ ├── BookingCard.tsx
│ └── BookingForm.tsx
├── hooks/
│ └── useBookingList.ts
├── types.ts
├── api.ts # 跟 booking 相關的 API 呼叫
└── index.ts # barrel export:對外只暴露需要的 API
這跟後端的「bounded context(限定上下文)」很像:booking 這個業務範圍有自己的所有東西,不會跟 customer 互相依賴。要新增跟預約有關的小工具,往 features/booking/ 找位置就對了;不需要的東西不會被誤 import。這條對「隨著專案長大、import 路徑變得亂七八糟」的問題是治本的方法。
完整實作:把扁平專案重構為標準分層
實際動手做一次最有感。我們從「全部塞 components」的扁平專案開始,把結構重整到標準分層。先看「重構前」的狀態:
src/
├── App.tsx
├── main.tsx
└── components/
├── Button.tsx
├── Card.tsx
├── BookingCard.tsx # booking 業務元件
├── BookingForm.tsx # booking 業務元件
├── useBookingList.ts # booking 相關 Hook
├── formatDate.ts # 純函式工具
├── validatePhone.ts # 純函式工具
├── api.ts # 所有後端呼叫混在一起
├── booking.ts # Booking 型別
└── customer.ts # Customer 型別
這個結構的問題:components/ 裡混著 UI 元件、Hook、工具函式、API 檔;型別散落在各處;找「怎麼打預約 API」得開三個檔案。我們重構到標準分層:
src/
├── app/
│ ├── main.tsx
│ ├── App.tsx
│ └── providers.tsx
├── components/
│ ├── Button.tsx
│ └── Card.tsx
├── features/
│ └── booking/
│ ├── components/
│ │ ├── BookingCard.tsx
│ │ └── BookingForm.tsx
│ ├── hooks/
│ │ └── useBookingList.ts
│ ├── api.ts
│ └── types.ts
├── hooks/
│ └── useDebounce.ts # 跨功能共用
├── lib/
│ ├── formatDate.ts
│ └── validatePhone.ts
├── api/
│ └── client.ts # 共用 HTTP client
├── types/
│ └── customer.ts # 跨功能共用
└── styles/
└── tailwind.css
幾個重構決策值得記下來。第一,UI 元件只有「通用」與「業務」兩種,後者搬到 features/booking/components/。第二,Hook 拆成兩層:跨功能共用的(如 useDebounce)放 hooks/;跟單一業務相關的(如 useBookingList)放在 feature 內部。第三,api/ 只放「跨功能共用的 HTTP client」(例如 axios 實例、攔截器),每個 feature 自己的 API 呼叫放在 features/<name>/api.ts,呼叫時用共用的 client。第四,types/ 只放跨功能共用的型別;feature 自己的型別放在 feature 內部。
重構完之後,新成員 onboard 只要被告知「找 UI 元件看 components/,找業務邏輯看 features/booking/」,整個專案的形狀就清楚了。Code review 也比較容易:「這個 PR 改了 features/booking/api.ts」比「這個 PR 改了 components/api.ts」更直覺,重構風險一眼看得出來。
為了讓重構更具體,我們看一個典型的「預約 Hook」應該長什麼樣:
// src/features/booking/hooks/useBookingList.ts
// 業務專屬 Hook:放在 feature 內部,不對外暴露
import { useEffect, useState } from "react";
import { listBookings } from "../api";
import type { Booking, BookingFilter } from "../types";
type State = {
bookings: Booking[];
loading: boolean;
error: Error | null;
};
export function useBookingList(filter: BookingFilter): State {
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
listBookings(filter)
.then((data) => {
if (!cancelled) setBookings(data);
})
.catch((e: unknown) => {
if (e instanceof Error) setError(e);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [filter]);
return { bookings, loading, error };
}
這支 Hook 引用同 feature 內的 api.ts 與 types.ts,對外只暴露 useBookingList。把它放在 features/booking/hooks/ 而不是 hooks/,是因為它的存在完全依賴 booking 業務;booking 不做了,這個 Hook 也不該被任何地方引用。
路徑別名、Lint 規則與 Barrel Export 的取捨
結構蓋好之後,import 路徑會開始變長。從 src/features/booking/components/BookingCard.tsx 引用 src/lib/formatDate.ts 要寫 ../../../lib/formatDate,三個點的相對路徑對眼睛負擔很大。修法是用 TypeScript 的 paths 設定路徑別名,並讓 Vite 7 自動讀取:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/features/*": ["src/features/*"],
"@/hooks/*": ["src/hooks/*"],
"@/lib/*": ["src/lib/*"],
"@/api/*": ["src/api/*"],
"@/types/*": ["src/types/*"]
}
},
"include": ["src"]
}
設好之後,整個專案都寫 import { Button } from "@/components/Button"、import { formatDate } from "@/lib/formatDate"。Vite 7 會自動讀 tsconfig.json 的 paths,不需要另外寫 vite.config.ts 的 alias(除非你想自訂 alias 跟 tsconfig 不一樣)。記得裝 @types/node,tsc --noEmit 與 vitest 都能正確解析。
「Barrel Export(在 index.ts 集中 re-export)」是另一個常見手法。一個 feature 的入口通常長這樣:
// src/features/booking/index.ts
// Barrel Export:對外契約集中點
export { BookingCard } from "./components/BookingCard";
export { BookingForm } from "./components/BookingForm";
export { useBookingList } from "./hooks/useBookingList";
export type { Booking, BookingStatus, BookingFilter } from "./types";
export { listBookings, createBooking } from "./api";
這樣外面只要寫 import { BookingCard, useBookingList } from "@/features/booking",不必管內部結構。Barrel Export 的好處是「內部結構可以重構、對外 API 穩定」;壞處是「所有東西都被綁進同一個 chunk,可能拖慢初次載入」。在純 React 加 Vite 專案影響不大;到 Next.js 15 的 App Router 就要特別小心,Server Component 的 import 路徑會被追蹤,不小心把 client 邏輯拉進 server bundle 就糟了(Day 17 會展開)。
Lint 規則的部分,ESLint 9 flat config 推薦裝 eslint-plugin-import 並啟用「import 順序」與「禁止互相依賴」。下面是實際可跑的設定檔:
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
plugins: { import: importPlugin },
rules: {
"import/order": [
"warn",
{
groups: [
"builtin",
"external",
"internal",
["parent", "sibling", "index"],
],
pathGroups: [{ pattern: "@/**", group: "internal" }],
"newlines-between": "always",
alphabetize: { order: "asc", caseInsensitive: true },
},
],
"import/no-cycle": ["error", { maxDepth: 5 }],
"import/no-self-import": "error",
"import/no-unresolved": "error",
},
},
];
這份設定做了三件事:第一,import/order 把內建、外部、@/、相對路徑分群,CI 跑 lint 時自動排整齊;第二,import/no-cycle 抓到互相依賴(追 5 層以內),這在前述分層沒做好時最容易出現;第三,import/no-self-import 禁止「自己 import 自己」、import/no-unresolved 確保路徑寫錯會立刻被擋下。裝好這幾條規則後,PR review 就不必再花時間討論「這行 import 該放哪」。
工具函式與共用工廠應該放在 lib/。舉一個「預約系統格式化時間」的範例:
// src/lib/formatDate.ts
// 純函式工具:沒有 React、沒有 fetch、沒有 DOM
const TAIWAN_TIME_FORMAT = new Intl.DateTimeFormat("zh-Hant-TW", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export function formatBookingTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "—";
return TAIWAN_TIME_FORMAT.format(date);
}
export function isPastBooking(iso: string, now: Date = new Date()): boolean {
return new Date(iso).getTime() < now.getTime();
}
這支檔案沒任何 import、沒 React、沒 fetch,可以單獨被 Vitest 測試、可以被 Server Component 引用(明天就會用到)、也可以被 Client Component 引用。「lib/ 只能放純函式」這個約定守住,整個專案的工具程式就能維持單純。
常見錯誤與踩雷
第一個雷是「把 features/<name>/components/ 跟 components/ 混著用」。很多人最後會在 components/ 看到 BookingCard,又在 features/booking/components/ 看到 BookingCard,結果兩個版本各自演進。修法是寫進 README:「通用元件放 components/、業務元件放 feature 內」,code review 時嚴格執行,必要時開一個 ESLint 自訂規則擋下違規。一個簡單的做法是在 eslint.config.js 加 no-restricted-imports:
// eslint.config.js(追加規則)
{
files: ["src/features/booking/components/**/*.tsx"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@/components/*"],
message: "booking 元件請放在 src/features/booking/components/",
},
],
},
],
},
}
這條規則只對 booking feature 內的元件檔生效:禁止從 @/components/ 引入,避免誤把通用元件跟業務元件混用。對其他 feature 可以複製貼上、再改 pattern 與 message,整套 Lint 規則就能隨著團隊約定一起長大。
第二個雷是「lib/ 變成垃圾場」。lib/ 一開始放 formatDate、validatePhone,後來有人把 api.ts、useToggle.ts、colors.ts 全塞進去,原本的「純函式工具」定位完全崩壞。修法是約定 lib/ 只能放「無 React、無 fetch、無 DOM」的純函式;違反這條的 PR 一律退件,並寫成 README 守則。另一個保險做法是在 lib/ 底下加一個 README.md,明文列出允許與禁止的內容。
第三個雷是「Barrel Export 把整個 feature 拉進來」。例如 import "@/features/booking" 想拿 BookingCard,但這個 index.ts 也 re-export 了其他重型元件,結果 bundle 把它們全部載入。修法是 Barrel Export 只 re-export「對外契約」,內部 helper 不要放進去;或改用具名 import import { BookingCard } from "@/features/booking/components/BookingCard",配合 ESLint 的 no-restricted-imports 規則擋下「從 index 拉整包」。
第四個雷是「路徑別名跟 IDE 對不上」。裝好 paths 之後,VS Code 的 TypeScript Language Server 有時候沒抓到,要重啟或刪掉 .vscode/.cache 才會生效。修法是在 tsconfig.json 裡多加 "plugins": [{ "name": "@typescript-eslint" }],或在 VS Code 設定裡指定 "typescript.tsdk": "./node_modules/typescript/lib"。裝好之後,「跳到定義」與「自動 import」才會用上路徑別名。
效能與實務提醒
結構對執行效能的直接影響很小,主要影響的是「bundle 的 code splitting(程式碼分割)」與「tree shaking(搖樹最佳化)」。以 Vite 7 為例,它依賴 ES Module 的靜態分析來做搖樹;如果 Barrel Export 把一堆東西 re-export 出去,靜態分析可能誤判「有引用」而把整個模組打包。要達到最佳搖樹,每個 import 必須用具名、不能 import * as、不能從 index 拉一整包。
路徑別名設定完成後,可以用 tsc --noEmit 與 vitest run 兩個指令確認兩件事:第一,TypeScript 能正確解析 @/ 開頭的路徑(沒有「Cannot find module」錯誤);第二,Vitest 在 jsdom 環境下能載入元件測試、跑 @testing-library/react 的 render 與 screen 工具。這兩個指令應該在 CI 的兩個獨立 step 跑,先 typecheck、再跑測試,能抓出「型別正確但 runtime 失敗」與「runtime 通過但型別錯誤」兩種不同性質的問題。
實務上另一個常見痛點是「co-located 測試」的位置。features/booking/components/BookingCard.tsx 旁邊到底要不要放 BookingCard.test.tsx?兩派各有擁護者。我自己的偏好是「小元件 co-locate、大元件集中」:小於 100 行的元件把測試放旁邊方便找,超過的集中到 __tests__/ 子資料夾;Vitest 3.x 對兩種位置都能正確解析,並支援 vitest.config.ts 的 test.include 設定:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "jsdom",
include: [
"src/**/*.{test,spec}.{ts,tsx}",
"src/**/__tests__/**/*.{ts,tsx}",
],
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.{test,spec}.{ts,tsx}", "src/test/**"],
},
},
});
這份設定把兩種測試位置(co-locate 與 __tests__/)都納入掃描,Vite 解析 @/ 路徑別名,jsdom 提供瀏覽器環境;coverage 用 v8 provider 跑 text 與 html 兩種報表,測試本體排除在覆蓋率統計之外。
最後是「命名一致性」。資料夾用單數還是複數?components/(複數)與 feature/(單數)混用是新團隊最常見的小摩擦。建議在 README 寫明:放東西的資料夾用複數(components/、hooks/、features/),表示「這裡裝了一組同類的東西」;業務範圍的資料夾用單數(booking/、customer/),表示「這是一個獨立的概念」。這個小約定能讓資深開發者看一眼就知道新檔案該放哪。
另一個常被忽略的細節是「package.json 的 scripts 區塊」。專案結構蓋好後,pnpm dev、pnpm build、pnpm test、pnpm typecheck、pnpm lint 五個指令應該在 scripts 裡齊全。新成員 clone 專案只要看 README 跑 pnpm install 與 pnpm dev 就能動起來,不必另外問人「怎麼跑測試」。CI 端則把同樣這五個指令排成獨立 step,依序執行、任一失敗就中斷 build。
預約管理系統從 Day 31 開始會大量引用今天定的這套結構:features/booking/ 裝預約相關的所有程式、features/customer/ 裝顧客相關的;明天進入 App Router 後,src/app/ 只放路由檔、共用元件依然在 src/components/、feature 內部的元件會跟著對應的 route 一起長大。
小結
今天我們把 React 專案的結構問題攤開來看。「以角色為主」的分層——app/、components/、features/、hooks/、lib/、api/、types/、styles/、test/——可以讓數百個檔案的專案維持秩序;feature 內部用 co-locate 結構保持內聚;路徑別名讓 import 路徑簡潔;Barrel Export 與 Lint 規則把「跨檔案」的不一致擋在 CI。我們也看到一個業務專屬 Hook、一個純函式工具、ESLint 9 flat config 的真實寫法,分別示範「feature 內部」、「lib/」、「eslint.config.js」三種角色的程式該長什麼樣。
對後端工程師來說,這套分層最直接的對應是:features/<name>/ 像是一個 bounded context、api/ 像 service 層、lib/ 像 util 層、components/ 像 view 層。一旦你接受這個比喻,把後端熟悉的依賴方向(controller → service → repository)套到 React 上就會非常自然,React 元件不該直接呼叫 api.ts、應該透過 Hook 或 props 串接;Hook 才是「React 版的 controller」,負責組裝狀態與副作用。這條原則在大型專案特別重要,少了它,前端很容易長出「每個元件都自己抓資料」的怪結構。
結語
明天,我們會正式進入「前端開發實戰」系列的下一個里程碑——Next.js 15 起步。我們會用 create-next-app 建立一個 App Router 專案,把 layout.tsx、page.tsx、loading.tsx、error.tsx 四個約定檔都寫成完整範本,搞懂 Server Component 與 Client Component 的邊界,並讓你看到這套檔案約定怎麼跟我們今天蓋好的分層結構共存。Day 17 結束時,你會有一個能跑、能加新頁面、能擴充巢狀路由的最小 Next.js 專案。
延伸資源
- React 官方「Thinking in React」結構建議:
https://react.dev/learn/thinking-in-react - Feature-Sliced Design 官方方法論(前端分層設計):
https://feature-sliced.design/ - TypeScript 官方 paths 設定說明:
https://www.typescriptlang.org/tsconfig#paths - Vite 7 官方 resolve.alias 與 tsconfig 相容性:
https://vite.dev/config/shared-options.html#resolve-alias - ESLint 9 flat config 官方範例:
https://eslint.org/docs/latest/use/configure/configuration-files
留言
張貼留言