diff --git a/docs/guide/12-ink-render-engine.md b/docs/guide/12-ink-render-engine.md new file mode 100644 index 0000000..cc56c2a --- /dev/null +++ b/docs/guide/12-ink-render-engine.md @@ -0,0 +1,603 @@ +# 自研 Ink 渲染引擎:在终端里跑一个 React + +## 一句话理解 + +Claude Code 的终端界面不是简单地 `console.log` 输出文字。它背后是一个**完整的 UI 渲染引擎**——用 React 写组件,用 Yoga 做 Flexbox 布局,用双缓冲做差量更新,还支持滚动、焦点管理、鼠标点击和文本选择。这就是一个跑在终端里的"浏览器渲染引擎"。 + +> **比喻**:浏览器有 DOM → 布局 → 绘制 → 合成 这条渲染管线。Claude Code 在终端里重建了同样的管线:虚拟 DOM → Yoga 布局 → 屏幕缓冲区 → 差量输出到终端。 + +## 为什么不用现成的 Ink + +[Ink](https://github.com/vadimdemedes/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 │ +│ │ +│ │ +│ Hello │ +│ ... │ +│ │ +│ │ +│ → 生成/更新虚拟 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 + +### 节点类型 + +```typescript +// src/ink/dom.ts +type ElementNames = + | 'ink-root' // 根节点 + | 'ink-box' // 容器(对应 ) + | 'ink-text' // 文本(对应 ) + | 'ink-virtual-text' // 嵌套文本(Text 内的 Text) + | 'ink-link' // 超链接(OSC 8) + | 'ink-progress' // 进度条 + | 'ink-raw-ansi' // 预渲染的 ANSI 字符串 +``` + +### 节点结构 + +```typescript +// src/ink/dom.ts +type DOMElement = { + nodeName: ElementNames + childNodes: DOMNode[] + attributes: Record + yogaNode?: LayoutNode // Yoga 布局节点 + dirty: boolean // 是否需要重新渲染 + + // 滚动状态 + scrollTop?: number + pendingScrollDelta?: number // 待消耗的滚动距离 + scrollHeight?: number // 内容总高度 + scrollViewportHeight?: number // 可视区高度 + + // 焦点 + focusManager?: FocusManager + + // 事件处理器(存在这里避免每次 render 重新绑定) + _eventHandlers?: Record +} +``` + +### 脏标记传播 + +当一个节点发生变化时,脏标记会**向上传播到根节点**: + +```typescript +// 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 字节)**: + +```typescript +// 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 分钟重置一次,防止长时间运行导致无限增长。 + +### 屏幕操作 + +```typescript +// 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 对比**前后两帧,只输出发生变化的部分: + +```typescript +// 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 (重置滚动区域) +``` + +```typescript +// 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 复制 │ │ 重新渲染 │ │ +│ │ 前一帧的 │ │ 新内容 │ │ +│ │ 对应区域 │ │ │ │ +│ └──────────┘ └────────────┘ │ +└─────────────────────────────┘ +``` + +```typescript +// 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 绑定调用: + +```typescript +// 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 几乎一样: + +```tsx +// 水平排列,间距 1 + + + 侧边栏 + + + 主内容区(占满剩余空间) + + +``` + +### 文本测量 + +文本节点有特殊的测量函数,告诉 Yoga 这段文本需要多大空间: + +```typescript +// 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 +} +``` + +## 焦点管理 + +```typescript +// 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 事件一样,事件分**捕获阶段**和**冒泡阶段**: + +```typescript +// 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 退出时恢复原始屏幕 +``` + +全屏模式由 `` 组件触发: + +```tsx + + {/* 这里面的所有内容都在全屏画布上渲染 */} + + + + + +``` + +## 滚动的平滑处理 + +ScrollBox 的滚动不是一次性跳到目标位置,而是**分帧消耗**: + +```typescript +// 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 字节)。 + +## 文本选择与搜索 + +引擎还实现了终端中的**文本选择**和**搜索高亮**: + +```typescript +// 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 分钟) | 防止长会话**内存无限增长** | + +## 小结 + +这个自研渲染引擎体现了**"在约束中做到极致"**的工程哲学: + +1. **React 组件模型**:用熟悉的声明式 API 写终端 UI,但底层完全自定义 +2. **Yoga Flexbox**:终端里也能用 Flexbox 布局,和 CSS 几乎一样的 API +3. **压缩 Cell 表示**:每个字符 8 字节,Int32 比较代替对象比较 +4. **三级缓存**:CharPool + StylePool + HyperlinkPool,热路径零分配 +5. **脏标记 + Blit**:没变的子树直接从前一帧复制,跳过整棵渲染 +6. **DECSTBM 硬件滚动**:利用终端自身的滚动能力,传输量降 40 倍 +7. **事件系统**:完整的捕获/冒泡,和浏览器 DOM 事件一致 +8. **焦点管理**:Tab 循环、自动聚焦、栈式恢复 + +> **一个有趣的对比**:Chrome 浏览器的渲染引擎 Blink 有几百万行代码。这个终端渲染引擎只有 ~7000 行,但覆盖了布局、渲染、差量更新、事件、焦点、选择、搜索、滚动等完整功能。约束(终端只有字符 Cell)反而简化了很多问题。 diff --git a/docs/guide/_meta.json b/docs/guide/_meta.json index 1d8a970..9a6100c 100644 --- a/docs/guide/_meta.json +++ b/docs/guide/_meta.json @@ -9,5 +9,6 @@ "08-background-task", "09-skill-mcp", "10-buddy-companion", - "11-team-swarm" + "11-team-swarm", + "12-ink-render-engine" ] diff --git a/docs/index.md b/docs/index.md index 8b31123..6c3ed4c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,4 +42,7 @@ features: - title: '11. Team/Swarm' details: '持久化多 Agent 团队:邮箱通信、权限审批、计划审批的完整协作体系。' link: /guide/11-team-swarm + - title: '12. Ink 渲染引擎' + details: '自研终端 UI 引擎:React 组件模型、Yoga 布局、Cell 压缩、差量更新。' + link: /guide/12-ink-render-engine ---