前端实现大模型流式对话渲染(Markdown + 代码高亮 + 表格防爆 + 公式避坑)
简言:
做过大模型(ChatGPT、DeepSeek 等)接入的兄弟们肯定懂这种痛苦。
接口调通、SSE(流式传输)接好之后,真正的噩梦才刚刚开始:大模型的数据是一个字一个字往外蹦的(文本碎片)。如果你图省事,直接拿个 <div> 挨个往里塞,或者每次接收到新字符就全量 innerHTML 一遍,你大概率会遇到以下几个鬼畜画面:
-
排版满天飞:AI 刚写了一半的代码,因为反引号
```还没闭合,整个聊天气泡的布局瞬间塌陷,直到代码写完才“吧嗒”一下恢复正常。 -
表格顶爆屏幕:AI 吐了个大表格,直接把整个对话框横向撑爆,页面连个滚动条都没有,内容全在屏幕外面。
-
卡成幻灯片:对话内容一长,字数变多,前端高频触发 DOM 重绘,低端机型直接卡死,打字机光标走得像 PPT 翻页。
-
数学公式乱码:遇到
$或者$$包裹的 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 般的极致体验。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)