iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

程式碼門診:診斷壞味道、開出重構處方系列 第 17 篇

蕭規曹隨的智慧 - 慣例優於設定 (Convention over Configuration)

  • 分享至 

  • xImage
  •  

簡單介紹

西漢初年,丞相蕭何制定了一整套治國法規。蕭何過世後,曹參接任相國,卻幾乎什麼都沒改,完全沿用蕭何留下的制度,史稱「蕭規曹隨」。有人笑曹參偷懶,但他心裡明白:蕭何的制度已經過千錘百鍊,貿然推翻重來,只會讓整個國家陷入混亂。

寫程式也是一樣的道理。當我們選擇了一個成熟的框架,框架其實已經幫我們做好了大量決定——檔案要放哪裡、路由怎麼對應、設定檔叫什麼名字。慣例優於設定(Convention over Configuration,簡稱 CoC) 這個原則告訴我們:與其為每個細節寫下顯式的設定,不如遵循框架的預設慣例;只有在真正需要「不一樣」的時候,才動手寫設定。

根據 Wikipedia 的說法,這個詞由 Ruby on Rails 框架的作者 David Heinemeier Hansson(DHH)提出,核心精神是:開發者只需要描述應用程式中「不符合慣例」的部分,其餘的一切交給框架的合理預設值(sensible defaults)處理。這讓開發者要做的決策大幅減少,也自然符合了我們先前談過的 DRY 精神——連「設定」這種重複的樣板都省下來了。

換句話說,遵循慣例省下的不只是那幾行設定碼,還有更昂貴的東西——團隊的溝通成本。當專案完全遵循框架慣例時,任何熟悉這個框架的開發者一打開專案就知道東西在哪裡;反之,每一條自創的規則,都是新成員必須額外學習、老成員必須反覆解釋的負擔。

在前端世界裡,Next.js 的檔案路由(File-based Routing) 就是 CoC 最經典的體現:把檔案放進 app/ 目錄,路由就自動成立,完全不需要註冊任何路由表。接下來我們就透過一個電商網站的例子,看看當團隊「不信任」框架慣例、堅持自己造輪子時,會付出什麼代價。

TypeScript 不好的範例

問題:無視檔案路由,自建一張手動維護的路由對照表

想像一個團隊用 Next.js(App Router)開發電商網站,卻覺得「路由應該集中管理才清楚」,於是繞過框架的檔案慣例,自己刻了一套路由系統:

// 不好的範例:無視框架慣例,自建路由對照表

// === 檔案一:config/routes.config.ts ===
// 問題:這張對照表需要「人工」維護,框架完全幫不上忙
import type { ComponentType } from 'react';
import { HomePage } from '@/components/pages/HomePage';
import { ProductListPage } from '@/components/pages/ProductListPage';
import { ProductDetailPage } from '@/components/pages/ProductDetailPage';
import { CartPage } from '@/components/pages/CartPage';

interface RouteConfig {
  path: string; // 問題:路徑是純字串魔法值,打錯字編譯器不會發現
  component: ComponentType<{ params: Record<string, string> }>;
  title: string;
}

export const routeTable: RouteConfig[] = [
  { path: '/', component: HomePage, title: '首頁' },
  { path: '/products', component: ProductListPage, title: '商品列表' },
  // 問題:動態參數得自創語法(:id),這套規則只存在於我們團隊的腦中,
  // 任何文件或框架都查不到
  { path: '/products/:id', component: ProductDetailPage, title: '商品明細' },
  { path: '/cart', component: CartPage, title: '購物車' },
];
// === 檔案二:app/[[...slug]]/page.tsx ===
// 問題:整個網站只剩一個進入點,Next.js 的路由系統形同虛設
import { notFound } from 'next/navigation';
import { routeTable } from '@/config/routes.config';

// 問題:框架早就內建的「路徑比對」,我們自己重新發明了一次
// 而且這個陽春版本沒有處理路徑優先順序、URL 編碼字元等邊界情況
function matchRoute(pathname: string) {
  for (const route of routeTable) {
    const routeSegments = route.path.split('/').filter(Boolean);
    const pathSegments = pathname.split('/').filter(Boolean);

    if (routeSegments.length !== pathSegments.length) continue;

    const params: Record<string, string> = {};
    let isMatched = true;

    for (let i = 0; i < routeSegments.length; i++) {
      if (routeSegments[i].startsWith(':')) {
        // 問題:若之後有 /products/featured 這種靜態路徑,
        // 會因為對照表的「排列順序」被 /products/:id 搶先吃掉——
        // 這種 Bug 只能靠人工排順序來避免
        params[routeSegments[i].slice(1)] = pathSegments[i];
      } else if (routeSegments[i] !== pathSegments[i]) {
        isMatched = false;
        break;
      }
    }

    if (isMatched) return { ...route, params };
  }
  return null;
}

export default async function CatchAllPage({
  params,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const { slug } = await params;
  const pathname = '/' + (slug ?? []).join('/');

  const matched = matchRoute(pathname);
  if (!matched) notFound();

  // 問題:所有頁面元件都從同一個進入點載入,
  // 框架依路由自動分割程式碼的最佳化也一併失效
  const PageComponent = matched.component;
  return <PageComponent params={matched.params} />;
}
// === 檔案三:lib/navigation.ts ===
// 問題:為了讓站內連結有型別保護,又手刻了一份路徑聯集型別
// 新增頁面時,這裡是「第三個」必須記得修改的地方!
export type AppRoute = '/' | '/products' | `/products/${string}` | '/cart';

export function buildLink(route: AppRoute): string {
  return route;
}

問題分析

這套「集中管理」的路由系統,看起來井然有序,實際上問題重重:

  1. 新增一個頁面要改三個地方:假設現在要加一個結帳頁 /checkout,我們必須(一)建立 components/pages/CheckoutPage.tsx 元件、(二)到 routes.config.ts 註冊路由、(三)到 lib/navigation.ts 更新 AppRoute 聯集型別。忘記任何一處,輕則型別錯誤,重則使用者看到 404——這正是**散彈槍手術(Shotgun Surgery)**的典型症狀
  2. 重新發明輪子:matchRoute 這個手工路徑比對函式,框架內建的版本早已處理過路徑優先順序、動態參數、巢狀路由等無數邊界情況;我們的陽春版本,就是未來 Bug 的溫床
  3. 犧牲了框架紅利:所有頁面都擠在同一個 catch-all 進入點,Next.js 依路由自動進行的程式碼分割(Code Splitting)、以及 loading.tsx、error.tsx 等按路由掛載的慣例檔案,全部失效
  4. 溝通成本暴增::id 這套自創語法、對照表的排序規則,在任何官方文件裡都查不到。每位熟悉 Next.js 的新同事報到後,都得重新學一遍「我們家特有的路由規則」——框架知識歸零,部落知識滿點

這時候我們可以發現,這個團隊等於是付出三倍的維護成本,換來一個功能更少、Bug 更多的路由系統。框架幫我們做好的決定,被親手推翻了。

修正後範例

解法:回歸 app/ 目錄檔案慣例,路由自動成立

修正的方式出乎意料地簡單:把頁面放回框架約定的位置。根據 Next.js 官方文件的說法,app/ 目錄中的資料夾定義 URL 區段,只要某個資料夾裡出現 page.tsx,這個路由就對外公開——不需要任何註冊動作。

先看修正後的目錄結構:

app/
├── layout.tsx                → 全站共用版面(自動套用到所有頁面)
├── page.tsx                  → 路由 /
├── products/
│   ├── page.tsx              → 路由 /products
│   └── [id]/
│       ├── page.tsx          → 路由 /products/:id(動態路由)
│       └── loading.tsx       → 該頁專屬的載入中畫面
└── cart/
    └── page.tsx              → 路由 /cart

接著看各個頁面的實作:

// 修正範例:回歸 app/ 目錄檔案慣例,路由自動成立

// === app/page.tsx ===
// 改動重點 1:檔案放進 app/,路由「/」自動成立
// 不需要對照表、不需要註冊、不需要 matchRoute
export default function HomePage() {
  return <h1>歡迎光臨我們的商店</h1>;
}
// === app/products/page.tsx ===
// 改動重點 2:資料夾名稱就是 URL 區段,/products 路由自動成立
interface Product {
  id: string;
  name: string;
  price: number;
}

async function fetchProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products');
  if (!res.ok) {
    throw new Error('無法取得商品列表');
  }
  return res.json();
}

export default async function ProductListPage() {
  const products = await fetchProducts();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.name} - NT${product.price}
        </li>
      ))}
    </ul>
  );
}
// === app/products/[id]/page.tsx ===
// 改動重點 3:動態參數改用框架慣例 [id]——
// 這套語法寫在官方文件裡,全世界的 Next.js 開發者都看得懂
interface Product {
  id: string;
  name: string;
  price: number;
}

async function fetchProduct(id: string): Promise<Product> {
  const res = await fetch(`https://api.example.com/products/${id}`);
  if (!res.ok) {
    throw new Error(`無法取得商品 ${id} 的資料`);
  }
  return res.json();
}

interface ProductPageProps {
  params: Promise<{ id: string }>; // 框架自動解析出 id,型別清清楚楚
}

export default async function ProductDetailPage({ params }: ProductPageProps) {
  const { id } = await params;
  const product = await fetchProduct(id);

  return (
    <article>
      <h1>{product.name}</h1>
      <p>價格:NT${product.price}</p>
    </article>
  );
}
// === app/products/[id]/loading.tsx ===
// 改動重點 4:想要載入中的骨架畫面?按慣例放一個 loading.tsx 就好
// 在舊架構裡,這個功能得自己在 catch-all 進入點手刻 Suspense 邏輯
export default function Loading() {
  return <p>商品資料載入中...</p>;
}

少寫的那些設定碼

值得一提的是,這次重構最漂亮的地方,不是我們「寫了什麼」,而是我們「刪了什麼」:

  1. config/routes.config.ts 整份刪除:路由對照表與 RouteConfig 介面不再需要——目錄結構本身就是路由表
  2. matchRoute 函式(約 30 行)刪除:路徑比對、動態參數解析、優先順序判斷,全部交還給框架
  3. app/[[...slug]]/page.tsx catch-all 進入點刪除:每個頁面各自獨立,程式碼分割自動恢復運作
  4. lib/navigation.ts 的 AppRoute 聯集型別刪除:若想要站內連結的型別保護,Next.js 提供了 typedRoutes 選項,能依照目錄結構自動為 <Link> 產生型別檢查——連這份型別都不必手工維護

現在要新增結帳頁 /checkout,只需要建立 app/checkout/page.tsx 一個檔案,完成。從「改三個地方」變成「改一個地方」,而且這個地方的位置,任何看過 Next.js 官方文件的人都猜得到。

改進重點說明

  1. 結構即路由:資料夾層級直接對應 URL 區段,page.tsx 出現路由就成立;「頁面在哪裡」與「網址長怎樣」永遠一致,不會有對照表與實際檔案不同步的問題
  2. 慣例語法有文件可查:[id] 動態區段、[...slug] 萬用區段、(group) 路由群組,這些慣例全部寫在官方文件裡;相較之下,自創的 :id 語法只存在於團隊的腦中
  3. 框架紅利全數回收:layout.tsx 共用版面、loading.tsx 載入畫面、error.tsx 錯誤邊界,都按慣例放置檔案即可啟用,且各路由自動做程式碼分割
  4. 溝通成本歸零:新同事第一天就能憑框架知識找到任何頁面——因為我們的專案結構,跟他過去看過的每一個 Next.js 專案長得一模一樣

這時候我們可以發現,「蕭規曹隨」不是偷懶,而是一種成本意識:框架的慣例是全世界開發者用無數專案驗證過的決定,跟隨它,我們就能把力氣花在真正屬於自己業務的邏輯上。

總結

綜合以上所述,我們成功避免了「為了集中管理而自建路由系統」的陷阱,把三個必須同步維護的檔案,收斂成框架慣例下的單一目錄結構:

  1. 慣例優於設定的核心:只描述「不符合慣例」的部分,其餘交給框架的合理預設值;決策變少了,樣板設定碼也變少了
  2. 省下的不只是程式碼:遵循慣例最大的紅利是溝通成本——框架慣例是「可以 Google 到的知識」,自創規則是「只能問資深同事的知識」
  3. 慣例是踩坑的結晶:檔案路由如何處理優先順序、動態參數、程式碼分割,框架作者與社群早已替我們踩過所有的坑;自己重刻一套,等於把這些坑重新踩一遍
  4. 偏離慣例前先自問:我的需求真的特殊到框架無法滿足嗎?還是只是「感覺這樣比較整齊」?

不過需要注意的是,這個原則並不是要我們無腦地拒絕一切設定。在 Rakesh Kumar 討論 CoC 的文章中就提醒:像「多環境的 API 端點管理」(開發、測試、正式環境各自不同的網址)這類需求,顯式的設定反而更清楚、更可控——因為這些資訊本來就沒有放諸四海皆準的慣例可循。換句話說,成熟的做法是混合式的:框架有慣例的地方跟隨慣例,業務上真正需要差異化的地方才寫設定。

最後還有一個很容易被忽略的提醒:當你的團隊不得不建立「自訂慣例」時,請務必把它寫下來。框架慣例之所以便宜,是因為官方文件替我們承擔了說明的責任;而自訂慣例若沒有文件,就會退化成部落知識(Tribal Knowledge)——只存在於幾位資深成員腦中、靠口耳相傳的規則。一旦這些人離開,規則就成了無人能解的謎。查得到文件的慣例是資產,查不到文件的慣例是負債。

下次當你想推翻框架的預設做法之前,不妨先想想曹參:蕭何的規矩他不是不能改,而是他知道——已經被驗證過的決定,本身就是一種價值。

參考資料

上一篇
擁抱重複,直到看清全貌 - AHA Programming (Avoid Hasty Abstractions)
下一篇
程式碼裡的神祕數字 - 魔術數字 (Magic Numbers)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言