AI 总结经 SSE 一边生成一边推流,前端每收到一个 token 就把全文重新渲染一次。总结一般都有 5000 字以上,marked 解析一遍要 200ms 开外,流式场景下每秒要更新好几次,页面直接卡到没法看。我一开始怀疑是 marked 本身慢,后来测了半天才发现症结在”全量重渲染”这个写法上。这篇文章记录我从五个维度把渲染开销从 200ms 压到 30ms 以内的完整过程,包括每步的对比结论和踩过的坑。
一、流式渲染的痛点
先把问题摊开。很多做流式输出的团队第一版都是这么写的:维护一个字符串,SSE 每推一个 token 就拼上去,然后整段丢给 marked 重新解析,再塞进容器。传统做法:
let text = ''
// 每次 SSE 推 token
function onToken(token) {
text += token
document.getElementById('summary').innerHTML =
marked.parse(text) // 重新解析全文!
}
5000 字全文 marked 解析 200ms,每次 token 都重新解析卡爆。
实测数据更吓人:一次 8000 字的总结,SSE 推了 200 多个包,每个包都触发一次全量解析,累积下来用户要等十几秒页面才稳定,期间滚动条反复跳,光标打字都受影响。而且越到后面越慢,因为文本在增长,单次解析成本也在涨。我意识到这不是 marked 的锅,是我把”渲染”和”追加”混成了一件事。
二、优化策略
优化方向其实就一条主线:减少解析次数和减少 DOM 重建。我把市面上能想到的路子都试了一遍,下面按复杂程度从低到高列出来。
1. 节流渲染
不每个 token 都渲染,攒一波再渲染:
let pendingText = ''
let renderTimer = null
function onToken(token) {
pendingText += token
if (!renderTimer) {
renderTimer = setTimeout(() => {
document.getElementById('summary').innerHTML =
marked.parse(pendingText)
renderTimer = null
}, 100) // 100ms 节流
}
}
// SSE 完成时立即渲染最后一批
onComplete(() => {
if (renderTimer) clearTimeout(renderTimer)
document.getElementById('summary').innerHTML =
marked.parse(pendingText)
})
每 100ms 渲染一次(10 FPS),用户无感知卡顿。
这是投入产出比很高的一步。原来每秒解析十几次,现在降到十次以内,CPU 占用肉眼可见地掉下来。要注意的是结束时必须把最后一波立即渲染掉,不然末尾那几百毫秒的内容要干等一个定时器周期,会显得”最后一段卡一下”。
2. requestAnimationFrame 渲染
更细粒度的优化:
let pendingText = ''
let rafId = null
function onToken(token) {
pendingText += token
if (!rafId) {
rafId = requestAnimationFrame(() => {
document.getElementById('summary').innerHTML =
marked.parse(pendingText)
rafId = null
})
}
}
每帧最多一次渲染(60 FPS)。
RAF 的好处是跟浏览器刷新节奏对齐,不会出现”定时器触发时浏览器正在重绘”的冲突。我试过把节流改成 RAF,感官上没差太多,但它对帧率的控制更规范,适合对动画流畅度敏感的页面。
3. 增量渲染
只渲染新增部分,保留已渲染的 DOM:
let renderedLength = 0
let fullText = ''
let renderedText = ''
function onToken(token) {
fullText += token
// 检查是否有未渲染的新内容
if (fullText.length - renderedLength > 100) {
// 只解析新增
const newPart = fullText.slice(renderedLength)
const newHtml = marked.parse(renderedText + newPart)
document.getElementById('summary').innerHTML = newHtml
renderedText = fullText
renderedLength = fullText.length
}
}
问题:marked 是全文解析,增量只省了字符串拼接,DOM 重建仍全量。
这条路子我验证下来性价比偏低。marked 没有真正的增量解析能力,所谓增量只是减少触发频率,DOM 照样整块重建,滚动位置还会被重置。如果你的场景没有保留用户滚动位置的需求,不如直接用节流。
4. 虚拟 DOM 框架
用 React/Vue 框架管理 diff:
function SummaryView({ text }: { text: string }) {
return <div dangerouslySetInnerHTML={{ __html: marked(text) }} />
}
// 父组件
function Parent() {
const [text, setText] = useState('')
useEffect(() => {
// SSE
onToken(token => setText(t => t + token))
}, [])
return <SummaryView text={text} />
}
React diff 减少 DOM 重建,但 marked 解析仍是全文。
引入框架是笔不小的账:包体积、学习成本、和现有原生 JS 代码的磨合。我们项目前端是原生 JS,为这个痛点引入 React 不划算。如果你们的项目本来就用框架,这个方案倒是顺水推舟,diff 能保住大部分 DOM 节点。
5. 标记流式输出
marked 5+ 支持 walkTokens 增量解析:
import { marked } from 'marked'
const renderer = new marked.Renderer()
let lastNode = null
renderer.code = function(code, infostring) {
lastNode = document.createElement('pre')
return lastNode.outerHTML
}
// 流式输出时,逐节点 append
平台没用这么复杂,节流 + 全文 marked 解析已经够用。
这算是终极方案,但工程量大:要处理”当前解析到哪个节点””节点中途变文本又变代码”的边界,AI 输出不规则时会崩。我们评估后决定不追这个复杂度,把精力放在代码块高亮和 XSS 防护上。
三、Code Block 高亮
代码块高亮是渲染瓶颈(highlight.js 100ms+):
import { marked } from 'marked'
import hljs from 'highlight.js'
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
}
})
我踩过一个很隐蔽的坑:highlightAuto 在识别不了语言时会做全量探测,比指定语言慢好几倍。AI 总结里经常出现没有标注语言的代码块,那一段就会拖慢整体渲染。
流式渲染时,代码块未完成不要高亮:
let inCodeBlock = false
let pendingText = ''
function onToken(token) {
pendingText += token
inCodeBlock = pendingText.includes('```') &&
(pendingText.match(/```/g) || []).length % 2 === 1
if (!inCodeBlock) {
// 文本块可以全文渲染
document.getElementById('summary').innerHTML =
marked.parse(pendingText)
}
// 代码块未完成不渲染(避免中途高亮)
}
效果很明显:高亮这个环节从 100ms 降到只在代码块收尾时执行一次。代价是代码块生成期间用户看不到内容更新,但代码块通常就几行,等 200ms 换来的是全程不卡,这笔账划算。
四、图片懒加载
marked.use({
renderer: {
image(src, title, alt) {
返回 img 标签的 HTML 字符串,含 src/alt
loading="lazy"
style="max-width: 100%; height: auto;" />`
}
}
})
loading="lazy" 让图片进入视口才加载。
这是个小改动但很管用。AI 总结里常带视频截图,一次几百张图全加载,带宽直接打满。加了 lazy 之后,只有滚到的地方才拉图。还有个细节是给图片加了 max-width: 100%,不然长截图会撑破容器,页面横向滚出滚动条,这是我用手机测的时候发现的。
五、Sanitize(XSS 防护)
AI 输出可能含恶意 HTML,必须过滤:
import DOMPurify from 'dompurify'
const html = marked.parse(text)
const safe = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li',
'strong', 'em', 'code', 'pre', 'a', 'img',
'blockquote', 'br'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class']
})
document.getElementById('summary').innerHTML = safe
这是整个方案里不能省的一步。AI 模型可能被提示词注入诱导输出危险 HTML,或者干脆是用户上传的视频标题里带了恶意标签。marked 默认允许原始 HTML 透传,等于把 XSS 大门开着。DOMPurify 白名单过滤后,能保住的标签就只剩上面这些,链接的 href 也会被清洗。
六、性能监控
function renderSummary(text) {
const start = performance.now()
const html = marked.parse(text)
const clean = DOMPurify.sanitize(html)
document.getElementById('summary').innerHTML = clean
const duration = performance.now() - start
if (duration > 100) {
console.warn(`渲染慢: ${duration.toFixed(0)}ms, ` +
`文本长度 ${text.length}`)
}
}
慢渲染上报到监控。
优化做完之后,监控是保证不回归的关键。我把渲染耗时埋点打到了内部指标平台,超过 100ms 的渲染会单独标记。上线两周就抓到一例:某次 AI 返回了超长表格,marked 解析表格本身就费劲,卡到 300ms,最后靠限制表格列数才解决。没有监控,这类问题只能等用户投诉。
七、虚拟滚动(超长文本)
万字以上时滚动卡顿,用虚拟滚动:
import { VirtualScroller } from 'https://cdn.jsdelivr.net/npm/virtual-scroller@1.0.0'
new VirtualScroller({
container: document.getElementById('summary-container'),
items: lines,
renderItem: (line) => `<div>${line}</div>`
})
平台限制 5000 字以内,不虚拟化。
虚拟滚动我调研过但最终没上。原因很简单:我们的产品把总结长度限制在 5000 字以内,在这个量级下,节流加 lazy 图已经够顺。虚拟滚动要处理行高测量、滚动锚定一堆问题,和 markdown 这种块级结构还容易打架。如果你们的产品允许万字长文,再考虑这一层。
八、Marked 配置
marked.setOptions({
// GFM(GitHub Flavored Markdown)
gfm: true,
// 换行成 <br>
breaks: true,
// 同步渲染(异步在流式场景下不必要)
async: false,
// 性能选项
renderer: customRenderer,
// 高亮
highlight: function(code, lang) {
// ...
}
})
这几个开关值都踩过:breaks 不开的话,AI 输出的单行换行不会生效,总结会糊成一大坨;gfm 不开的话表格和删除线全没了。async 我在流式场景下关掉了,因为同步渲染的返回时机可控,便于跟节流配合,异步反而多一层回调地狱。
九、Markdown 预处理
AI 输出常有奇怪字符,预处理一下:
function preprocessMarkdown(text) {
return text
// 修复未闭合的代码块
.replace(/```\s*$/, '```\n')
// 转义危险 HTML(marked 已处理但保险)
// 统一换行
.replace(/\r\n/g, '\n')
}
这一段解决的是脏数据问题。AI 偶尔会返回以三个反引号开头但没闭合的代码块,marked 会把它之后的整段都当成代码吞掉,页面直接缺一半。我在预处理里先补一个换行,把未闭合的代码块兜住。\r\n 统一成 \n 是为了避免 Windows 风格换行在某些正则下的边界问题。
十、完整优化实现
上面每一条单独看都不复杂,难点是组合。我把它们收拢成一个渲染器类,SSE 回调只负责喂数据:
class MarkdownRenderer {
constructor(container) {
this.container = container
this.pendingText = ''
this.renderTimer = null
this.inCodeBlock = false
// 配置 marked
marked.setOptions({
gfm: true,
breaks: true,
highlight: this.highlight.bind(this)
})
}
append(chunk) {
this.pendingText += chunk
// 检查代码块状态
this.inCodeBlock = (this.pendingText.match(/```/g) || []).length % 2 === 1
if (this.inCodeBlock) {
// 代码块未完成,不渲染
return
}
// 节流渲染
if (!this.renderTimer) {
this.renderTimer = setTimeout(() => this.render(), 100)
}
}
complete() {
// 立即渲染最终版本
if (this.renderTimer) {
clearTimeout(this.renderTimer)
this.renderTimer = null
}
this.render()
}
render() {
const start = performance.now()
const html = marked.parse(this.pendingText)
const safe = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li',
'strong', 'em', 'code', 'pre', 'a', 'img',
'blockquote', 'br', 'table', 'tr', 'td', 'th',
'thead', 'tbody'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'target']
})
this.container.innerHTML = safe
this.renderTimer = null
const duration = performance.now() - start
if (duration > 100) {
console.warn(`渲染慢: ${duration.toFixed(0)}ms`)
}
}
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value
} catch {}
}
return hljs.highlightAuto(code).value
}
}
// 使用
const renderer = new MarkdownRenderer(
document.getElementById('summary')
)
// SSE 回调
onToken: token => renderer.append(token)
onComplete: () => renderer.complete()
把逻辑收进类之后,业务代码干净了很多:SSE 那边只管 append,结束调一次 complete,渲染细节全部封装。这也是后期维护的关键——优化方案再多,入口统一了才不怕后人改乱。
十一、性能对比
优化前后的数据放到一起,效果一目了然:
| 方案 | 5000 字渲染 | 流式 100 token |
|---|---|---|
| 每次 token 全量渲染 | 200ms × 100 = 20s | 卡死 |
| 100ms 节流 | 200ms × 10 = 2s | 流畅 |
| RAF 节流 | 200ms × 6 = 1.2s | 流畅 |
| 增量 + 缓存 | < 50ms × 6 | 流畅 |
平台用 100ms 节流 + 完整 marked + DOMPurify,效果够用。
从 20 秒到 2 秒,中间只隔了一行 setTimeout。这组数据也说明:流式渲染的问题九成出在”重渲染频率”上,先把频率降下来,再谈别的花活。增量方案理论上更快,但工程复杂度不值得为这个量级买单。
十二、踩过的坑
一路踩下来,值得记录的坑都在这:
- marked XSS:默认允许 HTML 注入。必须 DOMPurify。
- marked 异步模式:异步在流式场景下不必要,设
async: false。 - 代码块高亮慢:highlight.js 100ms+,代码块未完成时不要高亮。
- 图片加载慢:未设置
max-width撑破布局。 - 链接 target:外链应
target="_blank" rel="noopener"。 - 滚动条抖动:内容增长时滚动条出现导致内容右移。CSS
overflow-y: scroll。 - marked 缓存:marked 不内置缓存,重复文本不命中。
- HTML 实体:AI 输出
<期望显示<,要正确转义。
挑一个最阴的说。滚动条抖动:页面内容超过一屏后滚动条出现,页面宽度被压缩一截,内容整体右移,视觉上就是”每次刷新都跳一下”。解决办法是让容器从一开始就 overflow-y: scroll,把滚动条位置预留出来。这种问题不放大看不出来,一放大非常影响观感。
十三、SEO 友好
<!-- 服务器端先渲染 Markdown 给爬虫 -->
<div id="summary" th:utext="${markdown.toHtml()}">
{{ markdown_content }}
</div>
<!-- 客户端 hydrate 接管 -->
module 方式引入主脚本
平台用 SSR 预渲染(虽然前端是原生 JS)。优点:Google/Bing 能索引。
这是容易被忽略的一环。总结是用户生成内容,但如果只靠客户端 JS 渲染,搜索引擎爬虫拿到的是空页面。我们让服务端先渲一遍完整 HTML,爬虫能直接抓到正文,浏览器端再接管交互。对做内容型产品来说,这一步直接决定你能不能在搜索引擎里被找到。
十四、未来优化
现在的方案够用,但也不是终点:
- 增量解析:自己实现 Markdown 解析器,支持按 token 增量;
- Web Worker 渲染:不阻塞主线程;
- 虚拟列表:超长文分页渲染。
这三条我按收益排了序。增量解析改动量太大,暂时观望;Web Worker 是把 marked 搬进 worker,主线程彻底解放,但代码块高亮、DOM 操作这些得重新设计通信;虚拟列表等哪天我们放开字数限制再上。
常见问题(FAQ)
Q1:节流 100ms 会不会延迟显示?
会 100ms 内最尾的 token,但人眼难感知。
Q2:marked 还是 markdown-it?
marked 5+ 性能优于 markdown-it。marked 默认更快。
Q3:如何支持 GFM?
marked.setOptions({ gfm: true }) 开启。