# 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
1
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 左右拼接
}
1
2
3
4
5

diff 刷屏在 LogUpdate.render():

export class LogUpdate {
  private prev: string[] = [];
  render(next: string[]) {
    // 上移到上一帧顶部
    // 新旧逐行比较:变了才清行重写,没变只换行跳过
    this.prev = next;
  }
}
1
2
3
4
5
6
7
8

数据流:

box("column", text("title"), box("row", text("⠋"), text(" loading")))
  → renderToLines
  → ["title", "⠋ loading"]
  → LogUpdate 和上一帧比较
  → 只清除并重写变化行
1
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 驱动这棵终端节点树。