# AI Chat 流式 Markdown 渲染调研(2026-06-15) > 范围:`AiChat.vue` 流式渲染优化。当前 AR-1 修复(commit f58743e)用「流式纯文本短路」防掉帧,副作用是流式过程无格式。本调研找不掉帧且有格式的更好方案。 > 方法:3 路并行调研(机制方案 / 现成库 / Vue3 落地)+ 主代理整合。 > 性质:调研 + 方案,**未实施**。 > 关联:[aichat审查报告-2026-06-14.md](aichat审查报告-2026-06-14.md) AR-1;[近期改动代码审查-2026-06-15.md](../05-代码审查/近期改动代码审查-2026-06-15.md) §亮点「renderMd 流式纯文本短路」。 --- ## §1 问题根因 现状(`src/components/AiChat.vue:200,347`): ```vue
``` ```js function renderMd(text, isStreaming=false){ if (isStreaming || !mdReady) return escapeHtml(text) // 流式纯文本短路 return _purify.sanitize(_marked.parse(text)) } ``` 掉帧两因素叠加: - **(A) 解析成本**:每个 delta 全量 `marked.parse()` O(N),N 随回复增长,后期单帧解析几十 ms。 - **(B) 重渲染成本**:`v-html` 整段替换触发子树重布局。 当前方案牺牲「流式格式」躲掉 (A)(B),但 delta 20-80ms 一个,流式时用户看到裸 markdown 源码(`#`、`-`、```` ``` ```` 符号),结束才出格式。 --- ## §2 五大机制对比 | 机制 | 消除瓶颈 | 流式有格式 | 成本 | 适用 | 独立够吗 | |---|---|---|---|---|---| | 1. rAF 批量节流 | 中(砍频率) | 是 | 低 | 全部 | 否(长文本单次仍重) | | 2. 块级 diff + memo | **高**(砍到 O(末块)) | 是 | 低-中 | 全部 | **基本够(主流首选)** | | 3. Web Worker 解析 | 高(主线程归零) | 是 | 中 | 超长文档/重 sanitize | 通常与 2 叠加 | | 4a. 代码块延迟高亮 | 高(代码块零成本) | 代码块流式无色 | 低 | 有代码块 | 仅代码块,需叠加 | | 4b. shiki-stream 增量高亮 | 高 | 代码块流式有色 | 中 | 有代码块 | 仅代码块 | | 5. 虚拟滚动 | 中(砍 DOM 不砍 parse) | 是 | 中 | 长会话消息列表 | 否 | **机制 2(块级 diff + memo)是业界主流首选**——Vercel AI SDK 官方 cookbook 做法。原理:`marked.lexer()` 按双换行/代码围栏切块,每块独立 memoize,只有「正在生长的末块」重 parse,已完成块缓存跳过。解析成本从 O(全文) 降到 O(末块)。 **大厂可见行为**:ChatGPT / Claude / Gemini 流式时均有格式,代码块流式时等宽 + 基本着色。Chrome 官方文档明确反对「整篇重 parse + innerHTML 替换」(正是当前实现),推荐 streaming-markdown 增量 append。 **rAF 实测数据**(Sitepoint,React 18.3 生产构建 M2):朴素逐 token setState 平均 commit 18ms(80 token/s 达 52ms 可见卡顿),rAF 批量后 3-5ms。 --- ## §3 现成库评估 | 库 | Vue3 兼容 | 流式 | 安全 | 维护 | 结论 | |---|---|---|---|---|---| | **markstream-vue** | ✅ 原生组件 | ✅ 双模式(虚拟窗口 + 增量批处理) | ✅ 内置 safe HTML | ✅ 2.2k star / open issue 2 / 近乎日更 / 尤雨溪背书 / 1.0 稳定 | **首选** | | streamdown (Vercel) | ❌ React 绑定 + 强绑 Tailwind/shadcn | ✅ | ✅ rehype-harden | ✅ | 仅参考(issue #19 求 Vue 版未解决) | | streaming-markdown | ⚠️ callback 驱动需自己接线 | ✅ token 级 | ❌ 需自配 | 功能不全 | 参考 | | marked(当前用) | ✅ | ❌ 无官方流式 API | ❌ 需自配 DOMPurify | ✅ 36.9k star | 不直用做流式层 | | markdown-it | ✅ | ❌ 无原生流式 | - | ✅ | 同上 | | antfu/shiki-stream | ✅ 组件 | ✅ 增量高亮 | - | ✅ 511 star | 代码块高亮专库 | **核心结论**:有 Vue3 原生可直接用的流式 markdown 库 **markstream-vue**(尤雨溪推荐),命中全部诉求——流式有格式、不掉帧、TS-first、内置 sanitize、`final` prop 解未闭合 token 卡死。内部已含 marked + Shiki + sanitize 组合,等于替你造好轮子。 **降级路径**:若样式侵入太重,只用其解析内核 `stream-markdown-parser`(框架无关纯函数)+ 现有 DOMPurify 自控渲染。 --- ## §4 落地方案(递进) ### 方案 A:rAF 节流渲染(最小改动) 流式时 `requestAnimationFrame` 每帧最多 parse 一次(封顶 60fps),`v-html` 绑响应式 `streamingHtml`。流式结束取消 rAF + 强制最终渲染落 mdCache。 - 性能:频率从「每 delta」降到「每帧」,主线程有空隙让浏览器 paint。 - 观感:流式全程有格式;**瑕疵**:未闭合代码块(末尾 ```)会闪烁。 - 成本:低(~60 行)。 - 风险:超长回答(>20k 字)每帧 parse 全文仍偏重。 ```js const streamingHtml = ref('') let rafId = null, lastStreamText = '' function scheduleStreamParse(text){ if (rafId !== null) return rafId = requestAnimationFrame(()=>{ rafId = null if (text === lastStreamText) return lastStreamText = text streamingHtml.value = _purify.sanitize(_marked.parse(text)) }) } watch(()=>store.state.streaming,(s,old)=>{ if(old && !s){ if(rafId) cancelAnimationFrame(rafId); rafId=null; lastStreamText=''; streamingHtml.value='' } }) onBeforeUnmount(()=>{ if(rafId) cancelAnimationFrame(rafId) }) ``` ### 方案 B:方案 A + 代码块流式降级(体验最佳,推荐起步) 在 A 基础上,流式期把 fenced code 块正则抠出降级为纯文本(`
`),其余正常 marked。流式结束一次完整 marked 替换。

- 消掉方案 A 的「未闭合代码块闪烁」瑕疵——AI 回答大量含代码块,高频可见。
- 成本:中(~100 行 + CSS)。
- **推荐起步方案**(A→B 平滑叠加,B 只替换 `doStreamParse` 一个函数,不返工)。

### 方案 C:Web Worker(彻底解耦)
marked+sanitize 移 Worker,主线程零 parse。Worker 内 DOMPurify 需 jsdom shim(或换 `sanitize-html` 免 DOM)。

- 成本:高(~150 行 + worker 文件 + jsdom)。
- 适用:极端长回答/多会话。方案 B 不够再上。

### 方案 D(换库):直上 markstream-vue
用 `` 替换整个 renderMd 手写层。

- 成本:中(库接入 + 样式对接)。
- 收益:省自造轮子,拿到虚拟窗口 + 增量高亮 + 未闭合处理全套。
- 风险:样式侵入、新依赖、迁移工作量需评估。

---

## §5 推荐结论

**两条路,按风险偏好二选一**:

| 路线 | 方案 | 收益 | 风险 | 工作量 |
|---|---|---|---|---|
| **保守(自研改造)** | 方案 B(rAF 节流 + 代码块降级) | 流式全程有格式、不掉帧、消代码块闪烁、无新依赖 | 超长回答边界需观察 | 中(~100 行 + CSS) |
| **激进(换库)** | 方案 D(markstream-vue) | 同上 + 虚拟窗口 + 增量高亮 + 长期省维护 | 新依赖、样式侵入、迁移成本 | 中-高 |

**渐进建议**:先上方案 B 跑通(验证 rAF 节流 + 代码块降级在当前 delta 频率下不掉帧)→ 观察长回答边界 → 若需更强能力(虚拟窗口/增量高亮)再评估换 markstream-vue。

**当前 AR-1 临时方案(流式纯文本短路)由方案 D 替换后退役**——纯增量收益,流式全程有格式。

> **决策(2026-06-15):选方案 D**(换 markstream-vue)。理由:自研块级 memo 与 D 复杂度接近,D 白送 shiki 代码高亮 + 虚拟窗口 + 未闭合 token 修复器,自研只在「坚决不引新依赖」时才划算。落地 todo:ARC-260615-08。
>
> **决策转向(2026-06-15,同日复盘):方案 D 试装后弃用,改自研块级 memo(方案 B 增强版)**。复盘:markstream-vue@1.0.1 接入 + vue-tsc 通过无技术障碍,但「样式 100% 还原现有 .ai-md」成本高且脆——markstream DOM 异构于 marked 输出(代码块 `.code-block-container` chrome 结构 / 暗色 `--ms-*` 变量体系依赖 `.dark` class 而 DevFlow 用 `[data-theme]` / prose 作用域 `.markstream-vue`),4 处对接且随 markstream 升级易漂移。用户优先级是「保留原样式」>「虚拟窗口/shiki 高级能力」,转自研块级 memo:marked 输出标准 HTML → .ai-md 样式零对接,借鉴 §2 机制 2(块级 memo,业界主流)实现 splitBlocks(代码围栏整体一块/非代码双换行切)+ 前块缓存命中 O(末块) + 末块不缓存处理未闭合 token + rAF 节流。结论:**自研块级 memo = 方案 B 增强(rAF + 真块级 memo,非仅代码块降级),零新依赖、零样式对接、D 级流式性能**。§5 原决策表「自研只在坚决不引新依赖时才划算」判断在「不要高级能力、要原样式」前提下反转——此时自研反而最优。落地:ARC-260615-08 自研版已实施,vue-tsc exit 0,待 dev 运行时验证。

---

## 来源

- [Best practices to render streamed LLM responses — Chrome for Developers](https://developer.chrome.com/docs/ai/render-llm-responses)(streaming-markdown 增量 append、Paint flashing 验证)
- [Markdown Chatbot with Memoization — Vercel AI SDK Cookbook](https://ai-sdk.dev/cookbook/next/markdown-chatbot-with-memoization)(块级 diff + memo 官方实现)
- [Streaming Backends & React: Controlling the Re-render Chaos — Sitepoint](https://www.sitepoint.com/streaming-backends-react-controlling-re-render-chaos/)(rAF 实测数据)
- [antfu/shiki-stream — GitHub](https://github.com/antfu/shiki-stream)
- [markstream-vue — GitHub](https://github.com/Simon-He95/markstream-vue)
- [vercel/streamdown — GitHub](https://github.com/vercel/streamdown)
- [HuggingFace chat-ui: webworker for markdown parsing #1733](https://huggingface.co/spaces/jdelavande/chat-ui-energy/commit/7d6fc19984864d960f2875391858ea067b030dc6)
- [shikijs/shiki Discussion #891](https://github.com/shikijs/shiki/discussions/891)(GrammarState 流式高亮底层)