# Step 64: 最小终端渲染引擎
一句话导读:用
box/text虚拟节点描述界面,算成一屏文本,再用行级 diff 只重画变化的行,终端 UI 从console.log变成可组合的数据树。
# 一、这一步做了什么(What)
step64 在 ink/ 目录里手写一个玩具版 Ink:dom.ts 定义虚拟节点,render.ts 把节点树布局成多行文本,logUpdate.ts 把新旧帧做 diff 后写到终端。
它没有接入主 agent,只是 UI 底层实验。目的不是做完整终端界面,而是看懂真实 Ink/React 终端 UI 的地基。
# 二、面试官视角:为什么要做?(Why)
面试题:终端不就是打印字符串吗,为什么 Claude Code 需要 Ink 这种 UI 框架?
因为 agent UI 是动态的:流式文本在变、spinner 在转、工具状态在更新、权限弹窗会出现。一直 console.log 会把屏幕刷成流水账,无法更新已有区域。
Ink 的核心是“界面即数据”:
| console.log | Ink 思路 |
|---|---|
| 直接输出,不可更新 | 先描述界面树,再渲染 |
| 每次追加一行 | 同一屏幕局部刷新 |
| 状态散在输出里 | 状态驱动 UI 树 |
# 三、原理:它是怎么工作的(How)
节点模型在 step64/ink/dom.ts:
export function text(s: string | number): TextNode
export function box(direction: "column" | "row", ...children: Node[]): BoxNode
2
布局在 renderToLines():
export function renderToLines(node: Node): string[] {
if (node.type === "text") return String(node.value).split("\n");
const blocks = node.children.map(renderToLines);
// column 上下堆叠,row 左右拼接
}
2
3
4
5
diff 刷屏在 LogUpdate.render():
export class LogUpdate {
private prev: string[] = [];
render(next: string[]) {
// 上移到上一帧顶部
// 新旧逐行比较:变了才清行重写,没变只换行跳过
this.prev = next;
}
}
2
3
4
5
6
7
8
数据流:
box("column", text("title"), box("row", text("⠋"), text(" loading")))
→ renderToLines
→ ["title", "⠋ loading"]
→ LogUpdate 和上一帧比较
→ 只清除并重写变化行
2
3
4
5
# 四、深入追问(面试常见 follow-up)
Q:为什么只做行级 diff,不做字符级 diff?
A:教学版要抓住“不清屏重画”的本质。行级 diff 已能证明标题/页脚不闪;真实 Ink 再进一步做 ANSI/单元格级 diff。
Q:row 布局为什么要补空格?
A:左右拼接时每个子块高度和宽度可能不同。短块需要补空格,下一块才能对齐到正确列。
Q:为什么 UI 要先变成节点树?
A:节点树让 UI 可组合、可 diff、可被 React reconciler 驱动。step65 接 JSX 正是依赖这层。
Q:这和浏览器 DOM 有什么相似?
A:都是先构造虚拟结构,再 commit 到宿主环境。区别是浏览器宿主是 DOM,终端宿主是一屏文本和 ANSI 控制码。
# 五、踩坑 / 设计权衡
教学版用 s.length 算宽度,不处理中文全角、emoji、ANSI 颜色码。真实终端 UI 必须处理显示宽度,否则对齐会乱。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
box/text 虚拟节点 | src/ink/dom.ts 更完整节点类型 |
renderToLines column/row | yoga 布局、折行、padding、边框 |
LogUpdate 行级 diff | log-update.ts + 单元格/ANSI diff |
| 简单字符串宽度 | line-width-cache 处理全角和 ANSI |
# 七、一句话总结
终端 UI 的关键不是“打印更多字符串”,而是把界面抽成数据树,再通过 diff 把变化最小化地提交到终端。
# 下一节预告
下一篇是 step65 手写 mini-React:让 JSX、函数组件和 useState 驱动这棵终端节点树。