終端 UI:用 React 渲染終端機介面

了解 Claude Code 如何自建一套完整的 React 終端渲染框架——從自訂調和器、Yoga Flexbox 排版到雙緩衝差異更新——讓終端機也能擁有宣告式 UI。

90 秒總覽

Claude Code 沒有瀏覽器,卻實作了一套完整的 React 渲染管線來驅動終端機介面。它透過自訂 React 調和器(Custom Reconciler)將元件樹轉換成虛擬 DOM,再由 Facebook 的 Yoga 引擎進行 Flexbox 排版,最終經由雙緩衝差異引擎只輸出有變動的 ANSI 跳脫序列到 TTY。這套架構讓開發者可以用宣告式 JSX 撰寫終端 UI,同時享有增量渲染與記憶體池化帶來的高效能。

執行路徑(渲染管線)

input: React 元件樹更新
1. React 調和器比對新舊元件樹
2. 將變更分為三類:Styles / Text styles / Event handlers
3. 標記 dirty flag 並向上級聯
4. Yoga 引擎只對 dirty 子樹重新排版
5. Output Builder 產生 2D Cell 陣列(Screen Buffer)
6. Diff Engine 比對前後兩幀
7. 只輸出差異部分的 ANSI 跳脫序列到 TTY
flowchart TD
  A[React 元件] --> B[自訂 React 調和器]
  B --> C[虛擬 DOM 樹]
  C --> D{Dirty Flag 檢查}
  D -->|有變更| E[Yoga Flexbox 排版]
  D -->|無變更| F[跳過排版]
  E --> G[Output Builder]
  F --> G
  G --> H[Screen Buffer — 2D Cell 陣列]
  H --> I[Diff Engine 比對前後幀]
  I --> J[ANSI 跳脫序列 → TTY]

整條管線的核心思想:只重算有變動的部分,只輸出有差異的像素(Cell)。

虛擬 DOM 元素類型

// 自訂調和器支援的元素類型
type InkElementType =
  | 'ink-root'          // 根節點
  | 'ink-box'           // 容器(對應 Yoga 佈局節點)
  | 'ink-text'          // 文字節點
  | 'ink-virtual-text'  // 虛擬文字(不佔佈局空間)
  | 'ink-link'          // 超連結
  | 'ink-progress'      // 進度條
  | 'ink-raw-ansi'      // 原始 ANSI 透傳

Screen Buffer — 2D Cell 模型

flowchart LR
  subgraph Cell
    direction TB
    C1[char: 字元]
    C2[width: 正常 / 寬字元]
    C3[styleId: 樣式池索引]
    C4[hyperlink: 連結池索引]
  end
  subgraph 記憶體池
    P1[CharPool]
    P2[StylePool]
    P3[HyperlinkPool]
  end
  C1 -.-> P1
  C3 -.-> P2
  C4 -.-> P3

三個 interning pool 避免重複字串分配,大幅降低 GC 壓力。

路徑判讀重點

  • 調和器追蹤三類變更:佈局樣式(Yoga)、文字樣式(色彩)、事件處理器。
  • Dirty flag 從子節點向上級聯,確保父節點知道需要重排。
  • 雙緩衝(Double Buffering):前後兩幀交換,Diff Engine 只輸出差異。
  • Blit 優化:未變動區域直接複製,跳過重繪。

關鍵決策(為什麼這樣設計)

決策 1:自建 React 調和器而非直接操作 ANSI

原因:終端 UI 的狀態管理與瀏覽器 UI 本質相同——都是「狀態變更 → 最小化重繪」。使用 React 調和器可以沿用宣告式元件模型,開發者寫 JSX 即可,不需手動追蹤游標位置與清除邏輯。

代價:必須自行維護一整套調和器與虛擬 DOM 實作,複雜度遠高於直接印出 ANSI 字串。

決策 2:使用 Yoga 做 Flexbox 排版

原因:Yoga 是 Facebook 開源的跨平台 Flexbox 引擎,已在 React Native 驗證過穩定性。用它來處理終端的行列排版,可以直接套用 Web 開發者熟悉的 flexDirectionpaddingmargin 等語意。

代價:Yoga 是原生 C++ 綁定,帶來 FFI 成本與潛在的記憶體洩漏風險(需世代重置模式應對)。

決策 3:雙緩衝加 Blit 優化

原因:終端輸出的瓶頸是 I/O——每次全畫面重繪會產生大量 ANSI 序列,造成閃爍與延遲。雙緩衝讓前後兩幀交替使用,Diff Engine 只輸出有差異的 Cell,Blit 則直接複製未變動區域,大幅減少寫入量。

代價:選取範圍覆蓋(Selection overlay)會「汙染」幀資料,迫使該區域放棄 Blit 而改為完整重繪。

替代方案與取捨

方案 優點 缺點 為何未採用/何時可採用
直接輸出 ANSI 跳脫序列 零依賴、啟動快、實作簡單 狀態管理全靠手動,複雜 UI 難以維護 適合極簡 CLI 工具,不適合有互動式多區塊介面的應用
ncurses / blessed 成熟的終端 UI 函式庫,跨平台支援好 命令式 API,無宣告式元件模型;blessed 已停止維護 需要底層終端控制時可作為補充,但無法取代元件化開發體驗
標準 Ink(不修改) 社群維護、文件齊全、生態系豐富 效能不足以應付高頻更新;缺少 CJK 寬字元、雙緩衝等需求 小型 CLI 工具適用;Claude Code 的規模需要深度客製
Web 技術(Electron / 瀏覽器) 完整的 CSS 排版與 DOM API 啟動重、記憶體佔用高、脫離終端環境 適合需要圖形化的桌面應用,不適合嵌入終端的工作流

失敗路徑與防護

Failure 1:Yoga 原生綁定記憶體洩漏

症狀:長時間執行後記憶體持續增長,最終導致 OOM 或嚴重效能衰退。

防護:採用世代重置模式(Generational Reset)——定期釋放整棵 Yoga 節點樹並重建,避免原生綁定累積未回收的記憶體。每次重置時機與 React 調和器的完整重渲染對齊。

Failure 2:寬字元(CJK / Emoji)排版錯位

症狀:中文字、日文字或 Emoji 佔據兩個字元寬度,但佈局引擎只計算一個寬度,導致後續內容偏移、邊框錯位。

防護:Screen Buffer 的 Cell 模型明確記錄每個字元的 width(正常 / 寬字元),排版時以實際顯示寬度而非字元數計算。寬字元的第二格填入佔位 Cell,避免被後續字元覆蓋。

Failure 3:Dirty Flag 級聯風暴

症狀:單一底層節點的頻繁更新導致 dirty flag 不斷向上傳播,觸發整棵樹重排,增量渲染退化為全量渲染,FPS 驟降。

防護:批次合併更新(batching)——在同一幀內累積所有變更,只做一次向上級聯與排版計算。搭配 requestAnimationFrame 風格的排程機制,確保每幀最多觸發一次完整排版。

實作驗證(你改完要怎麼確認)

  • 渲染基本元件:確認 BoxText 元件能正確顯示於終端,排版符合 Flexbox 語意。
  • 驗證增量渲染:只更新單一元件的文字,確認 Diff Engine 只輸出該區域的 ANSI 序列,而非全畫面重繪。
  • 寬字元測試:輸入中文、日文、Emoji,確認佈局寬度正確、邊框未錯位。
  • 記憶體穩定性:長時間執行(模擬多次重渲染),確認記憶體用量穩定,世代重置正常觸發。
  • Blit 優化驗證:比對有無 Blit 時的 ANSI 輸出量,確認未變動區域被跳過。
  • Selection overlay 回退:啟用選取覆蓋後,確認受影響區域正確放棄 Blit 並完整重繪。

這六步涵蓋「基本渲染、增量更新、寬字元、記憶體、Blit、選取回退」,是渲染管線變更的最低可接受驗證集。

← 上一頁:Hooks 與自動化 回首頁 →