# 自研块级 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, '
')` 两处重复,可抽 `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)之处。 **质量评级**:良(改 ①②③ 达优)