Ink 渲染器
Claude Code 如何将 React 组件树转换为终端输出——在微秒内从协调器提交到屏幕差异。
每一帧——无论是由旋转器刻度、流令牌还是窗口大小调整触发——都会经历七个阶段。 单击任意阶段即可查看其中发生的情况。
commit
脏旗
calculateLayout()
位块传输或写入
剪辑/位块传送/写入
每个单元的存储
ANSI 到标准输出
react-reconciler 主机已连接到 ink自己的DOM 而不是浏览器的。 当 React 提交树更新时,它会调用主机方法,例如 createInstance, commitUpdate, appendChild, 和 removeChildFromContainer。 每一个调用都会落在 dom.ts 它将突变镜像到 Yoga 节点树中并调用 markDirty() 沿着祖先链向上走,为每个父代设置脏标志。 在每次提交结束时,React 都会调用 resetAfterCommit(rootNode),这会触发 onComputeLayout() then onRender() — 到 Yoga 和渲染器的两阶段切换。
DOMElement 与一个 nodeName (ink-root, ink-box, ink-text, ink-virtual-text, ink-raw-ansi, ETC。), style, attributes, childNodes,一个可选的 yogaNode,和一个 dirty 布尔值。 脏标志是渲染器的主要信号 - 布局位置未更改的干净节点可以从前一帧中位块传输,而不是重新渲染,从而为稳态帧(如旋转器刻度或附加到固定高度框的流令牌)提供 O(更改的单元)性能。
layout/yoga.ts。 适配器翻译 Ink 的摘要 LayoutNode 接口(带有字符串枚举,例如 'flex-start') 转换为 Yoga 的数字常量。 calculateLayout(width, height) 在镜像 Yoga 树上运行完整的 Flexbox 算法,填充 getComputedLeft(), getComputedTop(), getComputedWidth(), 和 getComputedHeight() 在每个节点上。 文本节点得到自定义 测量功能 在计算布局之前回调到 JS 来测量渲染的文本宽度。 Yoga 缓存布局结果 - 未更改的节点不会重新计算。 这 getYogaCounters() 调用提交日志暴露 visited, measured, cacheHits, 和 live 用于分析。
prevScreen 可用,发出一个 output.blit() 调用从前一帧的屏幕复制单元格。 这可以避免完全下降到子树中。 (c) 对于脏节点,发出 output.clear() 在旧位置,然后渲染节点: ink-text 节点经过挤压→包裹→样式→写入; ink-box 节点设置剪辑区域并递归; ink-raw-ansi 节点直接写入预先构建的 ANSI 字符串。 这 nodeCache 存储每个渲染节点的 {x, y, width, height} 用于下一帧位块传送检查。
Output 是命令缓冲区的抽象。 在树遍历期间,它按顺序收集操作: write (x,y 处的文本 + ANSI), blit (从上一个屏幕复制一个区域), clear (将矩形归零), clip/unclip (推入/弹出溢出的剪切矩形:隐藏), shift (硬件滚动 — DECSTBM 行),以及 noSelect (标记不可选择的装订线)。 步行期间不执行操作。 output.get() 分两遍重播:第 1 课 遍从所有空白区域扩展伤害边界框; 第 2 课 步按顺序执行每个操作 — 通过 blitting 行 TypedArray.set(),应用剪辑交叉点,并将书面文本标记为 ClusteredChar[] 通过 charCache (跨帧持续 - 大多数行不会改变,因此 tokenize+grapheme 是缓存命中)。
CharPool)。 字 1 = styleId[31:17] | 超链接 ID[16:2] | 单元格宽度[1:0]。 这种布局将 diff 循环中的内存访问减半,并消除了每个单元的对象分配(否则 200×120 终端将分配 24,000 Cell 每帧的对象)。 CharPool and HyperlinkPool 在两个缓冲区之间共享,因此 blit 可以直接复制整数 ID,而无需重新驻留。 屏幕上还带有一个 damage 边界框,每个单元格 noSelect 位图和每行 softWrap 用于选择副本的数组。
StylePool.transition(fromId, toId) — 在首次调用任何给定对后缓存为预序列化字符串)以及字符本身。 宽字符(CJK、表情符号)写入间隔单元格,以便光标定位保持正确。 结果是写入标准输出的最小字节序列。
React 的协调器被设计为可重定向的。 通过实现主机配置接口,Ink 免费获得 React 的所有状态管理、钩子、Suspense 和并发功能 - 针对终端而不是 DOM。
resetAfterCommit calls onComputeLayout() 首先,然后 onRender()。 这种两相分离是承重的:瑜伽的 calculateLayout() 必须在渲染器读取任何内容之前完成 getComputedLeft() / getComputedTop() 价值观。 在测试模式下,仅 onImmediateRender() 被调用以完全跳过异步渲染周期。
脏标志是渲染器中最重要的优化。 它支持位块传送快速路径,使稳态帧接近自由。
FocusManager。 总是有一个yogaNode。<Box>。 拥有具有完全弹性属性的yogaNode。 处理溢出剪切、滚动、边框、背景。<Text>。 有 YogaNode 和自定义 测量功能 回调 JS 获取文本宽度。 挤压子文本节点以进行渲染。<Text> 嵌套在另一个里面 <Text>。 没有 YogaNode — 对布局引擎不可见。 用于内联样式。Ink 使用与 React Native 相同的 Yoga 布局引擎。 布局适配器在 layout/yoga.ts 包装 Yoga 的 C API,将字符串枚举转换为整数常量并抽象出 WASM 内存管理。
当 DOM 节点被删除时,协调器会调用 cleanupYogaNode(),这称为 clearYogaNodeReferences(node) BEFORE yogaNode.freeRecursive()。 顺序很重要:先释放,然后清除,这会在任何仍然保留节点引用的并发代码中留下悬空的 WASM 内存引用。 该评论明确警告了这一点。
blit 快速路径是将 60fps 终端渲染器与每次击键时重新绘制所有内容的终端渲染器区分开来的。 关键不变量:当且仅当节点是干净的、其位置没有改变并且前一屏幕有效时,节点才可以安全地进行 blit。
屏幕缓冲区是渲染器的中心数据结构。 其中的每个设计决策都是为了消除分配并最小化 diff 循环中的内存带宽。
索引到 CharPool 中
15 位 → StylePool 索引×2 + 空间可见
15 位 → 超链接池
Narrow/Wide/SpacerTail/SpacerHead
重置+写入每一帧。
框架端
blit 快速路径 + 单元格差异。
改变了细胞
写入标准输出。
createRenderer 返回一个每帧调用一次的闭包。 它捕捉到一个 Output 跨帧实例,因此 charCache 持续存在——稳态渲染的主要性能优势。
- 输出实例(
charCache) - CharPool + HyperlinkPool(共享)
- StylePool(会话生存)
- nodeCache(blit 矩形)
- 前/后屏幕缓冲区(交换)
- 输出操作列表(清除)
- 屏幕单元格数据(零填充)
- 损坏边界框
- 滚动提示、滚动漏节点
- 布局移位标志
- 脏标志是核心优化。 一个干净的节点,其布局与上一个屏幕的 blit 相同 - 无需重新渲染,无需重新标记,无需重新样式。 稳态帧(旋转刻度、流附加)接触 O(更改的单元格),而不是 O(总单元格)。
- Yoga 是一个 WASM Flexbox 引擎,而不是 CSS 解析。 Ink 运行与 React Native 相同的布局算法。 布局适配器在
layout/yoga.ts将字符串枚举转换为 Yoga 的整数常量,使代码库的其余部分与 WASM 绑定分离。 - 屏幕缓冲区是一个类型化数组,而不是对象。 连续 ArrayBuffer 中每个单元 2 个 Int32 消除了每个单元的 GC 压力。 跨两个缓冲区共享 CharPool 和 HyperlinkPool 意味着 blit 复制整数 ID,无需重新驻留 - diff 循环比较整数,而不是字符串。
- Output 类是命令缓冲区,而不是立即渲染器。 操作在树遍历期间收集并在
output.get()。 这种分离允许剪辑区域和 blit 操作正确交互,并让第 1 课 遍在第 2 课 遍执行任何操作之前计算损坏边界框。 - Alt-screen 模式具有三个互锁的不变量。 瑜伽高度固定至
terminalRows。 视口高度伪造为terminalRows + 1。 光标 y 固定到terminalRows - 1。 每一个的存在都是为了防止不同的故障模式:分别是内容溢出、全屏清除触发和光标恢复LF滚动。 - 协调器主动避免将事件处理程序标记为脏。 将处理程序存储在
node._eventHandlers而不是node.attributes意味着创建新回调闭包的父级重新渲染不会触发重绘。 防止处理程序流失导致视觉闪烁的关键设计。 - Output 中的 charCache 是分词器热路径加速器。 大多数线条在帧之间不会改变。 通过跨帧持久保存 charCache(绑定内存的上限为 16384 个条目),标记化 + 字素聚类成为未更改行的映射查找。 这是流响应的主要胜利——每个新令牌仅标记其自己的行。
setState()。 渲染器为其干净的同级渲染器采用哪条路径?renderNodeToOutput set prevScreen = undefined 当绝对定位的节点被删除时?viewport.height = terminalRows + 1?