跳转至

Topic | Ink 渲染管线全拆

重要性:⭐⭐⭐⭐⭐(理解 TUI 渲染的"内幕") 出现位置src/ink/(50+ 文件 / 13306 行) 关联phase-07-advanced.md 的 Ink fork


1. Ink 是什么

Ink = "React for CLI"。在终端里写 React 组件,渲染成 ASCII/ANSI 输出。

类比: - Web = React → DOM → 浏览器 - CLI = React → Y 流(ANSI 转义码)→ 终端模拟器

Claude Code 整 fork Ink 进 src/ink/(50+ 文件 / 13306 行),因为仓库没 package.json

2. 子目录结构

src/ink/
├── ink.tsx (1722 行)                入口:Ink class
├── reconciler.ts                    React reconciler 适配
├── renderer.ts + render-to-screen.ts + render-node-to-output.ts   渲染管线
├── dom.ts + node.ts                 虚拟 DOM
├── events/                          事件系统
│   ├── click-event.ts
│   ├── dispatcher.ts
│   ├── emitter.ts
│   ├── event.ts
│   ├── event-handlers.ts
│   ├── focus-event.ts
│   ├── input-event.ts
│   ├── keyboard-event.ts
│   ├── terminal-event.ts
│   └── terminal-focus-event.ts
├── hooks/                           React hooks
│   ├── use-animation-frame.ts
│   ├── use-app.ts
│   ├── use-declared-cursor.ts
│   ├── use-input.ts                 键盘输入
│   ├── use-interval.ts
│   ├── use-search-highlight.ts
│   ├── use-selection.ts
│   ├── use-stdin.ts                 原始 stdin
│   ├── use-tab-status.ts
│   ├── use-terminal-focus.ts
│   ├── use-terminal-title.ts
│   └── use-terminal-viewport.ts
├── layout/                          布局引擎
│   ├── engine.ts
│   ├── geometry.ts
│   ├── node.ts
│   └── yoga.ts                      Yoga 绑定
├── components/                      内置组件
│   ├── AlternateScreen.tsx
│   ├── App.tsx
│   ├── AppContext.ts
│   ├── Box.tsx                      容器(flexbox)
│   ├── Button.tsx
│   ├── ClockContext.tsx
│   ├── CursorDeclarationContext.ts
│   ├── ErrorOverview.tsx
│   ├── Link.tsx
│   ├── Newline.tsx
│   ├── NoSelect.tsx
│   ├── RawAnsi.tsx
│   ├── ScrollBox.tsx                可滚动容器
│   ├── Spacer.tsx
│   ├── StdinContext.ts
│   ├── TerminalFocusContext.tsx
│   ├── TerminalSizeContext.tsx
│   └── Text.tsx                     文本
├── Ansi.tsx                         ANSI 颜色
├── colorize.ts                      颜色化辅助
├── constants.ts
├── clearTerminal.ts                 清屏
├── frame.ts                         帧管理
├── screen.ts                        屏幕缓冲
├── output.ts                        输出
├── parse-keypress.ts                按键解析
├── stringWidth.ts                   字符宽度(CJK)
├── widest-line.ts                   最宽行
├── wrap-text.ts                     文本换行
├── wrapAnsi.ts                      ANSI 换行
├── optimizer.ts                     渲染优化
├── squash-text-nodes.ts             合并 Text 节点
├── node-cache.ts                    节点缓存
├── line-width-cache.ts              行宽缓存
├── measure-element.ts               元素测量
├── measure-text.ts                  文本测量
├── hit-test.ts                      命中测试(鼠标)
├── selection.ts                     文本选择
├── searchHighlight.ts               搜索高亮
├── instances.ts                     实例管理
├── log-update.ts                    日志更新
├── bidi.ts                          双向文字
├── render-border.ts                 边框渲染
├── render-node-to-output.ts         节点 → 输出
├── supports-hyperlinks.ts           超链接支持
├── tabstops.ts                      Tab stops
├── terminal-focus-state.ts          焦点状态
├── terminal-querier.ts              终端能力查询
├── terminal.ts                      终端能力
├── termio.ts + termio/              终端 IO
└── useTerminalNotification.ts

3. 渲染管线:5 阶段

┌─────────────┐
│ React Tree  │ <Box><Text>hello</Text></Box>
└──────┬──────┘
┌─────────────┐
│ Reconciler  │ reconciler.ts:diff + apply
│ (React)     │ 输出 virtual DOM (ink node tree)
└──────┬──────┘
┌─────────────┐
│ Layout      │ layout/yoga.ts:flexbox 布局
│ (Yoga)      │ 给每个 node 算 (x, y, width, height)
└──────┬──────┘
┌─────────────┐
│ Output      │ render-node-to-output.ts:node tree → Y 输出
│ Generation  │ 输出 ANSI 转义码 + 文本
└──────┬──────┘
┌─────────────┐
│ Diff & Draw │ renderer.ts:对比上一帧,新内容写到 stdout
│ (stdout)    │ 老的行清除,新的行绘制
└──────┬──────┘
   ┌───────┐
   │ 终端  │ 用户看到的画面
   └───────┘

4. 关键文件详解

4.1 ink.tsx (1722 行) —— 入口

// 推测的 Ink class
export default class Ink {
  private rootNode: InkNode
  private stdout: NodeJS.WriteStream
  private stdin: NodeJS.ReadStream
  private frameTimer: NodeJS.Timeout | null = null

  constructor(tree: React.ReactNode, options: Options) {
    // 1. 构造 root 节点
    this.rootNode = createRootNode(tree)

    // 2. 启动 React reconciler
    const reconciler = createInkReconciler({
      // 自定义 host config
      createInstance: ...,
      commitUpdate: ...,
    })

    // 3. 第一次渲染
    this.render()

    // 4. 启动事件循环
    this.startLoop()
  }

  // 60fps 渲染
  private startLoop() {
    const tick = () => {
      this.render()
      this.frameTimer = setTimeout(tick, 16)  // ~60fps
    }
    tick()
  }

  unmount() {
    if (this.frameTimer) clearTimeout(this.frameTimer)
    // 清理
  }
}

4.2 reconciler.ts —— React reconciler 适配

// 关键:自定义 host config
const reconciler = ReactReconciler({
  // 创建 instance
  createInstance(type, props, root, hostContext, internalInstanceHandle) {
    return createInkNode(type, props)
  },

  // 添加 child
  appendChild(parent, child) { ... },

  // 提交更新
  commitUpdate(instance, updatePayload, type, oldProps, newProps, fiber) { ... },

  // ... 几十个 host config 方法
})

这是 Ink 的"魔法" —— React reconciler 是可插拔的,任何"host"(DOM、Canvas、Ink Y 输出)都可以实现。

4.3 layout/yoga.ts —— 布局引擎

import Yoga from 'yoga-layout'

export function layoutNode(node: InkNode, availableWidth: number, availableHeight: number) {
  // 1. 构造 Yoga 节点
  const yogaNode = Yoga.Node.create()

  // 2. 应用 props
  applyFlexProps(yogaNode, node.props)
  // 例如:
  // flexDirection: 'row' | 'column'
  // justifyContent: 'flex-start' | 'center' | 'space-between'
  // alignItems: 'stretch' | 'center'
  // flex: 1
  // width, height, padding, margin...

  // 3. 递归布局子节点
  for (const child of node.children) {
    layoutNode(child, ...)
  }

  // 4. 计算最终位置
  yogaNode.calculateLayout(availableWidth, availableHeight)
  return {
    x: yogaNode.getComputedLeft(),
    y: yogaNode.getComputedTop(),
    width: yogaNode.getComputedWidth(),
    height: yogaNode.getComputedHeight(),
  }
}

Yoga = Facebook 的 flexbox C++ 实现。Claude Code 在终端里实现 <Box flexDirection="row"> 的关键

4.4 parse-keypress.ts —— 按键解析

// 解析 raw stdin bytes 为 KeyboardEvent
export function parseKeypress(data: Buffer): Key | null {
  // 1. 识别转义序列
  // CSI u: '\x1b[97;5u' = Ctrl+A
  // ANSI: '\x1b[A' = Up arrow
  // Legacy: '\x1bOA' = Up arrow (older terminals)

  // 2. 提取 key + modifiers
  return {
    key: 'a',
    ctrl: true,
    meta: false,
    shift: false,
    sequence: ...,
  }
}

为什么复杂: - 终端模拟器有 3-4 种按键编码(ANSI、CSI u、legacy) - 跨平台差异(macOS Terminal.app vs iTerm2 vs Linux xterm) - 修饰键组合(Ctrl+Shift+Alt+Key)

4.5 stringWidth.ts —— 字符宽度

// 一个字符在终端里占几列?
export function stringWidth(str: string): number {
  let width = 0
  for (const char of str) {
    width += charWidth(char)
  }
  return width
}

function charWidth(char: string): number {
  // 1. ASCII 字母数字 → 1
  // 2. CJK 字符 → 2
  // 3. Emoji → 2(带 ZWJ 组合也是 2)
  // 4. 控制字符 → 0
  // 5. ZWJ 序列特殊处理
}

关键:CJK 字符占 2 列。如果不处理,对齐会错位。

5. 性能优化模式

5.1 节点缓存 node-cache.ts

const cache = new WeakMap<InkNode, RenderedNode>()
// 同一 node 引用 → 同一 rendered node(避免重算)

5.2 行宽缓存 line-width-cache.ts

const widthCache = new Map<string, number>()
// 同一字符串 → 同一宽度(stringWidth 是热路径)

5.3 Text 节点合并 squash-text-nodes.ts

// 优化前:<Text>hello</Text><Text> </Text><Text>world</Text>
// 优化后:<Text>hello world</Text>
// → 减少 ANSI 转义码

5.4 渲染优化 optimizer.ts

跳过渲染结果不变的子树: - props 没变 → 不重渲染 - state 没变 → 不重渲染

6. 终端能力查询

// src/ink/terminal-querier.ts
export async function getTerminalCapabilities(): Promise<TerminalCaps> {
  // 1. OSC 4: 查询颜色支持(true color / 256 color / 16 color)
  // 2. DECRQM: 查询焦点事件支持
  // 3. XTVERSION: 查询终端类型
  // 4. DA1/DA2: 查询设备属性
}

应用: - 不支持 true color → 用 256 色降级 - 不支持鼠标 → 关闭 hit-test - iTerm2 → 启用超链接 - xterm → 用 ANSI 标准

7. 关键洞察

7.1 React 是"host-agnostic"

通过自定义 reconciler host config,React 可以在任何输出设备上跑:浏览器(react-dom)、服务端(react-server)、Native(react-native)、CLI(ink)

7.2 Yoga 是 Ink 的"布局引擎"

Web 用 CSS(浏览器解析),CLI 用 Yoga(Node 内嵌)。两者都是"声明式布局"

7.3 字符宽度计算是 TUI 的"基本功"

CJK、Emoji、ZWJ 序列、控制字符 —— 任何 TUI 都要自己处理。
Web 是 <canvas> 自动处理,TUI 是手算

7.4 60fps 渲染 = 不阻塞事件循环

setTimeout(tick, 16)  // 16ms ≈ 60fps
关键:渲染在 setTimeout 里,不阻塞 stdin 事件处理。这是 TUI 能响应用户的关键。

8. 阅读清单

  1. src/ink/ink.tsx:1-100(入口)
  2. src/ink/reconciler.ts:1-80(host config)
  3. src/ink/layout/yoga.ts:1-50(布局)
  4. src/ink/parse-keypress.ts:1-50(按键解析)
  5. src/ink/stringWidth.ts(字符宽度)
  6. 📌 src/ink/renderer.ts + render-to-screen.ts(输出)
  7. 📌 src/ink/optimizer.ts + squash-text-nodes.ts(优化)

9. 练习任务

  1. 手写一个最小 Ink —— 不要用 React reconciler,直接 mount + render 一个 <Box><Text>hi</Text></Box>
  2. 实现 stringWidth —— 处理 CJK、ASCII、控制字符的宽度
  3. 写 parseKeypress —— 至少识别 Up/Down/Enter/Ctrl+C
  4. 思考:Ink 的渲染管线和 React DOM 的渲染管线有什么相似和差异?React Server Components 的渲染和 Ink 的渲染同构吗?