跳转至

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. 阅读清单

  1. src/components/Markdown.tsx:1-80(优化注释)
  2. src/components/Markdown.tsx 主组件(render 流程)
  3. 📌 src/utils/markdown.ts(configureMarked、formatToken)
  4. 📌 src/utils/hash.ts(hashContent)
  5. 📌 src/utils/cliHighlight.ts(异步高亮)

8. 练习任务

  1. 手写一个 LRU cache(500 项 O(1))—— 用 Mapkeys().next().value 取最旧
  2. 手写一个 regex 短路函数 —— 检查字符串是否含 MD 标记
  3. 手写一个 useMemo vs module-level 对比 —— 在虚拟列表场景下,用 React profiler 测
  4. 思考:除了 LRU tokenCache,Markdown 渲染还有哪些可以缓存的?样式?AST?highlighted output?