给前端 AI 流式 Markdown 渲染加护栏时,我会先把解析和净化拆开
最近在做一个前端 AI 工作台,模型会一边输出 Markdown,一边在右侧预览答案、表格和代码块。最早我直接把流式文本拼进状态,再交给 Markdown 解析器渲染。普通段落看起来没问题,一遇到长表格、未闭合代码围栏、HTML 片段或连续撤回重试,页面就会闪烁,滚动位置乱跳,偶尔还会把半截标签送进 DOM。这个问题提醒我,AI 流式渲染要按工程链路设计,不能只把字符串追加到页面上。

原创示意图:把 Stream Reader、Token Buffer、Parser Worker、Sanitizer、Preview Renderer 和 Cleanup Ledger 放进同一条可取消链路。 来源:Codex image generation
问题背景
MDN 的 Streams 文档说明,ReadableStream 可以通过 reader 按 chunk 读取数据,也支持取消和释放来源;Web Workers 可以把耗时处理放到独立线程,避免拖慢 UI 主线程。Marked 文档也提醒,Markdown 转 HTML 后需要自行处理安全净化;DOMPurify 的项目说明则把它定位为 HTML、MathML 和 SVG 的 XSS sanitizer。把这些能力放在一起看,前端 AI 预览的边界就很清楚:流负责接收,解析负责理解 Markdown,净化负责安全,渲染负责稳定展示。
踩坑和关键难点
第一个坑是 token 边界不等于 Markdown 边界。模型可能先吐出三根反引号,下一秒才补语言名;表格行可能分成两个 chunk;链接括号也可能跨片段。只要每个 chunk 都全量 parse,预览就会频繁抖动。
第二个坑是安全处理放得太晚。Markdown 支持内联 HTML,解析器输出 HTML 后,如果直接塞进 innerHTML,风险会沿着流式链路进入页面。这里的净化层要成为固定步骤,不能交给业务组件临时决定。
第三个坑是错误状态没有账本。用户点击停止、模型重试、网络中断、回答被替换时,旧 reader、Worker、计时器和预览缓存如果没有统一清理,很容易出现旧内容回写到新会话里的问题。
解决思路
我把链路拆成五层。第一层是 Stream Reader,只接收文本并维护 requestId、messageId 和 sequence。第二层是 Token Buffer,用一个很小的窗口合并碎片,等到段落、代码围栏、列表或表格达到相对稳定的边界后再提交。第三层是 Parser Worker,把 Markdown 解析放到后台线程,复杂文档不会卡住输入框和滚动。第四层是 Sanitizer,统一把解析后的 HTML 经过 DOMPurify 处理。第五层是 Preview Renderer,只接收已经标记版本号的安全片段,并拒绝过期结果。
这里我没有追求真正的逐字符完美预览。实际产品里,200 到 500 毫秒的微批处理更稳:用户仍然能感知 AI 在输出,浏览器也有足够时间合并 DOM 更新。对于代码块和表格,我会等到结构闭合或缓冲超时再渲染,避免半截结构造成布局跳动。
关键步骤
第一步是定义片段协议。每次流事件都带上 requestId、partIndex、isFinal 和 receivedAt,Buffer 只处理当前请求,遇到新请求立即丢弃旧队列。
第二步是把解析搬进 Worker。主线程只发送纯文本片段和解析选项,Worker 返回 html、outline、warnings 和 sourceMapRange。如果 Worker 超时,主线程显示纯文本降级预览,同时保留重试入口。
第三步是固定净化策略。允许的标签和属性集中配置,链接统一补 rel,图片和 iframe 走白名单。Marked 可以承担快速 parse,micromark 更适合需要 token 位置和更细控制的场景,两者都不应该替代净化层。
第四步是建立清理账本。每个回答维护 reader、Worker、定时器、缓冲区和最后一次渲染版本。完成、失败、取消、重试、切换会话都走同一个 cleanup 函数,防止旧任务继续写 UI。
可复用经验
流式 Markdown 渲染最容易被当成 UI 小优化,实际它是 AI 应用的稳定性边界。我的经验是先把职责拆清楚:流只管顺序和取消,Buffer 只管稳定边界,Parser 只管结构,Sanitizer 只管安全,Renderer 只管最小更新。这样后面接 RAG 引用卡片、Agent 工具结果、长表格分析或 Electron 本地会话预览时,都能沿用同一套护栏。