Topic | Markdown.tsx 渲染优化深度拆解¶
重要性:⭐⭐⭐⭐(前端 TUI 性能教科书案例) 出现位置:
src/components/Markdown.tsx关联:phase-04-components.md 的 Markdown 优化
1. 问题背景¶
Markdown.tsx 是 Claude Code 最频繁渲染的组件之一:
- 每个 assistant 文本回复都包含 Markdown
- 长会话可能有 100+ 条消息
- 虚拟滚动会 unmount/remount 已滚走的消息
性能挑战:
- marked.lexer() 解析 Markdown:~3ms / 调用
- CJK / Emoji 宽度计算:~1ms / 1000 字符
- 语法高亮:~10ms / 100 行
用户能感受到的卡顿: - 滚动到之前看过的消息 → 不应该重新解析(应该秒渲染) - 快速流式响应 → 不应该每次 delta 都重新 parse 整段
2. 三个核心优化¶
2.1 模块级 LRU token cache¶
// src/components/Markdown.tsx 头部
const TOKEN_CACHE_MAX = 500
const tokenCache = new Map<string, Token[]>()
function getTokensFromCache(text: string): Token[] | null {
const cached = tokenCache.get(text)
if (cached) {
// LRU: 移到末尾(Map 保持插入顺序)
tokenCache.delete(text)
tokenCache.set(text, cached)
return cached
}
return null
}
function setTokensInCache(text: string, tokens: Token[]): void {
if (tokenCache.size >= TOKEN_CACHE_MAX) {
// 淘汰最旧的(第一个 key)
const firstKey = tokenCache.keys().next().value
if (firstKey !== undefined) {
tokenCache.delete(firstKey)
}
}
tokenCache.set(text, tokens)
}
为什么 module-level 而非 useMemo:
- useMemo 在 unmount 时丢失
- 虚拟滚动必然触发 unmount
- module-level Map 不依赖 React 生命周期
为什么用 text 作 key 会有问题:
- 注释里提到 "turn50→turn99 RSS regression, #24180"
- 100 条长消息都缓存 → 内存爆炸
- 解决:用 hash 作 key(见 2.2)
2.2 用 hash 作 key¶
import { hashContent } from '../utils/hash.js'
function getTokensFromCache(text: string): Token[] | null {
const hash = hashContent(text) // sha256 → 8 字节 hex
return tokenCache.get(hash)
}
意义: - key 始终是 16 字节(hex) - 避免 key 本身占用内存 - 仍然可以精确匹配
2.3 Skip-on-no-markdown 短路¶
// src/components/Markdown.tsx 头部
// Characters that indicate markdown syntax. If none are present, skip the
// ~3ms marked.lexer call entirely — render as a single paragraph.
// Single regex: matches any MD marker or ordered-list start (N. at line start).
const MD_MARKER_REGEX = /[#*_`~>\-\[\]|\d+\./
function renderMarkdown(text: string): ReactNode {
// 1. 快速检查
if (!MD_MARKER_REGEX.test(text)) {
// 没 MD 标记 → 当纯文本
return <Text>{text}</Text>
}
// 2. 命中 → 调 marked.lexer
return renderTokens(marked.lexer(text))
}
关键洞察:
- 80% 的 assistant 回复是短句,没有 MD 标记
- MD_MARKER_REGEX.test() ≈ 0.01ms
- 跳过 3ms 的 lexer 调用 → 节省 99%
为什么用单个 regex:
- 注释说 "One pass instead of 10× includes scans"
- 一次 regex 扫描 vs 10 次 text.includes() 循环
3. 异步语法高亮¶
// src/components/Markdown.tsx
import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js'
import { Suspense, use } from 'react'
function renderCodeBlock(code: string, lang: string) {
// getCliHighlightPromise 立即返回 Promise,不阻塞
const highlightPromise = getCliHighlightPromise(code, lang)
return (
<Suspense fallback={<Text dimColor>...</Text>}>
<HighlightedCode promise={highlightPromise} />
</Suspense>
)
}
function HighlightedCode({ promise }: { promise: Promise<...> }) {
// React 19 use() 读 Promise
const highlighted = use(promise)
return <RawAnsi>{highlighted}</RawAnsi>
}
关键设计:
- getCliHighlightPromise 是异步的(可能调 N-API 的 Tree-sitter)
- 不阻塞 Markdown 主体渲染
- Suspense 提供 fallback
- React 19 use() 读 Promise
前端类比:和 React.lazy() + <Suspense> 同种"lazy evaluation"。
4. 完整渲染流程¶
输入:text = "Hello **world**\n```ts\nconst x = 1\n```"
↓
1. 快速检查 regex
→ 命中(包含 ** 和 ```)
↓
2. 查 token cache
→ 命中 → 直接用
→ 未命中 → 调 marked.lexer + 存 cache
↓
3. 解析为 token 列表
→ [paragraph, code_block]
↓
4. 渲染 paragraph
→ 普通 Text 组件
↓
5. 渲染 code_block
→ 异步启动 highlight
→ 立即返回 Suspense fallback
→ highlight 完成 → re-render 高亮版本
5. 性能基准(推测)¶
| 场景 | 无优化 | 有优化 |
|---|---|---|
| 短文本(< 100 字符,无 MD) | 3ms | 0.01ms |
| 中等文本(500 字符,3 个段落) | 4ms | 4ms(首次)< 0.5ms(缓存) |
| 长代码块(1000 行 TS) | 20ms | 20ms(首次)< 5ms(缓存) |
| 滚动到之前看过的消息 | 重新解析 3ms | 0.01ms(缓存命中) |
关键收益:滚动回旧消息时是 0.01ms —— 用户感受是"瞬时"。
6. 关键洞察¶
6.1 "useMemo 不可靠" 的真实教训¶
Claude Code 的注释说 "useMemo doesn't survive unmount→remount"。
这是 React 性能优化的真实陷阱 —— 在虚拟列表、长列表场景下,module-level cache 是唯一可靠方案。
6.2 "正则短路" 是个常被忽视的优化¶
80% 的优化机会是避免不必要的计算,不是让计算更快。
MD_MARKER_REGEX.test() 比 marked.lexer() 快 300 倍。
6.3 缓存大小的 trade-off¶
TOKEN_CACHE_MAX = 500:够大(500 条消息很多了)又不会爆内存。
注释里提到 "turn50→turn99 RSS regression" —— 说明这个数字是踩过坑后调出来的。
6.4 异步 + Suspense 是 React 19 的杀手锏¶
getCliHighlightPromise + Suspense + use() 三件套实现了:
- 不阻塞 Markdown 主体
- 降级显示 fallback
- 完成后自动 re-render
7. 阅读清单¶
- ✅
src/components/Markdown.tsx:1-80(优化注释) - ✅
src/components/Markdown.tsx主组件(render 流程) - 📌
src/utils/markdown.ts(configureMarked、formatToken) - 📌
src/utils/hash.ts(hashContent) - 📌
src/utils/cliHighlight.ts(异步高亮)
8. 练习任务¶
- 手写一个 LRU cache(500 项 O(1))—— 用
Map的keys().next().value取最旧 - 手写一个 regex 短路函数 —— 检查字符串是否含 MD 标记
- 手写一个 useMemo vs module-level 对比 —— 在虚拟列表场景下,用 React profiler 测
- 思考:除了 LRU tokenCache,Markdown 渲染还有哪些可以缓存的?样式?AST?highlighted output?