# 自研块级 memo 流式 Markdown 渲染 代码审查（2026-06-15）

> 范围：`src/components/AiChat.vue` ARC-260615-08 实施 diff —— 块级 memo 流式渲染（splitBlocks/parseBlock/parseBlockNoCache/renderStreamingMd/scheduleStreamParse/renderContent + watch/onBeforeUnmount）+ confirm 抽取（CR-260615-02）。约 ~90 行改动。
> 背景：决策转向（同日复盘）—— markstream-vue 试装弃用（样式 100% 还原成本高且脆），改自研块级 memo（方案 B 增强版）。详见 [aichat流式Markdown渲染调研-2026-06-15.md](../02-架构设计/构想审查/aichat流式Markdown渲染调研-2026-06-15.md) §5。
> 性质：**dry — 仅走查 + 文档，不改代码**（本会话职责：走查/整理/建待办，见 memory session-role-diagnose-only）。

---

## 🔴 必须修复（0）

无。

---

## 🟡 建议改进（3）

### ① [AiChat.vue splitBlocks] 手写正则切块与 marked 语法不一致 → 改用 marked.lexer()

**改什么**：`fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g` 两个不一致点：
- 不要求 ``` 在行首 → 行中裸 ```（如文本解释 markdown 语法）被误当围栏切入；marked 要求行首（≤3 空格缩进）。
- 固定匹配 3 backtick → LLM 展示嵌套代码用 4+ backtick 围栏（````````）时按 3 切错；marked 按围栏 backtick 数判定。

前块缓存（parseBlock）会**固化错误 html**——文本稳定后 splitBlocks 切错的块被缓存，marked 实际解析与之不符。决策备注「借鉴机制 2」，但机制 2 原生做法是 `marked.lexer()` 切块，实现用了手写正则偏离。

**怎么改**：用 marked.lexer 切块，与 marked 解析天然一致，零切错风险，零额外成本（marked 已加载）：

```diff
- /// 切块:代码围栏(```...```)整体一块(跨双换行不切),非代码段按双换行切段。
- /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
- function splitBlocks(text: string): string[] {
-   const blocks: string[] = []
-   const fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g
-   let last = 0
-   let m: RegExpExecArray | null
-   while ((m = fenceRe.exec(text)) !== null) {
-     if (m.index > last) {
-       for (const b of text.slice(last, m.index).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
-     }
-     blocks.push(m[0])
-     last = m.index + m[0].length
-   }
-   if (last < text.length) {
-     for (const b of text.slice(last).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
-   }
-   return blocks.length ? blocks : [text]
- }
+ /// 切块:用 marked.lexer 切块(机制2原生做法),与 marked 解析一致,零切错风险。
+ /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
+ function splitBlocks(text: string): string[] {
+   const tokens = _marked!.lexer(text)
+   const blocks = tokens.map(t => t.raw).filter(Boolean)
+   return blocks.length ? blocks : [text]
+ }
```

> 注：Vercel AI SDK 官方 cookbook `parseMarkdownIntoBlocks` 正是 `marked.lexer(md).map(t => t.raw)`。

### ② [parseBlock/parseBlockNoCache] DRY — parse 核心逻辑重复

**改什么**：`_purify!.sanitize(_marked!.parse(block) as string)` 在 parseBlock 和 parseBlockNoCache 各写一遍。

**怎么改**：parseBlock 内部调 parseBlockNoCache：

```diff
  function parseBlock(block: string): string {
    const cached = _blockCache.get(block)
    if (cached !== undefined) return cached
-   const html = _purify!.sanitize(_marked!.parse(block) as string)
+   const html = parseBlockNoCache(block)
    if (_blockCache.size > BLOCK_CACHE_LIMIT) _blockCache.clear()
    _blockCache.set(block, html)
    return html
  }
```

### ③ [loadMarkdown] marked 就绪后流式不主动重算 → 首屏 marked 慢时末段纯文本

**改什么**：流式中 marked 异步加载完成（mdReady 翻转）后，streamingHtml 不会自动重算 —— scheduleStreamParse 只在 `currentText` 变化时触发。若 marked 在流式结束前就绪且无新 delta 到达，最后一段停留在 escapeHtml 纯文本（renderStreamingMd 的 `!mdReady` 兜底分支产物）。

**怎么改**：loadMarkdown 成功后若正在流式，主动触发一次：

```diff
  _purify = dp.default
  _mdCache.clear()
  mdReady.value = true
+ // marked 就绪后若正在流式,主动重算(防首屏 marked 慢致末段停留纯文本)
+ if (store.state.streaming && store.state.currentText) scheduleStreamParse(store.state.currentText)
```

---

## ⚪ 可选优化（2）

### ④ [parseBlock _blockCache] 超 limit 整体 clear 粗暴

`if (_blockCache.size > BLOCK_CACHE_LIMIT) _blockCache.clear()` —— 清后下一帧所有前块全重 parse，可能瞬时卡顿。300 limit 对单条回复够（块数远不到），跨多条回复累积才触发，影响小。可改删最早一条做简易 LRU：`_blockCache.delete(_blockCache.keys().next().value)`。

### ⑤ [renderMd/renderStreamingMd 兜底] escapeHtml 重复

`escapeHtml(text).replace(/\n/g, '<br>')` 两处重复，可抽 `escapeFallback(text)`。一行重复，收益轻。

---

## ✅ 亮点

- **退役 AR-1 纯文本短路**：流式全程有格式（标题/列表/代码块），D 级流式性能，零新依赖零样式对接（决策转向 markstream→自研的核心理由：保留原 `.ai-md` 样式 > 虚拟窗口/shiki 高级能力）。
- **末块不缓存处理未闭合 token**：`parseBlockNoCache` 每次重 parse 末块，正确处理流式未闭合围栏/半截段落；前块缓存命中 O(全文)→O(末块)。
- **rAF 节流 + 块级 memo 组合**：业界主流机制（Vercel AI SDK 做法），60fps 封顶防主线程阻塞掉帧。
- **watch streaming 翻转清理时序正确**：结束 cancelAnimationFrame + 清 streamingHtml/lastStreamText，回 renderMd 走 final marked + 整段缓存；onBeforeUnmount 清 rAF 防 leak。
- **confirm 顺带抽取（CR-260615-02）**：第五份 confirm 样板迁 useConfirm，DRY 收口（原 Projects/ProjectDetail/Ideas/Settings/AiChat 各一份）。
- **决策转向有完整留痕**：试装 markstream-vue 弃用 → 自研块级 memo，调研文档 §5 复盘记录含反转理由（「不要高级能力、要原样式」前提下自研最优）。

---

## 📊 摘要

| # | 等级 | 文件:行 | 修改内容 |
|---|------|---------|----------|
| ① | 🟡 | AiChat.vue splitBlocks | 手写正则切块改 marked.lexer()（与 marked 一致，防切错固化错误 html） |
| ② | 🟡 | AiChat.vue parseBlock | parseBlock 内部调 parseBlockNoCache（DRY） |
| ③ | 🟡 | AiChat.vue loadMarkdown | marked 就绪后若流式中主动 scheduleStreamParse（防末段纯文本） |
| ④ | ⚪ | AiChat.vue _blockCache | clear 改删最早一条（简易 LRU） |
| ⑤ | ⚪ | AiChat.vue 兜底 | escapeFallback 抽公共（DRY） |

**总计**：🔴0 🟡3 ⚪2

**总体评价**：块级 memo 实现方向正确（机制 2 + rAF + 末块重 parse + 退役 AR-1 纯文本短路），核心建议 ① 改 lexer 消除「手写切块与 marked 解析不一致」的固化风险——这是唯一偏离决策原意（机制 2 原生 = lexer）之处。

**质量评级**：良（改 ①②③ 达优）
