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

7.1 KiB
Raw Blame History

自研块级 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 §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 高级能力)。
  • 末块不缓存处理未闭合 tokenparseBlockNoCache 每次重 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之处。

质量评级:良(改 ①②③ 达优)