squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
7.1 KiB
自研块级 memo 流式 Markdown 渲染 代码审查(2026-06-15)
范围:
src/components/AiChat.vueARC-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 §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 已加载):
- /// 切块:代码围栏(```...```)整体一块(跨双换行不切),非代码段按双换行切段。
- /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 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:
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 成功后若正在流式,主动触发一次:
_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)之处。
质量评级:良(改 ①②③ 达优)