《拒绝卡顿:深入解析 AI 流式 Markdown 的高性能渲染架构》
引言:当 AI 遇上浏览器的渲染瓶颈
最近在开发一款 AI 对话/知识库生成类产品时,遇到了一个典型的性能问题:SSE 流式响应渲染卡顿。
虽然已经成功解析 SSE 事件并拿到了 answer 数据,但页面偶尔会出现"卡断 - 爆发 - 卡顿"的抽搐效果,严重影响用户体验。
问题出在哪?
后端 SSE 推流速度很快,但前端如果每收到一个 chunk 就执行 setState → render → markdown-it 解析,会带来双重性能开销:
- Virtual DOM Diff 开销过大:当内容变更达到 20~30% 时,Diff 算法实际退化成了"销毁旧树,重建新树",触发多次回流重绘。
- 正则解析阻塞主线程:Markdown 解析本身是 CPU 密集型操作,高频调用会堵塞主线程,导致页面掉帧。
两者叠加,就造成了"卡顿 - 爆发 - 卡顿"的抽搐效果。
解决方案
在社区学习并验证了一套生产环境通用的解法:抛弃框架绑定,回归底层,用 markdown-it + DOMPurify + throttle 硬刚性能。
这套方案的核心思想是:解析、安全、频率控制三者分离,各司其职,皆可控制。
表格
| 工具 | 作用 |
|---|---|
| markdown-it | 业界最快的 Markdown 解析器之一 |
| DOMPurify | 浏览器端最快的 HTML 清洗库,剔除 XSS 风险 |
| lodash.throttle | 渲染频率控制,确保主线程始终能响应用户交互 |
代码实现
1. 创建独立的 Markdown 渲染工具(建议全局单例)
// utils/markdownRenderer.js
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import throttle from 'lodash/throttle';
// 全局单例实例
const md = new MarkdownIt({
html: true, // 允许原始 HTML(后续由 DOMPurify 清洗)
linkify: true, // 自动转换 URL 为链接
typographer: true, // 智能排版(中文友好)
breaks: true, // 换行符转换为 <br>
highlight: function (str, lang) {
// 可选:代码高亮(推荐 highlight.js 或 prism)
return `<pre class="hljs"><code>${str}</code></pre>`;
}
});
export function createStreamRenderer(containerElement) {
let accumulatedMarkdown = '';
let isDone = false;
// 节流渲染:80ms ≈ 12 次/秒,视觉平滑且不过度消耗主线程
const throttledRender = throttle(() => {
// 1. Markdown → 原始 HTML
const rawHtml = md.render(accumulatedMarkdown);
// 2. 清洗 XSS 风险
const cleanHTML = DOMPurify.sanitize(rawHtml, {
ADD_TAGS: ['iframe', 'video'], // 按需放行标签
ADD_ATTR: ['target', 'rel', 'autoplay', 'loop'], // 按需放行属性
FORBID_TAGS: ['script', 'style', 'object', 'embed', 'frame'], // 禁止危险标签
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|data|blob):|[^&:/?#]*(?:[/?#]|$))/i // 安全 URI 校验
});
// 3. 渲染到 DOM
containerElement.innerHTML = cleanHTML;
}, 80);
return {
// 追加内容
append(chunk) {
accumulatedMarkdown += chunk;
throttledRender();
},
// 完成流式传输
complete() {
throttledRender.flush(); // 必须 flush!否则末尾内容可能延迟渲染
throttledRender.cancel(); // 清理定时器
isDone = true;
},
// 重置状态
reset() {
accumulatedMarkdown = '';
containerElement.innerHTML = '';
throttledRender.cancel();
isDone = false;
}
};
}
2. 使用示例(Fetch + ReadableStream)
// 在组件中使用
import { createStreamRenderer } from '@/utils/markdownRenderer';
const container = document.getElementById('ai-response'); // 或 Vue/React 的 ref.value
const renderer = createStreamRenderer(container);
async function fetchAndStream() {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: '写一篇前端文章' })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) {
renderer.complete();
break;
}
const chunkText = decoder.decode(value, { stream: true });
// 如果后端发的是纯文本 delta → 直接 append
renderer.append(chunkText);
}
}
核心思路:把"渲染权"抢回来
在超高频率的流式场景下,框架的 useState 每次修改都会触发 Virtual DOM 流程,频繁更新反而成了性能累赘。
本方案的关键优化点:
表格
| 优化点 | 说明 |
|---|---|
| 绕过 Virtual DOM | 使用 ref 获取真实 DOM,直接操作 innerHTML
|
| 节流控制 | 80ms 节流,平衡流畅度与性能消耗 |
| 增量累积 | 内容累积后统一解析,避免碎片化渲染 |
| 安全隔离 | DOMPurify 独立处理 XSS,与解析逻辑解耦 |
| 资源清理 |
complete 时 flush + cancel,避免内存泄漏 |
性能对比参考
表格
| 方案 | 帧率 | 主线程占用 | 适用场景 |
|---|---|---|---|
| useState + Virtual DOM | 30-40 FPS | 高 | 低频更新 |
| 本方案 | 55-60 FPS | 低 | 高频流式更新 |
结语
习惯了框架开发,确实提升了效率和可维护性,但在某些场景下,原生反而是更优解,能带来意想不到的收获,哈哈。