Blog

/

Frontend

Next.js 網頁架構設計

Next.js .env

Posted at

2024年10月19日

Posted on

Frontend

這篇會用一個 mid level 的視角來分享我眼中良好的 Next.js 網頁架構原則,作者尚未到 Architect 的程度,希望未來技術成長到那一天時,Next.js 仍然受主流歡迎,屆時會記得回來更新。一個好的架構設計不是為了好看,是為了讓下週的自己不會大罵:幹 @#%# 我上禮拜這是什麼鬼。今天我們聊怎麼設計 Next.js 項目的檔案結構。

Colocation: 把相關的東西放在一起

一個新手常見的錯誤是按檔案類型分類,所有組件放一個資料夾,所有工具函數放另一個。

src/
  components/
    ProductCard.tsx
    ProductList.tsx
    CartButton.tsx
  utils/
    formatPrice.ts
    validateProduct.ts
  hooks/
    useCart.ts
    useProduct.ts
  types/
    product.ts
    cart.ts

這樣的結構看起來很整齊,但實際上沒用。ProductCard 用 formatPrice、useCart、product.ts,這些檔案散落在四個地方。改 ProductCard 時要同時改五個檔案,更好的做法是 Colocation,把相關的檔案放在一起。

app/
  products/
    [id]/
      page.tsx           # 詳情頁
      ProductDetail.tsx  # Server Component
      layout.tsx
    ProductCard.tsx      # Card 組件
    useProduct.ts        # Hook
    types.ts             # Types
    page.tsx             # 列表頁
    layout.tsx

ProductCard 所有相關的東西都在 products 資料夾。需要修改時可以很快找到所有需要的檔案,新來的工程師也能快速理解「products 資料夾裡是所有和產品相關的邏輯」。

這個原則叫做 colocation,就是把功能相關的東西放在一起。大型專案裡,這個決策能減少一半的溝通成本。

核心業務邏輯單獨抽出

但 colocation 有個限制。當多個功能都用同一個工具函數,你該怎麼放?比如 formatPrice 被 ProductCard 和 OrderItem 都用。這時候不該 colocation,應該把這種共享邏輯單獨放一個地方。

app/
  lib/
    formatting/
      formatPrice.ts
      formatDate.ts
      formatCurrency.ts
    validation/
      validateEmail.ts
      validateProduct.ts
    database/
      queries.ts       # Shared DB queries
      schema.ts
  products/
    ProductCard.tsx
    useProduct.ts
  orders/
    OrderItem.tsx
    useOrder.ts

lib 資料夾是專門放共享邏輯,但不要把所有東西都扔進去。如果某個工具只被一個功能用,還是應該 colocation。

判斷標準很簡單:這個檔案被多少個地方 import?一個就 colocation,兩個以上就丟進 lib。

環境變數管理

很多人的環境變數長這樣。

.env.local
NEXT_PUBLIC_API_URL=http://localhost:3000
API_KEY=secret123
DATABASE_URL=postgres://...

這樣其實也不太對。環境變數很容易出錯,型別也沒保護。你改了一個環境變數的名稱,JavaScript 代碼裡仍然用老名稱,只有到了生產環境才會炸。應該在一個地方集中驗證和類型化所有環境變數比較好。

// lib/env.ts
const envSchema = {
  NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
  API_KEY: process.env.API_KEY,
  DATABASE_URL: process.env.DATABASE_URL,
  JWT_SECRET: process.env.JWT_SECRET,
}

type Env = typeof envSchema

const env: Env = {
  NEXT_PUBLIC_API_URL: envSchema.NEXT_PUBLIC_API_URL,
  API_KEY: validateRequired(envSchema.API_KEY, 'API_KEY'),
  DATABASE_URL: validateRequired(envSchema.DATABASE_URL, 'DATABASE_URL'),
  JWT_SECRET: validateRequired(envSchema.JWT_SECRET, 'JWT_SECRET'),
}

function validateRequired(value: string | undefined, name: string): string {
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`)
  }
  return value
}

export default env

然後在 code 裡用 env 物件。

// app/api/products/route.ts
import env from '@/lib/env'

export async function GET() {
  const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/products`, {
    headers: {
      Authorization: `Bearer ${env.API_KEY}`
    }
  })
  return response
}

好處是什麼?TypeScript 會提示你所有可用的環境變數,改錯了名稱編譯時就會爆。啟動時如果缺少必需的環境變數,會立刻失敗,而不是等到運行時才炸。

平鋪 vs 分層

小項目用平鋪 app 資料夾的結構。

app/
  page.tsx
  layout.tsx
  products/
    page.tsx
    [id]/page.tsx
  orders/
    page.tsx
  api/
    cart/route.ts
    orders/route.ts

每個業務模組一個資料夾,資料夾裡放 page.tsx、layout.tsx、components、hooks、types。

但當專案變大,比如有 20+ 個頁面,這個結構會很容易變亂。這時候考慮分層。

app/
  (marketing)/          # 行銷頁面
    page.tsx
    about/page.tsx
    blog/[slug]/page.tsx
  (dashboard)/          # 需要登入的功能
    layout.tsx          # 檢查登入狀態
    products/page.tsx
    orders/page.tsx
  (admin)/              # 管理員功能
    layout.tsx          # 檢查管理員權限
    users/page.tsx
    analytics/page.tsx
  api/
    auth/route.ts
    admin/route.ts

(marketing)、(dashboard)、(admin) 是 Route Groups,用來組織相關的頁面,但不會影響 URL。不像 /products,Route Group 不會出現在 URL 裡。

這樣的好處是可以給不同的模組應用不同的 layout,dashboard 區塊的所有頁面都會經過 (dashboard)/layout.tsx,裡面可以檢查登入狀態。admin 區塊也是。

共享組件怎麼放

共享 UI 組件放在 components 資料夾是合理的。但要分類。

components/
  ui/                  # 無業務邏輯的基礎組件
    Button.tsx
    Input.tsx
    Card.tsx
    Modal.tsx
  products/           # 產品相關的複合組件
    ProductCard.tsx
    ProductList.tsx
    ProductFilters.tsx
  common/             # 頻繁用到的複合組件
    Header.tsx
    Footer.tsx
    Navigation.tsx

ui 裡面的組件完全沒有業務邏輯,只是樣式和互動,其他組件可以自由複用。

products 資料夾裡的組件和產品業務邏輯綁定,只在產品相關頁面用。

common 是在多個頁面出現的複合組件,比如導航欄。

資料庫查詢的組織

如果你用 Prisma 或其他 ORM,查詢邏輯也要集中管理。

lib/
  db/
    products.ts
    orders.ts
    users.ts

// lib/db/products.ts
import { prisma } from '@/lib/prisma'

export async function getProduct(id: string) {
  return prisma.product.findUnique({ where: { id } })
}

export async function getProducts(limit: number = 10) {
  return prisma.product.findMany({ take: limit })
}

export async function createProduct(data: ProductInput) {
  return prisma.product.create({ data })
}

所有產品相關的查詢都在 products.ts。需要改查詢邏輯時,改一個地方就行。

然後在 Server Components 或 API Routes 裡直接用。

// app/products/page.tsx
import { getProducts } from '@/lib/db/products'

export default async function ProductsPage() {
  const products = await getProducts()
  // ...
}

實際的項目結構

綜合以上考慮,一個中等規模專案的結構應該是這樣,會是一個比較易讀好維護的結構。

app/
  (marketing)/
    page.tsx
    about/page.tsx
    layout.tsx
  (dashboard)/
    products/
      page.tsx
      [id]/page.tsx
      ProductCard.tsx
      useProduct.ts
    orders/page.tsx
    layout.tsx           # 登入檢查
  api/
    auth/route.ts
    products/route.ts
components/
  ui/
    Button.tsx
    Input.tsx
  common/
    Header.tsx
lib/
  db/
    products.ts
    orders.ts
  formatting/
    formatPrice.ts
  env.ts
  prisma.ts
public

避免的陷阱

不要建立過度深的資料夾結構。app/features/products/components/cards/ProductCard.tsx 這種太深了,改個檔案要點十次 WTF

不要把所有東西都 colocation,有時候確實需要集中管理,判斷標準是複用度。

不要把業務邏輯混在 UI 組件裡。如果組件有複雜的邏輯,提出去放在 hooks 或 lib 裡。

不要硬編碼環境變數,記得用 env 記得用 env 記得用 env !

我的建議

新專案一開始用簡單的平鋪結構,當頁面增長到 20 個頁面以上時,考慮加入 Route Groups。

共享邏輯馬上就 colocation,一開始就對,後面要複用了再提到 lib。

環境變數從第一天就用驗證的 env 物件。

下次來聊 TypeScript 在 Next.js 中的應用,以及如何最大化開發體驗。