自研 Ink 渲染引擎:在终端里跑一个 React
一句话理解
Claude Code 的终端界面不是简单地 console.log 输出文字。它背后是一个完整的 UI 渲染引擎——用 React 写组件,用 Yoga 做 Flexbox 布局,用双缓冲做差量更新,还支持滚动、焦点管理、鼠标点击和文本选择。这就是一个跑在终端里的"浏览器渲染引擎"。
比喻:浏览器有 DOM → 布局 → 绘制 → 合成 这条渲染管线。Claude Code 在终端里重建了同样的管线:虚拟 DOM → Yoga 布局 → 屏幕缓冲区 → 差量输出到终端。
为什么不用现成的 Ink
Ink 是 npm 上流行的终端 React 框架,但 Claude Code 选择了完全自研。主要原因:
| 需求 | Ink (npm) | 自研 Ink |
|---|---|---|
| 全屏模式(Alt Screen) | 不支持 | 支持 |
| 鼠标点击/滚轮 | 不支持 | 支持 |
| 文本选择 + 搜索高亮 | 不支持 | 支持 |
| DECSTBM 硬件滚动 | 不支持 | 支持 |
| 性能级差量更新 | 基础 | Cell 级 diff + blit 优化 |
| 光标定位(IME 输入法) | 不支持 | 支持 |
核心文件
| 文件 | 行数 | 职责 |
|---|---|---|
ink.tsx |
1722 | 主协调器:渲染循环、事件派发、选择/搜索 |
reconciler.ts |
512 | React 接入层:虚拟 DOM 变更处理 |
log-update.ts |
773 | 差量算法:生成终端补丁 |
screen.ts |
1486 | 屏幕缓冲区:压缩 Cell 表示、池化 |
output.ts |
797 | 渲染树 → 屏幕操作转换 |
render-node-to-output.ts |
1462 | DOM 树遍历、裁剪、滚动处理 |
dom.ts |
484 | 虚拟 DOM 节点管理 |
focus.ts |
181 | 焦点系统 |
渲染管线:6 个阶段
React 组件树
│
▼ 阶段 1: React 协调
┌────────────────────────┐
│ react-reconciler │
│ │
│ <Box flexDirection="row">│
│ <Text>Hello</Text> │
│ <ScrollBox>...</> │
│ </Box> │
│ │
│ → 生成/更新虚拟 DOM │
└──────────┬──────────────┘
│
▼ 阶段 2: Yoga 布局
┌────────────────────────┐
│ calculateLayout() │
│ │
│ Flexbox 计算每个节点的 │
│ x, y, width, height │
└──────────┬──────────────┘
│
▼ 阶段 3: 渲染到缓冲区
┌────────────────────────┐
│ renderNodeToOutput() │
│ │
│ DFS 遍历 DOM 树 │
│ → 生成 Write/Blit/ │
│ Clear/Clip 操作 │
│ → 写入 Screen 缓冲区 │
└──────────┬──────────────┘
│
▼ 阶段 4: 叠加层
┌────────────────────────┐
│ 文本选择反色 │
│ 搜索关键词高亮 │
└──────────┬──────────────┘
│
▼ 阶段 5: 差量计算
┌────────────────────────┐
│ diffEach(prev, next) │
│ │
│ 逐 Cell 对比 │
│ → 生成 Patch[] 补丁 │
└──────────┬──────────────┘
│
▼ 阶段 6: 终端输出
┌────────────────────────┐
│ Patch → ANSI 转义序列 │
│ → 写入 stdout │
└─────────────────────────┘
虚拟 DOM
节点类型
// src/ink/dom.ts
type ElementNames =
| 'ink-root' // 根节点
| 'ink-box' // 容器(对应 <Box>)
| 'ink-text' // 文本(对应 <Text>)
| 'ink-virtual-text' // 嵌套文本(Text 内的 Text)
| 'ink-link' // 超链接(OSC 8)
| 'ink-progress' // 进度条
| 'ink-raw-ansi' // 预渲染的 ANSI 字符串
节点结构
// src/ink/dom.ts
type DOMElement = {
nodeName: ElementNames
childNodes: DOMNode[]
attributes: Record<string, DOMNodeAttribute>
yogaNode?: LayoutNode // Yoga 布局节点
dirty: boolean // 是否需要重新渲染
// 滚动状态
scrollTop?: number
pendingScrollDelta?: number // 待消耗的滚动距离
scrollHeight?: number // 内容总高度
scrollViewportHeight?: number // 可视区高度
// 焦点
focusManager?: FocusManager
// 事件处理器(存在这里避免每次 render 重新绑定)
_eventHandlers?: Record<string, unknown>
}
脏标记传播
当一个节点发生变化时,脏标记会向上传播到根节点:
// src/ink/dom.ts
function markDirty(node) {
node.dirty = true
if (node.parentNode) {
markDirty(node.parentNode) // 递归向上
}
}
ink-root (dirty=true) ← 传播到这里
└── ink-box (dirty=true) ← 传播到这里
├── ink-text (dirty=false) // 没变,跳过
└── ink-text (dirty=true) // 内容变了
屏幕缓冲区:每个字符 8 字节
Cell 压缩表示
普通实现每个 Cell 是一个对象(属性、样式、字符……),内存开销大。自研引擎把每个 Cell 压缩到两个 Int32(8 字节):
// src/ink/screen.ts
// 每个 Cell = 2 个 Int32
// 第 1 个 Int32: 字符 ID
word0 = charId // 指向 CharPool 的索引
// 第 2 个 Int32: 打包的元数据
word1 = styleId << 17 // 高 15 位:样式 ID
| hyperlinkId << 2 // 中间 15 位:超链接 ID
| width // 低 2 位:字符宽度
// 字符宽度
enum CellWidth {
Narrow = 0 // 普通字符(1 列宽)
Wide = 1 // CJK/emoji(2 列宽)
SpacerTail = 2 // 宽字符的右半部分
SpacerHead = 3 // 行末软换行的宽字符
}
设计思路:用
Int32Array而不是对象数组,有两大好处: 1. 内存:对象每个至少 64 字节(V8 开销),Int32 只要 8 字节,省 8 倍 2. 比较:diff 时比较两个 Int32 是一条 CPU 指令,比较对象要逐字段遍历
三大池化系统
字符、样式、超链接都通过池化去重:
┌──────────────────────────────────────────┐
│ CharPool │
│ │
│ ID=0 → " " (空格) │
│ ID=1 → "H" │
│ ID=2 → "你" (2列宽) │
│ ID=3 → "🎉" (2列宽) │
│ ... │
│ │
│ 同一个字符只存一份,Cell 中只存 ID │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ StylePool │
│ │
│ ID=0 → ""(默认样式) │
│ ID=1 → "\x1b[1m"(加粗) │
│ ID=2 → "\x1b[31m"(红色) │
│ ... │
│ │
│ 还缓存了样式转换字符串: │
│ (ID=0 → ID=2) → "\x1b[31m" │
│ (ID=2 → ID=1) → "\x1b[0m\x1b[1m" │
│ 热路径零分配 │
└──────────────────────────────────────────┘
池会每 5 分钟重置一次,防止长时间运行导致无限增长。
屏幕操作
// src/ink/screen.ts
// 整屏清除:用 BigInt64Array 一条指令清空
function resetScreen(screen) {
screen.cells64.fill(0n) // 每 8 字节一个 BigInt64,极快
}
// 区域复制:TypedArray.set() 一次性拷贝
function blitRegion(dst, src, rect) {
for (let row = rect.top; row < rect.bottom; row++) {
const srcSlice = src.cells.subarray(srcStart, srcEnd)
dst.cells.set(srcSlice, dstStart) // O(1) per row
}
}
差量更新:只写变化的 Cell
每一帧渲染完成后,引擎会逐 Cell 对比前后两帧,只输出发生变化的部分:
// src/ink/log-update.ts (lines 305-388)
function diffEach(prevScreen, nextScreen) {
const patches = []
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const prevCell = prevScreen.cellAtIndex(i)
const nextCell = nextScreen.cellAtIndex(i)
// 两个 Int32 都相同 → 跳过(热路径,极快)
if (prevCell.word0 === nextCell.word0
&& prevCell.word1 === nextCell.word1) continue
// 跳过宽字符的占位 Cell(终端自动处理)
if (nextCell.width === SpacerTail) continue
// 生成补丁:移动光标 + 写入字符
patches.push(
{ type: 'cursorTo', col },
{ type: 'styleStr', str: stylePool.transition(prevStyleId, nextStyleId) },
{ type: 'stdout', content: charPool.get(nextCell.charId) },
)
}
}
return patches
}
前一帧: 当前帧:
┌──────────────────┐ ┌──────────────────┐
│ Hello World │ │ Hello Claude │ ← 只有这里变了
│ Status: running │ │ Status: running │ ← 完全相同,跳过
│ > _ │ │ > _ │ ← 完全相同,跳过
└──────────────────┘ └──────────────────┘
输出的终端指令:
CSI 1;7H (移动到第 1 行第 7 列)
"Claude" (只写变化的 6 个字符)
CSI 1;13H (移到 "World" 剩余位置)
" " (清除多余字符)
DECSTBM 硬件滚动优化
在全屏模式下,当 ScrollBox 滚动时,引擎不会重绘整个屏幕。而是利用终端的滚动区域指令(DECSTBM)让终端硬件完成滚动:
用户滚动 ↓ 3 行
传统方式(重绘全部): DECSTBM(硬件滚动):
─────────────────── ───────────────────
发送 2000 个字符 发送 ~50 个字符
CSI H CSI 3;20r (设置滚动区域)
第1行的全部内容 CSI 3S (滚动 3 行)
第2行的全部内容 CSI 18;1H (移到新行)
第3行的全部内容 新第18行的内容
... 新第19行的内容
第20行的全部内容 新第20行的内容
CSI 1;999r (重置滚动区域)
// src/ink/log-update.ts (lines 165-185)
// 检测到 ScrollBox 的 scrollTop 变化时
if (altScreen && scrollDelta !== 0) {
// 1. 在前一帧缓冲区上模拟滚动
simulateScroll(prevScreen, scrollDelta)
// 2. 生成 DECSTBM 指令
patches.push({ type: 'stdout', content: `\x1b[${top};${bottom}r` })
patches.push({ type: 'stdout', content: `\x1b[${delta}S` })
// 3. diff 只需要找出滚动后"新露出"的行
// 已经在屏幕上的内容不需要重传
}
脏区域追踪 + Blit 优化
不是每一帧都需要重新渲染整棵树。引擎使用脏标记 + 区域复制来跳过没变化的子树:
┌─────────────────────────────┐
│ ink-root │
│ │
│ ┌──────────┐ ┌────────────┐ │
│ │ 侧边栏 │ │ 主内容区 │ │
│ │ (没变化) │ │ (有变化) │ │
│ │ │ │ │ │
│ │ blit 复制 │ │ 重新渲染 │ │
│ │ 前一帧的 │ │ 新内容 │ │
│ │ 对应区域 │ │ │ │
│ └──────────┘ └────────────┘ │
└─────────────────────────────┘
// src/ink/render-node-to-output.ts
function renderNodeToOutput(node, prevScreen) {
if (!node.dirty && canBlit) {
// 这个子树没变化,直接从前一帧复制
operations.push({
type: 'blit',
sourceScreen: prevScreen,
rect: node.computedRect
})
return // 跳过整棵子树
}
// 有变化,正常渲染
for (const child of node.childNodes) {
renderNodeToOutput(child, prevScreen)
}
}
布局系统:终端里的 Flexbox
布局使用 Yoga(Meta 开源的跨平台 Flexbox 引擎),通过 WASM 绑定调用:
// src/ink/layout/node.ts
type LayoutNode = {
// 设置 Flexbox 属性
setFlexDirection(direction) // row | column | row-reverse | column-reverse
setFlexGrow(n)
setFlexShrink(n)
setFlexBasis(value)
setFlexWrap(wrap)
setAlignItems(align)
setJustifyContent(justify)
setDisplay(display) // flex | none
setOverflow(overflow) // visible | hidden | scroll
setMargin/Padding/Border(edge, value)
// 计算布局
calculateLayout(width?, height?)
// 读取计算结果
getComputedLeft/Top/Width/Height()
}
组件中使用方式和 CSS Flexbox 几乎一样:
// 水平排列,间距 1
<Box flexDirection="row" gap={1}>
<Box width={20}>
<Text>侧边栏</Text>
</Box>
<Box flexGrow={1}>
<Text>主内容区(占满剩余空间)</Text>
</Box>
</Box>
文本测量
文本节点有特殊的测量函数,告诉 Yoga 这段文本需要多大空间:
// src/ink/dom.ts (lines 332-374)
function measureTextNode(node, width, widthMode) {
// 1. 展开 Tab(每个 Tab 最多 8 个空格到下一个 Tab Stop)
const expanded = expandTabs(text)
// 2. 测量文本尺寸
const measured = measureText(expanded)
// 3. 如果超出宽度,执行换行
if (measured.width > width) {
const wrapped = wrapText(expanded, width)
return measureText(wrapped)
}
return measured
}
焦点管理
// src/ink/focus.ts
class FocusManager {
activeElement: DOMElement | null // 当前焦点元素
focusStack: DOMElement[] // 焦点栈(最多 32 层)
// 设置焦点(自动 blur 上一个)
focus(node) {
if (this.activeElement) this.blur()
this.activeElement = node
this.focusStack.push(node)
dispatch(node, new FocusEvent('focus'))
}
// Tab 键循环焦点
focusNext(root) {
const tabbable = collectTabbable(root) // DFS 收集 tabIndex >= 0 的节点
const currentIndex = tabbable.indexOf(this.activeElement)
const next = tabbable[(currentIndex + 1) % tabbable.length]
this.focus(next)
}
// 节点被移除时,从栈中恢复焦点
handleNodeRemoved(node, root) {
if (node === this.activeElement) {
this.focusStack.pop()
const prev = this.focusStack[this.focusStack.length - 1]
if (prev) this.focus(prev)
}
}
// 点击时设置焦点
handleClickFocus(node) {
if (node.attributes.tabIndex >= 0) {
this.focus(node)
}
}
}
Tab 键焦点循环:
┌──────┐ Tab ┌──────┐ Tab ┌──────┐
│按钮 A │ ────────▶ │按钮 B │ ────────▶ │按钮 C │
└──────┘ └──────┘ └──────┘
▲ │
│ Tab │
└──────────────────────────────────────┘
事件系统:捕获 + 冒泡
和浏览器 DOM 事件一样,事件分捕获阶段和冒泡阶段:
// src/ink/events/dispatcher.ts
class Dispatcher {
dispatch(target, event) {
// 1. 收集路径:target → root
const path = collectPath(target)
// 2. 捕获阶段(root → target)
for (const node of path.reverse()) {
const handler = node._eventHandlers?.['onCaptureKeyDown']
if (handler) handler(event)
if (event.stopped) return
}
// 3. 冒泡阶段(target → root)
for (const node of path) {
const handler = node._eventHandlers?.['onKeyDown']
if (handler) handler(event)
if (event.stopped) return
}
}
}
事件优先级(仿 React DOM):
| 优先级 | 事件类型 | 说明 |
|---|---|---|
| Discrete | keyboard, click, focus, blur, paste | 同步处理,立即触发 re-render |
| Continuous | resize, scroll, mousemove | 可以合并,降低频率 |
| Default | 其他 | 正常优先级 |
全屏模式 vs 普通模式
普通模式(Main Screen) 全屏模式(Alt Screen)
───────────────────── ─────────────────────
共享终端滚动历史 独立的全屏画布
光标可以超出屏幕底部 光标被限制在屏幕内
相对光标移动 绝对光标定位 CSI H
无鼠标支持 鼠标跟踪(mode 1003)
内容追加到 scrollback 退出时恢复原始屏幕
全屏模式由 <AlternateScreen> 组件触发:
<AlternateScreen>
{/* 这里面的所有内容都在全屏画布上渲染 */}
<ScrollBox>
<Messages />
</ScrollBox>
<PromptInput />
</AlternateScreen>
滚动的平滑处理
ScrollBox 的滚动不是一次性跳到目标位置,而是分帧消耗:
// src/ink/ink.tsx (lines 757-759)
const SCROLL_MAX_PER_FRAME = 4 // 每帧最多滚动 4 行
// pendingScrollDelta 分多帧消耗
// 例如鼠标滚轮一次滚 12 行
// 帧 1: 消耗 4 行,剩余 8
// 帧 2: 消耗 4 行,剩余 4
// 帧 3: 消耗 4 行,剩余 0
这样做的好处是避免一次滚轮事件触发一次全量重绘。中间帧可以用 DECSTBM 硬件滚动,极低的传输成本(~50 字节 vs ~2000 字节)。
文本选择与搜索
引擎还实现了终端中的文本选择和搜索高亮:
// src/ink/ink.tsx (lines 534-551)
// 选择:反转前景色和背景色
function applySelectionOverlay(screen, selection) {
for (cell in selectionRange) {
// 交换前景色和背景色
cell.styleId = invertedStyleId
}
}
// 搜索:高亮匹配文本
function applySearchHighlight(screen, matches) {
for (cell in matchRange) {
cell.styleId = highlightStyleId // 黄底黑字
}
}
这些叠加层在差量计算之前应用,所以它们和正常内容一样高效。
性能数据
| 优化项 | 效果 |
|---|---|
| Cell 压缩(8 字节 vs 64+ 字节) | 内存减少 8 倍 |
| 脏标记 + Blit 跳过 | 大部分帧只重绘 <10% 的 Cell |
| StylePool 转换缓存 | 热路径零内存分配 |
| CharCache 跨帧缓存 | 大部分行不需要重新 tokenize |
| DECSTBM 硬件滚动 | 滚动操作从 ~2KB 降到 ~50 字节 |
| BigInt64Array.fill | 清屏一条指令,O(1) |
| 池重置(每 5 分钟) | 防止长会话内存无限增长 |
小结
这个自研渲染引擎体现了"在约束中做到极致"的工程哲学:
- React 组件模型:用熟悉的声明式 API 写终端 UI,但底层完全自定义
- Yoga Flexbox:终端里也能用 Flexbox 布局,和 CSS 几乎一样的 API
- 压缩 Cell 表示:每个字符 8 字节,Int32 比较代替对象比较
- 三级缓存:CharPool + StylePool + HyperlinkPool,热路径零分配
- 脏标记 + Blit:没变的子树直接从前一帧复制,跳过整棵渲染
- DECSTBM 硬件滚动:利用终端自身的滚动能力,传输量降 40 倍
- 事件系统:完整的捕获/冒泡,和浏览器 DOM 事件一致
- 焦点管理:Tab 循环、自动聚焦、栈式恢复
一个有趣的对比:Chrome 浏览器的渲染引擎 Blink 有几百万行代码。这个终端渲染引擎只有 ~7000 行,但覆盖了布局、渲染、差量更新、事件、焦点、选择、搜索、滚动等完整功能。约束(终端只有字符 Cell)反而简化了很多问题。