Claude Code中从VDOM到ANSI转义序列的渲染管线
│ React 组件树(JSX) ││ </Box> │↓│ DOM 节点树(createNode) ││ yogaNode: Yoga.Node, ← 一一对应 ││ } │↓│ Yoga 布局计算(calculateLayout) ││ 1. 文本节点调用 measureTextNode() ││ → measureText() 计算视觉宽度 ││ → wrapText() 处理换行 ││ 2
Claude Code 使用了一个高度优化的终端渲染系统,基于 Ink(一个用于构建终端 UI 的 React 渲染器)。整个渲染流程从 React 19 的虚拟 DOM 开始,经过多个优化层,最终生成 ANSI 转义序列输出到终端。
首先回顾一下核心概念,Claude Code 的终端渲染系统就是把网页那套东西搬到终端里用,这里需要提前了解的几个概念
Reconciler(协调器):只动变化的部分,管理虚拟 DOM,而不是直接操作终端
Yoga(布局引擎):把"相对尺寸"(百分比、flex)换算成"绝对坐标"(第几行第几列)
双缓冲(Double Buffering):在Claude Code 的"双缓冲"是两帧状态对比(Frame Differencing)——维护前后两帧的 Screen 对象,通过差异生成最小化 ANSI 更新序列
Blit:计算机图形中的光栅操作,指矩形像素区域的快速复制,例如Blit(src, dst, src_x, src_y, dst_x, dst_y, width, height),
在 Claude Code 的渲染管线中,当节点满足以下条件时触发 Blit:
-
node.dirty === false(自上次渲染后未发生属性变更) -
布局几何未改变(
x,y,width,height恒定) -
存在有效的
prevScreen引用
复杂度分析:Blit 操作时间复杂度为 O(width × height),但避免了完整的 DOM 遍历和样式计算,在实际场景中可将渲染复杂度从 O(n) 降至 O(1)。
TypedArray 紧凑存储:每个字符是一个对象 {char: 'A', color: 'red', x: 10, y: 5} → 1000 个字符就是 1000 个对象,内存爆炸,Claude Code 用 Int32Array(一个巨大的数字表格),每个字符只占 2 个格子(8 字节),内存少、CPU 缓存友好、垃圾回收压力小
StylePool(样式池):终端里颜色样式几种:红色、绿色、加粗、下划线... 与其每次都写 "红色加粗",不如给每种组合编个号
Damage Tracking(损伤追踪):每帧都擦整块黑板重画。Claude Code只擦掉变化的那几个字,配合 ANSI 序列:终端支持光标跳转 \x1b[5;10H(跳到第5行第10列),直接在那画,不用从头刷。
SGR / ANSI 转义序列:
常见指令:
-
\x1b[2J:清屏 -
\x1b[5;10H:光标移到第5行第10列 -
\x1b[31m:红色 -
\x1b[1m:加粗 -
\x1b]8;;https://xxx\x07:超链接(OSC 8)
普通字符:Hello
带颜色: \x1b[31mHello\x1b[0m 红色开始 重置
软换行(Soft Wrap):终端宽度有限,长句子会自动折行。但复制粘贴时,不希望中间有多余的换行符。用 screen.softWrap[row] 标记哪些行是"被折行"的,复制时跳过这些位置的换行符
宽字符(CJK/Emoji):英文字母 A 占 1 个字符宽度,但中文 或 emoji 在终端里占 2 个位置,宽字符后面要补一个"占位符"(SpacerTail),防止其他字符挤进来
理解了以上概念后,Claude Code的架构层次就是
React 19 Reconciler → Yoga 布局引擎 → 渲染器 → Output → Screen 缓冲区 → ANSI 输出
详细叙述如下:
Ink 的渲染管线是一个高度优化的多阶段流程,它将 React 组件树转换为终端屏幕上的像素。首先,reconciler.ts 使用 React 19 的 createReconciler 创建自定义渲染器,将 React 组件树转换为 Ink 的 DOM 节点树(ink-box、ink-text),每个节点都关联一个 yogaNode 用于后续布局计算。
接着进入布局阶段,dom.ts 和 ink.tsx 调用 Yoga(Facebook 的跨平台布局引擎)计算每个节点的尺寸和位置,其中文本节点通过 measureText 和 wrapText 精确计算实际占用空间,布局结果存储在 yogaNode.getComputedLeft/Top/Width/Height 中。
然后是最核心的渲染阶段(render-node-to-output.ts),将布局后的 DOM 树转换为写入操作。这里包含多项关键优化:squashTextNodesToSegments 将多个文本节点压缩为带样式的段以减少处理单元;wrapWithSoftWrap 实现智能换行,区分软换行(词 wrap)与硬换行;同时支持 OSC 8 超链接,使用 ESC]8;;URL BEL 序列包装超链接。
在输出阶段(output.ts 和 screen.ts),采用池化技术减少内存分配:CharPool/StylePool/HyperlinkPool 分别缓存字符、样式和超链接对象;packWord1 将样式 ID、超链接 ID 和宽度打包到单个 Int32 以节省内存;charCache 缓存已解析的 ANSI 字符串避免重复解析。
最后是屏幕更新阶段(screen.ts 和 log-update.ts),通过 damage tracking 只跟踪被写入的区域以缩小 diff 范围;blit optimization 允许未变化的区域直接从 prevScreen 块传输复制;findNextDiff 快速跳过相同的单元格,且代码结构对 JIT 编译器友好。
具体流程如下:
1️⃣ React Component → Ink DOM (VDOM 创建)
2️⃣ Layout Calculation (Yoga 布局引擎)
3️⃣ Render Tree → Output Operations
4️⃣ Output → Screen Buffer (屏幕缓冲区)
5️⃣ Screen Buffer Diff (差异计算)
6️⃣ ANSI Escape Sequence Generation (转义序列生成)
代码来源:Claude Code文件src/ink/reconciler.ts
首先整个渲染系统的起点,负责将 React 组件树转换为 DOM 节点树,这需要Reconciler(协调器)将 React 的通用操作映射到终端特定的 DOM 节点系统,先实现 React Reconciler 的 Host Config 接口:
// 宿主上下文:追踪文本嵌套状态
type HostContext = {
isInsideText: boolean // 防止 <Box> 嵌套在 <Text> 中
}
// Fiber 结构子集(用于调试追踪)
type FiberLike = {
elementType?: { displayName?: string; name?: string } | string | null
_debugOwner?: FiberLike | null // 实际组件(开发环境)
return?: FiberLike | null // 父 Fiber(生产环境)
}
isInsideText 上下文确保组件层级合法性——<Box> 不能出现在 <Text> 内部,这是由终端渲染特性决定的(块级元素与行内元素的强制分离)
Reconciler 实例化
const reconciler = createReconciler<
ElementNames, // 宿主元素类型(ink-box/ink-text/ink-link)
Props, // 属性对象类型
DOMElement, // 宿主实例类型(内部节点)
DOMElement, // 文本宿主实例类型(此处复用)
TextNode, // 文本节点类型
DOMElement, // suspense 边界类型
unknown, // hydrate 实例类型
unknown, // 公共实例类型
DOMElement, // 宿主上下文根类型
HostContext, // 宿主上下文类型
null, // UpdatePayload(React 19 弃用)
NodeJS.Timeout, // 超时句柄类型
-1, // 无超时标记
null // 过渡类型
>({ /* Host Config */ })
元素节点创建(createInstance)
createInstance(
originalType: ElementNames,
newProps: Props,
_root: DOMElement,
hostContext: HostContext,
internalHandle?: unknown, // Fiber 节点,用于调试追踪
): DOMElement {
// 层级合法性校验
if (hostContext.isInsideText && originalType === 'ink-box') {
throw new Error(`<Box> can't be nested inside <Text> component`)
}
// 文本上下文转换:ink-text → ink-virtual-text(用于样式继承)
const type = originalType === 'ink-text' && hostContext.isInsideText
? 'ink-virtual-text'
: originalType
// 创建宿主节点
const node = createNode(type)
// 应用初始属性
for (const [key, value] of Object.entries(newProps)) {
applyProp(node, key, value)
}
// 调试模式:记录组件所有权链
if (isDebugRepaintsEnabled()) {
node.debugOwnerChain = getOwnerChain(internalHandle)
}
return node
}
属性应用策略(applyProp):
| 属性类型 | 处理方式 |
|---|---|
children |
忽略(由 Reconciler 管理子树) |
style |
设置样式 + 应用到 Yoga 节点 |
textStyles |
设置文本样式(用于子文本节点继承) |
| 事件处理器 | 注册到 _eventHandlers 映射 |
| 其他属性 | 作为 DOM 属性存储 |
文本节点创建(createTextInstance)
createTextInstance(
text: string,
_root: DOMElement,
hostContext: HostContext,
): TextNode {
// 强制文本必须在 <Text> 组件内
if (!hostContext.isInsideText) {
throw new Error(`Text string "${text}" must be rendered inside <Text> component`)
}
return createTextNode(text)
}
上下文传播机制
getChildHostContext(
parentHostContext: HostContext,
type: ElementNames,
): HostContext {
const previousIsInsideText = parentHostContext.isInsideText
// 判断当前节点是否为文本容器
const isInsideText = type === 'ink-text'
|| type === 'ink-virtual-text'
|| type === 'ink-link'
// 无变化时复用父上下文(保持引用相等性,优化性能)
if (previousIsInsideText === isInsideText) {
return parentHostContext
}
return { isInsideText }
}
优化细节:通过引用相等性判断避免不必要的上下文对象创建。
属性差异计算(diff)
const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => {
if (before === after) return // 引用相等,无变化
if (!before) return after // 新增属性集
const changed: AnyObject = {}
let isChanged = false
// 检测删除的属性
for (const key of Object.keys(before)) {
if (!Object.hasOwn(after, key)) {
changed[key] = undefined
isChanged = true
}
}
// 检测新增/变更的属性
for (const key of Object.keys(after)) {
if (after[key] !== before[key]) {
changed[key] = after[key]
isChanged = true
}
}
return isChanged ? changed : undefined
}
O(n),其中 n 为属性键数量。采用两遍扫描策略分别处理删除和变更。
节点更新(commitUpdate)
commitUpdate(
node: DOMElement,
_type: ElementNames,
oldProps: Props,
newProps: Props,
): void {
// React 19 直接提供新旧 props,无需 updatePayload
const props = diff(oldProps, newProps)
const style = diff(
oldProps['style'] as Styles,
newProps['style'] as Styles
)
// 应用普通属性变更
if (props) {
for (const [key, value] of Object.entries(props)) {
if (key === 'style') {
setStyle(node, value as Styles)
continue
}
if (key === 'textStyles') {
setTextStyles(node, value as TextStyles)
continue
}
if (EVENT_HANDLER_PROPS.has(key)) {
setEventHandler(node, key, value)
continue
}
setAttribute(node, key, value as DOMNodeAttribute)
}
}
// 样式变更触发 Yoga 布局重计算
if (style && node.yogaNode) {
applyStyles(node.yogaNode, style, newProps['style'] as Styles)
}
}
路径:样式变更 → applyStyles → Yoga 节点属性更新 → 标记脏节点 → 后续布局重计算。
文本更新(commitTextUpdate)
commitTextUpdate(node: TextNode, _oldText: string, newText: string): void {
setTextNodeValue(node, newText) // 触发文本节点内容变更
}
树操作原语
| Reconciler 方法 | 对应 DOM 操作 | 清理工作 |
|---|---|---|
appendInitialChild / appendChild |
appendChildNode |
- |
insertBefore |
insertBeforeNode |
- |
removeChild |
removeChildNode |
cleanupYogaNode + 焦点管理 |
removeChildFromContainer |
removeChildNode |
同上 + 根级焦点清理 |
Yoga 节点清理:
const cleanupYogaNode = (node: DOMElement | TextNode): void => {
const yogaNode = node.yogaNode
if (yogaNode) {
yogaNode.unsetMeasureFunc() // 解除测量回调
clearYogaNodeReferences(node) // 清除反向引用(防悬挂指针)
yogaNode.freeRecursive() // 递归释放 WASM 内存
}
}
在释放 WASM 内存前清除所有 JavaScript 到 WASM 的引用,防止并发操作访问已释放内存。
可见性控制
hideInstance(node) {
node.isHidden = true
node.yogaNode?.setDisplay(LayoutDisplay.None) // Yoga 布局层面隐藏
markDirty(node) // 标记需重布局
}
unhideInstance(node) {
node.isHidden = false
node.yogaNode?.setDisplay(LayoutDisplay.Flex) // 恢复布局参与
markDirty(node)
}
hideTextInstance(node) {
setTextNodeValue(node, '') // 文本置空(不删节点)
}
unhideTextInstance(node, text) {
setTextNodeValue(node, text) // 恢复原内容
}
元素节点通过 Yoga display 属性控制布局参与;文本节点通过内容置空实现,保留节点引用以优化切换性能。
提交后处理(resetAfterCommit)
resetAfterCommit(rootNode) {
// 性能测量:提交阶段耗时
_lastCommitMs = _commitStart > 0 ? performance.now() - _commitStart : 0
// 调试日志输出(条件编译,生产环境剔除)
if (COMMIT_LOG) { /* 详细性能指标记录 */ }
// 阶段一:布局计算
if (typeof rootNode.onComputeLayout === 'function') {
rootNode.onComputeLayout() // 触发 Yoga 布局
}
// 测试环境:同步渲染
if (process.env.NODE_ENV === 'test') {
if (rootNode.childNodes.length === 0 && rootNode.hasRenderedContent) return
if (rootNode.childNodes.length > 0) rootNode.hasRenderedContent = true
rootNode.onImmediateRender?.()
return
}
// 阶段二:异步渲染调度
rootNode.onRender?.() // 触发实际终端输出
}
双阶段架构:
-
布局阶段:Yoga 计算所有节点几何(可能触发 C++ WASM 调用)
-
渲染阶段:生成 ANSI 序列并输出到终端
性能监控点:
-
提交间隔(gap)
-
协调耗时(reconcileMs)
-
Yoga 布局耗时(layoutMs)
-
绘制耗时(renderMs)
焦点管理集成
finalizeInitialChildren(_node, _type, props): boolean {
// 返回 true 表示需要执行 commitMount
return props['autoFocus'] === true
}
commitMount(node: DOMElement): void {
getFocusManager(node).handleAutoFocus(node) // 处理自动聚焦
}
finalizeInitialChildren → commitMount 构成完整的挂载后初始化管线
React 19 兼容性适配
// 新增必需方法
maySuspendCommit(): boolean { return false }
preloadInstance(): boolean { return true }
startSuspendingCommit(): void {}
suspendInstance(): void {}
waitForCommitToBeReady(): null { return null }
// 过渡相关
NotPendingTransition: null
HostTransitionContext: { /* ... */ }
// 更新优先级与事件系统
setCurrentUpdatePriority(newPriority: number): void {
dispatcher.currentUpdatePriority = newPriority
}
resolveUpdatePriority(): number {
return dispatcher.resolveEventPriority()
}
resolveEventType(): string | null {
return dispatcher.currentEvent?.type ?? null
}
resolveEventTimeStamp(): number {
return dispatcher.currentEvent?.timeStamp ?? -1.1
}
通过 Dispatcher 对象解耦 Reconciler 与事件系统,避免循环依赖。
数据流总结
React 组件树(用户代码)
↓
React Reconciler(通用算法)
↓
Host Config 适配(本模块)
- createInstance → DOMElement
- commitUpdate → 属性/样式同步
- removeChild → 清理 Yoga 内存
↓
Ink DOM 节点树(dom.ts)
↓
Yoga 布局引擎(C++ WASM)
↓
终端渲染器(renderer.ts)
通过以上的操作将将 React 组件树转换为 DOM 节点树,接下来看Yoga 布局引擎
代码来源:Claude Code文件src/ink/dom.ts, src/ink/ink.tsx
Yoga 节点创建与配置
// dom.ts 中的 createNode 函数
export const createNode = (nodeName: ElementNames): DOMElement => {
const needsYogaNode =
nodeName !== 'ink-virtual-text' &&
nodeName !== 'ink-link' &&
nodeName !== 'ink-progress'
const node: DOMElement = {
nodeName,
style: {},
attributes: {},
childNodes: [],
parentNode: undefined,
yogaNode: needsYogaNode ? createLayoutNode() : undefined, // 创建 Yoga 节点
dirty: false,
}
// 文本节点设置测量函数
if (nodeName === 'ink-text') {
node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node))
} else if (nodeName === 'ink-raw-ansi') {
node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node))
}
return node
}
关键点:只有需要布局的节点才创建 Yoga 节点,文本节点设置自定义测量函数。
文本节点测量函数
// dom.ts 中的 measureTextNode
const measureTextNode = function (
node: DOMNode,
width: number,
widthMode: LayoutMeasureMode, // Undefined | Exactly | AtMost
): { width: number; height: number } {
// 1. 获取原始文本(如果是容器节点,合并所有子文本)
const rawText =
node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
// 2. 展开制表符(测量时按最坏情况:8空格)
const text = expandTabs(rawText)
// 3. 基础测量
const dimensions = measureText(text, width)
// 4. 文本能放入容器,无需换行
if (dimensions.width <= width) {
return dimensions
}
// 5. 处理预换行文本(包含 \n)
if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
// Undefined 模式:Yoga 询问固有尺寸,避免在 min/max 检查时高度膨胀
const effectiveWidth = Math.max(width, dimensions.width)
return measureText(text, effectiveWidth)
}
// 6. 执行文本换行
const textWrap = node.style?.textWrap ?? 'wrap'
const wrappedText = wrapText(text, width, textWrap)
return measureText(wrappedText, width)
}
实际测量工具函数
// measure-text.ts(从 import 推断)
// 使用 string-width 计算视觉宽度(处理 Unicode、emoji、CJK)
function measureText(text: string, maxWidth: number): { width: number; height: number } {
const lines = text.split('\n')
let maxLineWidth = 0
for (const line of lines) {
const lineWidth = stringWidth(line) // 视觉宽度,非字节长度
maxLineWidth = Math.max(maxLineWidth, lineWidth)
}
return {
width: Math.min(maxLineWidth, maxWidth),
height: lines.length, // 高度 = 行数
}
}
// wrap-text.ts
function wrapText(text: string, width: number, mode: 'wrap' | 'end' | 'middle' | 'truncate'): string {
// 实现文本换行策略
// - 'wrap': 单词边界换行
// - 'end': 截断末尾加省略号
// - 'middle': 中间截断
// - 'truncate': 直接截断
}
Yoga 布局计算流程
根节点布局触发
// ink.tsx 中的 onComputeLayout
this.rootNode.onComputeLayout = () => {
if (this.isUnmounted) return
if (this.rootNode.yogaNode) {
const t0 = performance.now()
// 1. 设置根节点宽度(终端宽度)
this.rootNode.yogaNode.setWidth(this.terminalColumns)
// 2. 执行 Yoga 布局计算(自动遍历整棵树)
this.rootNode.yogaNode.calculateLayout(this.terminalColumns)
const ms = performance.now() - t0
recordYogaMs(ms)
}
}
Yoga 布局算法内部流程
Yoga 的 calculateLayout 执行流程:
1. 测量阶段(Measure Phase)- 自底向上
├─ 叶子节点(有 measureFunc)调用测量函数
├─ 文本节点 → measureTextNode 返回 (width, height)
├─ 容器节点 → 根据子节点和 flex 属性计算
└─ 向上传播尺寸信息
2. 布局阶段(Layout Phase)- 自顶向下
├─ 根节点 (0, 0) 开始
├─ 根据 flex 方向分配位置
├─ 计算每个子节点的 (left, top)
└─ 递归到叶子节点
3. 结果存储
├─ yogaNode.getComputedLeft() // 相对父节点的 X
├─ yogaNode.getComputedTop() // 相对父节点的 Y
├─ yogaNode.getComputedWidth() // 实际宽度
└─ yogaNode.getComputedHeight() // 实际高度
布局结果的应用
渲染时读取布局结果
// render-node-to-output.ts(从 ink.tsx 推断)
function renderNodeToOutput(
node: DOMElement,
output: Output,
options: {
offsetX: number // 父节点累积的 X 偏移
offsetY: number // 父节点累积的 Y 偏移
prevScreen?: Screen
}
) {
const yoga = node.yogaNode
if (!yoga || yoga.getDisplay() === LayoutDisplay.None) return
// 读取 Yoga 计算的位置(相对父节点)
const left = yoga.getComputedLeft()
const top = yoga.getComputedTop()
const width = yoga.getComputedWidth()
const height = yoga.getComputedHeight()
// 计算绝对屏幕位置
const absoluteX = options.offsetX + left
const absoluteY = options.offsetY + top
// 存储到 nodeCache 用于后续命中测试
nodeCache.set(node, {
x: absoluteX,
y: absoluteY,
width,
height,
})
// 递归渲染子节点,传递累积偏移
for (const child of node.childNodes) {
if (isDOMElement(child)) {
renderNodeToOutput(child, output, {
offsetX: absoluteX, // 子节点的偏移 = 当前绝对位置
offsetY: absoluteY,
prevScreen: options.prevScreen,
})
}
}
}
父子节点关系维护
// dom.ts 中的 appendChildNode
export const appendChildNode = (
node: DOMElement,
childNode: DOMElement,
): void => {
// ... 处理 parentNode 引用
// 关键:同步 Yoga 树结构
if (childNode.yogaNode) {
node.yogaNode?.insertChild(
childNode.yogaNode,
node.yogaNode.getChildCount(), // 插入到最后
)
}
markDirty(node) // 标记需要重新布局
}
// insertBeforeNode 同样需要计算 yogaIndex
export const insertBeforeNode = (...) => {
// ...
if (newChildNode.yogaNode && node.yogaNode) {
// 计算 yoga 索引(跳过没有 yogaNode 的子节点)
let yogaIndex = 0
for (let i = 0; i < index; i++) {
if (node.childNodes[i]?.yogaNode) {
yogaIndex++
}
}
node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex)
}
markDirty(node)
}
脏标记与增量更新
// dom.ts 中的 markDirty
export const markDirty = (node?: DOMNode): void => {
let current: DOMNode | undefined = node
let markedYoga = false
while (current) {
if (current.nodeName !== '#text') {
(current as DOMElement).dirty = true
// 只对叶子测量节点调用 yogaNode.markDirty()
if (
!markedYoga &&
(current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') &&
current.yogaNode
) {
current.yogaNode.markDirty()
markedYoga = true
}
}
current = current.parentNode // 向上传播到根节点
}
}
完整数据流总结
┌─────────────────────────────────────────────────────────┐
│ React 组件树(JSX) │
│ <Box flexDirection="column"> │
│ <Text>Hello World</Text> │
│ <Box flexGrow={1}>...</Box> │
│ </Box> │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ DOM 节点树(createNode) │
│ DOMElement { │
│ yogaNode: Yoga.Node, ← 一一对应 │
│ style: { flexDirection: 'column' }, │
│ childNodes: [...] │
│ } │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Yoga 布局计算(calculateLayout) │
│ 1. 文本节点调用 measureTextNode() │
│ → measureText() 计算视觉宽度 │
│ → wrapText() 处理换行 │
│ 2. 容器节点根据 flex 算法分配空间 │
│ 3. 结果存入 Yoga 节点内部结构 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 读取布局结果(renderNodeToOutput) │
│ const x = yogaNode.getComputedLeft() // 相对父节点 │
│ const y = yogaNode.getComputedTop() │
│ const w = yogaNode.getComputedWidth() │
│ const h = yogaNode.getComputedHeight() │
│ │
│ absoluteX = parentOffsetX + x → 写入 Screen 缓冲区 │
│ absoluteY = parentOffsetY + y │
└─────────────────────────────────────────────────────────┘
关键设计亮点
| 特性 | 实现方式 |
|---|---|
| 跨平台布局 | Yoga C++ 引擎的 Node.js 绑定 |
| 文本测量 | string-width 处理 Unicode + 自定义换行 |
| 增量布局 | markDirty 只重新测量变化的子树 |
| 绝对定位 | 递归传递 offsetX/Y 累积计算屏幕坐标 |
| 缓存优化 | nodeCache 存储绝对坐标用于命中测试 |
render-node-to-output.ts 的核心实现流程,这是 Ink 渲染引擎最关键的阶段:
代码来源:Claude Code文件src/ink/render-node-to-output.ts
整体架构图
┌─────────────────────────────────────────
│ Yoga 布局已完成 (x, y, width, height)
└─────────────────┬───────────────────────
▼
┌─────────────────────────────────────────
│ 1. 脏检查 & Blit 优化 (快速路径)
│ - 节点未变化?直接复制上一帧像素
└─────────────────┬───────────────────────
▼
┌─────────────────────────────────────────
│ 2. 清理旧位置 (位置变化/内容变化)
│ - output.clear() 清除残留像素
└─────────────────┬───────────────────────
▼
┌─────────────────────────────────────────
│ 3. 按节点类型渲染
│ ink-text → 文本处理流程
│ ink-box → 背景/边框/子节点
│ ink-raw-ansi → 直接写入
▼
┌─────────────────────────────────────────
│ 4. 缓存布局 & 标记干净
│ - nodeCache.set() 保存当前状态
│ - node.dirty = false
└─────────────────────────────────────────
第一步:入口函数与坐标计算
function renderNodeToOutput(
node: DOMElement,
output: Output,
{
offsetX = 0,
offsetY = 0,
prevScreen, // 上一帧的屏幕缓冲区
skipSelfBlit = false, // 强制重新渲染自己
inheritedBackgroundColor,
}
): void {
const { yogaNode } = node
if (!yogaNode) return
// Yoga 布局结果 → 绝对屏幕坐标
const x = offsetX + yogaNode.getComputedLeft()
const yogaTop = yogaNode.getComputedTop()
let y = offsetY + yogaTop
const width = yogaNode.getComputedWidth()
const height = yogaNode.getComputedHeight()
// 绝对定位元素可能被挤出视口上方,钳制到 0
if (y < 0 && node.style.position === 'absolute') {
y = 0
}
第二步:Blit 快速路径(性能核心)
const cached = nodeCache.get(node)
// 🚀 快速路径:节点完全未变化,直接复制上一帧
if (
!node.dirty && // 内容未变
!skipSelfBlit && // 未被强制禁用
node.pendingScrollDelta === undefined && // 无待处理滚动
cached && // 有缓存
cached.x === x && cached.y === y && // 位置相同
cached.width === width && cached.height === height &&
prevScreen // 有上一帧数据
) {
const fx = Math.floor(x), fy = Math.floor(y)
const fw = Math.floor(width), fh = Math.floor(height)
// 🔥 关键优化:直接内存复制,跳过所有渲染逻辑
output.blit(prevScreen, fx, fy, fw, fh)
// 绝对定位子元素可能超出父元素边界,需要额外处理
if (node.style.position === 'absolute') {
absoluteRectsCur.push(cached)
}
blitEscapingAbsoluteDescendants(node, output, prevScreen, fx, fy, fw, fh)
return // ← 直接返回,跳过后续所有渲染!
}
Blit 的本质:prevScreen 是上一帧的字符单元格矩阵,output.blit() 直接内存拷贝对应区域到新帧,零解析、零计算。
第三步:清理旧内容
// 位置变化检测(用于 layoutShifted 标记)
const positionChanged = cached && (
cached.x !== x || cached.y !== y ||
cached.width !== width || cached.height !== height
)
if (positionChanged) layoutShifted = true
// 清理旧位置的残留像素
if (cached && (node.dirty || positionChanged)) {
output.clear({
x: Math.floor(cached.x),
y: Math.floor(cached.y),
width: Math.floor(cached.width),
height: Math.floor(cached.height),
}, node.style.position === 'absolute')
}
// 处理子节点删除遗留的清理区域
const clears = pendingClears.get(node)
if (clears) {
layoutShifted = true
for (const rect of clears) {
output.clear(rect)
}
pendingClears.delete(node)
}
第四步:按节点类型渲染
4.1 原始 ANSI 节点(直接透传)
if (node.nodeName === 'ink-raw-ansi') {
const text = node.attributes['rawText'] as string
if (text) output.write(x, y, text) // 直接写入,不处理
}
4.2 文本节点
else if (node.nodeName === 'ink-text') {
// ① 压缩多个文本子节点为带样式的段
const segments = squashTextNodesToSegments(node, inheritedBackgroundColor)
// ② 拼接纯文本用于测量
const plainText = segments.map(s => s.text).join('')
if (plainText.length === 0) return
const maxWidth = Math.min(getMaxWidth(yogaNode), output.width - x)
const textWrap = node.style.textWrap ?? 'wrap'
const needsWrapping = widestLine(plainText) > maxWidth
let text: string
let softWrap: boolean[] | undefined // 标记软换行位置
// ③ 分支处理:单段 vs 多段 × 换行 vs 不换行
if (needsWrapping && segments.length === 1) {
// 单段 + 需换行:先换行,再逐行应用样式
const segment = segments[0]!
const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
softWrap = w.softWrap
text = w.wrapped.split('\n').map(line => {
let styled = applyTextStyles(line, segment.styles)
// OSC 8 超链接:每行独立包装
if (segment.hyperlink) {
styled = wrapWithOsc8Link(styled, segment.hyperlink)
}
return styled
}).join('\n')
} else if (needsWrapping) {
// 多段 + 需换行:复杂映射处理
const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
softWrap = w.softWrap
const charToSegment = buildCharToSegmentMap(segments)
// 关键:逐字符映射回原段,保持样式连续性
text = applyStylesToWrappedText(
w.wrapped, segments, charToSegment, plainText,
textWrap === 'wrap-trim'
)
} else {
// 无需换行:直接应用样式
text = segments.map(segment => {
let styled = applyTextStyles(segment.text, segment.styles)
if (segment.hyperlink) styled = wrapWithOsc8Link(styled, segment.hyperlink)
return styled
}).join('')
}
// ④ 应用 Box 内边距偏移(Yoga 子节点坐标)
text = applyPaddingToText(node, text, softWrap)
// ⑤ 最终写入输出缓冲区
output.write(x, y, text, softWrap)
}
applyStylesToWrappedText 核心逻辑(多段文本换行时):
function applyStylesToWrappedText(
wrappedPlain: string, // 换行后的纯文本
segments: StyledSegment[],
charToSegment: number[], // 每个字符属于哪个段
originalPlain: string,
trimEnabled: boolean
): string {
const lines = wrappedPlain.split('\n')
const resultLines: string[] = []
let charIndex = 0 // 跟踪原字符串位置
for (const line of lines) {
// trim 模式:跳过被修剪的前导空白
if (trimEnabled) skipTrimmedWhitespace()
let styledLine = ''
let runStart = 0
let runSegmentIndex = charToSegment[charIndex] ?? 0
// 逐字符处理,同一段的字符合并处理
for (let i = 0; i < line.length; i++) {
const currentSegIdx = charToSegment[charIndex] ?? runSegmentIndex
if (currentSegIdx !== runSegmentIndex) {
// 段变化:刷新当前 run
styledLine += flushRun(line, runStart, i, segments[runSegmentIndex])
runStart = i
runSegmentIndex = currentSegIdx
}
charIndex++
}
// 刷新最后一段
styledLine += flushRun(line, runStart, line.length, segments[runSegmentIndex])
resultLines.push(styledLine)
// 同步原字符串中的换行符
if (originalPlain[charIndex] === '\n') charIndex++
// trim 模式:跳过被换行替换的空白
if (trimEnabled) skipReplacedWhitespace()
}
return resultLines.join('\n')
}
4.3 Box 容器节点
else if (node.nodeName === 'ink-box') {
const boxBg = node.style.backgroundColor ?? inheritedBackgroundColor
// ① 处理 noSelect 区域(文本选择禁用)
if (node.style.noSelect) {
output.noSelect({ x, y, width, height, fromEdge: ... })
}
// ② 设置裁剪区域(overflow hidden/scroll)
const overflowX = node.style.overflowX ?? node.style.overflow
const overflowY = node.style.overflowY ?? node.style.overflow
const needsClip = overflowX === 'hidden' || overflowX === 'scroll' ||
overflowY === 'hidden' || overflowY === 'scroll'
if (needsClip) {
output.clip({ x1, x2, y1, y2 }) // 计算内边距后的裁剪区域
}
// ③ 滚动容器特殊处理(ScrollBox)
if (overflowY === 'scroll') {
renderScrollContainer(node, output, x, y, width, height, boxBg, prevScreen)
} else {
// ④ 普通容器:填充背景 + 渲染子节点
if (node.style.backgroundColor || node.style.opaque) {
fillBackground(output, x, y, width, height, boxBg)
}
renderChildren(node, output, x, y, hasRemovedChild,
boxBg || node.style.opaque ? undefined : prevScreen, // 有背景时禁用子节点 blit
boxBg
)
}
if (needsClip) output.unclip()
// ⑤ 边框最后渲染(覆盖子节点)
renderBorder(x, y, node, output)
}
第五步:滚动容器渲染
function renderScrollContainer(node, output, x, y, width, height, boxBg, prevScreen) {
// 计算可视区域
const padTop = yogaNode.getComputedPadding(LayoutEdge.Top)
const innerHeight = (y2 - y1) - padTop - padBottom
// 找到内容包装器(flexShrink: 0 的单个子元素)
const content = node.childNodes.find(c => c.yogaNode)
const contentYoga = content?.yogaNode
const scrollHeight = contentYoga?.getComputedHeight() ?? 0
// 更新滚动状态
node.scrollHeight = scrollHeight
node.scrollViewportHeight = innerHeight
node.scrollViewportTop = (y1 ?? y) + padTop
const maxScroll = Math.max(0, scrollHeight - innerHeight)
// 处理锚定滚动(scrollAnchor)
if (node.scrollAnchor) {
const anchorTop = node.scrollAnchor.el.yogaNode?.getComputedTop()
if (anchorTop != null) {
node.scrollTop = anchorTop + node.scrollAnchor.offset
node.pendingScrollDelta = undefined
}
node.scrollAnchor = undefined
}
// 底部跟随逻辑(sticky scroll)
handleStickyScroll(node, maxScroll, prevScrollHeight, prevInnerHeight)
// 消耗待处理滚动增量(平滑滚动动画)
let scrollTop = applyScrollDrain(node, innerHeight, maxScroll)
// 虚拟滚动钳制(只渲染已挂载的子节点范围)
if (node.scrollClampMin !== undefined && node.scrollClampMax !== undefined) {
scrollTop = Math.max(node.scrollClampMin, Math.min(scrollTop, node.scrollClampMax))
}
// 计算内容偏移(-scrollTop)
const contentX = x + contentYoga.getComputedLeft()
const contentY = y + contentYoga.getComputedTop() - scrollTop
// 检测是否可以使用 DECSTBM 硬件滚动优化
const contentCached = nodeCache.get(content)
let hint: ScrollHint | null = null
if (contentCached && contentCached.y !== contentY) {
const delta = contentCached.y - contentY // 正数 = 向下滚动
if (canUseDecstbm(node, cached, delta, innerHeight)) {
hint = { top: regionTop, bottom: regionBottom, delta }
scrollHint = hint
} else {
layoutShifted = true
}
}
// 渲染路径分支
if (hint && prevScreen && isSafeForFastPath(hint, content)) {
// 🚀 快速路径:DECSTBM + blit + shift + 只渲染边缘
renderWithDecstbm(content, output, contentX, contentY, hint, boxBg)
} else {
// 普通路径:清除视口 + 裁剪渲染可见子节点
renderScrolledChildren(content, output, contentX, contentY,
scrollTop, scrollTop + innerHeight, boxBg)
}
// 缓存内容包装器位置(包含 -scrollTop)
nodeCache.set(content, { x: contentX, y: contentY, width, height: scrollHeight })
content.dirty = false
}
DECSTBM 快速路径(终端硬件滚动优化):
function renderWithDecstbm(content, output, cx, cy, hint, boxBg) {
const { top, bottom, delta } = hint
const w = Math.floor(width)
// ① 复制上一帧的滚动区域
output.blit(prevScreen, Math.floor(x), top, w, bottom - top + 1)
// ② 内存中的行移位(模拟终端的 SU/SD)
output.shift(top, bottom, delta)
// ③ 只清除和渲染边缘新出现的行
const edgeTop = delta > 0 ? bottom - delta + 1 : top
const edgeBottom = delta > 0 ? bottom : top - delta - 1
output.clear({ x: Math.floor(x), y: edgeTop, width: w,
height: edgeBottom - edgeTop + 1 })
output.clip({ y1: edgeTop, y2: edgeBottom + 1 })
// ④ 只渲染边缘区域的子节点
renderScrolledChildren(content, output, cx, cy,
edgeTop - cy, edgeBottom + 1 - cy, boxBg, true)
output.unclip()
// ⑤ 第二遍:修复脏节点(内容变化但不在边缘)
repairDirtyChildren(content, output, cx, cy, delta, boxBg)
// ⑥ 第三遍:修复绝对定位覆盖层
repairAbsoluteOverlays(output, x, top, bottom, delta, w, boxBg)
}
第六步:子节点渲染与污染控制
function renderChildren(node, output, x, y, hasRemovedChild, prevScreen, inheritedBg) {
let seenDirtyChild = false
let seenDirtyClipped = false
for (const childNode of node.childNodes) {
const childElem = childNode as DOMElement
const wasDirty = childElem.dirty
const isAbsolute = childElem.style.position === 'absolute'
// 🔥 关键:脏节点后的兄弟节点禁用 blit(防止溢出污染)
renderNodeToOutput(childElem, output, {
offsetX: x,
offsetY: y,
// 有删除子节点或已见脏节点时,禁用 blit
prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen,
// 脏裁剪节点后的绝对定位节点需要特殊处理
skipSelfBlit: seenDirtyClipped && isAbsolute && !childElem.style.opaque
&& !childElem.style.backgroundColor,
inheritedBackgroundColor: inheritedBg,
})
// 跟踪脏节点状态
if (wasDirty && !seenDirtyChild) {
if (!clipsBothAxes(childElem) || isAbsolute) {
seenDirtyChild = true // 普通脏节点,后续兄弟禁用 blit
} else {
seenDirtyClipped = true // 裁剪的脏节点,绝对定位兄弟需 skipSelfBlit
}
}
}
}
第七步:缓存与收尾
// 缓存当前布局状态用于下一帧比较
const rect = { x, y, width, height, top: yogaTop }
nodeCache.set(node, rect)
// 记录绝对定位区域(用于滚动修复)
if (node.style.position === 'absolute') {
absoluteRectsCur.push(rect)
}
// 标记节点已处理
node.dirty = false
}
完整数据流总结
React 组件树
↓ ( reconciler )
Ink 虚拟 DOM (DOMElement)
↓ ( yoga-layout )
计算布局 (x, y, width, height)
↓ ( renderNodeToOutput )
┌─────────────────────────────────────┐
│ 遍历节点树 │
│ ├── 检查脏标记 & 缓存对比 │
│ ├── Blit 快速路径?直接复制 │
│ ├── 清理旧位置 │
│ ├── 按类型渲染: │
│ │ ├── Text: squash → wrap → style │
│ │ ├── Box: clip → children → border│
│ │ └── Scroll: DECSTBM 优化路径 │
│ └── 缓存状态 & 清除脏标记 │
↓
Output 对象 (write/clear/blit/clip 操作队列)
↓ ( output.get() )
ANSI 转义序列字符串
↓ ( stdout.write )
终端屏幕
这个渲染系统的设计是尽可能少做工作。通过脏检查、Blit 优化、DECSTBM 硬件滚动、裁剪剔除等手段,在保持 60fps 的同时最小化 CPU 和终端带宽消耗。
Output → Screen Buffer 的流程
代码来源:Claude Code文件src/ink/output.ts, src/ink/screen.ts
一、整体架构概览
┌─────────────────────────────────────────────────────────────┐
│ Output 类 (output.ts) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ operations │ │ charCache │ │ stylePool │ │
│ │ (操作队列) │ │ (字符串缓存) │ │ (样式池化) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Screen Buffer (screen.ts) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ cells: Int32Array [charId, packedWord1] │ │
│ │ cells64: BigInt64Array (用于批量操作) │ │
│ │ noSelect: Uint8Array (选择排除标记) │ │
│ │ softWrap: Int32Array (软换行标记) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ CharPool │ │ StylePool │ │ HyperlinkPool │ │
│ │ (字符池化) │ │ (样式池化) │ │ (超链接池化) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
二、详细流程分析
阶段 1: Output 收集渲染操作
1.1 操作类型定义
// output.ts - 支持的操作类型
type Operation =
| WriteOperation // 写入文本
| ClipOperation // 裁剪区域
| UnclipOperation // 取消裁剪
| BlitOperation // 块复制(优化滚动)
| ClearOperation // 清除区域
| NoSelectOperation // 标记不可选择区域
| ShiftOperation // 行位移(滚动优化)
// 写入操作 - 核心
type WriteOperation = {
type: 'write'
x: number
y: number
text: string // 可能包含 ANSI 转义序列
softWrap?: boolean[] // 软换行标记
}
1.2 操作收集(非立即执行)
// output.ts - 收集阶段(轻量,仅推入数组)
export default class Output {
private readonly operations: Operation[] = []
private charCache: Map<string, ClusteredChar[]> = new Map()
write(x: number, y: number, text: string, softWrap?: boolean[]): void {
if (!text) return
this.operations.push({ type: 'write', x, y, text, softWrap })
}
blit(src: Screen, x: number, y: number, width: number, height: number): void {
this.operations.push({ type: 'blit', src, x, y, width, height })
}
clear(region: Rectangle, fromAbsolute?: boolean): void {
this.operations.push({ type: 'clear', region, fromAbsolute })
}
// ... 其他操作类似
}
关键设计:所有操作先收集到队列,在 get() 方法中统一执行,这样可以:
-
优化执行顺序(如先处理 clear,再处理 write)
-
合并相邻操作
-
支持双缓冲(传入可复用的 Screen 对象)
阶段 2: 执行渲染操作(get() 方法)
// output.ts - 核心渲染入口
get(): Screen {
const screen = this.screen
// Pass 1: 预处理 clear 操作,标记 damage 区域
const absoluteClears: Rectangle[] = []
for (const operation of this.operations) {
if (operation.type === 'clear') {
// 标记需要重新渲染的区域
screen.damage = unionRect(screen.damage, operation.region)
if (operation.fromAbsolute) absoluteClears.push(rect)
}
}
// Pass 2: 执行所有操作
for (const operation of this.operations) {
switch (operation.type) {
case 'write': /* ... */ break
case 'blit': /* ... */ break
case 'shift': /* ... */ break
// ...
}
}
// Pass 3: 最后应用 noSelect 标记(确保在最上层)
for (const operation of this.operations) {
if (operation.type === 'noSelect') {
markNoSelectRegion(screen, x, y, width, height)
}
}
return screen
}
阶段 3: 文本写入核心流程(writeLineToScreen)
这是最关键的函数,负责将一行文本解析并写入 Screen Buffer:
// output.ts - 逐行写入(已提取为独立函数便于 JIT 优化)
function writeLineToScreen(
screen: Screen,
line: string, // 单行文本(已按 \n 分割)
x: number, // 起始 X 坐标
y: number, // 行号
screenWidth: number,
stylePool: StylePool,
charCache: Map<string, ClusteredChar[]>, // 关键优化:解析缓存
): number { // 返回 contentEnd(内容结束列,用于 softWrap)
// ========== 步骤 1: 解析缓存(避免重复 tokenize)==========
let characters = charCache.get(line)
if (!characters) {
// 首次解析:ANSI tokenize → 样式字符 → 字形聚类 → 双向重排
characters = reorderBidi(
styledCharsWithGraphemeClustering(
styledCharsFromTokens(tokenize(line)), // 解析 ANSI 转义序列
stylePool,
)
)
charCache.set(line, characters)
}
// ========== 步骤 2: 逐字符写入 ==========
let offsetX = x
for (let charIdx = 0; charIdx < characters.length; charIdx++) {
const character = characters[charIdx]!
const codePoint = character.value.codePointAt(0)
// --- 特殊处理:C0 控制字符(0x00-0x1F)---
if (codePoint !== undefined && codePoint <= 0x1f) {
// Tab (0x09): 扩展到空格到下一个制表位
if (codePoint === 0x09) {
const tabWidth = 8
const spacesToNextStop = tabWidth - (offsetX % tabWidth)
for (let i = 0; i < spacesToNextStop && offsetX < screenWidth; i++) {
setCellAt(screen, offsetX, y, {
char: ' ',
styleId: stylePool.none,
width: CellWidth.Narrow,
hyperlink: undefined,
})
offsetX++
}
continue
}
// ESC (0x1B): 跳过未识别的转义序列
else if (codePoint === 0x1b) {
// 处理 CSI 序列、字符集选择、OSC 序列等...
// 代码较长,详见源文件
continue
}
// 其他控制字符:跳过
continue
}
// --- 零宽字符(组合标记等):跳过 ---
const charWidth = character.width
if (charWidth === 0) continue
const isWideCharacter = charWidth >= 2
// --- 宽字符在行尾的特殊处理 ---
// 如果宽字符无法完整放入当前行,放置 SpacerHead
if (isWideCharacter && offsetX + 2 > screenWidth) {
setCellAt(screen, offsetX, y, {
char: ' ',
styleId: stylePool.none,
width: CellWidth.SpacerHead, // 标记宽字符被截断
hyperlink: undefined,
})
offsetX++
continue
}
// --- 正常写入单元格 ---
// 关键优化:styleId 和 hyperlink 在缓存阶段已预计算
setCellAt(screen, offsetX, y, {
char: character.value,
styleId: character.styleId, // 直接读取,无需重新 intern
width: isWideCharacter ? CellWidth.Wide : CellWidth.Narrow,
hyperlink: character.hyperlink, // 直接读取
})
offsetX += isWideCharacter ? 2 : 1
}
return offsetX // 返回内容结束位置(用于 softWrap 记录)
}
阶段 4: 字符解析与缓存(styledCharsWithGraphemeClustering)
这是性能优化的关键,将昂贵的解析操作缓存:
// output.ts - 将 ANSI 字符串转换为可渲染的聚类字符
function styledCharsWithGraphemeClustering(
chars: StyledChar[],
stylePool: StylePool,
): ClusteredChar[] {
const charCount = chars.length
if (charCount === 0) return []
const result: ClusteredChar[] = []
const bufferChars: string[] = []
let bufferStyles: AnsiCode[] = chars[0]!.styles
// 按相同样式分组,减少 stylePool.intern 调用
for (let i = 0; i < charCount; i++) {
const char = chars[i]!
const styles = char.styles
// 样式变化时,flush 当前缓冲区
if (bufferChars.length > 0 && !stylesEqual(styles, bufferStyles)) {
flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result)
bufferChars.length = 0
}
bufferChars.push(char.value)
bufferStyles = styles
}
// 处理最后一组
if (bufferChars.length > 0) {
flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result)
}
return result
}
// 将一组相同样式的字符进行字形分割(处理 emoji 等)
function flushBuffer(
buffer: string,
styles: AnsiCode[],
stylePool: StylePool,
out: ClusteredChar[],
): void {
// 每个样式组只计算一次 styleId 和 hyperlink(关键优化!)
const hyperlink = extractHyperlinkFromStyles(styles) ?? undefined
const hasOsc8Styles = /* ... */
const filteredStyles = hasOsc8Styles ? filterOutHyperlinkStyles(styles) : styles
const styleId = stylePool.intern(filteredStyles) // 样式池化
// 使用 Intl.Segmenter 进行字形聚类(处理 👨👩👧👦 等)
for (const { segment: grapheme } of getGraphemeSegmenter().segment(buffer)) {
out.push({
value: grapheme,
width: stringWidth(grapheme), // 计算终端宽度(CJK=2)
styleId, // 共享的样式 ID
hyperlink, // 共享的超链接
})
}
}
阶段 5: Screen Buffer 存储(setCellAt)
这是底层存储的核心,使用 packed array 实现零 GC 压力:
// screen.ts - 单元格打包存储
export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): void {
if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return
const ci = (y * screen.width + x) << 1 // 单元格索引 × 2(双字)
const cells = screen.cells
// ========== 宽字符孤儿清理 ==========
// 如果当前位置是 Wide 字符,需要清理其 SpacerTail
const prevWidth = cells[ci + 1]! & WIDTH_MASK
if (prevWidth === CellWidth.Wide && cell.width !== CellWidth.Wide) {
const spacerX = x + 1
if (spacerX < screen.width) {
const spacerCI = ci + 2
if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
cells[spacerCI] = EMPTY_CHAR_INDEX
cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
}
}
}
// 如果当前位置是 SpacerTail,清理其对应的 Wide 字符
let clearedWideX = -1
if (prevWidth === CellWidth.SpacerTail && cell.width !== CellWidth.SpacerTail) {
if (x > 0) {
const wideCI = ci - 2
if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
cells[wideCI] = EMPTY_CHAR_INDEX
cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
clearedWideX = x - 1
}
}
}
// ========== 写入新单元格数据 ==========
// Word 0: 字符 ID(通过 CharPool 池化)
cells[ci] = internCharString(screen, cell.char)
// Word 1: 打包 [styleId(15位) | hyperlinkId(15位) | width(2位)]
cells[ci + 1] = packWord1(
cell.styleId,
internHyperlink(screen, cell.hyperlink),
cell.width,
)
// ========== 更新 damage 区域(用于 diff 优化)==========
const minX = clearedWideX >= 0 ? Math.min(x, clearedWideX) : x
updateDamage(screen, minX, y, x)
// ========== 宽字符自动创建 SpacerTail ==========
if (cell.width === CellWidth.Wide) {
const spacerX = x + 1
if (spacerX < screen.width) {
const spacerCI = ci + 2
// 清理可能存在的孤儿 SpacerTail
if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
// ... 清理逻辑
}
// 写入 SpacerTail
cells[spacerCI] = SPACER_CHAR_INDEX
cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.SpacerTail)
// 扩展 damage 区域包含 SpacerTail
expandDamageForSpacer(screen, spacerX)
}
}
}
阶段 6: 打包函数详解
// screen.ts - 位打包布局
// Word 1 布局: [styleId(15位) | hyperlinkId(15位) | width(2位)]
// 总计 32 位,完美利用 Int32
const STYLE_SHIFT = 17 // styleId 左移 17 位
const HYPERLINK_SHIFT = 2 // hyperlinkId 左移 2 位
const HYPERLINK_MASK = 0x7fff // 15 位掩码
const WIDTH_MASK = 3 // 2 位掩码(0-3)
function packWord1(styleId: number, hyperlinkId: number, width: number): number {
return (styleId << STYLE_SHIFT) | (hyperlinkId << HYPERLINK_SHIFT) | width
}
// 解包(在 cellAt 等读取函数中使用)
function unpackWord1(word1: number) {
return {
styleId: word1 >>> STYLE_SHIFT, // 无符号右移
hyperlinkId: (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK,
width: word1 & WIDTH_MASK,
}
}
三、关键数据结构总结
| 组件 | 类型 | 用途 | 优化点 |
|---|---|---|---|
Screen.cells |
Int32Array |
单元格存储 | 2 Int32/单元格,无对象开销 |
Screen.cells64 |
BigInt64Array |
批量填充 | fill(0n) 快速清零 |
CharPool |
Map<string, number> |
字符池化 | ASCII 快速路径,共享字符串 |
StylePool |
Map<string, number> |
样式池化 | 位编码可见性,过渡缓存 |
HyperlinkPool |
Map<string, number> |
超链接池化 | 5分钟自动清理 |
charCache |
Map<string, ClusteredChar[]> |
解析缓存 | 避免重复 ANSI 解析 |
noSelect |
Uint8Array |
选择排除 | 1 字节/单元格 |
softWrap |
Int32Array |
软换行标记 | 每行一个值,记录连接点 |
四、性能优化策略总结
1. 内存优化
对象池化: CharPool + StylePool + HyperlinkPool
↓ 避免重复创建相同字符串/样式对象
Packed Array: Int32Array 代替 Cell 对象
↓ 200×120 屏幕从 24,000 个对象 → 48,000 个整数(无 GC 压力)
双缓冲: 复用 Screen 对象
↓ 每帧零分配( amortized )
2. 计算优化
解析缓存: charCache 存储已解析的行
↓ 静态内容每帧 O(1) 读取,避免重复 tokenize
样式批处理: 相同样式字符批量 intern
↓ 80 字符行 3 个样式组 = 3 次 intern 而非 80 次
SIMD 友好: 连续内存布局,支持未来 Bun.indexOfFirstDifference
3. 渲染优化
Damage Tracking: 只 diff 变化区域
↓ 从全屏 O(n) 降到 O(变化单元格)
Blit 优化: 滚动时使用 TypedArray.copyWithin
↓ 纯滚动场景零重新渲染
宽字符处理: 显式 Spacer 单元格
↓ 无需运行时宽度计算,直接索引
五、完整数据流图
用户代码 (ink components)
│
▼
┌─────────────────┐
│ 构建渲染树 (yoga) │ ← 计算布局(x, y, width, height)
└─────────────────┘
│
▼
┌─────────────────┐
│ Output 收集操作 │ ← write(), blit(), clear(), clip()...
│ (operations[]) │
└─────────────────┘
│
▼
┌─────────────────┐
│ Output.get() │
│ ───────────── │
│ 1. 预处理 clears │
│ 2. 遍历执行操作 │
│ 3. 应用 noSelect │
└─────────────────┘
│
├──► write 操作 ──► writeLineToScreen()
│ │
│ ├──► charCache.get(line) [命中: 直接返回]
│ │ │
│ │ └──► 未命中: tokenize → styledChars →
│ │ graphemeCluster → reorderBidi
│ │ → charCache.set()
│ │
│ └──► 遍历 ClusteredChar[]
│ └──► setCellAt(screen, x, y, cell)
│ │
│ ├──► CharPool.intern(char) → charId
│ ├──► HyperlinkPool.intern(link) → linkId
│ └──► packWord1(styleId, linkId, width)
│ ↓
│ screen.cells[ci] = charId
│ screen.cells[ci+1] = packedWord
│
├──► blit 操作 ───► blitRegion()
│ └──► TypedArray.set() / copyWithin()
│
└──► clear 操作 ──► clearRegion()
└──► cells64.fill(EMPTY_CELL_VALUE)
│
▼
┌─────────────────┐
│ 返回 Screen │ ← 包含完整的 cells, damage, noSelect, softWrap
│ (供 diff 使用) │
└─────────────────┘
这个设计实现了零分配渲染路径(steady state),在 60fps 的终端 UI 中表现优异。
Screen Buffer Diff(屏幕缓冲区差异计算)
代码来源:Claude Code文件src/ink/screen.ts, src/ink/log-update.ts
整体架构
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 上一帧屏幕 │ │ 差异计算引擎 │ │ ANSI 输出序列 │
│ (prev.screen) │────▶│ (diffEach) │────▶│ (log-update.ts) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
▲ │
│ ▼
┌─────┴─────┐ ┌─────────────────┐
│ blit优化 │◄───────────────────────────────│ 下一帧屏幕 │
│ (块传输) │ 未变化区域直接复制 │ (next.screen) │
└───────────┘ └─────────────────┘
第一步:Screen 数据结构(紧凑存储)
// screen.ts - 每个单元格用 2 个 Int32 存储(8 字节),而非对象
export type Screen = Size & {
// cells 数组布局:[charId, packedWord1, charId, packedWord1, ...]
// 每单元格 2 个 Int32 = 8 字节
cells: Int32Array // 单元格数据
cells64: BigInt64Array // 同一块内存的 64 位视图,用于批量填充
// 共享字符串池(避免重复存储相同字符/样式)
charPool: CharPool // 字符 → ID 映射
hyperlinkPool: HyperlinkPool // 超链接 → ID 映射
// ⭐ 关键:damage 跟踪
damage: Rectangle | undefined // 本帧被写入的区域边界
// 选择禁用标记(行号等不可选区域)
noSelect: Uint8Array
// 软换行标记
softWrap: Int32Array
}
打包格式(word1):
bits [31:17] styleId (15 bits) ← 样式 ID
bits [16:2] hyperlinkId (15 bits) ← 超链接 ID
bits [1:0] width (2 bits) ← 单元格宽度类型
第二步:Damage Tracking(损伤跟踪)
这是性能核心——只记录实际被修改的区域:
// screen.ts - setCellAt 中自动更新 damage
export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): void {
// ... 写入单元格数据 ...
// ⭐ 关键:动态扩展 damage 矩形
const damage = screen.damage
if (damage) {
// 扩展现有边界(包含新单元格)
const right = damage.x + damage.width
const bottom = damage.y + damage.height
if (minX < damage.x) {
damage.width += damage.x - minX
damage.x = minX
} else if (x >= right) {
damage.width = x - damage.x + 1
}
// ... 同理处理 y/height ...
} else {
// 首次写入,创建 damage 矩形
screen.damage = { x: minX, y, width: x - minX + 1, height: 1 }
}
}
效果: 如果只有屏幕右下角一个单元格变化,damage 就是 {x:79, y:23, width:1, height:1},diff 只扫描这 1 个单元格而非 1920 个(80x24)。
第三步:Blit Optimization(块传输优化)
对于未变化的区域,直接内存复制而非重新渲染:
// screen.ts - blitRegion 实现
export function blitRegion(
dst: Screen, // 目标屏幕(下一帧)
src: Screen, // 源屏幕(上一帧)
regionX: number, // 区域起始
regionY: number,
maxX: number, // 区域结束
maxY: number
): void {
// 快速路径:整行复制(使用 TypedArray.set,C 级速度)
if (regionX === 0 && maxX === src.width && src.width === dst.width) {
const srcStart = regionY * srcStride
const totalBytes = (maxY - regionY) * srcStride
dstCells.set(
srcCells.subarray(srcStart, srcStart + totalBytes),
srcStart
)
// 同时复制 noSelect 和 softWrap 标记
dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart)
dst.softWrap.set(src.softWrap.subarray(regionY, maxY), regionY)
} else {
// 逐行复制(部分宽度或不同步长)
for (let y = regionY; y < maxY; y++) {
dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI)
// ...
}
}
// ⭐ blit 的区域也要标记为 damage(因为 dst 被修改了)
if (dst.damage) {
dst.damage = unionRect(dst.damage, regionRect)
} else {
dst.damage = regionRect
}
}
应用场景: 滚动时,上方 90% 内容不变,直接 blitRegion 复制,只渲染新滚入的 10%。
第四步:Diff 算法(diffEach)
核心差异计算,使用多种优化策略:
// screen.ts
export function diffEach(
prev: Screen,
next: Screen,
cb: DiffCallback // (x, y, removed, added) => boolean|void
): boolean {
// 1. 确定扫描区域:优先用 next.damage,否则用 prev.damage
let region: Rectangle
if (next.damage) {
region = next.damage
if (prev.damage) region = unionRect(region, prev.damage)
} else if (prev.damage) {
region = prev.damage
}
// 2. 处理屏幕尺寸变化(缩小/放大)
if (prevHeight > nextHeight) {
// 旧屏幕更高的部分需要"删除"
region = unionRect(region, {x:0, y:nextHeight, width:prevWidth, height:prevHeight-nextHeight})
}
// 3. 选择优化路径
if (prevWidth === nextWidth) {
return diffSameWidth(prev, next, region.x, endX, region.y, endY, cb)
} else {
return diffDifferentWidth(prev, next, /* ... */) // 慢路径:屏幕宽度变化
}
}
4.1 同宽优化路径(最常见情况)
function diffSameWidth(
prev: Screen,
next: Screen,
startX: number, endX: number,
startY: number, endY: number,
cb: DiffCallback
): boolean {
const stride = width << 1 // 每行 2*width 个 Int32
// ⭐ 重用 Cell 对象,避免 GC
const prevCell: Cell = { char: ' ', styleId: 0, width: 0, hyperlink: undefined }
const nextCell: Cell = { char: ' ', styleId: 0, width: 0, hyperlink: undefined }
let rowCI = (startY * width + startX) << 1
for (let y = startY; y < endY; y++) {
// ⭐ 快速跳过相同单元格(JIT 友好)
if (diffRowBoth(prevCells, nextCells, prev, next, rowCI, y,
startX, rowEndX, prevCell, nextCell, cb)) {
return true // 回调要求提前退出
}
rowCI += stride
}
return false
}
4.2 行级差异扫描(findNextDiff)
// ⭐ 超快扫描:一次比较两个 Int32,找到第一个差异
function findNextDiff(
a: Int32Array, // prev.cells
b: Int32Array, // next.cells
w0: number, // 起始索引(指向 word0)
count: number // 最多检查多少个单元格
): number {
for (let i = 0; i < count; i++, w0 += 2) {
const w1 = w0 | 1 // word1 索引 = w0 + 1
// 同时比较 charId 和 packedWord1
if (a[w0] !== b[w0] || a[w1] !== b[w1]) return i
}
return count // 全部相同
}
// 在行中使用
function diffRowBoth(/* ... */): boolean {
let x = startX
while (x < endX) {
// 跳过连续相同的单元格
const skip = findNextDiff(prevCells, nextCells, ci, endX - x)
x += skip
ci += skip << 1
if (x >= endX) break
// 发现差异:解包并报告
cellAtCI(prev, ci, prevCell) // 填充 prevCell 对象
cellAtCI(next, ci, nextCell) // 填充 nextCell 对象
if (cb(x, y, prevCell, nextCell)) return true
x++
ci += 2
}
return false
}
findNextDiff 是纯整数比较循环,V8 可以 JIT 优化为 SIMD 级别速度。
第五步:生成 ANSI 序列(log-update.ts)
将差异转换为终端命令:
// log-update.ts - render 方法
render(prev: Frame, next: Frame, altScreen = false, decstbmSafe = true): Diff {
// 1. 尺寸变化 → 全量重置(无法增量更新)
if (next.viewport.height < prev.viewport.height ||
next.viewport.width !== prev.viewport.width) {
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
}
// 2. ⭐ DECSTBM 滚动优化:硬件滚动代替重绘
let scrollPatch: Diff = []
if (altScreen && next.scrollHint && decstbmSafe) {
const { top, bottom, delta } = next.scrollHint
// 在 prev.screen 上模拟滚动,让 diffEach 只发现真正的新内容
shiftRows(prev.screen, top, bottom, delta)
// 发送硬件滚动命令(CSI n S/T)
scrollPatch = [
{ type: 'stdout',
content: setScrollRegion(top+1, bottom+1) +
(delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) +
RESET_SCROLL_REGION + CURSOR_HOME }
]
}
// 3. 检查 scrollback 区域的变更(不可达区域)
const cursorAtBottom = prev.cursor.y >= prev.screen.height
const prevHadScrollback = cursorAtBottom && prev.screen.height >= prev.viewport.height
if (prevHadScrollback && !isGrowing) {
const viewportY = prev.screen.height - prev.viewport.height
const scrollbackRows = viewportY + 1 // +1 为光标恢复导致的额外滚动
// 检查 scrollback 中是否有变更
let scrollbackChangeY = -1
diffEach(prev.screen, next.screen, (_x, y) => {
if (y < scrollbackRows) {
scrollbackChangeY = y
return true // 提前退出
}
})
if (scrollbackChangeY >= 0) {
// 必须全量重置,因为无法移动光标到 scrollback
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool)
}
}
// 4. ⭐ 主 diff 循环:生成增量更新
const screen = new VirtualScreen(prev.cursor, next.viewport.width)
diffEach(prev.screen, next.screen, (x, y, removed, added) => {
// 跳过新行(将在后面批量渲染)
if (growing && y >= prev.screen.height) return
// 跳过 spacer 单元格(宽字符的第二列)
if (added?.width === CellWidth.SpacerTail) return
// 跳过空单元格(无需覆盖)
if (added && isEmptyCellAt(next.screen, x, y) && !removed) return
// 检查是否超出可视区域(viewportY 以上在 scrollback 中)
if (y < viewportY) {
needsFullReset = true
return true // 触发全量重置
}
// ⭐ 生成移动光标命令(相对优化)
moveCursorTo(screen, x, y)
if (added) {
// 处理超链接变化
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, added.hyperlink)
// ⭐ 样式差异计算(缓存优化)
const styleStr = stylePool.transition(currentStyleId, added.styleId)
if (writeCellWithStyleStr(screen, added, styleStr)) {
currentStyleId = added.styleId
}
} else if (removed) {
// 单元格被删除 → 写入空格清除
// ...
}
})
// 5. 处理新增行(自然滚动,无需光标移动)
if (growing) {
renderFrameSlice(screen, next, prev.screen.height, next.screen.height, stylePool)
}
// 6. 光标恢复(主屏幕需要,alt 屏幕不需要)
if (!altScreen && next.cursor.y >= next.screen.height) {
// 使用 \n 创建新行(光标移动无法创建行)
screen.txn(prev => {
const rowsToCreate = next.cursor.y - prev.y
const patches: Diff = [CARRIAGE_RETURN] // \r 到行首
for (let i = 0; i < rowsToCreate; i++) {
patches.push(NEWLINE) // \n 创建新行
}
return [patches, { dx: -prev.x, dy: rowsToCreate }]
})
}
return scrollPatch.length > 0 ? [...scrollPatch, ...screen.diff] : screen.diff
}
5.1 样式差异优化
// StylePool 缓存样式转换字符串
class StylePool {
private transitionCache = new Map<number, string>() // (fromId << 20 | toId) -> ansiString
transition(fromId: number, toId: number): string {
if (fromId === toId) return ''
const key = fromId * 0x100000 + toId // 复合键
let str = this.transitionCache.get(key)
if (str === undefined) {
// 计算差异:只发送必要的 SGR 代码
str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId)))
this.transitionCache.set(key, str)
}
return str // 后续调用零分配
}
}
5.2 光标移动优化(VirtualScreen)
class VirtualScreen {
cursor: Point = { x: 0, y: 0 }
diff: Diff = []
// 使用相对移动而非绝对坐标(节省字节)
txn(fn: (prev: Point) => [patches: Diff, next: Delta]): void {
const [patches, delta] = fn(this.cursor)
for (const patch of patches) this.diff.push(patch)
this.cursor.x += delta.dx
this.cursor.y += delta.dy
}
}
function moveCursorTo(screen: VirtualScreen, targetX: number, targetY: number) {
screen.txn(prev => {
const dx = targetX - prev.x
const dy = targetY - prev.y
// 策略:跨行移动先用 \r 回行首,再水平移动
// 比 CSI CUP (CSI y;x H) 更短,且支持滚动
if (dy !== 0 || prev.x >= screen.viewportWidth) {
return [
[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }],
{ dx, dy }
]
}
// 同行移动
return [[{ type: 'cursorMove', x: dx, y: 0 }], { dx, dy: 0 }]
})
}
关键优化总结
| 优化技术 | 实现位置 | 效果 |
|---|---|---|
| 紧凑存储 | Screen.cells: Int32Array |
80x24 屏幕仅需 ~3.7KB,无 GC 压力 |
| Damage Tracking | setCellAt 自动更新 screen.damage |
只扫描变化区域,而非全屏 |
| Blit 复制 | blitRegion |
未变化区域内存复制,O(1) |
| 快速扫描 | findNextDiff |
JIT 友好,跳过相同单元格 |
| 对象池 | 重用 Cell 对象 |
避免 diff 过程中的分配 |
| 样式缓存 | StylePool.transitionCache |
样式转换字符串零分配复用 |
| 硬件滚动 | shiftRows + DECSTBM |
滚动时只渲染新行,而非全屏 |
| 相对光标 | VirtualScreen |
最短 ANSI 序列,减少带宽 |
这套系统使得 Ink 能在 60fps 下流畅渲染复杂终端 UI,即使在大型输出场景下也能保持高性能
ANSI 转义序列生成的实现机制
代码来源:
Claude code的三个文件:
src/ink/termio/csi.ts, src/ink/termio/osc.ts, src/ink/terminal.ts
构成了分层设计:
-
csi.ts— 底层 CSI (Control Sequence Introducer) 序列生成器 -
osc.ts— OSC (Operating System Command) 序列生成器 + 终端复用器封装 -
terminal.ts— 高层终端能力检测与统一写入接口
1. CSI 序列生成 (csi.ts)
核心基础架构
// 比简化版更严谨:使用字符码而非字符串拼接
export const CSI_PREFIX = ESC + String.fromCharCode(ESC_TYPE.CSI) // ESC + '['
// 字节范围验证(用于解析时校验)
export const CSI_RANGE = {
PARAM_START: 0x30, // '0'
PARAM_END: 0x3f, // '?'
INTERMEDIATE_START: 0x20, // ' '
INTERMEDIATE_END: 0x2f, // '/'
FINAL_START: 0x40, // '@'
FINAL_END: 0x7e, // '~'
} as const
区分了 参数字节 (0x30-0x3f)、中间字节 (0x20-0x2f) 和 最终字节 (0x40-0x7e),这是完整实现 CSI 状态机的基础。
通用 CSI 生成器
export function csi(...args: (string | number)[]): string {
if (args.length === 0) return CSI_PREFIX
if (args.length === 1) return `${CSI_PREFIX}${args[0]}` // 单参数 = 原始体
// 多参数:最后一个是最终字节,前面的是用 ; 分隔的参数
const params = args.slice(0, -1)
const final = args[args.length - 1]
return `${CSI_PREFIX}${params.join(SEP)}${final}`
}
使用示例:
-
csi('H')→ESC[H(光标归位) -
csi(10, 5, 'H')→ESC[10;5H(光标定位到第10行第5列)
命令常量表(类型安全)
export const CSI = {
CUU: 0x41, // A - Cursor Up
CUD: 0x42, // B - Cursor Down
CUP: 0x48, // H - Cursor Position
ED: 0x4a, // J - Erase in Display
SGR: 0x6d, // m - Select Graphic Rendition
DECSTBM: 0x72, // r - Set Top/Bottom Margins
// ... 完整覆盖
} as const
生产级光标移动
// 智能优化:n=0 时返回空字符串,避免无意义输出
export function cursorUp(n = 1): string {
return n === 0 ? '' : csi(n, 'A')
}
// 相对移动:组合水平和垂直(匹配 ansi-escapes 行为)
export function cursorMove(x: number, y: number): string {
let result = ''
// 水平优先(与 ansi-escapes 保持一致)
if (x < 0) result += cursorBack(-x)
else if (x > 0) result += cursorForward(x)
if (y < 0) result += cursorUp(-y)
else if (y > 0) result += cursorDown(y)
return result
}
批量擦除优化
export function eraseLines(n: number): string {
if (n <= 0) return ''
let result = ''
for (let i = 0; i < n; i++) {
result += ERASE_LINE // CSI 2 K
if (i < n - 1) result += cursorUp(1) // 除最后一行外都上移
}
result += CURSOR_LEFT // 最终回到行首
return result
}
高级协议支持
// Kitty 键盘协议(增强按键报告)
export const ENABLE_KITTY_KEYBOARD = csi('>1u') // 区分 Escape 序列
export const DISABLE_KITTY_KEYBOARD = csi('<u')
// xterm modifyOtherKeys(tmux 兼容路径)
export const ENABLE_MODIFY_OTHER_KEYS = csi('>4;2m')
2. OSC 序列生成 (osc.ts)
终止符智能选择
export function osc(...parts: (string | number)[]): string {
// Kitty 使用 ST (ESC \) 避免蜂鸣声,其他终端用 BEL (\x07)
const terminator = env.terminal === 'kitty' ? ST : BEL
return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
}
固定用 BEL,实际代码根据终端类型选择最优终止符。
终端复用器封装(核心设计)
export function wrapForMultiplexer(sequence: string): string {
// tmux 拦截转义序列,需要用 DCS 透传
if (process.env['TMUX']) {
const escaped = sequence.replaceAll('\x1b', '\x1b\x1b') // ESC 双倍转义
return `\x1bPtmux;${escaped}\x1b\\` // DCS 透传格式
}
// GNU screen 类似处理
if (process.env['STY']) {
return `\x1bP${sequence}\x1b\\`
}
return sequence
}
tmux/screen 会拦截 OSC 序列,必须用 DCS (Device Control String) 封装才能透传到外层终端。
剪贴板写入的多路径策略
export async function setClipboard(text: string): Promise<string> {
const b64 = Buffer.from(text, 'utf8').toString('base64')
const raw = osc(OSC.CLIPBOARD, 'c', b64) // OSC 52
// 路径1: 本地原生工具(pbcopy/wl-copy/xclip)- 最高置信度
if (!process.env['SSH_CONNECTION']) copyNative(text)
// 路径2: tmux 缓冲区(SSH 环境下最可靠)
const tmuxBufferLoaded = await tmuxLoadBuffer(text)
// 路径3: OSC 52 直接写入(保底方案)
if (tmuxBufferLoaded) {
return tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) // DCS 封装
}
return raw
}
三层降级策略:
-
Native (macOS pbcopy, Linux wl-copy/xclip, Windows clip) — 本地最高效
-
tmux-buffer — SSH 环境下
tmux load-buffer -w同时填充 tmux 缓冲区并尝试 OSC 52 透传 -
OSC 52 raw — 最终保底,依赖终端支持
超链接生成(带 ID 优化)
export function link(url: string, params?: Record<string, string>): string {
if (!url) return LINK_END
// 自动生成 ID 使换行链接被识别为同一链接
const p = { id: osc8Id(url), ...params }
const paramStr = Object.entries(p)
.map(([k, v]) => `${k}=${v}`)
.join(':')
return osc(OSC.HYPERLINK, paramStr, url)
}
// FNV-1a 哈希生成短 ID
function osc8Id(url: string): string {
let h = 0
for (let i = 0; i < url.length; i++)
h = ((h << 5) - h + url.charCodeAt(i)) | 0
return (h >>> 0).toString(36)
}
OSC 8 规范要求相同 URI 且非空 ID 的单元格才会被合并为同一链接。自动生成的 ID 确保长 URL 换行后仍能正确悬停/点击。
状态栏协议(自定义扩展)
export const OSC = {
// ... 标准命令
TAB_STATUS: 21337, // 自定义扩展(Ant 内部使用)
} as const
export function tabStatus(fields: TabStatusAction): string {
const parts: string[] = []
// 转义 ; 和 \ 遵循规范
if ('status' in fields)
parts.push(`status=${fields.status?.replaceAll('\\', '\\\\').replaceAll(';', '\\;') ?? ''}`)
// ...
return osc(OSC.TAB_STATUS, parts.join(';'))
}
3. 终端能力检测与统一写入 (terminal.ts)
进度条支持检测
export function isProgressReportingAvailable(): boolean {
if (!process.stdout.isTTY) return false
// Windows Terminal 排除:OSC 9;4 在它那里是通知而非进度
if (process.env.WT_SESSION) return false
// ConEmu (Windows) 全版本支持
if (process.env.ConEmuANSI || process.env.ConEmuPID) return true
// 语义化版本比较
const version = coerce(process.env.TERM_PROGRAM_VERSION)
if (!version) return false
if (process.env.TERM_PROGRAM === 'ghostty')
return gte(version.version, '1.2.0')
if (process.env.TERM_PROGRAM === 'iTerm.app')
return gte(version.version, '3.6.6')
return false
}
同步输出支持(DEC 2026)
export function isSynchronizedOutputSupported(): boolean {
// tmux 透传但不实现,跳过以节省 16 字节/帧
if (process.env.TMUX) return false
const termProgram = process.env.TERM_PROGRAM
const term = process.env.TERM
// 白名单检测(支持 DEC 2026 的终端)
if (['iTerm.app', 'WezTerm', 'WarpTerminal', 'ghostty',
'vscode', 'alacritty'].includes(termProgram)) return true
// 特殊标识检测
if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) return true
if (term === 'xterm-ghostty') return true
if (term?.startsWith('foot')) return true
// VTE 版本号检测 (GNOME Terminal 等)
const vteVersion = process.env.VTE_VERSION
if (vteVersion && parseInt(vteVersion, 10) >= 6800) return true
return false
}
tmux 虽然透传 BSU/ESU,但破坏了原子性(分块处理),所以显式禁用。
XTVERSION 探测(SSH 穿透)
let xtversionName: string | undefined
// 通过 CSI > 0 q 查询真实终端(穿透 SSH)
export function setXtversionName(name: string): void {
if (xtversionName === undefined) xtversionName = name // 只设一次
}
export function isXtermJs(): boolean {
if (process.env.TERM_PROGRAM === 'vscode') return true
// SSH 环境下 TERM_PROGRAM 不转发,但 XTVERSION 回复能穿透
return xtversionName?.startsWith('xterm.js') ?? false
}
SSH 不转发 TERM_PROGRAM,但 XTVERSION 查询 (CSI > 0 q) 通过 pty 到达客户端终端,回复通过 stdin 返回,因此能检测 VS Code/Cursor 等 xterm.js 终端。
统一 Diff 写入
export function writeDiffToTerminal(
terminal: Terminal,
diff: Diff,
skipSyncMarkers = false,
): void {
if (diff.length === 0) return
const useSync = !skipSyncMarkers && SYNC_OUTPUT_SUPPORTED
let buffer = useSync ? BSU : '' // Begin Synchronized Update
for (const patch of diff) {
switch (patch.type) {
case 'stdout': buffer += patch.content; break
case 'clear': buffer += eraseLines(patch.count); break
case 'cursorMove': buffer += cursorMove(patch.x, patch.y); break
case 'hyperlink': buffer += link(patch.uri); break
case 'styleStr': buffer += patch.str; break
// ... 其他 case
}
}
if (useSync) buffer += ESU // End Synchronized Update
// 单次写入减少系统调用
terminal.stdout.write(buffer)
}
关键设计对比
| 特性 | 简化版 | 生产版 |
|---|---|---|
| CSI 生成 | 字符串拼接 | 类型安全的 csi() 函数 + 字节范围验证 |
| OSC 终止符 | 固定 BEL | 根据终端智能选择 BEL/ST |
| 复用器支持 | 无 | wrapForMultiplexer() 处理 tmux/screen |
| 剪贴板 | 单一路径 | 三层降级 (native → tmux → OSC 52) |
| 超链接 ID | 无 | FNV-1a 哈希自动生成 |
| 能力检测 | 无 | 多维度检测 (env 变量 + 版本号 + XTVERSION) |
| 同步输出 | 无 | DEC 2026 BSU/ESU 防闪烁 |
| 写入优化 | 多次 write | 单次 buffer 写入 |
需要处理 tmux/screen 透传、SSH 环境变量丢失、不同终端对同一序列的不同解释(如 Windows Terminal 的 OSC 9;4 是通知而非进度)等问题。
小结:
一个高性能的终端 UI 渲染引擎,其核心在于分层架构与极致的内存优化。顶层通过 React 19 Reconciler 管理组件状态,经由 Yoga Flexbox 引擎完成布局计算,最终下沉到基于 TypedArray 的 Screen 缓冲区——每个单元格压缩至 8 字节,字符、样式、超链接均采用对象池复用与 ID 引用,避免字符串重复创建。渲染阶段引入 Blit 技术与 Damage Tracking,仅对变更区域执行 ANSI 序列生成,配合字符级缓存将稳定帧的复杂度从 O(行×列) 降至 O(变更单元格数)。针对 CJK 与 Emoji 的宽字符场景,采用 Wide + SpacerTail 双单元格模型保证光标对齐;同时支持 OSC 8 超链接、软换行文本选择、自适应排水算法的平滑滚动等高级特性。整体性能控制在单次渲染 1-3ms,实现了大型终端应用的高帧率与低内存占用平衡。
参考阅读1
常见 ANSI 序列:
| 序列 | 功能 | 示例 |
|---|---|---|
CSI n S |
向上滚动 n 行 | \x1b[3S |
CSI n T |
向下滚动 n 行 | \x1b[3T |
CSI top;bot r |
设置滚动区域 | \x1b[5;20r |
CSI y;x H |
移动光标到 (y,x) | \x1b[10;5H |
OSC 8;;URL BEL |
超链接 | \x1b]8;;https://example.com\x07 |
SGR n m |
样式 (颜色/粗体等) | \x1b[33m (黄色) |
参考阅读2
Claude Code从 VDOM 到 ANSI 转义序列的转换过程
React Component (<Text color="red">Hello</Text>)
↓
React 19 Reconciler (createInstance, commitUpdate)
↓
Ink DOM Element ({ nodeName: 'ink-text', style: { color: 'red' }})
↓
Yoga Layout (getComputedLeft/Top/Width/Height)
↓
renderNodeToOutput (squashTextNodes, wrapText)
↓
Output Operations (write(x, y, "\x1b[31mHello\x1b[39m"))
↓
Screen Buffer (setCellAt - packed Int32Array)
↓
Diff Engine (diffEach - compare with prevScreen)
↓
ANSI Patches ([{ type: 'stdout', content: "\x1b[31mHello\x1b[39m" }])
↓
Terminal Write (process.stdout.write)
↓
Physical Terminal (renders colored "Hello")
参考阅读3
OSC 8 超链接实现详解
1. 基础原理
OSC 8 是终端超链接协议,格式为:
ESC ] 8 ; params ; url BEL
链接文本
ESC ] 8 ; ; BEL
代码中的常量定义:
const OSC = '\u001B]' // ESC ]
const BEL = '\u0007' // \a 响铃字符(终止符)
2. 包装函数实现
function wrapWithOsc8Link(text: string, url: string): string {
return `${OSC}8;;${url}${BEL}${text}${OSC}8;;${BEL}`
}
拆解结构:
| 部分 | 含义 |
|---|---|
\u001B]8;; |
OSC 8 开始,params 为空(使用 ;;) |
url |
超链接目标地址 |
\u0007 |
BEL 终止符 |
text |
显示给用户的文本 |
\u001B]8;;\u0007 |
空 URL 表示超链接结束 |
3. 多行处理策略
关键设计:每行独立包装,而非整体包装:
// ✅ 正确做法:每行单独包装
text = w.wrapped.split('\n').map(line => {
let styled = applyTextStyles(line, segment.styles)
if (segment.hyperlink) {
styled = wrapWithOsc8Link(styled, segment.hyperlink) // 每行都包
}
return styled
}).join('\n')
原因在代码注释中说明:
output.ts按换行符分割并逐行独立 tokenize,整体包装只会让第一行获得超链接
4. 多段文本的复杂场景
当文本包含多个不同样式的段且需要换行时:
// 构建字符→段的映射表
function buildCharToSegmentMap(segments: StyledSegment[]): number[] {
const map: number[] = []
for (let i = 0; i < segments.length; i++) {
const len = segments[i]!.text.length
for (let j = 0; j < len; j++) {
map.push(i) // 每个字符记录属于哪个段
}
}
return map
}
换行后样式恢复流程:
原始文本: [段0: "点击"] [段1: "这里"] [段2: "访问"]
↓ 红色 ↓ 蓝色+下划线 ↓ 绿色
换行后: "点击这"
"里访问"
处理过程:
1. 按字符映射找到 "点击这" 包含段0全部 + 段1部分
2. 对 "点击" 应用段0样式(红色)
3. 对 "这" 应用段1样式(蓝色+下划线+超链接)
4. 第二行同理,"里"用段1样式,"访问"用段2样式
核心函数 applyStylesToWrappedText 通过 charToSegment 映射表,确保换行后的每个字符都能找回原始段的样式和超链接属性。
5. 完整数据流
ink-text 节点
↓
squashTextNodesToSegments → StyledSegment[] (含 hyperlink 字段)
↓
wrapWithSoftWrap → 换行 + 软换行标记
↓
applyStylesToWrappedText/buildCharToSegmentMap
↓
逐行处理:applyTextStyles(颜色/样式) + wrapWithOsc8Link(超链接)
↓
output.write(x, y, text, softWrap)
6. 终端兼容性
现代支持 OSC 8 的终端:
-
iTerm2 (首创)
-
VS Code 终端 (xterm.js)
-
Ghostty
-
Alacritty (部分版本)
-
Windows Terminal
检测机制:
function isXtermJsHost(): boolean {
return process.env.TERM_PROGRAM === 'vscode' || isXtermJs()
}
不支持的终端会静默显示纯文本,不会渲染为可点击链接,也不会报错。
更多推荐



所有评论(0)