vue-tui 性能优化 RFC
文档类型: RFC / 草案
状态: Phase 1 已实施;Phase 2+ 待实施
创建日期: 2026-07-09
修订版本: v4 (基于四轮 review 反馈)
Unicode 版本: 17.0.0
Update 2026-07-09: Phase 1 (Unicode Width Correctness) 已在 PR #114 实现。 本 RFC 中 Phase 2+ 仍作为后续独立 PR 的参考路线。
📋 执行摘要
本 RFC 基于代码审查和多轮 review 反馈,提出 vue-tui 性能优化方向和实施建议。
- ✅ 本文档是优化方向草案,非最终实施方案
- ✅ Phase 1 已完成 - Unicode 17.0.0 East Asian Width 正确性修复
- ⏳ Phase 2+ 待实施 - 需要独立 PR,基于真实 baseline 数据
- 📊 后续优化应拆分为多个小 PR,每个 PR 配真实性能数据
- ⚠️ 所有诊断和收益预期基于代码审查,需要真实 baseline 数据验证
- 📊 后续应拆分为多个小 PR,每个 PR 配真实性能数据
🔍 发现的潜在问题
问题 #1: 补充平面 CJK 宽度覆盖不足
根因诊断:
当前 isFullWidthCodePoint 的 fast reject:
// src/core/buffer/width.ts
if (codePoint < 0x1100 || codePoint > 0xffe6) return false;这会将 𠮷 (U+20BB7) 等补充平面 CJK 扩展字符直接排除,导致宽度计算错误。
建议方向:
基于 Unicode 17.0.0 的 EastAsianWidth.txt 生成/维护 W/F 范围表。
重要说明: East_Asian_Width 是基础数据,不是现代终端宽度的最终规则。Unicode UAX #11 明确指出 East_Asian_Width 不适合作为现代 terminal emulator 的开箱即用方案,terminal 需要 case-by-case tailoring。
宽度判断优先级:
- Custom widthProvider
- Grapheme/emoji sequence rules (ZWJ, VS16, keycap, emoji tag sequence)
- Terminal overrides (box drawing remains narrow)
- Unicode EastAsianWidth W/F table
- Ambiguous 仅在 provider === "cjk" 时视为 wide
- Default fallback: narrow
参考:
测试建议 (基于 Unicode 17.0.0):
// CJK Extensions 完整覆盖 (Unicode 17.0.0 包含 Extension J)
expect(charCellWidth("\u{20BB7}")).toBe(2); // Ext B
expect(charCellWidth("\u{2A700}")).toBe(2); // Ext C
expect(charCellWidth("\u{2B740}")).toBe(2); // Ext D
expect(charCellWidth("\u{2B820}")).toBe(2); // Ext E
expect(charCellWidth("\u{2CEB0}")).toBe(2); // Ext F
expect(charCellWidth("\u{30000}")).toBe(2); // Ext G (TIP)
expect(charCellWidth("\u{31350}")).toBe(2); // Ext H (TIP)
expect(charCellWidth("\u{2EBF0}")).toBe(2); // Ext I (SIP)
expect(charCellWidth("\u{323B0}")).toBe(2); // Ext J (TIP, Unicode 17.0.0)
// 基本功能测试
expect(textCellWidth("𠮷x")).toBe(3);
expect(sliceByCells("𠮷x", 1)).toBe("");
expect(sliceByCells("𠮷x", 2)).toBe("𠮷");
// 终端集成测试
terminal.write("𠮷x", { x: 0, y: 0 });
expect(terminal.getCell(0, 0).ch).toBe("𠮷");
expect(terminal.getCell(0, 0).width).toBe(2);
expect(terminal.getCell(1, 0).continuation).toBe(true);
expect(terminal.getCell(2, 0).ch).toBe("x");
// 反例:非 CJK 补充平面字符
expect(charCellWidth("𝄞")).toBe(1); // U+1D11E musical symbol
// 回归测试:现有 tailoring 必须保留
expect(charCellWidth("⏱")).toBe(1); // 无 VS16 时窄
expect(charCellWidth("⏱️")).toBe(2); // 有 VS16 时宽
// Box drawing 在 cjk mode 仍然窄 (现有行为)优先级: P1 (功能正确性)
预期难度: 中等
建议实施: 独立 PR,不依赖性能优化
问题 #2: Cell 缓存策略可能的改进空间
当前状态:
// src/core/buffer/buffer.ts
const cellCacheWidth1 = new WeakMap<Style, Map<string, Cell>>();
const cellCacheWidth2 = new WeakMap<Style, Map<string, Cell>>();
const MAX_CACHED_CELLS_PER_STYLE = 128;
// 超过上限时全部清空
if (map.size > MAX_CACHED_CELLS_PER_STYLE) map.clear();关键事实:
createCell 流程是:
- 先调用
charCellWidth(ch, widthProvider)计算宽度 - 根据 width 选择 width1/width2 cache
- 查找或创建 Cell 对象
因此,这个 cache 优化主要减少 Cell 对象分配和对象复用,不直接减少宽度计算成本。
建议方向 (需 profiler 验证):
// 方案 A: Candidate - 调大上限 (需谨慎评估)
const MAX_CACHED_CELLS_PER_STYLE = 512; // 原 128
// 注意: Cache 按 Style 分桶,width1/width2 各一套
// 如果 live styles 多,512 会增加 retained memory
// 方案 B: FIFO-like partial eviction (如果方案 A 不够)
// 注意: 这不是真 LRU,真 LRU 需要在 get 时 refresh order
function evictOldestInStyleMap(map: Map<string, Cell>, keepRatio = 0.75) {
const toDelete = Math.floor(map.size * (1 - keepRatio));
const keys = Array.from(map.keys());
for (let i = 0; i < toDelete; i++) {
map.delete(keys[i]);
}
}验收标准 (完整指标):
createCell调用次数Cell新建次数map.clear()次数- width1/width2 cache hit/miss
- Live Style 数量
- 每个 live Style 下 width1/width2 cache size 分布
- Cache 容量调整后的 retained Cell 上限估算
- p50/p95 渲染耗时
- heap delta / retained memory
- Partial eviction 的额外开销 (如适用)
门槛: 只有当 cache miss 对 p95 或 allocation pressure 有可见影响时才优化;hit rate 只是辅助指标。
优先级: P2 (性能优化,需先 profiler 证明是瓶颈)
问题 #3: Provider-Aware 缓存策略 (Future Consideration)
当前状态:
// src/vue/utils/text.ts
const renderPassTextWidthCache = new Map<string, number>(); // 全局
const textWidthProviderStack: WidthProvider[] = []; // 全局源码中 canUseDefaultTextCache 只允许 "default" 和 "narrow-ambiguous" 使用默认 cache。"cjk" 和 function provider 不走默认 cache。
当前 default 和 narrow-ambiguous 对 ambiguous 字符的宽度规则相同,因此目前没有明确的 provider cache 污染证据。
未来考虑:
如果引入更多 provider 或改变 cache 策略,需要同时审查这些 cache:
renderPassTextWidthCachetextWidthCacheinlineLineCacheByWidthwrapCacheByWidth
防回归测试建议:
// 确保 default 和 narrow-ambiguous 一致
expect(textCellWidth("Ω", "default")).toBe(textCellWidth("Ω", "narrow-ambiguous"));
// 确保 cjk 不复用 default cache
clearTextCaches();
expect(textCellWidth("Ω", "default")).toBe(1);
expect(textCellWidth("Ω", "cjk")).toBe(2);
// Nested provider 场景
clearTextCaches();
withTextRenderPass(() => {
expect(textCellWidth("Ω", "default")).toBe(1);
expect(textCellWidth("Ω", "cjk")).toBe(2);
});
withTextRenderPass(() => {
expect(textCellWidth("Ω", "default")).toBe(1);
});优先级: P3 (未来考虑,需要先证明存在问题)
问题 #4: 长文本性能优化方向
性能分类:
长文本热点需要区分三类:
- ASCII fast path: 已优化,直接返回
text.length - 普通非 ASCII: code point iteration +
charCellWidth - 复杂 grapheme: ZWJ/VS/combining mark/emoji modifier 等需要
Intl.Segmenter
关键澄清:
纯 CJK 长文本不会走完整 grapheme segmentation。当前 segmentedGraphemes 只在检测到 ZWJ、variation selector、combining mark 等情况时才返回 segmenter,否则只用 for...of 按 code point 迭代。
真正昂贵的通常是第三类(复杂 grapheme),纯 CJK 的成本主要来自逐 code point 宽度判断和 cache 行为。
建议方向:
const MAX_GLOBAL_CACHEABLE_TEXT_LENGTH = 1000;
const MAX_RENDER_PASS_CACHEABLE_TEXT_LENGTH = 4000;
// 全局 cache 受长 transcript/日志污染风险高,应限制
const useGlobalCache = useCache && text.length <= MAX_GLOBAL_CACHEABLE_TEXT_LENGTH;
// render-pass cache 只在 render pass 期间存在,风险较小
const useRenderPassCache =
useCache && renderPassDepth > 0 && text.length <= MAX_RENDER_PASS_CACHEABLE_TEXT_LENGTH;关于 Chunking:
当前 textCellWidth / sliceByCells 已经是 streaming iteration,不应为了"分块"引入额外 string[] 分配。
只有在 profiler 证明需要以下目标时才考虑 chunk:
- 跨 render pass 复用 chunk width
- 长文本中断/分片计算
- 避免 wrap/split 产生大中间数组
验收标准:
需要 benchmark 两类场景:
// Scenario A: unique long text
// 10k 条不同日志,验证 cache 不污染/heap 不膨胀
// Scenario B: repeated long text
// 同一长文本多次渲染,验证限制长度不会误伤真实复用收益优先级: P2 (性能优化)
建议实施: Profiler 确认 textCellWidth 是热点后再实施
🛠️ 建议的实施路线
Phase 1: 功能正确性修复
PR #1: Unicode Width Correctness
- 基于 Unicode 17.0.0
EastAsianWidth.txt生成/维护 W/F 范围 - 测试覆盖所有 CJK Extensions (B/C/D/E/F/G/H/I/J)
- 添加非 CJK 补充平面反例(如音乐符号)
- 保留现有 emoji/VS16/keycap/combining mark 测试
- 确保宽度判断优先级正确(custom provider → grapheme → terminal override → EAW table)
- 不涉及性能优化
验收: 新增测试通过,现有测试不失败,回归测试覆盖 tailoring
Phase 2: 真实性能基线
PR #2: Real Baseline Benchmark
需要新增 baseline harness,因为现有脚本不统一输出所需格式:
check-bench-baselines.ts: 跑 bench 并检查预算,只打印 pass/failbench-dom-renderer.ts: 单次 scenario 测量,非 samples/p50/p95bench-stdout-column-diff.ts: 输出 table 和 ratio,非 JSON baseline
建议: 新增 pnpm run bench:perf-baseline harness,复用现有场景,输出:
{
"commit": "abc123",
"unicodeVersion": "17.0.0",
"node": "v18.19.0",
"v8": "10.2.154.26",
"os": "darwin-arm64",
"cpu": "Apple M1",
"warmup": 100,
"samples": 1000,
"clock": "process.hrtime.bigint",
"gc": "--expose-gc (if memory benchmark)",
"results": {
"textCellWidth_ascii_100": {
"p50": 45,
"p95": 68,
"p99": 80,
"mean": 50,
"stdev": 5,
"cv": 0.1,
"samples": 1000
}
}
}注意: DOM renderer 性能数据应区分 happy-dom 和真实浏览器。声称用户可感知的 DOM 渲染提升时,需补充 Playwright/browser benchmark。
验收: 可重复运行,数据稳定(CV < 10%),环境信息完整
Phase 3: 低风险 Candidate Optimizations
PR #3: Cache Parameter Tuning (如果 Phase 2 数据显示需要)
仅作为 profiler-driven candidate,不是 quick-win。
必需数据:
- Before/after
createCell调用次数 - Before/after
Cell新建次数 - Before/after
map.clear()次数 - Live Style 数量和分布
- 每个 Style 下 cache size 分布
- Before/after p50/p95 渲染耗时
- Heap delta 和 retained Cell 估算
验收: Cache miss 对 p95 的影响降低,retained memory 在可接受范围
Phase 4: Profiler 驱动优化
仅在 profiler 证明瓶颈后实施:
PR #4: Long Text Strategy (如果 textCellWidth 是 top hotspot)
- 实现
MAX_GLOBAL/RENDER_PASS_CACHEABLE_TEXT_LENGTH - Benchmark unique vs repeated long text 场景
验收: 长文本场景更稳定,有 before/after 数据,heap 不膨胀
PR #5: Provider-Aware Cache (如果可证明污染)
- 实现缓存分桶,覆盖所有相关 cache
验收: 可复现的污染场景被修复,nested provider 测试通过
PR #6: Virtual Scroll Optimizations
- 基于 profiler 数据优化实际瓶颈
验收: 滚动场景 FPS 稳定提升,有工作负载描述
⚠️ 需要避免的陷阱
1. Unicode 完整性
❌ 错误: 只添加部分 Extension,使用过时 Unicode 版本
✅ 正确: Pin Unicode 17.0.0,测试所有 Extension (B-J),添加反例
❌ 错误: 把所有补充平面字符都判宽
✅ 正确: 基于 EastAsianWidth.txt,保留 terminal tailoring 优先级
2. 破坏 Grapheme
❌ 错误: 按 UTF-16 index 直接切分文本
✅ 正确: 基于 grapheme 边界
3. 过度优化
❌ 错误: 没有 profiler 数据就调 cache 参数或做 chunking
✅ 正确: Profiler 先行,数据驱动决策
4. 验收标准不足
❌ 错误: 只报 cache hit rate 提升
✅ 正确: 提供完整指标(live styles、allocation、p50/p95、heap)
📋 严格的验收标准
Correctness PR
- ✅ 基于 Unicode 17.0.0 EastAsianWidth.txt
- ✅ 测试覆盖所有 Extension (B/C/D/E/F/G/H/I/J)
- ✅ 包含非 CJK 补充平面反例
- ✅ 回归测试:⏱ vs ⏱️,box drawing 仍窄
- ✅ 现有 emoji/VS16/grapheme 测试仍通过
Baseline PR
- ✅ 新增 baseline harness (现有脚本不足)
- ✅ JSON 包含: commit, Unicode version, Node, V8, OS, CPU, p50/p95/p99/mean/stdev/CV
- ✅ 多次运行数据稳定(CV < 10%)
- ✅ DOM benchmark 区分 happy-dom vs 真实浏览器
Cache Tuning PR
- ✅ Profiler 证明 Cell allocation 是瓶颈
- ✅ Before/after: createCell count, new Cell count, map.clear count
- ✅ Live Style 数量和 cache size 分布
- ✅ Retained Cell 上限估算
- ✅ Before/after p50/p95
- ✅ Heap delta 测量
Long Text PR
- ✅ Profiler 证明 textCellWidth 是 hotspot
- ✅ Benchmark: unique vs repeated long text
- ✅ 证明不污染 global cache
- ✅ Heap 不膨胀
Virtual Scroll PR
- ✅ Profiler 指出具体瓶颈
- ✅ 滚动工作负载描述
- ✅ Dirty rows, candidate fallback 指标
- ✅ FPS 或 frame duration 数据
🎓 经验教训
从四轮 Review 学到的
- ✅ Pin Unicode 版本 - 2026 年应使用 Unicode 17.0.0 (包含 Extension J)
- ✅ 测试标注准确 - U+2B820 是 Ext E,U+2CEB0 是 Ext F,U+2EBF0 是 Ext I
- ✅ EAW 不是 Oracle - 需要 terminal tailoring 优先级
- ✅ Cache 调参需谨慎 - 不是 quick-win,需完整指标
- ✅ Chunking 需明确目的 - 不应引入额外分配
✅ 下一步行动
- 立即: 合并本 RFC,作为优化方向参考
- Week 1: 实施 PR #1 (Unicode Correctness, Unicode 17.0.0)
- Week 2: 实施 PR #2 (Real Baseline with harness)
- Week 3: 根据 baseline + profiler 数据决定候选优化
- Week 4+: 仅实施 profiler 证明的瓶颈优化
文档状态: ✅ RFC / 草案
实施状态: ⏳ 待实施
验证方式: 真实 baseline + profiler 数据驱动
Unicode 版本: 17.0.0
最后更新: 2026-07-09 (v4, 基于四轮 review 修正)