Markdown 渲染优化方法(AI 视频下载总结器的实时渲染性能)

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 输出 &lt; 期望显示 <,要正确转义。

挑一个最阴的说。滚动条抖动:页面内容超过一屏后滚动条出现,页面宽度被压缩一截,内容整体右移,视觉上就是”每次刷新都跳一下”。解决办法是让容器从一开始就 overflow-y: scroll,把滚动条位置预留出来。这种问题不放大看不出来,一放大非常影响观感。

十三、SEO 友好

<!-- 服务器端先渲染 Markdown 给爬虫 -->
<div id="summary" th:utext="${markdown.toHtml()}">
    {{ markdown_content }}
</div>

<!-- 客户端 hydrate 接管 -->
module 方式引入主脚本

平台用 SSR 预渲染(虽然前端是原生 JS)。优点:Google/Bing 能索引。

这是容易被忽略的一环。总结是用户生成内容,但如果只靠客户端 JS 渲染,搜索引擎爬虫拿到的是空页面。我们让服务端先渲一遍完整 HTML,爬虫能直接抓到正文,浏览器端再接管交互。对做内容型产品来说,这一步直接决定你能不能在搜索引擎里被找到。

十四、未来优化

现在的方案够用,但也不是终点:

  1. 增量解析:自己实现 Markdown 解析器,支持按 token 增量;
  2. Web Worker 渲染:不阻塞主线程;
  3. 虚拟列表:超长文分页渲染。

这三条我按收益排了序。增量解析改动量太大,暂时观望;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 }) 开启。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部