简言:

做过大模型(ChatGPT、DeepSeek 等)接入的兄弟们肯定懂这种痛苦。

接口调通、SSE(流式传输)接好之后,真正的噩梦才刚刚开始:大模型的数据是一个字一个字往外蹦的(文本碎片)。如果你图省事,直接拿个 <div> 挨个往里塞,或者每次接收到新字符就全量 innerHTML 一遍,你大概率会遇到以下几个鬼畜画面

  1. 排版满天飞:AI 刚写了一半的代码,因为反引号 ``` 还没闭合,整个聊天气泡的布局瞬间塌陷,直到代码写完才“吧嗒”一下恢复正常。

  2. 表格顶爆屏幕:AI 吐了个大表格,直接把整个对话框横向撑爆,页面连个滚动条都没有,内容全在屏幕外面。

  3. 卡成幻灯片:对话内容一长,字数变多,前端高频触发 DOM 重绘,低端机型直接卡死,打字机光标走得像 PPT 翻页。

  4. 数学公式乱码:遇到 $ 或者 $$ 包裹的 LaTeX 公式,要么渲染不出来,要么流式输出到一半直接报错。

今天咱们不聊虚的,直接上干货。聊聊怎么用纯前端优雅地搞定大模型流式文本的格式化渲染、性能优化与排版防爆

一、 核心技术栈:前端“黄金铁三角”

要想让大模型返回的粗糙文本变成精美的 UI,咱们得先把基础设施搭好。千万别自己去写正则解析 Markdown,工作量大还全是 Bug。直接上社区最成熟的方案:

  • marked / markdown-it:负责把 Markdown 字符串翻译成标准的 HTML 标签。

  • highlight.js / Prism.js:负责给翻译出来的代码块套上 IDE 级别的皮肤(代码高亮)。

  • katex / mathjax:如果要支持数学公式,用 KaTeX 速度最快。

  • dompurify(重中之重):负责洗稿。大模型生成的内容不可控,万一里面夹带了恶意 <script> 脚本,直接 innerHTML 就是在给你的项目大门钥匙送给黑客。必须用它过滤 XSS 注入。

二、 痛点一:如何搞定“半截子” Markdown?

大模型一边思考一边吐字,在它吐出 ```javascript 但还没吐出结尾的 ``` 时,解析器会把后面所有的普通文本都当成代码处理。同样的,列表(1. 2. 3.)或者粗体()没吐完时也会有闪烁问题。

核心解法:动态“骗”解析器

简单粗暴但极为管用的骚操作:在交给解析器之前,先用正则检查这些特殊标记的数量,不完整的临时给它补齐。 注意:只是在解析渲染时拼,别污染了你本地存对话历史的真实变量!

// 动态补全不完整的 Markdown
function fixIncompleteMarkdown(rawText) {
  let fixed = rawText;

  // 1. 修复代码块未闭合(反引号是奇数个,说明有个代码块张着嘴呢)
  const codeBlockCount = (rawText.match(/```/g) || []).length;
  if (codeBlockCount % 2 !== 0) {
    fixed += '\n```';
  }

  // 2. 修复粗体未闭合
  const boldCount = (rawText.match(/\*\*/g) || []).length;
  if (boldCount % 2 !== 0) {
    fixed += '**';
  }

  // 3. 修复 LaTeX 块级公式未闭合
  const mathBlockCount = (rawText.match(/\$\$/g) || []).length;
  if (mathBlockCount % 2 !== 0) {
    fixed += '\n$$';
  }

  return fixed;
}

三、 痛点二:流式表格如何“排版防爆”?

大模型在画表格时,是一行一行往外吐的。在第二行 | --- | --- | 没出来前,页面上显示的是一排丑陋的、带着竖线的文本;等第二行出来的瞬间,才会突兀地坍缩成一个标准的表格结构。

说实话,在未闭合时做复杂的动态正则表格补全,成本极高且极易翻车。 用户的核心痛点其实并不是那两秒钟的结构跳动,而是大表格直接撑爆了聊天气泡

我们直接用纯 CSS 对表格进行“降维打击”:

/* 限制聊天气泡内的表格样式 */
.chat-message-content table {
  width: 100%;
  border-collapse: collapse;
  margin: 12px 0;
  
  /* 关键:允许表格在气泡内部横向滚动,死活不让它撑爆父壳 */
  display: block; 
  overflow-x: auto;
  white-space: nowrap; /* 防止表格单元格里的文字被挤得上下换行 */
}

/* 顺便优化一个大厂质感的表格 UI */
.chat-message-content th, 
.chat-message-content td {
  border: 1px solid #e2e8f0;
  padding: 8px 14px;
  text-align: left;
}
.chat-message-content th {
  background-color: #f8fafc;
  font-weight: 600;
}
/* 隔行变色,防止列数太多看花眼 */
.chat-message-content tr:nth-child(even) {
  background-color: #fdfdfd;
}

只要加上 display: block; overflow-x: auto;,即便大模型吐出来的表格有 20 列,也只会在聊天气泡内部出现优雅的横向滚动条,绝不破坏页面布局。

四、 痛点三:高频重绘卡成幻灯片怎么办?

后端吐字可能一秒钟触发几十次回调。如果文章已经两千字了,你每收一个字就全量做一次 marked.parse() 和 DOM 替换,浏览器绝对会当场崩溃。

进阶打法:剥离渲染,拦截交给 requestAnimationFrame

不要后端给你塞多少次你就渲染多少次。我们用浏览器自带的帧渲染机制做一个简单的防抖节流,把渲染频率死死卡在屏幕刷新率(通常是 60fps,即 16.6ms 渲染一次)以内:

let isRendering = false;
let hasPendingRender = false;
let myGlobalResponseText = ""; // 存放累加的真实文本

function onChunkReceived(newChunk) {
  myGlobalResponseText += newChunk;
  hasPendingRender = true;
  triggerRender();
}

function triggerRender() {
  // 如果当前帧已经在渲染了,或者没有新数据,就直接拦截
  if (isRendering || !hasPendingRender) return;
  
  isRendering = true;
  hasPendingRender = false;
  
  // 把高耗能的解析和 DOM 操作塞进下一帧
  requestAnimationFrame(() => {
    const fixedText = fixIncompleteMarkdown(myGlobalResponseText);
    
    // 渲染核心链路:解析 -> 净化 -> 塞入 DOM
    const rawHtml = marked.parse(fixedText);
    chatBox.innerHTML = DOMPurify.sanitize(rawHtml);
    
    isRendering = false;
    // 渲染完看一眼,如果刚才期间又来新字了,立马接着 call 下一帧
    if (hasPendingRender) triggerRender();
  });
}

这样写完,无论后端的流吐得有多疯狂、字数多长,前端渲染都能保证丝滑稳定,光标绝不卡顿。

五、 体验加分项:一键复制与灵魂光标

1. 动态代码块“一键复制”

别等流式结束了再去 querySelector 引导事件,在单页应用里 DOM 随时被刷掉,绑定的事件极易丢失。

最佳玩法:直接重写 Marked 的 Renderer,在解析阶段就把复制按钮的 HTML 骨架和数据塞进去:

const renderer = new marked.Renderer();

renderer.code = function(code, language) {
  const validLang = hljs.getLanguage(language) ? language : 'plaintext';
  const highlighted = hljs.highlight(code, { language: validLang }).value;
  
  // 利用自定义属性 data-code 把源码带过去(记得转义防崩溃)
  return `
    <div class="code-block-wrapper" style="position: relative;">
      <div class="code-header">
        <span class="lang-name">${language || 'text'}</span>
        <button class="copy-btn" data-code="${encodeURIComponent(code)}">复制</button>
      </div>
      <pre><code class="hljs ${validLang}">${highlighted}</code></pre>
    </div>
  `;
};
marked.setOptions({ renderer });

然后在最外层的 chatBox 上绑定一个事件委托,一劳永逸:

chatBox.addEventListener('click', (e) => {
  if (e.target.classList.contains('copy-btn')) {
    const code = decodeURIComponent(e.target.getAttribute('data-code'));
    navigator.clipboard.writeText(code).then(() => {
      e.target.innerText = '已复制!';
      setTimeout(() => e.target.innerText = '复制', 2000);
    });
  }
});

2. 紧贴文本的“闪烁光标”

如果直接把闪烁光标加在最外层容器上(如 ::after),当 AI 输出段落换行时,光标会因为块级元素的挤压,另起一行单独闪烁,显得极不专业。

正确的 CSS 细节:让光标永远只加在容器的最后一个子元素的内部末尾

/* 只在气泡内部最后一个标签的最后面加光标 */
.chat-message-content > *:last-child::after {
  content: '▋';
  display: inline-block;
  vertical-align: text-bottom;
  animation: blink 1s step-end infinite;
  margin-left: 4px;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

六、 总结

搞定大模型前端渲染,核心就是三步走:在解析前补齐结构、在解析时注入 UI 元素、在渲染时拦截帧节流。把这几招落实,不管是代码块、普通文本还是复杂的表格,在流式输出时都能做到如同官方 ChatGPT 般的极致体验。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐