流式 Markdown 解析 + 代码块防截断闪烁

一、流式 Markdown 解析器实现原理

常规 Markdown 解析是全量解析:等完整文本返回后一次性分词、转 HTML。流式解析:边接收分片字符串,边增量解析,不用等全部数据,适配 LLM 逐 Token 吐出。

核心实现思路(三段式)

  1. 分片缓冲区 Buffer维护内存字符串 buffer,持续追加 SSE/Fetch 流拿到的增量文本。不能每来一个字就解析,频繁 DOM 操作会卡顿;设置最小分片阈值(比如积累 4~10 个字符再触发解析)。
  2. **状态机分词(核心)**手写极简状态机,识别 Markdown 标记:#**、````、>[]()、换行。状态枚举:普通文本、粗体开始、代码块开始、行内代码、标题行、链接。每新增一段字符,只遍历新增部分,复用上次解析的末尾状态,不用重头全量重跑,保证性能。
  3. 增量 DOM Patch,而非全量重渲染绝大多数新手错误:每次新分片直接 innerHTML 整段覆盖,页面抖动。正确:只把新增解析后的 HTML 追加到已有 DOM 末尾,已渲染的 DOM 不动。主流方案二选一:
  • 轻量自研:维护文本光标位置,append 新增 DOM 片段;
  • 成熟开源:marked、remark 搭配流式插件,或使用 @microsoft/vscode-markdown-languageservice 流式 API;业内最常用:marked + 手动流式分片封装。

两种落地路线

  1. 自研轻量流式解析(适合 AI 对话简单 MD:标题、换行、加粗、代码块)只做高频语法,砍掉表格、复杂公式降低复杂度;靠状态机记住上一段收尾语法状态。
  2. 基于成熟库二次封装(生产首选)普通 marked 不支持流式,需要做「分片喂料 + 缓存回溯」:持续 append 分片到缓存,每次喂给 marked 解析最新一整段,利用正则切分完整行 / 完整标记,保证语法闭合。

二、AI 逐字输出代码块,解决标签截断、页面闪烁

1. 闪烁、错乱根本原因

LLM 流式是断断续续吐出字符,比如代码块流程:

plaintext

go 复制代码
` ``js
con

中途只返回了`````j``,标签不完整;解析器识别一半,先渲染成普通文字;等字符补齐又变成代码块,DOM 结构来回改动,页面剧烈闪烁、样式跳变。本质:中途标记不闭合,解析结果反复横跳

2. 分层落地解决方案

方案 1:缓存延迟渲染(最通用)

区分「完整可渲染内容」和「末尾不完整残段」:

  1. 总缓存 = 稳定已渲染内容 + 末尾临时残差 buffer;
  2. 只有能判定语法闭合的片段交给 MD 解析渲染;
  3. 末尾残缺的`````、加粗**、链接括号全部留在临时 buffer,不渲染;
  4. 等后续 Token 补齐语法,再把残段并入正式内容渲染。

举例:收到 ```j,不渲染;后续收到s\nconst a=1,标记完整闭合,整段送入渲染。

方案 2:语法预锁状态机(专门针对代码块)

单独维护全局状态:inCodeBlock: boolean

  • 状态 = 不在代码块:逐字符正常解析;检测到连续三个反引号,切换为代码块模式;
  • 状态 = 正在代码块:内部所有字符原封不动纯文本输出,不做任何 Markdown 解析,直到再次命中三个反引号退出代码块;代码块内部永远不会解析加粗、链接、标题,彻底避免中途解析错乱。AI 场景 90% 闪烁都是代码块导致,这套状态机成本极低、效果最好。
方案 3:DOM 结构固定兜底,不销毁容器
  1. 对话 DOM 提前预创建固定容器:

html

预览

xml 复制代码
<div class="md-content">
  <div class="normal-text"></div>
  <pre class="code-block"><code></code></pre>
</div>
  1. 一旦进入代码块状态,后续字符全部追加进<code>,不会临时删掉 pre 标签;
  2. 哪怕标记没写完,容器始终存在,只是文字慢慢变长,样式不会切换,无闪烁。
方案 4:节流 + 防抖减少 DOM 频繁更新

最小渲染间隔限制(100ms),无论每秒收到多少 Token,最多 100ms 更新一次 DOM;高频小字只攒批,降低重绘次数。

兜底边界

流式结束时(SSE close)强制清空所有残差 buffer,兜底渲染剩余全部字符,保证不会漏文字。

第二部分:Web Worker 放 MD 解析 + 通信方案 + 虚拟列表结合流式渲染

一、Markdown 解析丢进 Web Worker:目的和完整通信设计

为什么放 Worker

MD 正则、字符串循环解析是 CPU 密集运算;放在主线程会阻塞 JS,导致页面滚动卡顿、打字光标延迟、按钮点击无响应。Worker 独立线程跑解析,主线程只负责 DOM 渲染。

完整通信模型(标准化落地)

采用双向消息通信 + 分片投递 + 终止信号

  1. 主线程 → Worker 下发数据
  • type: append:推送本次流式增量字符串;
  • type: flush:流式结束,强制解析剩余缓存;
  • type: reset:清空缓存(新开对话);
  • type: terminate:销毁 Worker。
  1. Worker 内部逻辑持有独立 buffer 缓存、MD 流式状态机;收到 append 增量追加,增量解析;解析完成,把新增 HTML 片段、当前语法状态(是否在代码块) 回传给主线程。
  2. Worker → 主线程回包结构

js

运行

arduino 复制代码
{
  type: "render",
  addHtml: "新增要追加的html",
  inCodeBlock: boolean,
  fullText: "完整文本(可选,用于复制)"
}
  1. 关键优化:避免频繁 postMessageWorker 攒够一定字符批量回传,不要一字一发;postMessage 有拷贝开销,频繁通信反而更卡。

额外优化:Transferable / 缓存复用

大段文本用可转移对象减少拷贝;多对话复用 Worker 实例,不用每次新建销毁。

二、虚拟列表 + 流式渲染结合落地

适用场景

聊天记录几十上百条,每条 AI 回复还在持续流式输出,普通 DOM 全部挂载会 DOM 节点爆炸、滚动卡顿,必须虚拟列表。

核心矛盾

常规虚拟列表只渲染可视区域条目;但 AI 消息是动态变长(边接收边增加高度),虚拟列表高度估算会失效,滚动错乱。

完整落地步骤

  1. 数据分层管理
  • 数组维护所有聊天消息:区分「已结束消息」「流式加载中消息」;
  • 每条消息独立维护:完整 MD 文本、解析后 HTML、实时高度。
  1. 虚拟列表改造要点(以 vue-virtual-scroller、react-window 为例)
  • 放弃固定高度,开启动态高度(variable size)
  • 每条消息实时监听 DOM 高度变化,更新缓存高度;虚拟列表依靠最新高度计算滚动位置。
  1. 流式增量更新逻辑AI 增量返回 → Worker 解析 → 主线程找到当前这条对话数据,追加 HTML;更新这条消息的实际高度缓存,虚拟列表自动重算可视区域。
  2. 滚动策略搭配 AI 场景
  • 用户无主动滚动:自动滚动到底部;
  • 用户手动向上滚动浏览历史:锁定自动触底,不再跟随流式滚动;
  • 新 Token 持续更新当前条目高度,虚拟列表自动撑开列表,不会空白、不会漏内容。
  1. 避坑重点不要把正在流式的长消息拆成无数条列表项;一条 AI 对话始终是虚拟列表的单个条目,条目内部自身变长,保证虚拟列表索引稳定。

第三部分:Fetch 中断、AbortController 在 AI 流式场景落地

一、Fetch 中断基础原理

  1. AbortController 浏览器原生 API,提供信号量controller.signal
  2. 创建控制器:const controller = new AbortController()
  3. Fetch 配置挂载 signal:fetch(url, { signal: controller.signal })
  4. 调用controller.abort(),立刻终止请求,Fetch 抛出 AbortError,TCP 连接被浏览器强制关闭。
  5. 可复用:abort 后 controller 作废,必须新建;支持超时中断、手动取消。

二、AI 流式场景四大核心使用场景

场景 1:用户手动停止 AI 生成(最常用)

用户点「停止回复」按钮:执行controller.abort(),直接掐断流式 Fetch/SSE 请求,服务端收到连接断开,终止 LLM 的 Token 生成,节约算力。配套:全局保存当前对话的 controller 实例,每条流式对话绑定独立控制器,互不干扰。

场景 2:切换对话,强制取消上一条请求

用户一边 AI 还在打字,切到别的聊天会话:调用上一条 controller.abort (),停止无用流式,防止多条流同时返回乱序渲染,同时减少后端压力。

场景 3:超时自动熔断

防止大模型卡顿卡死、一直无数据返回:

js

运行

scss 复制代码
const controller = new AbortController();
// 20s无响应自动中断
const timer = setTimeout(() => controller.abort(), 20000);
// 收到流数据立刻清计时器
readableStream.ondata(() => clearTimeout(timer))

避免请求永久挂住占用 HTTP 连接。

场景 4:页面卸载、路由跳转取消请求

路由离开、页面关闭触发 beforeunload,批量 abort 所有活跃 AbortController,释放连接。

三、SSE(EventSource 无法原生绑定 AbortController)兼容方案

EventSource 不支持 signal,两套兼容方案:

  1. 优先用Fetch ReadableStream 实现 SSE 自研(现在 AI 项目主流)不用原生 EventSource,用 fetch + 可读流手动实现 SSE 协议,天然支持 AbortController 一键中断。可控性拉满,可中断、可自定义 Header,规避浏览器连接数限制。
  2. 原生 EventSource 兜底:调用eventSource.close()关闭;后端靠连接断开感知停止生成。

四、进阶工程细节

  1. 中断区分错误:捕获 fetch 异常,判断err.name === 'AbortError',是主动取消则不弹报错;网络异常才提示用户。
  2. 控制器管理:用 Map 存储「对话 ID → AbortController」,精准定位要终止的请求。
  3. 并发防护:同一个对话发起新流式前,先 abort 旧 controller,杜绝多条流并发返回消息错乱。

总结

  1. 流式 MD 靠缓冲区 + 语法状态机增量解析,分批追加 DOM;代码块单独加状态锁,残缺标签不渲染,解决闪烁;
  2. MD 计算丢 Worker 避免主线程阻塞,通过 postMessage 做分片双向通信;虚拟列表用动态高度,流式消息保持单个列表项,实时更新高度适配滚动;
  3. AbortController 挂载 Fetch 实现一键中断,用于停止 AI 生成、切会话取消、超时熔断;原生 SSE 不好中断,生产多用 fetch 可读流自研 SSE 兼顾中断与推送。
相关推荐
清风小道君1 天前
手搓一个零依赖的 Markdown 静态站点生成器,我学到了什么
markdown
X档案库3 天前
【开源】我做了一套可以 AI 托管的 Markdown 博客与知识库
rust·博客·markdown·marksharex
DeMinds5 天前
内容没有丢,我为什么总在重新整理?|DeMinds 如何让工作接着继续
ios·github·markdown
花褪残红青杏小6 天前
Rust图像处理第20节-PCA 主成分分析:把图片压缩到 3 个数字
rust·webassembly·图形学
acheding6 天前
File System Access API 实战:让网页真正读写本地文件
前端·javascript·vue.js·编辑器·markdown
acheding6 天前
把 CodeMirror 6 调教成 Markdown 编辑器:扩展、装饰与门面
javascript·vue.js·编辑器·markdown
卷无止境8 天前
Quarkdown:赋予 Markdown 超能力的现代排版系统
前端·markdown
DeMinds9 天前
这篇文章,真的有“结构”吗?
markdown
特立独行的猫a10 天前
Markmap 入门到精通:从一段 Markdown 到一张可交互思维导图
markdown·工具·思维导图·markmap