西漢初年,丞相蕭何制定了一整套治國法規。蕭何過世後,曹參接任相國,卻幾乎什麼都沒改,完全沿用蕭何留下的制度,史稱「蕭規曹隨」。有人笑曹參偷懶,但他心裡明白:蕭何的制度已經過千錘百鍊,貿然推翻重來,只會讓整個國家陷入混亂。
寫程式也是一樣的道理。當我們選擇了一個成熟的框架,框架其實已經幫我們做好了大量決定——檔案要放哪裡、路由怎麼對應、設定檔叫什麼名字。慣例優於設定(Convention over Configuration,簡稱 CoC) 這個原則告訴我們:與其為每個細節寫下顯式的設定,不如遵循框架的預設慣例;只有在真正需要「不一樣」的時候,才動手寫設定。
根據 Wikipedia 的說法,這個詞由 Ruby on Rails 框架的作者 David Heinemeier Hansson(DHH)提出,核心精神是:開發者只需要描述應用程式中「不符合慣例」的部分,其餘的一切交給框架的合理預設值(sensible defaults)處理。這讓開發者要做的決策大幅減少,也自然符合了我們先前談過的 DRY 精神——連「設定」這種重複的樣板都省下來了。
換句話說,遵循慣例省下的不只是那幾行設定碼,還有更昂貴的東西——團隊的溝通成本。當專案完全遵循框架慣例時,任何熟悉這個框架的開發者一打開專案就知道東西在哪裡;反之,每一條自創的規則,都是新成員必須額外學習、老成員必須反覆解釋的負擔。
在前端世界裡,Next.js 的檔案路由(File-based Routing) 就是 CoC 最經典的體現:把檔案放進 app/ 目錄,路由就自動成立,完全不需要註冊任何路由表。接下來我們就透過一個電商網站的例子,看看當團隊「不信任」框架慣例、堅持自己造輪子時,會付出什麼代價。
想像一個團隊用 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;
}
這套「集中管理」的路由系統,看起來井然有序,實際上問題重重:
/checkout,我們必須(一)建立 components/pages/CheckoutPage.tsx 元件、(二)到 routes.config.ts 註冊路由、(三)到 lib/navigation.ts 更新 AppRoute 聯集型別。忘記任何一處,輕則型別錯誤,重則使用者看到 404——這正是**散彈槍手術(Shotgun Surgery)**的典型症狀matchRoute 這個手工路徑比對函式,框架內建的版本早已處理過路徑優先順序、動態參數、巢狀路由等無數邊界情況;我們的陽春版本,就是未來 Bug 的溫床loading.tsx、error.tsx 等按路由掛載的慣例檔案,全部失效:id 這套自創語法、對照表的排序規則,在任何官方文件裡都查不到。每位熟悉 Next.js 的新同事報到後,都得重新學一遍「我們家特有的路由規則」——框架知識歸零,部落知識滿點這時候我們可以發現,這個團隊等於是付出三倍的維護成本,換來一個功能更少、Bug 更多的路由系統。框架幫我們做好的決定,被親手推翻了。
修正的方式出乎意料地簡單:把頁面放回框架約定的位置。根據 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>;
}
值得一提的是,這次重構最漂亮的地方,不是我們「寫了什麼」,而是我們「刪了什麼」:
config/routes.config.ts 整份刪除:路由對照表與 RouteConfig 介面不再需要——目錄結構本身就是路由表matchRoute 函式(約 30 行)刪除:路徑比對、動態參數解析、優先順序判斷,全部交還給框架app/[[...slug]]/page.tsx catch-all 進入點刪除:每個頁面各自獨立,程式碼分割自動恢復運作lib/navigation.ts 的 AppRoute 聯集型別刪除:若想要站內連結的型別保護,Next.js 提供了 typedRoutes 選項,能依照目錄結構自動為 <Link> 產生型別檢查——連這份型別都不必手工維護現在要新增結帳頁 /checkout,只需要建立 app/checkout/page.tsx 一個檔案,完成。從「改三個地方」變成「改一個地方」,而且這個地方的位置,任何看過 Next.js 官方文件的人都猜得到。
page.tsx 出現路由就成立;「頁面在哪裡」與「網址長怎樣」永遠一致,不會有對照表與實際檔案不同步的問題[id] 動態區段、[...slug] 萬用區段、(group) 路由群組,這些慣例全部寫在官方文件裡;相較之下,自創的 :id 語法只存在於團隊的腦中layout.tsx 共用版面、loading.tsx 載入畫面、error.tsx 錯誤邊界,都按慣例放置檔案即可啟用,且各路由自動做程式碼分割這時候我們可以發現,「蕭規曹隨」不是偷懶,而是一種成本意識:框架的慣例是全世界開發者用無數專案驗證過的決定,跟隨它,我們就能把力氣花在真正屬於自己業務的邏輯上。
綜合以上所述,我們成功避免了「為了集中管理而自建路由系統」的陷阱,把三個必須同步維護的檔案,收斂成框架慣例下的單一目錄結構:
不過需要注意的是,這個原則並不是要我們無腦地拒絕一切設定。在 Rakesh Kumar 討論 CoC 的文章中就提醒:像「多環境的 API 端點管理」(開發、測試、正式環境各自不同的網址)這類需求,顯式的設定反而更清楚、更可控——因為這些資訊本來就沒有放諸四海皆準的慣例可循。換句話說,成熟的做法是混合式的:框架有慣例的地方跟隨慣例,業務上真正需要差異化的地方才寫設定。
最後還有一個很容易被忽略的提醒:當你的團隊不得不建立「自訂慣例」時,請務必把它寫下來。框架慣例之所以便宜,是因為官方文件替我們承擔了說明的責任;而自訂慣例若沒有文件,就會退化成部落知識(Tribal Knowledge)——只存在於幾位資深成員腦中、靠口耳相傳的規則。一旦這些人離開,規則就成了無人能解的謎。查得到文件的慣例是資產,查不到文件的慣例是負債。
下次當你想推翻框架的預設做法之前,不妨先想想曹參:蕭何的規矩他不是不能改,而是他知道——已經被驗證過的決定,本身就是一種價值。