
前言:技術不是憑空出現的
你有沒有想過,為什麼我們要用 React?為什麼 async/await 會被發明?為什麼有人覺得 REST 不夠好,還要搞出 GraphQL?
很多人學技術的時候,只學「怎麼用」,卻不問「為什麼會有這個東西」。結果就是,面對技術選型的時候毫無判斷力——什麼火用什麼,別人用什麼就跟著用什麼。
事實上,軟體工程的歷史就是一部「痛點驅動」的演進史。每一個新工具、新框架、新模式的誕生,幾乎都是因為上一代方案有著讓人無法忍受的問題。 理解了這條演進鏈,你就能明白每個技術「解決了什麼」和「犧牲了什麼」,從而在面對選擇時做出真正有根據的判斷。
這篇文章不教你怎麼寫程式碼。它講的是故事——技術演進的故事。
graph TD subgraph DOM["DOM 操作"] A1["原生 JS"] -->|"跨瀏覽器地獄"| A2["jQuery"] A2 -->|"Spaghetti Code"| A3["Angular / React / Vue"] A3 -->|"Component Model 勝出"| A4["Component-Based Architecture"] end subgraph ARCH["前端架構"] B1["MPA"] -->|"整頁重新載入"| B2["SPA"] B2 -->|"SEO / 首屏效能差"| B3["SSR (Next.js / Nuxt)"] B3 -->|"JS 太多"| B4["Islands (Astro)"] end subgraph ASYNC["非同步處理"] C1["Callback"] -->|"Callback Hell"| C2["Promise"] C2 -->|".then() 太冗長"| C3["async/await"] end subgraph CSS["CSS 演進"] D1["原生 CSS"] -->|"沒有變數/巢狀"| D2["Sass / Less"] D2 -->|"仍然全域汙染"| D3["CSS-in-JS"] D3 -->|"Runtime 成本"| D4["Tailwind / CSS Modules"] end subgraph STATE["狀態管理"] E1["Global Variables"] -->|"混亂"| E2["Flux"] E2 -->|"概念正確但囉唆"| E3["Redux"] E3 -->|"Boilerplate 太重"| E4["Zustand / Pinia / Jotai"] E4 --> E5["React Query (Server State)"] end subgraph API["API 設計"] F1["SOAP"] -->|"太笨重"| F2["REST"] F2 -->|"Over/Under Fetching"| F3["GraphQL"] F3 -->|"Schema 太複雜"| F4["tRPC"] end subgraph DEPLOY["部署"] G1["FTP"] -->|"手動出錯"| G2["CI/CD"] G2 -->|"環境不一致"| G3["Docker"] G3 -->|"還要管 Server"| G4["Serverless"] end
1. DOM 操作的演進:原生 JS → jQuery → 框架
原生 DOM 時代的黑暗歲月
如果你在 2005 年寫前端,你面對的是一個令人崩潰的世界。
光是選取一個 DOM 元素,你就要寫出這樣的程式碼:
// 想要選取 class 為 "item" 的元素?
// 對不起,IE6 不支援 getElementsByClassName
var items = [];
var allElements = document.getElementsByTagName('*');
for (var i = 0; i < allElements.length; i++) {
if (allElements[i].className === 'item') {
items.push(allElements[i]);
}
}而且這還只是冰山一角。每個瀏覽器對 DOM API 的實作都不一樣。IE 有 attachEvent,其他瀏覽器用 addEventListener。IE 的 event 物件在 window.event 上,其他瀏覽器透過參數傳遞。CSS 的 opacity 在 IE 裡要用 filter: alpha(opacity=50)。
這就是所謂的「瀏覽器戰爭」時代。每寫一個功能,你都要寫三份甚至四份不同的程式碼來相容不同的瀏覽器。前端工程師的大量時間不是花在建構產品功能上,而是花在「讓它在 IE6 上也能跑」這件事上。
jQuery:救世主降臨
2006 年,John Resig 發佈了 jQuery。它做了一件偉大的事:把所有瀏覽器差異包裝起來,提供統一的 API。
// 原本需要十幾行的程式碼,現在一行搞定
$('.item').addClass('active').fadeIn(300);
// AJAX 請求?也是一行
$.get('/api/users', function(data) {
$('#user-list').html(data);
});
// 事件綁定?再也不用管 attachEvent 還是 addEventListener
$('#btn').on('click', function() {
$(this).toggleClass('active');
});jQuery 的 $() selector 讓 DOM 操作變得極其簡潔。鏈式呼叫(chaining)讓程式碼讀起來像在念句子。動畫 API 讓你不需要寫原生 CSS transition 就能做出流暢的效果。跨瀏覽器問題?jQuery 幫你全部處理掉了。
在它的鼎盛時期,全球超過 70% 的網站都在使用 jQuery。它不是一個框架——它是前端的基礎設施。
jQuery 的痛點:當程式碼開始失控
但隨著 Web 應用程式變得越來越複雜,jQuery 的問題開始浮現。
想像一個中等複雜度的單頁應用——一個有搜尋、篩選、分頁、即時更新的產品列表頁面。在 jQuery 的世界裡,你的程式碼大概會長這樣:
// 某個功能的 jQuery 程式碼
$('#search-btn').on('click', function() {
var keyword = $('#search-input').val();
$.get('/api/products?q=' + keyword, function(data) {
$('#product-list').empty();
data.forEach(function(product) {
$('#product-list').append(
'<div class="product">' +
'<h3>' + product.name + '</h3>' +
'<p>$' + product.price + '</p>' +
'<button class="add-to-cart" data-id="' + product.id + '">加入購物車</button>' +
'</div>'
);
});
// 重新綁定事件,因為 DOM 是新建的
$('.add-to-cart').off('click').on('click', function() {
var id = $(this).data('id');
addToCart(id);
updateCartCount();
updateCartTotal();
// ...更多手動更新 DOM 的程式碼
});
});
});這就是所謂的「spaghetti code」——義大利麵條式程式碼。資料邏輯、DOM 操作、事件處理全部攪在一起。沒有元件的概念、沒有資料流的概念、沒有生命週期的概念。你手動操作 DOM,手動保持畫面和資料的同步,手動管理事件監聽器的綁定與解除。
當應用程式長到一定規模,沒有人能搞清楚哪段程式碼改了哪段 DOM、資料狀態到底存在哪裡、為什麼這個按鈕點了沒反應。
框架的誕生:用不同的思維解決問題
2010 年代初期,三個框架幾乎同時出現,它們各自用不同的方式回應了同一個核心問題:如何讓 UI 和資料自動保持同步?
Angular(2010) 提出了 two-way data binding。資料改了,畫面自動更新;使用者在畫面上輸入了東西,資料也自動更新。聽起來很美好,但雙向綁定在複雜應用中會導致難以追蹤的資料流向——你不知道是誰改了這個值。
React(2013) 提出了完全不同的思路:Virtual DOM + 單向資料流。它說,別想著怎麼「操作」DOM 了——你只要告訴我「畫面應該長什麼樣」,我來幫你算出最小的 DOM 更新。Component 的概念讓你可以把 UI 拆成獨立、可重用的積木塊。
Vue(2014) 走了一條漸進式的路線。它的學習曲線比 Angular 平緩、比 React 更接近傳統的 HTML 思維。它證明了框架不一定要一步到位,可以從一個小功能開始,逐步導入更多功能。
最終,不管選擇哪個框架,業界達成了一個共識:Component-based architecture 是正確的方向。 UI 就是一棵元件樹,每個元件管理自己的狀態和渲染邏輯。這個思維模型至今仍然主宰著整個前端生態系。
2. 前端架構的演進:MPA → SPA → SSR → Islands
MPA:最初的模樣
在 Web 的最初年代,所有的頁面都是 Multi-Page Application(MPA)。使用者點一個連結,瀏覽器就向伺服器發一個請求,伺服器把整頁 HTML 渲染好丟回來,瀏覽器再整頁重新載入。
這個模式簡單直覺。SEO 天然就好——爬蟲看到的就是完整的 HTML。後端掌握所有邏輯,前端只負責呈現。
但使用者體驗?每次操作都要整頁重載,白色閃爍,狀態丟失。當 Gmail 在 2004 年展示了不需要整頁重載就能操作電子郵件的時候,人們意識到:Web 可以像原生應用一樣流暢。
SPA:前端的黃金年代
Single-Page Application 的概念隨著 Angular 和 React 的普及而爆發。整個應用只有一個 HTML 頁面,所有的路由切換和畫面更新都在客戶端完成,資料透過 API 從後端取得。
SPA 帶來了絲滑的使用者體驗。頁面切換瞬間完成,沒有白色閃爍。豐富的互動成為可能——拖放、即時預覽、複雜的表單流程。前後端也因此真正分離——後端只提供 API,前端獨立部署。
但 SPA 很快暴露出自己的問題。首先是 SEO——搜尋引擎爬蟲拿到的只是一個空的 <div id="root"></div>,看不到任何內容。其次是首屏載入效能——使用者要先下載一大包 JavaScript,等它執行完畢,畫面才會出現。在低端裝置或網路不好的環境,使用者可能要盯著白螢幕好幾秒。
更深層的問題是:我們把所有渲染工作都推給了客戶端。伺服器明明有強大的運算能力,卻只被當成 JSON API 使用,這合理嗎?
SSR:回到伺服器,但帶著新武器
Next.js(2016)和 Nuxt.js 的出現,代表了業界的集體反思:也許我們不該完全放棄伺服器端渲染。
SSR 的做法是:第一次請求由伺服器渲染好完整 HTML 發回瀏覽器(解決 SEO 和首屏問題),然後在客戶端「hydrate」——讓 JavaScript 接手互動功能(保留 SPA 的流暢體驗)。
這看似完美的折中方案,但也有代價。伺服器的負擔增加了、部署變得更複雜了、開發者需要思考哪些程式碼在伺服器跑、哪些在客戶端跑。Hydration 本身也有效能問題——瀏覽器需要重新執行一遍 JavaScript 來「啟動」那些已經渲染好的 HTML。
Islands Architecture:只 hydrate 需要互動的部分
Astro(2021)提出了一個更激進的想法:為什麼整個頁面都要 hydrate?大部分內容根本不需要互動。
Islands Architecture 的核心概念是「部分 hydration」。一個頁面中,靜態內容(標題、段落、圖片)就保持純 HTML,不需要任何 JavaScript。只有真正需要互動的部分(搜尋框、動態表單、互動圖表)才載入和執行 JavaScript。
這大幅減少了傳送到客戶端的 JavaScript 量。一個以內容為主的網站,可能只需要幾 KB 的 JavaScript 而不是幾百 KB。
每一次架構的演進,都是在 效能(performance)、互動性(interactivity) 和 開發者體驗(developer experience) 之間尋找新的平衡點。沒有哪個方案是萬能的——選擇取決於你的產品需求。
3. 非同步處理的演進:Callback → Promise → async/await
Callback Hell:巢狀地獄
JavaScript 是單執行緒的語言,但它需要處理大量的非同步操作:網路請求、檔案讀取、計時器。最初,JavaScript 使用 callback 來處理非同步——你傳一個函式進去,等操作完成後再呼叫它。
聽起來很合理,直到你需要依序執行多個非同步操作:
// 先登入,再取得使用者資料,再取得訂單列表,再取得訂單詳情
login(username, password, function(err, token) {
if (err) {
handleError(err);
return;
}
getUser(token, function(err, user) {
if (err) {
handleError(err);
return;
}
getOrders(user.id, function(err, orders) {
if (err) {
handleError(err);
return;
}
getOrderDetail(orders[0].id, function(err, detail) {
if (err) {
handleError(err);
return;
}
// 終於拿到資料了...
renderOrderDetail(detail);
});
});
});
});這就是臭名昭著的「Callback Hell」,也叫「Pyramid of Doom」(末日金字塔)。程式碼不斷向右縮排,形成一個三角形。每一層都要重複寫 error handling。邏輯分散在各個 callback 裡,難以理解整體流程。更糟的是,如果你想在中間加入條件判斷或平行處理,程式碼會變得更加混亂。
Promise:鏈式的救贖
ES2015(ES6)正式引入了 Promise。它的核心思想是:把非同步操作包裝成一個「承諾」物件,這個物件有統一的介面來處理成功和失敗。
login(username, password)
.then(token => getUser(token))
.then(user => getOrders(user.id))
.then(orders => getOrderDetail(orders[0].id))
.then(detail => renderOrderDetail(detail))
.catch(err => handleError(err));Promise 解決了幾個關鍵問題。鏈式呼叫(chaining)讓程式碼不再無限向右縮排。.catch() 統一了錯誤處理——鏈條中任何一步出錯,都會被最後的 .catch() 捕獲。Promise.all() 讓平行執行多個非同步操作變得優雅。
但 Promise 也有自己的不完美。.then() 的鏈條一長,仍然不太容易閱讀。如果你需要在中間某一步用到前面幾步的結果,就要把值一層層傳下去,或者使用外部變數。Promise 本質上還是一種特殊的語法,它和我們習慣的同步式寫法有著明顯的差距。
async/await:讓非同步看起來像同步
ES2017 引入的 async/await 是建立在 Promise 之上的語法糖。它做了一件看似簡單卻意義深遠的事:讓非同步程式碼看起來和同步程式碼一模一樣。
async function getOrderDetail() {
try {
const token = await login(username, password);
const user = await getUser(token);
const orders = await getOrders(user.id);
const detail = await getOrderDetail(orders[0].id);
renderOrderDetail(detail);
} catch (err) {
handleError(err);
}
}同樣的邏輯,讀起來就像是一行一行順序執行的程式碼。try/catch 取代了 .catch(),與同步的錯誤處理方式完全一致。中間步驟的結果可以直接用變數接住,後面隨時取用。
今天,async/await 已經是處理非同步的絕對標準。但請記住:它不是魔法,底層仍然是 Promise,而 Promise 底層仍然是 callback 機制。每一層抽象都沒有消除上一層,而是在上面蓋了一個更友善的介面。
4. CSS 的演進:原生 CSS → Preprocessors → CSS-in-JS → Utility-first
原生 CSS:簡單但原始
CSS 在設計之初是為了「文件樣式」服務的,而不是「應用程式 UI」。當 Web 應用的複雜度急劇增長,CSS 的設計缺陷就暴露無遺了。
沒有變數——同一個品牌色 #3b82f6 可能散落在一百個地方,改一次色要全域搜尋替換。沒有巢狀——寫 .sidebar .nav .item .link 這樣的選擇器時,每一層都要完整重寫。沒有作用域——所有 CSS 都是全域的,你在某個頁面寫了 .title { color: red; },可能無意間影響了另一個頁面的 .title。
這些問題在小型專案中還能忍受。但當團隊有十幾個工程師、CSS 檔案有上萬行的時候,維護就變成了噩夢。沒有人敢隨便改 CSS,因為你不知道改了之後會影響到哪裡。
Sass/Less:給 CSS 加上程式語言的能力
2006 年 Sass 出現、2009 年 Less 跟進。它們不是新語言,而是 CSS 的「預處理器」——你用它們的語法寫樣式,然後編譯成標準 CSS。
// Sass:終於有變數了!
$primary: #3b82f6;
$spacing: 8px;
// 終於有巢狀了!
.sidebar {
width: 260px;
.nav {
padding: $spacing;
.item {
color: $primary;
&:hover {
color: darken($primary, 10%);
}
}
}
}
// 終於有 mixin 了!
@mixin responsive($breakpoint) {
@media (max-width: $breakpoint) {
@content;
}
}Sass 解決了語言層面的問題——變數、巢狀、mixin、函式、模組。寫 CSS 終於有了工程感。
但 Sass 並沒有解決 CSS 最根本的問題:全域作用域。 你的 .title 不管寫在哪個 .scss 檔案裡,編譯出來還是全域的 .title。命名衝突的噩夢依然存在,只是被 BEM 之類的命名規範稍微緩解了一點。
CSS-in-JS:把 CSS 寫在 JavaScript 裡
隨著 React 的元件化思維普及,一個大膽的想法出現了:既然 HTML 已經寫進了 JS(JSX),那 CSS 是不是也可以?
// styled-components
const Button = styled.button`
background: ${props => props.primary ? '#3b82f6' : 'white'};
color: ${props => props.primary ? 'white' : '#3b82f6'};
padding: 8px 16px;
border-radius: 4px;
&:hover {
opacity: 0.9;
}
`;
// 使用
<Button primary>送出</Button>CSS-in-JS 真正實現了作用域隔離——每個元件的樣式都是局部的,不會影響外部。動態樣式也變得自然——根據 props 或 state 動態改變樣式,不再需要手動切換 class。刪除元件時,樣式也隨之消失,不會留下「死 CSS」。
但代價是什麼?Runtime cost。 styled-components 在執行時期動態生成 CSS 並注入到 <style> 標籤中,這會消耗效能。bundle size 也變大了。在 SSR 場景中,CSS-in-JS 的處理也相對複雜。
Utility-first:Tailwind 的反叛
Tailwind CSS(2017)走了一條完全相反的路:不要寫 CSS class,直接在 HTML 上堆 utility class。
<button class="bg-blue-500 text-white px-4 py-2 rounded hover:opacity-90">
送出
</button>Tailwind 的擁護者說:不用起名字了(再也不用糾結這個 class 該叫 .btn-primary 還是 .button--main)、不用切換檔案了(不用在 JSX 和 CSS 之間跳來跳去)、不會有死 CSS 了(用到什麼 class 就打包什麼)。
反對者說:HTML 太醜了、可讀性很差、這不就是 inline style 的變體嗎?
CSS Modules:折中方案
CSS Modules 是另一條路線——你還是寫普通的 CSS(或 Sass),但構建工具會自動把 class 名稱改成唯一的 hash,實現作用域隔離,而且沒有 runtime cost。
/* Button.module.css */
.primary {
background: #3b82f6;
color: white;
}import styles from './Button.module.css';
// styles.primary 會被編譯成類似 "Button_primary_x7Ks2" 的唯一名稱
<button className={styles.primary}>送出</button>現在的真相是:CSS 領域沒有絕對的贏家。 Tailwind、CSS Modules、styled-components 都有大量的使用者。這反映了一個事實——不同的專案需求、團隊偏好、效能要求,適合不同的方案。最重要的是理解每個方案的 trade-off,而不是盲目站隊。
5. 狀態管理的演進:Global → Flux → Redux → 更輕量的方案
全域變數時代:混沌
在 jQuery 時代,「狀態管理」這個概念基本不存在。資料存在哪裡?哪裡方便存哪裡。
// 全域變數
window.currentUser = null;
window.cartItems = [];
window.isLoading = false;
// 或者存在 DOM 上
$('#product-123').data('quantity', 3);
$('#product-123').data('inCart', true);當應用程式只有幾個頁面的時候,這沒什麼問題。但當它長成一個複雜的 SPA,有幾十個元件需要共享資料的時候,你就會發現:沒有人知道資料是什麼時候被誰改的、沒有任何改動歷史可以追蹤、Bug 難以重現更難以修復。
Flux:Facebook 的反思
2014 年,Facebook 在處理他們的訊息通知 bug 時(那個著名的「幽靈通知」問題——通知顯示有未讀訊息但打開卻找不到),意識到 MVC 模式中的雙向資料流是問題的根源。一個 Model 的改變可能觸發另一個 Model 的改變,形成連鎖反應,最終沒有人搞得清楚發生了什麼。
Flux 提出了一個核心原則:單向資料流。
Action → Dispatcher → Store → View → Action → ...
所有的狀態改變都必須透過「發送 Action」來觸發,經過統一的 Dispatcher 處理,更新 Store 中的資料,然後 View 根據新的資料重新渲染。這讓資料的流向變得可預測——任何時候出了問題,你都能追蹤這個 Action 是誰發的、Store 的狀態是怎麼變化的。
Flux 的概念是正確的,但它的實作相當繁瑣——需要手動建立 Dispatcher、手動管理多個 Store、大量重複的樣板程式碼。
Redux:一個 Store 統治所有
Dan Abramov 在 2015 年創造了 Redux,它把 Flux 的概念精煉成三個原則:Single source of truth(一個 Store)、State is read-only(只能透過 Action 改變)、Changes are made with pure functions(Reducer 是純函式)。
Redux 帶來了強大的開發工具——Time-travel debugging 讓你可以「倒帶」應用程式的狀態、Middleware 機制讓非同步操作和副作用有了標準化的處理方式。
但 Redux 最被詬病的問題也很明顯:Boilerplate 太多了。 新增一個簡單的功能,你需要寫 Action Type、Action Creator、Reducer,可能還需要 Selector 和 Middleware。很多團隊發現他們花在寫 Redux 樣板碼的時間比寫業務邏輯還多。
更輕量的替代方案:同樣的概念,更少的儀式
近年來湧現了一批「後 Redux」狀態管理方案,它們保留了 Redux 的核心思想,但大幅減少了使用成本。
Zustand 讓你用幾行程式碼就能建立一個 Store,不需要 Provider、不需要 Action Creator、不需要 connect:
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// 在元件中直接使用
function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>{count}</button>;
}Pinia(Vue 生態)和 Jotai(原子化狀態)也遵循類似的「少即是多」哲學。
而更深層的反思來自 React Query / TanStack Query。它提出了一個振聾發聵的觀點:你以為的「前端狀態」,大部分其實是「伺服器狀態的快取」。 使用者列表、商品資料、訂單詳情——這些資料的真相在伺服器上,前端只是暫存了一份。React Query 專門處理這種伺服器狀態的取得、快取、同步和更新,釋放了開發者大量的心智負擔。
6. API 設計的演進:SOAP → REST → GraphQL → tRPC
SOAP:企業級的沉重
在 Web 2.0 之前,企業系統之間的通訊靠的是 SOAP(Simple Object Access Protocol)。但它一點都不 Simple。
SOAP 基於 XML,每個請求和回應都包裹在冗長的 XML envelope 裡。你需要先定義 WSDL(Web Services Description Language)來描述服務的介面,然後根據 WSDL 生成客戶端程式碼。整個流程厚重、複雜、充滿了企業級的儀式感。
<!-- 一個簡單的 SOAP 請求 -->
<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetUser xmlns="http://example.com/users">
<UserId>123</UserId>
</GetUser>
</soap:Body>
</soap:Envelope>對比同樣的操作用 REST 來做:GET /api/users/123。你就明白為什麼 SOAP 被淘汰了。
REST:簡潔的革命
Roy Fielding 在 2000 年的博士論文中提出了 REST(Representational State Transfer),但它真正流行要等到 Web 2.0 和 JSON 的普及。
REST 的美在於它的簡潔:用 HTTP 動詞表達操作(GET 讀取、POST 新增、PUT 更新、DELETE 刪除)、用 URL 路徑表達資源(/users/123/orders)、用 JSON 作為資料格式。它和 HTTP 協議天然契合,不需要額外的工具或協議。
REST 統治了 Web API 設計十多年,至今仍然是最被廣泛使用的 API 風格。但它的問題也隨著 SPA 和行動裝置的普及而越來越明顯。
REST 的痛點:Over-fetching 和 Under-fetching
假設你要顯示一個使用者的個人頁面,上面有基本資訊、最近的文章、和追蹤者列表。用 REST 你需要三個請求:
GET /api/users/123 → 完整的使用者資料(但你只需要名字和頭像)
GET /api/users/123/posts → 所有文章(但你只需要最新 5 篇的標題)
GET /api/users/123/followers → 所有追蹤者(但你只需要數量)
Over-fetching: 每個 endpoint 回傳的資料比你實際需要的多。使用者資料包含 email、地址、設定等你這個頁面用不到的欄位。Under-fetching: 一個 endpoint 的資料不夠,你需要發多個請求才能拼湊出一個畫面所需的完整資料。
在桌面端高速網路下,這些問題不算嚴重。但在行動裝置、在弱網環境、在需要極致效能的場景,多餘的資料傳輸和多次的網路往返就成了效能殺手。
GraphQL:讓客戶端決定要什麼
Facebook 在 2015 年公開了 GraphQL,它的核心理念是:客戶端精確指定自己需要的資料,伺服器只回傳這些資料。
# 一個請求搞定所有資料
query {
user(id: 123) {
name
avatar
posts(last: 5) {
title
}
followersCount
}
}一個請求、精確的資料、沒有多餘的欄位。客戶端完全掌控取得的資料結構,不需要後端為每個頁面建立專用的 endpoint。
但 GraphQL 也帶來了新的複雜性。Schema 定義和維護需要額外成本。HTTP 快取變得困難——因為所有請求都打到同一個 endpoint(POST /graphql)。N+1 query 問題需要 DataLoader 之類的工具來解決。學習曲線也比 REST 陡峭不少。
tRPC:全端 TypeScript 的極致
如果你的前後端都用 TypeScript,tRPC 提出了一個更激進的方案:完全跳過 Schema 定義,直接把後端的型別推導到前端。
// 後端
const appRouter = router({
getUser: procedure
.input(z.object({ id: z.number() }))
.query(({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
});
// 前端 — 自動享有完整的型別推導
const user = trpc.getUser.useQuery({ id: 123 });
// user 的型別自動推導出來,修改後端會立即在前端報錯tRPC 的限制也很明顯:它只適用於 TypeScript 全端專案。如果你的客戶端有 iOS、Android、或者其他非 TypeScript 的消費者,tRPC 就不適合了。
API 設計的演進告訴我們一個道理:沒有最好的方案,只有最適合你當前場景的方案。 對大多數團隊來說,REST 仍然是最務實的選擇。GraphQL 適合資料需求複雜且變動頻繁的場景。tRPC 適合 TypeScript 全端的小團隊快速迭代。
7. 部署的演進:FTP → CI/CD → Container → Serverless
FTP 時代:用勇氣部署
在最早期,部署一個網站就是用 FTP 工具(像 FileZilla)把檔案上傳到伺服器。
這個流程幾乎完全依賴人工操作。你在本機改好程式碼,打開 FTP 工具,連上伺服器,把檔案拖過去覆蓋。沒有版本控制的概念——如果改壞了,你只能祈禱自己記得改了哪些檔案,或者希望上一個同事有留備份。
更可怕的場景是多人協作。兩個人同時改了同一個檔案,最後上傳的那個人會直接覆蓋前一個人的工作。沒有合併、沒有衝突提示、沒有任何保護機制。
部署到不同環境(測試、預覽、正式)?手動切換設定檔、手動上傳到不同的伺服器。出錯?回滾就是把「之前的檔案」再傳上去——前提是你還留著之前的檔案。
CI/CD:讓機器來做
Git 的普及首先解決了版本控制的問題。但部署仍然是手動的,直到 CI/CD(Continuous Integration / Continuous Deployment)工具的出現。
Jenkins、GitLab CI、GitHub Actions——這些工具讓你可以定義自動化的 pipeline:程式碼推送後自動執行測試、自動建置、自動部署。整個流程標準化、可重複、有紀錄。
# GitHub Actions 範例
on:
push:
branches: [main]
jobs:
deploy:
steps:
- run: npm test
- run: npm run build
- run: deploy-to-productionCI/CD 消除了「在我的電腦上可以跑」這類問題的一部分,但還沒有完全解決。因為你的 CI/CD 伺服器的環境、和正式伺服器的環境、和開發者的本機環境,可能還是不一樣的。
Docker:把環境打包帶走
Docker(2013)解決了「環境不一致」這個由來已久的問題。它的理念是:不只打包程式碼,連執行環境一起打包。
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]這個 Dockerfile 精確定義了應用程式需要的一切:Node.js 18 的執行環境、安裝的依賴、建置步驟、啟動命令。不管在開發者的 Mac、CI/CD 的 Linux、還是正式的雲端伺服器上,跑起來的結果都一模一樣。
Docker 還帶來了微服務架構的可能性——每個服務獨立打包、獨立部署、獨立擴展。Kubernetes 進一步解決了「如何管理大量容器」的問題。
但你仍然需要管理伺服器——不管是實體機器、虛擬機器、還是雲端主機。你要監控它們的健康狀態、管理它們的資源分配、處理它們的安全更新。
Serverless:連伺服器都不要了
AWS Lambda(2014)開創了 Serverless 的概念:你只要寫函式,雲端平台幫你處理一切基礎設施。
// 一個 Lambda function
exports.handler = async (event) => {
const userId = event.pathParameters.id;
const user = await db.getUser(userId);
return {
statusCode: 200,
body: JSON.stringify(user),
};
};不需要管理伺服器、不需要配置 Nginx、不需要擔心擴展——有請求來就自動處理,沒請求就不計費。按實際執行次數計價,對流量不穩定的應用特別友善。
Serverless 的限制也很明顯:冷啟動(Cold Start)會導致偶爾的延遲、有執行時間限制(通常 15 分鐘)、有狀態的長連線應用不太適合、Vendor lock-in 的風險。
整條部署演進鏈的方向一直都很明確:減少「人為操作」,增加「自動化」和「標準化」。 從手動 FTP 到自動化 pipeline,從手動配置環境到容器化打包,從管理伺服器到完全不碰伺服器——每一步都在把開發者從基礎設施的負擔中解放出來,讓他們把精力聚焦在真正重要的事情上:產品本身。
8. 總結:理解演進,才能做出選擇
回顧這七條演進鏈,你會發現一個反覆出現的模式:
痛點 → 解決方案 → 新痛點 → 新解決方案
jQuery 解決了瀏覽器不一致的問題,卻帶來了 spaghetti code 的問題。React 解決了 UI 狀態同步的問題,卻帶來了 SEO 和首屏效能的問題。Next.js 解決了 SSR 的問題,卻增加了部署的複雜度。
這不是退步——這是在不同的 trade-off 之間螺旋上升。每一代技術都站在上一代的肩膀上,解決了最緊迫的問題,同時也創造了新的挑戰留給下一代。
理解這些「為什麼」,對你有三個實際的好處:
第一,做出更好的技術選型。 當你理解每個工具存在的理由和它的 trade-off,你就不會被行銷話術或社群熱度左右。你會問:「這個工具解決的問題,是不是我真正面對的問題?」
第二,更快學會新技術。 當你理解了 Callback → Promise → async/await 的演進脈絡,學任何語言的非同步處理都會很快,因為核心概念是相通的。
第三,預測未來的方向。 當你看到一個技術的痛點越來越被社群討論,你可以合理推測下一代解決方案會往什麼方向走。
最後,請記住一件事:
不是每個新工具都適合你的場景。Context matters。
一個簡單的內容網站不需要 React。一個內部管理系統不需要 GraphQL。一個三人團隊的 MVP 不需要 Kubernetes。技術選型不是選「最新的」或「最流行的」——而是選「最適合你當前問題和約束條件的」。
不要追逐潮流。理解 trade-off,然後做出清醒的選擇。