Files
DevFlow/docs/05-代码审查/自研块级memo流式渲染审查-2026-06-15.md
绝尘 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00

130 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自研块级 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 样板迁 useConfirmDRY 收口(原 Projects/ProjectDetail/Ideas/Settings/AiChat 各一份)。
- **决策转向有完整留痕**:试装 markstream-vue 弃用 → 自研块级 memo调研文档 §5 复盘记录含反转理由(「不要高级能力、要原样式」前提下自研最优)。
---
## 📊 摘要
| # | 等级 | 文件:行 | 修改内容 |
|---|------|---------|----------|
| ① | 🟡 | AiChat.vue splitBlocks | 手写正则切块改 marked.lexer()(与 marked 一致,防切错固化错误 html |
| ② | 🟡 | AiChat.vue parseBlock | parseBlock 内部调 parseBlockNoCacheDRY |
| ③ | 🟡 | 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之处。
**质量评级**:良(改 ①②③ 达优)