普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月14日首页

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

2026年3月14日 18:04

引言:当 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 渲染工具(建议全局单例)

// 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 高频流式更新

结语

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

《前端细节控:如何完美实现聊天窗口的“智能自动滚动”?》

2026年3月14日 10:53

一、问题背景:流式输出的滚动痛点

刚实现流式输出,产品经理就来找麻烦了:用户正在复制 AI 回复的历史消息,新内容突然涌出,视口被强行踢到底部——操作被打断,体验极差。能不能实现一个「智能自动滚动」,别打扰用户?

二、需求分析:三种行为场景

分析下需求,分为三个行为:

  1. 默认行为:用户在底部时,新消息自动跟进。
  2. 干预行为:用户一旦上滑,查看历史,立即停止自动滚动,把控制权交给用户:
  3. 恢复行为:用户回到底部,恢复自动跟进。

三、核心原理

  • scrollTop:用户滚走了多少。
  • scrollHeight:内容总共有多长。
  • clientHeight:窗口能看见多长。

利用这三个值,就能精准判断用户是“在看最新消息”(在底部),还是“在翻看历史消息”(不在底部),从而决定是否要自动滚动。

判定公式

//浏览器里元素的总高度=滚动高度+可视区域高度+距离底部高度。
// 距离底部的剩余像素 
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;

// 是否在底部(留 5px 容错,防止亚像素渲染导致的抖动)
const isAtBottom = distanceFromBottom <= 5;

四、状态机设计:userScrolled 的妙用

底部高度既然判断出了,就需要一个标记,来判断“用户是否主动干涉过”。

逻辑:

  1. 监听元素的scroll事件。
  2. 计算isAtBottom(是否在底部):
    • 若 !isAtBottom无条件锁定 (userScrolled = true)。 (解释:包括用户滚到中间或顶部的所有情况)
    • 如果 isAtBottom:说明用户回去了 →→ 设置 userScrolled = false(解锁自动滚动)。

核心代码实现:状态机与滚动监听

import { useState, useCallback, useEffect, useRef } from 'react';

export default function ChatContainer({ messages, isStreaming }) {
  const scrollContainerRef = useRef(null);
  
  // 1. 状态定义
  // userScrolled: true = 用户已干预(锁定自动滚动), false = 允许自动跟随
  const [userScrolled, setUserScrolled] = useState(false);
  const [isAtBottom, setIsAtBottom] = useState(true);

  // 2. 滚动监听器 (状态机的核心)
  const handleScroll = useCallback(() => {
    const container = scrollContainerRef.current;
    if (!container) return;

    const { scrollTop, scrollHeight, clientHeight } = container;
    
    // 计算距离底部的像素 (兼容不同浏览器的亚像素渲染差异,留 5px 容错)
    const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
    const atBottom = distanceFromBottom <= 5;

    // 更新底部状态标记
    setIsAtBottom(atBottom);

    // --- 关键逻辑修正点 ---
    // 只要不在底部 (无论 scrollTop 是 100 还是 0),都视为用户正在阅读历史,必须锁定!
    if (!atBottom) {
      setUserScrolled(true); 
    } else {
      // 只有当用户主动滚回底部时,才解锁自动跟随
      setUserScrolled(false);
    }
  }, []);

  // 3. 自动滚动执行器 (响应新消息)
  useEffect(() => {
    // 【守卫条件】只有同时满足:正在流式输出 AND 用户未锁定 AND 容器存在
    if (!isStreaming || userScrolled || !scrollContainerRef.current) {
      return;
    }

    const container = scrollContainerRef.current;

    // 【性能优化】使用 requestAnimationFrame 确保 DOM 已更新
    requestAnimationFrame(() => {
      // 【策略选择】流式高频更新用 'auto' 防卡顿,非流式用 'smooth' 做过渡
      const behavior = isStreaming ? 'auto' : 'smooth';
      
      container.scrollTo({
        top: container.scrollHeight,
        behavior: behavior
      });
    });

  }, [messages, isStreaming, userScrolled]); // 依赖 messages 以触发更新

  return (
    <div 
      ref={scrollContainerRef}
      onScroll={handleScroll}
      style={{ height: '500px', overflowY: 'auto', border: '1px solid #ccc' }}
    >
      {messages.map((msg) => (
        <div key={msg.id}>{msg.content}</div>
      ))}
      {isStreaming && <div style={{ color: '#999', fontStyle: 'italic' }}>AI 正在思考...</div>}

    </div>
  );
}

五、总结

实现“智能自动滚动”其实就抓住了两个核心:

  1. 精准判断:用 scrollHeight - scrollTop - clientHeight 计算距底距离,5px 容错防抖动。
  2. 状态仲裁:用 userScrolled 做开关,用户在底部时自动跟随,用户上滑时立即锁定。
❌
❌