《拒绝卡顿:深入解析 AI 流式 Markdown 的高性能渲染架构》

引言:当 AI 遇上浏览器的渲染瓶颈

最近在开发一款 AI 对话/知识库生成类产品时,遇到了一个典型的性能问题:SSE 流式响应渲染卡顿。

虽然已经成功解析 SSE 事件并拿到了 answer 数据,但页面偶尔会出现"卡断 - 爆发 - 卡顿"的抽搐效果,严重影响用户体验。

问题出在哪?

后端 SSE 推流速度很快,但前端如果每收到一个 chunk 就执行 setState → render → markdown-it 解析,会带来双重性能开销:

  1. Virtual DOM Diff 开销过大:当内容变更达到 20~30% 时,Diff 算法实际退化成了"销毁旧树,重建新树",触发多次回流重绘。
  2. 正则解析阻塞主线程:Markdown 解析本身是 CPU 密集型操作,高频调用会堵塞主线程,导致页面掉帧。

两者叠加,就造成了"卡顿 - 爆发 - 卡顿"的抽搐效果。

解决方案

在社区学习并验证了一套生产环境通用的解法 :抛弃框架绑定,回归底层,用 markdown-it + DOMPurify + throttle 硬刚性能。

这套方案的核心思想是:解析、安全、频率控制三者分离,各司其职,皆可控制。

表格

工具 作用
markdown-it 业界最快的 Markdown 解析器之一
DOMPurify 浏览器端最快的 HTML 清洗库,剔除 XSS 风险
lodash.throttle 渲染频率控制,确保主线程始终能响应用户交互

代码实现

1. 创建独立的 Markdown 渲染工具(建议全局单例)

javascript 复制代码
// 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)

javascript 复制代码
// 在组件中使用
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 低 高频流式更新

结语

习惯了框架开发,确实提升了效率和可维护性,但在某些场景下,原生反而是更优解,能带来意想不到的收获,哈哈。

相关推荐
小码哥_常6 小时前
一套自己部署的 IM,客户端是同一份 Flutter
前端·后端
蜗牛攻城狮8 小时前
微前端四大方案选型对比与最佳实践:qiankun、micro-app、wujie、Module Federation
前端·微前端·qiankun·前端架构·micro-app·wujie
她说可以呀10 小时前
Vue插槽
前端·javascript·vue.js
qq_4480111610 小时前
Vue + Jinja2两套模版与法冲突造成undefinedError
前端·javascript·vue.js
aixingpan10 小时前
aixingpan.cn API开发文档:前端查询夏令时和时区接口指南
前端·php
2601_9583529011 小时前
23.5×19mm 换掉一整条音频链?|A-68 集成了什么:ADC / DAC / 24.576MHz 时钟 / AEC / 波束降噪,主控仅需 6 根线
前端·音视频·ai降噪·回声消除·语音模块
志尊宝11 小时前
AI原生前端开发(039):AI辅助的Nuxt 4全栈开发
前端·vue.js·ai-native
FRing0312 小时前
JavaScript入门到精通三十二(深拷贝、浅拷贝、递归函数)
开发语言·前端·javascript
excel12 小时前
双 Token 与前端使用 Crypto 公私钥鉴权换 Token,哪种方案更好?
前端·后端
外收内放12 小时前
Python基础语法练习题(73)
java·前端·python