2013-React函數式UI#
案件摘要#
2013 年 5 月,JSConf US 的舞台上,Facebook 工程師 Jordan Walke 公開了一個內部孕育兩年的專案——React。它的前身是 Walke 於 2011 年開發的 FaxJS,一款用 PHP(XHP)與 JavaScript 實驗「UI 是狀態的函數」的雛形。React 拋棄了 jQuery 時代直接操作 DOM 的命令式寫法,改以宣告式的純函數模型重建整個前端世界觀。這起案件的謎題是:為什麼一個看似違反常識(每次都「重新渲染整個頁面」)的方案,反而解開了億級使用者產品的状态同步噩夢?破案關鍵,藏在數學式的純度與虛擬 DOM 的 O(n) diff 演算法裡。
前因 -- 為什麼會有這個案子#
- jQuery 時代(2006–2013)的主流寫法是命令式:
$('.btn').on('click', function() { ... }),程式碼一步步「指示」瀏覽器改哪個節點、改什麼文字。 - UI 狀態散落各處:DOM 本身、JavaScript 變數、閉包、伺服器回傳值,四處各有一份真相,同步全靠人工維護。
- Facebook 的廣告與通知系統是重災區:一則通知的已讀狀態要同步到角標、列表、紅點、彈窗等多個地方,任何一處漏改就是 bug。工程師戲稱這是「兩年才敢動一次的程式碼」。
- 命令式更新無法放心重試:同一行程式碼跑兩次,結果可能不同(狀態被改兩次),於是無法用「重新執行」來修復錯誤。
- 函數式思想在 FB 內部已有共識:pure function(相同輸入必得相同輸出、無副作用)、immutability(資料建好就不再改)、one-way data flow(資料只往一個方向流)。Walke 的 FaxJS 正是把這些原則搬到 UI 上的實驗。
- 更早的先聲:Evan Czaplicki 於 2012 年的 Elm 語言與 The Elm Architecture,已經示範了
view : Model -> Html的純函數模型與單向資料流。
線索與推理 -- 數學式、程式、理論#
線索一:UI = f(state) 的純函數模型#
React 的核心假設可以用一條數學式寫下:
其中 \(f\) 是純函數:只要 state 相同,渲染出的 UI 必定相同。這意味著「UI 的歷史」不再重要——任何時刻的畫面都可以由當下的 state 完全重建。命令式時代的「狀態散落各處」被收斂成單一真相來源。
線索二:虛擬 DOM 與 O(n) diff#
若每次 state 改變都真的重建整個 DOM,效能會崩潰。React 的解法是虛擬 DOM:先在記憶體中建一棵輕量的 JS 物件樹,與上一次的樹做 diff,只把差異套用到真實 DOM。兩樹 diff 的樸素演算法是 \(O(n^3)\) ,React 的 reconciliation 演算法靠兩條啟發式假設壓到 \(O(n)\) :
- 不同型別的元素會產生不同的樹(直接整棵替換,不再往下比)。
- 開發者提供 key,暗示哪些子元素在同一列表中是穩定的。
用 Python 模擬這個 diff 思想:
def diff(old, new, path="root", patches=[]):
if old is None:
patches.append(("ADD", path, new))
elif new is None:
patches.append(("REMOVE", path))
elif old["type"] != new["type"]:
patches.append(("REPLACE", path, new))
elif old["type"] == "text":
if old["text"] != new["text"]:
patches.append(("TEXT", path, new["text"]))
else:
for i in range(max(len(old["children"]), len(new["children"]))):
o = old["children"][i] if i < len(old["children"]) else None
n = new["children"][i] if i < len(new["children"]) else None
diff(o, n, f"{path}/{i}", patches)
return patches
old = {"type": "div", "children": [
{"type": "text", "text": "通知 3 則"},
{"type": "text", "text": "廣告"},
]}
new = {"type": "div", "children": [
{"type": "text", "text": "通知 4 則"},
{"type": "text", "text": "廣告"},
]}
print(diff(old, new))
# [('TEXT', 'root/0', '通知 4 則')] -- 只偵測到一處差異,其餘不動
線索三:單向資料流與 Flux#
2014 年 Facebook 再發表 Flux 架構,把資料流固定成一個單向環:
View 只能「派發 Action」,不能直接改 Store。這是 Elm Architecture 的工業化版本:資料只往一個方向流,任何狀態變化都有明確的因果鏈,可追蹤、可重播。
線索四:純元件與 memoization#
因為元件是純函數,相同 props 必得相同輸出——這正是 memoization 的數學前提。React.memo 記住上一次的 props,若這次 props 相同(淺比較),直接跳過重渲染:
沒有純度,這個快取在數學上不成立;有了純度,效能優化只是查表。
線索五:Hooks——把副作用裝進容器#
2019 年 React 16.8 發表 Hooks。函數元件原本不能有狀態與副作用,useState 與 useEffect 改變了這件事:
function NotificationBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
// 副作用(訂閱、請求)被「裝進」effect 容器,
// 由 React 統一在渲染後執行與清理
const unsubscribe = subscribeNotifications(setCount);
return () => unsubscribe();
}, []);
return <span className="badge">{count}</span>;
}
useEffect 的本質,是把副作用從純函數本體抽離,放進一個由框架管理的容器。這與 Haskell 的 IO monad 同構:純函數不「執行」副作用,而是「描述」一個 IO a 型別的動作,由 runtime(React 或 GHC)在正確的時機執行。
純渲染與副作用分離——這正是 monad「分離純與不純」的核心思想,只是換了一身 JavaScript 的衣服。
結案 -- 後果與影響#
- React 成為全球最流行的前端框架,支撐 Facebook、Instagram、Netflix、Airbnb 等億級使用者產品。
- 函數式思想(pure function、immutability、宣告式 UI)從學院冷門變成前端工業主流。
- Redux(2015,受 Flux 與 Elm 啟發)確立單向資料流與「reducer 是純函數」的準則;MobX、Elm 各自延續這條路。
- Hooks 影響了 Vue 3 的 Composition API 與 SwiftUI 的宣告式模型——宣告式 UI 成為跨平台共識。
- 「狀態散落各處」的問題以 state 容器與單一真相來源收案,command-imperative 的 jQuery 模式退出主流。
關鍵人物與文獻(條列)#
- Jordan Walke——FaxJS(2011)與 React(2013)的原作者,Facebook 工程師。
- Evan Czaplicki——Elm 語言作者;Evan Czaplicki, "Elm: Concurrent FRP for Functional GUIs", Princeton Senior Thesis, 2012.
- Tom Occhino 與 Jordan Walke 在 JSConf US 2013 首次公開發表 React。
- Facebook 開源團隊, "React: A JavaScript library for building user interfaces", https://react.dev, 2013.
- Facebook, "Flux Application Architecture", 2014, https://facebook.github.io/flux.
- Andrew Clark et al., "React Hooks", React 16.8 官方文件, 2019, https://react.dev/reference/react.
- Philip Wadler, "Monads for functional programming", in Advanced Functional Programming, Springer, 1995——Hooks 與 IO monad 同構的理論源頭。