来源与授权
本文来自 Claude-Code-Source-Study 原文,固定导入版本为 31b6e07a82d4。Copyright (c) 2026 Yao,依据 MIT License 提供。
本站仅按“github-public-noncommercial-2026-07-11”用于匿名、免费、非商业学习,并调整章节编排、站内链接、重复标题与阅读标记;不代表原作者对本站的合作、认证或背书。如权利方要求删除,我们会立即下架;授权或删除请求请通过本站首页底部公开联系方式联系。
本章是《深入 Claude Code 源码》系列第 26 章,也是"终端 UI 与多模态输入"这一篇的开篇。前面几章我们走完了网络层的两条暗线(Bridge IPC、DirectConnect 与 Upstream Proxy),从这一章起视线转回 CLI 本机:用户敲下回车看到的那一帧画面,到底是怎么从一棵 React 树变成屏幕上 ANSI 字节的。
本章深入 Claude Code 的 forked Ink 框架(
ink/目录,96 个.ts/.tsx文件、19,842 行),揭示如何在终端中构建一个完整的 React 渲染引擎:从自定义 Reconciler、Yoga 布局、双缓冲渲染管线,到虚拟滚动、鼠标事件、文本选择等深度定制。这一版还要把视线拉到与ink/配套的native-ts/目录——团队把原本走 WASM / NAPI 的三段原生模块(yoga-layout/color-diff/file-index)重新写成了纯 TypeScript 实现,Ink 的布局引擎就是其中第一段。术语速查:本章会反复出现几个 ANSI / 终端协议缩写——DECSTBM(DEC Set Top/Bottom Margin,CSI
r,定义滚动区域上下边界,让终端用硬件指令滚动而非重写整屏)、DEC 2026("Synchronized Output",CSI?2026h/l,把一帧多次写入包成 BSU/ESU 块让终端原子刷新避免半帧闪烁)、OSC 8(OSC8;params;URI ST序列,把一段文本标成可点击超链接,iTerm2/WezTerm 支持)、DA1(Primary Device Attributes,CSIc,VT100 起所有终端都会回的"自报家门"查询,本章用它做能力探测哨兵)、BiDi(Bidirectional Text,阿拉伯文/希伯来文等 RTL 字符在 LTR 文本里的视觉重排算法)。后文出现时不再展开。
为什么要 Fork Ink?
Ink 是一个开源框架,让你在终端中使用 React 组件编写 UI。官方 Ink 适合简单的 CLI 工具 —— 但 Claude Code 不是简单的 CLI。它需要:
- 全屏模式:Alt Screen 下的完整 UI,不是"追加式"输出
- 虚拟滚动:对话历史可能有上千行,不能全部渲染
- 鼠标交互:点击、拖拽选择文本、滚轮滚动
- 60fps 渲染:流式输出时每 16ms 刷新一帧,不能闪烁
- IME 支持:CJK 输入法需要物理光标精确定位
官方 Ink 不支持这些。Claude Code 团队 fork 了 Ink 并进行了大量深度定制,最终形成了一个功能完备的终端 React 渲染引擎。
一、架构全景:从 React 到终端像素
整个渲染管线可以用以下架构图概括:
核心流程简述
- React Reconciler 将 JSX 变更映射到 Ink DOM 树(
DOMElement/TextNode) - Yoga 对 DOM 树执行 Flexbox 布局计算
- renderNodeToOutput 遍历 DOM 树,生成 write/blit/clip 操作
- Output.get() 将操作应用到 Screen 缓冲区(二维字符网格)
- LogUpdate.render() 对比前后帧的 Screen,生成最小 Patch 序列
- optimizer 合并冗余 Patch
- 最终序列化为 ANSI 转义码写入 stdout
二、Reconciler:React 与终端 DOM 的桥梁
2.1 自定义 React Reconciler
reconciler.ts 使用 react-reconciler 库创建了一个自定义的 React Reconciler —— 这与 react-dom 使用同样的底层机制,只是目标不是浏览器 DOM,而是 Ink 的终端 DOM。
// ink/reconciler.ts:224-239
const reconciler = createReconciler<
ElementNames, // 'ink-root' | 'ink-box' | 'ink-text' | ...
Props, // Record<string, unknown>
DOMElement, // Ink 的 DOM 元素
DOMElement, // Container 类型
TextNode, // 文本节点
...
>({
getRootHostContext: () => ({ isInsideText: false }),
// ...
})
关键概念:Reconciler 定义了 React 如何操作"宿主环境"。在浏览器中,宿主环境是 HTML DOM;在 Ink 中,宿主环境是一套内存中的轻量级 DOM 结构。
2.2 Ink DOM:7 种元素类型
Ink 定义了自己的 DOM 元素体系(dom.ts:18-27):
// ink/dom.ts:19-27
export type ElementNames =
| 'ink-root' // 根节点
| 'ink-box' // 容器(等价于 HTML div)
| 'ink-text' // 文本容器
| 'ink-virtual-text' // 嵌套文本(Text 内的 Text)
| 'ink-link' // 超链接
| 'ink-progress' // 进度条
| 'ink-raw-ansi' // 预渲染 ANSI 内容
每个 DOMElement 携带丰富的状态字段(dom.ts:31-91):
export type DOMElement = {
nodeName: ElementNames
attributes: Record<string, DOMNodeAttribute>
childNodes: DOMNode[]
style: Styles
yogaNode?: LayoutNode // Yoga 布局节点
dirty: boolean // 是否需要重新渲染
isHidden?: boolean // 隐藏状态
_eventHandlers?: Record<string, unknown> // 事件处理器
// 滚动状态(overflow: scroll)
scrollTop?: number
pendingScrollDelta?: number // 累积未消耗的滚动增量
scrollHeight?: number
scrollViewportHeight?: number
stickyScroll?: boolean // 自动吸底
// 焦点管理
focusManager?: FocusManager // 仅 ink-root 拥有
debugOwnerChain?: string[] // 调试用组件栈
} & InkNode
2.3 脏标记与渲染调度
当 React 更新 props 或子节点时,Reconciler 调用 markDirty() 向上冒泡标记整条祖先链为脏:
// ink/dom.ts:393-413
export const markDirty = (node?: DOMNode): void => {
let current: DOMNode | undefined = node
let markedYoga = false
while (current) {
if (current.nodeName !== '#text') {
(current as DOMElement).dirty = true
// 仅对叶子节点(ink-text / ink-raw-ansi)标记 yoga dirty
if (!markedYoga &&
(current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') &&
current.yogaNode) {
current.yogaNode.markDirty()
markedYoga = true
}
}
current = current.parentNode
}
}
设计要点:
- DOM 级
dirty冒泡是 O(depth),非常廉价 - Yoga
markDirty()仅在叶子文本节点上调用 —— 因为只有文本节点有measureFunc,需要重新测量 - 属性变更时做 shallow equal 检查避免无谓的 dirty(
setStyle、setTextStyles、setAttribute都有守卫)
2.4 React 19 适配
Reconciler 包含了对 React 19 的适配(reconciler.ts:425-506):
// React 19: commitUpdate 直接接收新旧 props,不再用 updatePayload
commitUpdate(node, _type, oldProps, newProps): void {
const props = diff(oldProps, newProps)
const style = diff(oldProps['style'], newProps['style'])
// ... 应用差异
},
// React 19 必需方法
maySuspendCommit: () => false,
preloadInstance: () => true,
startSuspendingCommit: () => {},
suspendInstance: () => {},
waitForCommitToBeReady: () => null,
三、布局引擎:Yoga 的抽象与适配
3.1 LayoutNode 抽象层
Claude Code 没有直接使用 Yoga API,而是定义了一个 LayoutNode 接口作为抽象层(layout/node.ts:93-152):
// ink/layout/node.ts:93-152
export type LayoutNode = {
// 树操作
insertChild(child: LayoutNode, index: number): void
removeChild(child: LayoutNode): void
getChildCount(): number
// 布局计算
calculateLayout(width?: number, height?: number): void
setMeasureFunc(fn: LayoutMeasureFunc): void
markDirty(): void
// 读取布局结果
getComputedLeft(): number
getComputedTop(): number
getComputedWidth(): number
getComputedHeight(): number
// 样式设置(Flexbox 全套)
setWidth(value: number): void
setFlexDirection(dir: LayoutFlexDirection): void
setDisplay(display: LayoutDisplay): void
setOverflow(overflow: LayoutOverflow): void
// ... 40+ 方法
}
layout/yoga.ts 中的 YogaLayoutNode 是这个接口的唯一实现,将抽象类型映射到真实的 Yoga 常量:
// ink/layout/yoga.ts:54-66
export class YogaLayoutNode implements LayoutNode {
readonly yoga: YogaNode
insertChild(child: LayoutNode, index: number): void {
this.yoga.insertChild((child as YogaLayoutNode).yoga, index)
}
// ...
}
为什么要这层抽象? Yoga 上游本是 C++,社区主流走法是 yoga-layout 的 WASM 绑定——但 Claude Code 把 Yoga 整段重写成了纯 TypeScript,落在 native-ts/yoga-layout/(见下文第九节)。LayoutNode 接口的存在让 ink/layout/yoga.ts 这层只关心"布局语义"而不绑定到任何一份 Yoga 实现:理论上替换布局引擎不需要动到 reconciler,实际上目前只有 YogaLayoutNode 一个实现,但它指向的已经不是 WASM 而是同进程的 JS 对象——ink/layout/yoga.ts:302 的注释把这事讲得很直白:"no WASM loading, no linear memory growth, so no preload/swap/reset machinery is needed"。
3.2 文本测量
Yoga 的 measureFunc 是布局的关键 —— 它告诉 Yoga 一个文本节点在给定宽度约束下的实际尺寸:
// ink/dom.ts:332-374
const measureTextNode = function (
node: DOMNode,
width: number,
widthMode: LayoutMeasureMode,
): { width: number; height: number } {
const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
const text = expandTabs(rawText)
const dimensions = measureText(text, width)
// 文本宽度小于容器 → 无需换行
if (dimensions.width <= width) return dimensions
// Undefined 模式 + 含换行 → 用自然宽度(避免高度膨胀)
if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
return measureText(text, Math.max(width, dimensions.width))
}
// 需要换行
const textWrap = node.style?.textWrap ?? 'wrap'
const wrappedText = wrapText(text, width, textWrap)
return measureText(wrappedText, width)
}
3.3 布局计算时机
布局计算发生在 React 的 commit 阶段(reconciler.ts:247-258),由 resetAfterCommit 触发:
// ink/ink.tsx:239-258 (onComputeLayout callback)
this.rootNode.onComputeLayout = () => {
if (this.isUnmounted) return
if (this.rootNode.yogaNode) {
const t0 = performance.now()
this.rootNode.yogaNode.setWidth(this.terminalColumns)
this.rootNode.yogaNode.calculateLayout(this.terminalColumns)
const ms = performance.now() - t0
recordYogaMs(ms)
}
}
时序:React commit → resetAfterCommit → onComputeLayout()(Yoga 布局)→ onRender()(渲染到终端)。这保证 useLayoutEffect 可以读到最新布局。
四、渲染管线:从 DOM 到 Screen 缓冲区
4.1 双缓冲 + 帧节流
Ink 类(ink.tsx)维护两个 Frame 对象实现双缓冲:
// ink/ink.tsx:99-100
private frontFrame: Frame // 当前显示帧
private backFrame: Frame // 后台缓冲帧
渲染通过 throttle 限制到 ~60fps(16ms 间隔):
// ink/ink.tsx:212-216 (scheduleRender)
const deferredRender = (): void => queueMicrotask(this.onRender)
this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, {
leading: true, // 第一次变更立即触发
trailing: true // 间隔结束后再触发一次
})
微任务延迟的设计原因:scheduleRender 从 Reconciler 的 resetAfterCommit 调用,此时 React 的 layout effects 还没执行。使用 queueMicrotask 延迟到 layout effects 完成后再渲染,确保 useDeclaredCursor(IME 光标定位)等 hook 的数据在同一帧内生效。
4.2 Screen:二维字符网格
Screen 是终端的虚拟帧缓冲区。它使用三个共享 Pool 实现内存高效的字符/样式/超链接存储(screen.ts):
// ink/screen.ts:21-53
export class CharPool {
private strings: string[] = [' ', ''] // 0=空格, 1=空
private ascii: Int32Array = initCharAscii() // ASCII 快速查找
intern(char: string): number {
// ASCII 快速路径:直接数组查找而非 Map.get
if (char.length === 1) {
const code = char.charCodeAt(0)
if (code < 128) {
const cached = this.ascii[code]!
if (cached !== -1) return cached
// ...
}
}
// ...
}
}
Pool 设计要点:
CharPool:字符串 → 整数 ID 映射,ASCII 字符直接用 Int32Array 索引(O(1))StylePool:ANSI 样式序列的 interning,transition(fromId, toId)缓存样式转换字符串HyperlinkPool:OSC 8 超链接 URL 的 interning。Ink.onRender()每 5 分钟调用resetPools()同时重置 CharPool 和 HyperlinkPool,防止长会话内存无限增长(ink.tsx:597-603)
4.3 Output:操作收集器
Output(output.ts)负责将 DOM 树遍历结果转化为 Screen 缓冲区。它支持 7 种操作:
| 操作 | 说明 |
|---|---|
write | 在 (x, y) 写入文本 |
blit | 从上一帧 Screen 块拷贝未变化区域 |
shift | 滚动操作:行移位(配合 DECSTBM) |
clip / unclip | 裁剪区域栈(overflow: hidden/scroll) |
clear | 清除区域(节点移除/缩小) |
noSelect | 标记不可选择区域(行号、diff 标记) |
Output.get() 按顺序执行这些操作,最终得到一个完整的 Screen 缓冲区。其中 writeLineToScreen(output.ts:633-797)是热路径,被专门提取为独立函数以优化 JIT 编译:
// ink/output.ts:633-651
function writeLineToScreen(
screen, line, x, y, screenWidth, stylePool, charCache,
): number {
// charCache 缓存:同一行的 tokenize + grapheme clustering 结果跨帧复用
let characters = charCache.get(line)
if (!characters) {
characters = reorderBidi(
styledCharsWithGraphemeClustering(
styledCharsFromTokens(tokenize(line)),
stylePool,
),
)
charCache.set(line, characters)
}
// ... 逐字符写入 Screen
}
charCache 是关键优化:流式输出时大部分行不会变化,缓存 tokenize + grapheme 分簇结果避免每帧重复计算。
4.4 Blit 优化:未变化子树的快速路径
renderNodeToOutput(render-node-to-output.ts)在遍历 DOM 树时,对未变化的子树执行 blit(块拷贝)而非重新渲染:
如果一个节点的 dirty 标记为 false、它的布局位置没有偏移、且上一帧的 Screen 缓冲可信(prevScreen 存在),就直接将上一帧对应区域的 Screen 数据块拷贝到新 Screen —— O(cells) 的 TypedArray 复制代替 O(nodes) 的 DOM 遍历 + 文本测量。
Output 内部会追踪 blit 与 write 的比例:
// ink/output.ts:523-528
const totalCells = blitCells + writeCells
if (totalCells > 1000 && writeCells > blitCells) {
logForDebugging(
`High write ratio: blit=${blitCells}, write=${writeCells} ...`,
)
}
Layout Shift 检测:一个全局标记 layoutShifted 追踪是否有节点的布局位置/尺寸与缓存不同。稳态帧(spinner 旋转、时钟跳动、文本流入固定高度容器)不触发 layout shift,此时窄 damage 范围使 diff 只比较变化区域,而非全屏。
五、差分引擎:最小终端更新
5.1 LogUpdate:Screen Diff
LogUpdate.render()(log-update.ts:123-467)是差分引擎的核心,对比前后帧的 Screen 缓冲区,生成最小更新指令(Patch 序列):
// ink/log-update.ts:123-131
render(prev: Frame, next: Frame, altScreen = false, decstbmSafe = true): Diff {
if (!this.options.isTTY) {
return this.renderFullFrame(next) // 非 TTY:全量输出
}
// ...
}
差分使用 diffEach() 逐 cell 比较两个 Screen,只为变化的 cell 生成光标移动 + 字符写入指令。核心优化包括:
- Damage Region 限定:只在
screen.damage矩形内比较 cell - SpacerTail/SpacerHead 跳过:宽字符(CJK、emoji)的第二列自动跳过
- 空 cell 跳过:空 cell 不覆盖已有内容时跳过(避免尾部空格导致换行)
- 样式转换缓存:
StylePool.transition(fromId, toId)返回预计算的 ANSI 转义字符串
5.2 DECSTBM 硬件滚动优化
当 ScrollBox 的 scrollTop 变化时,log-update 使用终端的硬件滚动区域(DECSTBM):
// ink/log-update.ts:166-185
if (altScreen && next.scrollHint && decstbmSafe) {
const { top, bottom, delta } = next.scrollHint
// 用 DECSTBM + CSI S/T 硬件滚动替代全区域重写
shiftRows(prev.screen, top, bottom, delta)
scrollPatch = [{
type: 'stdout',
content: setScrollRegion(top+1, bottom+1) +
(delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) +
RESET_SCROLL_REGION + CURSOR_HOME,
}]
}
约束:decstbmSafe 参数控制是否使用此优化。tmux 不支持 DEC 2026 同步输出,在 tmux 下 DECSTBM 序列的中间状态会被渲染出来(内容已滚动但边缘行未更新),造成视觉跳动。此时 fallback 到逐行重写。
5.3 Patch 优化器
optimizer.ts 对 Patch 序列做单遍优化:
// ink/optimizer.ts:16-93
export function optimize(diff: Diff): Diff {
// 8 条优化规则,单遍执行:
// 1. 移除空 stdout 内容
// 2. 合并连续 cursorMove
// 3. 折叠连续 cursorTo(只保留最后一个)
// 4. 移除 (0,0) 的 cursorMove
// 5. 拼接相邻 style 转换
// 6. 去重连续相同 URI 的 hyperlink
// 7. 抵消 cursorHide/cursorShow 对
// 8. 移除 count=0 的 clear
}
六、深度定制:超越原版 Ink 的扩展
6.1 虚拟滚动(ScrollBox)
ScrollBox(components/ScrollBox.tsx)是最重要的自定义组件,实现了类似浏览器 overflow: scroll 的终端滚动:
// ink/components/ScrollBox.tsx:82-87
function ScrollBox({ children, ref, stickyScroll, ...style }: ...) {
const domRef = useRef<DOMElement>(null)
// scrollTo/scrollBy 绕过 React:直接修改 DOM 节点的 scrollTop,
// 标记 dirty,调用 scheduleRender —— 零 reconciler 开销
// ...
}
关键设计:
- 绕过 React State:
scrollTo/scrollBy直接修改 DOM 节点的scrollTop属性,通过markDirty()+scheduleRenderFrom()触发 Ink 重渲染。不走setState→ reconcile → commit 的完整 React 流程,避免每次滚轮事件的 reconciler 开销。 - 微任务合并:多次
scrollBy在同一个discreteUpdates批次中累积pendingScrollDelta,然后通过queueMicrotask合并为一次scheduleRender。 - 分帧 Drain:快速滚轮不会一次跳到目标位置,而是每帧只消耗部分
pendingScrollDelta,产生平滑滚动效果。实际的 drain 策略分为两套(render-node-to-output.ts:106-176):xterm.js 终端(VS Code 等)使用自适应阶梯 drain —— 小量(≤5 行)一次消耗完,中等量每帧 2 行,大量(≥12)每帧 3 行,超过 30 行则 snap 多余部分;原生终端(iTerm2/Ghostty 等)使用 proportional drain —— 每帧消耗max(4, floor(abs*3/4))行,大量滚动按对数帧数追赶。两套策略都将单帧最大消耗限制在innerHeight - 1行以内,确保 DECSTBM 硬件滚动快速路径生效。 - Sticky Scroll:
stickyScroll属性自动吸底,新内容增长时自动跟随 —— 这正是流式 AI 输出场景的核心需求。 - Render-time Clamp:
scrollClampMin/scrollClampMax防止快速scrollTo超出已挂载子节点的范围(因为 React 的异步 re-render 可能尚未挂载新节点),避免出现空白屏。
6.2 事件系统:两条派发路径
Ink 的事件系统有两条不同的派发路径,服务于不同类型的事件:
路径一:Dispatcher(键盘、焦点事件)
events/dispatcher.ts 实现了 DOM Level 3 风格的 capture/bubble 两阶段派发模型,目前用于键盘事件和焦点事件。ink.tsx:1272 中键盘按键通过 dispatcher.dispatchDiscrete(target, event) 派发,焦点切换同样走此路径(ink.tsx:234)。
// ink/events/dispatcher.ts:46-79
function collectListeners(target: EventTarget, event: TerminalEvent): DispatchListener[] {
const listeners: DispatchListener[] = []
let node: EventTarget | undefined = target
while (node) {
const isTarget = node === target
const captureHandler = getHandler(node, event.type, true)
const bubbleHandler = getHandler(node, event.type, false)
if (captureHandler) {
listeners.unshift({ node, handler: captureHandler, phase: isTarget ? 'at_target' : 'capturing' })
}
if (bubbleHandler && (event.bubbles || isTarget)) {
listeners.push({ node, handler: bubbleHandler, phase: isTarget ? 'at_target' : 'bubbling' })
}
node = node.parentNode
}
return listeners
}
Dispatcher 还负责将事件类型映射到 React Scheduler 优先级(dispatcher.ts:122-138),影响 React 状态更新的调度:
| 事件类型 | React 优先级 |
|---|---|
| keydown, keyup, click, focus, blur, paste | DiscreteEventPriority(同步) |
| resize, scroll, mousemove | ContinuousEventPriority(可合并) |
| 其他 | DefaultEventPriority |
路径二:dispatchClick / dispatchHover(鼠标事件)
鼠标点击走的是 hit-test.ts 中的 dispatchClick(),它不经过 Dispatcher 的 capture/bubble 机制,而是直接沿 parentNode 链向上冒泡,查找 _eventHandlers.onClick 并调用(hit-test.ts:49-89)。鼠标 hover 事件同样走独立的 dispatchHover(),基于 enter/leave 差分模型而非冒泡。
6.3 Hit Test 与鼠标交互
hit-test.ts 实现了点击坐标到 DOM 节点的映射:
// ink/hit-test.ts:18-41
export function hitTest(node: DOMElement, col: number, row: number): DOMElement | null {
const rect = nodeCache.get(node)
if (!rect) return null
if (col < rect.x || col >= rect.x + rect.width ||
row < rect.y || row >= rect.y + rect.height) {
return null
}
// 后序遍历:后绘制的节点在上层,优先命中
for (let i = node.childNodes.length - 1; i >= 0; i--) {
const child = node.childNodes[i]!
if (child.nodeName === '#text') continue
const hit = hitTest(child, col, row)
if (hit) return hit
}
return node
}
利用 nodeCache(每帧 renderNodeToOutput 写入的布局缓存)做 AABB 碰撞检测,子节点逆序遍历保证后绘制的节点(视觉上在前)优先命中。
6.4 文本选择
selection.ts 实现了完整的终端文本选择系统,包括 anchor + focus 双端点模型、拖拽选择、双击选词、三击选行、以及跨 scroll 的选区保持:
// ink/selection.ts:19-56
export type SelectionState = {
anchor: Point | null // 拖拽起点
focus: Point | null // 当前拖拽位置
isDragging: boolean
anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null
scrolledOffAbove: string[] // 滚出视口上方的已选文本
scrolledOffBelow: string[] // 滚出视口下方的已选文本
virtualAnchorRow?: number // 用于 scroll 后恢复
virtualFocusRow?: number
}
选区作为样式覆盖(反色)直接写入 Screen 缓冲区,被 diff 引擎作为普通 cell 变化处理 —— 没有独立的覆盖层。
6.5 Terminal Querier:无超时终端能力探测
terminal-querier.ts 实现了一个优雅的终端能力查询系统,完全不依赖超时:
// ink/terminal-querier.ts:128-212
export class TerminalQuerier {
private queue: Pending[] = []
send<T>(query: TerminalQuery<T>): Promise<T | undefined> {
return new Promise(resolve => {
this.queue.push({ kind: 'query', match: query.match, resolve })
this.stdout.write(query.request)
})
}
flush(): Promise<void> {
return new Promise(resolve => {
this.queue.push({ kind: 'sentinel', resolve })
this.stdout.write(SENTINEL) // DA1 查询,所有终端都会响应
})
}
}
核心思想:每批查询后发送一个 DA1(Primary Device Attributes)作为哨兵。DA1 是 VT100 以来所有终端都支持的查询,且终端按顺序响应。如果某个查询的响应在 DA1 之前到达,说明终端支持它;如果 DA1 先到达,说明终端忽略了该查询(不支持)。
这比"等 200ms 超时"优雅得多 —— 它精确且无等待。
6.6 焦点管理
focus.ts 实现了类浏览器的焦点系统(FocusManager):
// ink/focus.ts:15-132
export class FocusManager {
activeElement: DOMElement | null = null
private focusStack: DOMElement[] = [] // 最大 32 层
focus(node: DOMElement): void {
const previous = this.activeElement
if (previous) {
// 去重后入栈,支持 Tab 循环时不无限增长
const idx = this.focusStack.indexOf(previous)
if (idx !== -1) this.focusStack.splice(idx, 1)
this.focusStack.push(previous)
this.dispatchFocusEvent(previous, new FocusEvent('blur', node))
}
this.activeElement = node
this.dispatchFocusEvent(node, new FocusEvent('focus', previous))
}
handleNodeRemoved(node: DOMElement, root: DOMElement): void {
// 节点移除时自动从焦点栈中恢复上一个焦点
// ...
}
}
亮点:焦点栈 + 自动恢复。当持有焦点的节点被移除时,自动从栈中弹出最近一个仍在树中的节点作为新焦点 —— 这在对话界面中(工具结果出现/消失、权限对话框关闭)至关重要。
七、性能优化:在终端中追求 60fps
7.1 line-width-cache
流式输出时,已完成的行是不可变的。line-width-cache.ts 缓存每行的 stringWidth 计算结果,避免每帧对数百行不变文本重复测量:
// ink/line-width-cache.ts:1-24
const cache = new Map<string, number>()
const MAX_CACHE_SIZE = 4096
export function lineWidth(line: string): number {
const cached = cache.get(line)
if (cached !== undefined) return cached
const width = stringWidth(line)
if (cache.size >= MAX_CACHE_SIZE) cache.clear() // 全清,下一帧重建
cache.set(line, width)
return width
}
~50x 减少 stringWidth 调用量(据注释说明)。
7.2 node-cache
node-cache.ts 用 WeakMap 缓存每个 DOM 节点的布局矩形:
// ink/node-cache.ts:10-18
export type CachedLayout = {
x: number; y: number; width: number; height: number;
top?: number // yoga getComputedTop(),用于 ScrollBox viewport culling
}
export const nodeCache = new WeakMap<DOMElement, CachedLayout>()
用途:
- Blit 判定:对比缓存位置与当前位置决定是否可以 blit
- Hit Test:点击坐标查找时 O(1) 获取节点矩形
- 光标定位:
useDeclaredCursor读取节点矩形计算物理光标位置
7.3 帧计时与调试工具
FrameEvent 提供细粒度的帧性能数据(frame.ts:38-71):
export type FrameEvent = {
durationMs: number
phases?: {
renderer: number // DOM → Yoga → Screen
diff: number // Screen → Patch[]
optimize: number // Patch 合并
write: number // Patch → stdout
yoga: number // Yoga calculateLayout
yogaVisited: number // layoutNode 调用次数
yogaMeasured: number // measureFunc 调用次数(昂贵部分)
yogaCacheHits: number // 缓存命中次数
yogaLive: number // 活跃 Yoga 节点数(增长=泄漏)
}
flickers: Array<{ reason: FlickerReason; ... }>
}
环境变量 CLAUDE_CODE_COMMIT_LOG 启用 commit 级日志:gap > 30ms、reconcile > 20ms、creates > 50 时自动记录,方便定位帧卡顿来源。
7.4 Sync Output(DEC 2026)
支持 DEC 2026 同步输出协议的终端(iTerm2、WezTerm、Ghostty、VS Code 等)可以将整帧更新包裹在 BSU/ESU 块中,终端在 ESU 到达时原子性地刷新显示,完全消除部分更新导致的闪烁。
SYNC_OUTPUT_SUPPORTED 是在模块加载时同步计算的(terminal.ts:183),使用 isSynchronizedOutputSupported() 基于环境变量做启发式判断(terminal.ts:70-118)—— 检查 TERM_PROGRAM、TERM、KITTY_WINDOW_ID、WT_SESSION、VTE_VERSION 等环境变量来判定终端类型,tmux 下直接返回 false(tmux 不实现 DEC 2026,BSU/ESU 透传到外层终端但 tmux 已经破坏了原子性)。这是一个纯同步的判断,不涉及 TerminalQuerier 的异步查询。
// ink/terminal.ts:181-183
// Computed once at module load — terminal capabilities don't change mid-session.
export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported()
TerminalQuerier 的 DECRQM 查询机制用于探测其他终端能力(如 Kitty keyboard protocol),但 DEC 2026 的支持判断走的是环境变量启发式路径。
八、可迁移的设计模式
模式 1:Pool + 双缓冲渲染管线
将渲染分为"构建缓冲区"和"差分输出"两步。缓冲区中使用 intern pool(CharPool、StylePool)将频繁出现的字符/样式映射为整数 ID,差分阶段只需比较 ID 而非字符串。双缓冲交换确保前台显示帧不被后台计算污染。
适用场景:任何需要高频率增量更新的 UI 系统(终端、游戏、Canvas 渲染器)。
模式 2:绕过 React 的命令式快速路径
对于高频操作(滚轮滚动),直接修改 DOM 属性 + markDirty() + scheduleRender(),绕过 React 的 setState → reconcile → commit 流程。React 管声明式 UI 结构,命令式路径管帧率敏感的状态更新。
适用场景:React 应用中需要 60fps 的交互(动画、拖拽、滚动),Canvas/WebGL 集成。
模式 3:哨兵式能力探测
用已知必有响应的查询(DA1)作为哨兵,将"等超时"变为"等确认"。多个查询批量发送,一个哨兵统一裁决。精确、无等待、无误判。
适用场景:任何需要探测对端能力的协议(终端、SMTP EHLO、HTTP 特性检测)。
九、把 native 模块搬回 JS 进程:native-ts/ 三段重写
如果你只读 ink/,会以为布局、配色、文件检索这些"看起来该用 C++/Rust"的模块还在某个原生绑定里。事实是,Claude Code 在仓库根的 native-ts/ 目录下把它们整段重写成了纯 TypeScript——目前共三段:yoga-layout/(2,712 行)、color-diff/(999 行)、file-index/(370 行),合计 4,081 行 TS。这三个模块原本对应三种不同的"原生形态",重写动机也不一样,但落地姿势一致:保留原 API 表面、消掉 native 依赖。
native-ts/yoga-layout/index.ts 顶部的注释自己给出了第一段重写的取舍:它要"匹配 yoga-layout/load 的 API 表面,恰好覆盖 Ink 用到的 flexbox 子集"——flex-direction / flex-grow / flex-shrink / flex-basis / align-items / justify-content / 六向 margin/padding/border/gap / position: absolute / display: flex|none / measureFunc 这些是 Ink 真正调到的;多遍 flex clamping、flex-wrap、align-content、align-items: baseline、display: contents、margin: auto 这些是"为对齐 spec 一并实现";aspect-ratio / box-sizing: content-box / RTL 这些"Ink 不用"的就索性不实现。ink/layout/yoga.ts 的引入路径直接落在 'src/native-ts/yoga-layout/index.js'——Ink 内部没有保留 WASM 退路。
第二段 color-diff/ 替换的是一个 Rust + NAPI 模块。原 Rust 版本用 syntect + bat 做语法高亮、用 similar crate 做 word diff;TS 版本改用 highlight.js(仓库里已经因 cli-highlight 拉过)+ diff npm 包的 diffArrays。重写后语义不是完全等价——头部注释明说"hljs 的语法定义有空洞:纯标识符与 = : 这类运算符没有 scope,因此会落在默认前景色而不是 syntect 习惯的白/粉"——但输出结构(行号、标记、底色、word-diff 区段)保持一致。这段额外讲究的是惰性加载:highlight.js 一次性注册 190+ 语法、加载时占 ~50MB / 100–200ms(Windows 上更慢),所以 TS 版本沿用了原 NAPI wrapper 的 dlopen 风格——cachedHljs 只在第一次渲染时 require('highlight.js'),避免被 StructuredDiff.tsx 的导入链拖进进程冷启动路径。
第三段 file-index/ 替换的是 vendor/file-index-src——一个包了 nucleo(helix-editor 出的 fzf 风格模糊搜索库)的 Rust NAPI 模块。TS 版本逐字段复刻了 nucleo 的打分常量:SCORE_MATCH=16、BONUS_BOUNDARY=8、BONUS_CAMEL=6、BONUS_CONSECUTIVE=4、BONUS_FIRST_CHAR=8、PENALTY_GAP_START=3、PENALTY_GAP_EXTENSION=1,所以输出排序行为与原模块对齐。这段最值得提的是它选择"按时间切片"而不是"按数量切片"——CHUNK_MS = 4,每跑 4ms 就 await 一次让出事件循环;M 系列芯片下 5k 路径打分约 2ms(按 native-ts/file-index/index.ts 顶部注释自述),但老 Windows 机器可能要 15ms+,按时间切片让慢机也能保持响应。
把这三段叠起来看,会发现 native-ts/ 的设计意图很统一:Claude Code 是一个长会话 CLI,而不是一次性的渲染任务。每多一段 WASM / NAPI,就多一份冷启动延迟、多一处跨平台二进制分发、多一条 OOM / dlopen 失败的链路。把这些都搬回 JS 进程后,整个 CLI 重新变成"纯 npm 包",安装体验回到 npm i 直接能跑——这是 ink/layout/yoga.ts:302 那句"no WASM loading"背后真正想表达的事。代价当然存在:纯 TS Yoga 比 C++ 慢(章节六中提到的 recordYogaMs 帧统计,正是为了在真机上盯着这个代价是否还可接受),highlight.js 的语法覆盖比 syntect 少几个 token,纯 JS 模糊搜索吃 CPU 比 Rust 多——但都被换成了"可以在长会话里 hot-fix 的纯 TS 代码"。这就是为什么这一节要把视线从 ink/ 拉到 native-ts/:原生模块的取舍,本来就是 Ink 渲染管线的延伸。
下一章预告
我们将深入 components/design-system/ 16 个文件,解析一套完整的终端 UI 设计系统如何构建——从主题系统、基础组件到工具 UI 协议。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)