Topic | Ink 渲染管线全拆¶
重要性:⭐⭐⭐⭐⭐(理解 TUI 渲染的"内幕") 出现位置:
src/ink/(50+ 文件 / 13306 行) 关联:phase-07-advanced.md 的 Ink fork
1. Ink 是什么¶
Ink = "React for CLI"。在终端里写 React 组件,渲染成 ASCII/ANSI 输出。
类比: - Web = React → DOM → 浏览器 - CLI = React → Y 流(ANSI 转义码)→ 终端模拟器
Claude Code 整 fork Ink 进 src/ink/(50+ 文件 / 13306 行),因为仓库没 package.json。
2. 子目录结构¶
src/ink/
├── ink.tsx (1722 行) 入口:Ink class
├── reconciler.ts React reconciler 适配
├── renderer.ts + render-to-screen.ts + render-node-to-output.ts 渲染管线
├── dom.ts + node.ts 虚拟 DOM
│
├── events/ 事件系统
│ ├── click-event.ts
│ ├── dispatcher.ts
│ ├── emitter.ts
│ ├── event.ts
│ ├── event-handlers.ts
│ ├── focus-event.ts
│ ├── input-event.ts
│ ├── keyboard-event.ts
│ ├── terminal-event.ts
│ └── terminal-focus-event.ts
│
├── hooks/ React hooks
│ ├── use-animation-frame.ts
│ ├── use-app.ts
│ ├── use-declared-cursor.ts
│ ├── use-input.ts 键盘输入
│ ├── use-interval.ts
│ ├── use-search-highlight.ts
│ ├── use-selection.ts
│ ├── use-stdin.ts 原始 stdin
│ ├── use-tab-status.ts
│ ├── use-terminal-focus.ts
│ ├── use-terminal-title.ts
│ └── use-terminal-viewport.ts
│
├── layout/ 布局引擎
│ ├── engine.ts
│ ├── geometry.ts
│ ├── node.ts
│ └── yoga.ts Yoga 绑定
│
├── components/ 内置组件
│ ├── AlternateScreen.tsx
│ ├── App.tsx
│ ├── AppContext.ts
│ ├── Box.tsx 容器(flexbox)
│ ├── Button.tsx
│ ├── ClockContext.tsx
│ ├── CursorDeclarationContext.ts
│ ├── ErrorOverview.tsx
│ ├── Link.tsx
│ ├── Newline.tsx
│ ├── NoSelect.tsx
│ ├── RawAnsi.tsx
│ ├── ScrollBox.tsx 可滚动容器
│ ├── Spacer.tsx
│ ├── StdinContext.ts
│ ├── TerminalFocusContext.tsx
│ ├── TerminalSizeContext.tsx
│ └── Text.tsx 文本
│
├── Ansi.tsx ANSI 颜色
├── colorize.ts 颜色化辅助
├── constants.ts
├── clearTerminal.ts 清屏
├── frame.ts 帧管理
├── screen.ts 屏幕缓冲
├── output.ts 输出
│
├── parse-keypress.ts 按键解析
├── stringWidth.ts 字符宽度(CJK)
├── widest-line.ts 最宽行
├── wrap-text.ts 文本换行
├── wrapAnsi.ts ANSI 换行
│
├── optimizer.ts 渲染优化
├── squash-text-nodes.ts 合并 Text 节点
├── node-cache.ts 节点缓存
├── line-width-cache.ts 行宽缓存
│
├── measure-element.ts 元素测量
├── measure-text.ts 文本测量
├── hit-test.ts 命中测试(鼠标)
│
├── selection.ts 文本选择
├── searchHighlight.ts 搜索高亮
│
├── instances.ts 实例管理
├── log-update.ts 日志更新
├── bidi.ts 双向文字
├── render-border.ts 边框渲染
├── render-node-to-output.ts 节点 → 输出
│
├── supports-hyperlinks.ts 超链接支持
├── tabstops.ts Tab stops
├── terminal-focus-state.ts 焦点状态
├── terminal-querier.ts 终端能力查询
├── terminal.ts 终端能力
├── termio.ts + termio/ 终端 IO
│
└── useTerminalNotification.ts
3. 渲染管线:5 阶段¶
┌─────────────┐
│ React Tree │ <Box><Text>hello</Text></Box>
└──────┬──────┘
│
▼
┌─────────────┐
│ Reconciler │ reconciler.ts:diff + apply
│ (React) │ 输出 virtual DOM (ink node tree)
└──────┬──────┘
│
▼
┌─────────────┐
│ Layout │ layout/yoga.ts:flexbox 布局
│ (Yoga) │ 给每个 node 算 (x, y, width, height)
└──────┬──────┘
│
▼
┌─────────────┐
│ Output │ render-node-to-output.ts:node tree → Y 输出
│ Generation │ 输出 ANSI 转义码 + 文本
└──────┬──────┘
│
▼
┌─────────────┐
│ Diff & Draw │ renderer.ts:对比上一帧,新内容写到 stdout
│ (stdout) │ 老的行清除,新的行绘制
└──────┬──────┘
│
▼
┌───────┐
│ 终端 │ 用户看到的画面
└───────┘
4. 关键文件详解¶
4.1 ink.tsx (1722 行) —— 入口¶
// 推测的 Ink class
export default class Ink {
private rootNode: InkNode
private stdout: NodeJS.WriteStream
private stdin: NodeJS.ReadStream
private frameTimer: NodeJS.Timeout | null = null
constructor(tree: React.ReactNode, options: Options) {
// 1. 构造 root 节点
this.rootNode = createRootNode(tree)
// 2. 启动 React reconciler
const reconciler = createInkReconciler({
// 自定义 host config
createInstance: ...,
commitUpdate: ...,
})
// 3. 第一次渲染
this.render()
// 4. 启动事件循环
this.startLoop()
}
// 60fps 渲染
private startLoop() {
const tick = () => {
this.render()
this.frameTimer = setTimeout(tick, 16) // ~60fps
}
tick()
}
unmount() {
if (this.frameTimer) clearTimeout(this.frameTimer)
// 清理
}
}
4.2 reconciler.ts —— React reconciler 适配¶
// 关键:自定义 host config
const reconciler = ReactReconciler({
// 创建 instance
createInstance(type, props, root, hostContext, internalInstanceHandle) {
return createInkNode(type, props)
},
// 添加 child
appendChild(parent, child) { ... },
// 提交更新
commitUpdate(instance, updatePayload, type, oldProps, newProps, fiber) { ... },
// ... 几十个 host config 方法
})
这是 Ink 的"魔法" —— React reconciler 是可插拔的,任何"host"(DOM、Canvas、Ink Y 输出)都可以实现。
4.3 layout/yoga.ts —— 布局引擎¶
import Yoga from 'yoga-layout'
export function layoutNode(node: InkNode, availableWidth: number, availableHeight: number) {
// 1. 构造 Yoga 节点
const yogaNode = Yoga.Node.create()
// 2. 应用 props
applyFlexProps(yogaNode, node.props)
// 例如:
// flexDirection: 'row' | 'column'
// justifyContent: 'flex-start' | 'center' | 'space-between'
// alignItems: 'stretch' | 'center'
// flex: 1
// width, height, padding, margin...
// 3. 递归布局子节点
for (const child of node.children) {
layoutNode(child, ...)
}
// 4. 计算最终位置
yogaNode.calculateLayout(availableWidth, availableHeight)
return {
x: yogaNode.getComputedLeft(),
y: yogaNode.getComputedTop(),
width: yogaNode.getComputedWidth(),
height: yogaNode.getComputedHeight(),
}
}
Yoga = Facebook 的 flexbox C++ 实现。Claude Code 在终端里实现 <Box flexDirection="row"> 的关键。
4.4 parse-keypress.ts —— 按键解析¶
// 解析 raw stdin bytes 为 KeyboardEvent
export function parseKeypress(data: Buffer): Key | null {
// 1. 识别转义序列
// CSI u: '\x1b[97;5u' = Ctrl+A
// ANSI: '\x1b[A' = Up arrow
// Legacy: '\x1bOA' = Up arrow (older terminals)
// 2. 提取 key + modifiers
return {
key: 'a',
ctrl: true,
meta: false,
shift: false,
sequence: ...,
}
}
为什么复杂: - 终端模拟器有 3-4 种按键编码(ANSI、CSI u、legacy) - 跨平台差异(macOS Terminal.app vs iTerm2 vs Linux xterm) - 修饰键组合(Ctrl+Shift+Alt+Key)
4.5 stringWidth.ts —— 字符宽度¶
// 一个字符在终端里占几列?
export function stringWidth(str: string): number {
let width = 0
for (const char of str) {
width += charWidth(char)
}
return width
}
function charWidth(char: string): number {
// 1. ASCII 字母数字 → 1
// 2. CJK 字符 → 2
// 3. Emoji → 2(带 ZWJ 组合也是 2)
// 4. 控制字符 → 0
// 5. ZWJ 序列特殊处理
}
关键:CJK 字符占 2 列。如果不处理,对齐会错位。
5. 性能优化模式¶
5.1 节点缓存 node-cache.ts¶
5.2 行宽缓存 line-width-cache.ts¶
5.3 Text 节点合并 squash-text-nodes.ts¶
// 优化前:<Text>hello</Text><Text> </Text><Text>world</Text>
// 优化后:<Text>hello world</Text>
// → 减少 ANSI 转义码
5.4 渲染优化 optimizer.ts¶
跳过渲染结果不变的子树: - props 没变 → 不重渲染 - state 没变 → 不重渲染
6. 终端能力查询¶
// src/ink/terminal-querier.ts
export async function getTerminalCapabilities(): Promise<TerminalCaps> {
// 1. OSC 4: 查询颜色支持(true color / 256 color / 16 color)
// 2. DECRQM: 查询焦点事件支持
// 3. XTVERSION: 查询终端类型
// 4. DA1/DA2: 查询设备属性
}
应用: - 不支持 true color → 用 256 色降级 - 不支持鼠标 → 关闭 hit-test - iTerm2 → 启用超链接 - xterm → 用 ANSI 标准
7. 关键洞察¶
7.1 React 是"host-agnostic"¶
通过自定义 reconciler host config,React 可以在任何输出设备上跑:浏览器(react-dom)、服务端(react-server)、Native(react-native)、CLI(ink)。
7.2 Yoga 是 Ink 的"布局引擎"¶
Web 用 CSS(浏览器解析),CLI 用 Yoga(Node 内嵌)。两者都是"声明式布局"。
7.3 字符宽度计算是 TUI 的"基本功"¶
CJK、Emoji、ZWJ 序列、控制字符 —— 任何 TUI 都要自己处理。
Web 是 <canvas> 自动处理,TUI 是手算。
7.4 60fps 渲染 = 不阻塞事件循环¶
关键:渲染在 setTimeout 里,不阻塞 stdin 事件处理。这是 TUI 能响应用户的关键。8. 阅读清单¶
- ✅
src/ink/ink.tsx:1-100(入口) - ✅
src/ink/reconciler.ts:1-80(host config) - ✅
src/ink/layout/yoga.ts:1-50(布局) - ✅
src/ink/parse-keypress.ts:1-50(按键解析) - ✅
src/ink/stringWidth.ts(字符宽度) - 📌
src/ink/renderer.ts+render-to-screen.ts(输出) - 📌
src/ink/optimizer.ts+squash-text-nodes.ts(优化)
9. 练习任务¶
- 手写一个最小 Ink —— 不要用 React reconciler,直接 mount + render 一个
<Box><Text>hi</Text></Box> - 实现 stringWidth —— 处理 CJK、ASCII、控制字符的宽度
- 写 parseKeypress —— 至少识别 Up/Down/Enter/Ctrl+C
- 思考:Ink 的渲染管线和 React DOM 的渲染管线有什么相似和差异?React Server Components 的渲染和 Ink 的渲染同构吗?