Appearance
Markdown 流式渲染 + 代码高亮的踩坑
流式渲染 那篇解决了"字怎么吐到屏幕上",这一篇接着解决一个更恶心的问题:LLM 一边吐 Markdown,前端一边渲染,怎么做到不闪、不抖、不卡?
三个核心问题
- 每来一个 chunk 就重新解析整段 Markdown 是 O(n²) ——会卡。
- 代码块还没闭合时,半截
```javascript是个语法错误——会闪。 - 高亮代码用 Shiki/highlight.js 是同步的——长代码会卡 UI。
一、为什么不能"每个 chunk 重新 render"
最朴素的写法:
vue
<template>
<div v-html="md.render(content)"></div>
</template>
<script setup>
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt();
const props = defineProps(['content']);
// content 是 LLM 流式累加的字符串
</script>LLM 输出 1000 字符 = 1000 次 md.render() + 1000 次 v-html 重渲染 + 浏览器 1000 次重排。一段 500 字回答能让低端机 freeze 几秒。
二、思路:分块 + 增量
观察一个事实:Markdown 的渲染单元是"块"——段落、列表、代码块、引用。LLM 流式输出时,新内容总是追加到最后一个块,前面的块不会变。
利用这个性质:
ts
// 简化版:把内容切成"已稳定"的块 + "正在写"的最后一块
function splitMarkdown(content: string) {
const blocks = content.split(/\n\n+/);
return {
stable: blocks.slice(0, -1).join('\n\n'),
pending: blocks[blocks.length - 1] ?? '',
};
}模板里:
vue
<template>
<div class="md">
<!-- 稳定部分:只在新块出现时重新 render,缓存 HTML -->
<div v-html="stableHtml"></div>
<!-- 流式部分:每个 chunk 都 render,但内容很短 -->
<div v-html="pendingHtml"></div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({ html: false, linkify: true });
const props = defineProps(['content']);
const stableHtml = ref('');
const pendingHtml = ref('');
const lastStable = ref('');
watch(
() => props.content,
(cur) => {
const { stable, pending } = splitMarkdown(cur);
if (stable !== lastStable.value) {
stableHtml.value = md.render(stable);
lastStable.value = stable;
}
pendingHtml.value = md.render(pending);
},
{ immediate: true },
);
</script>实际效果:稳定部分一行代码一行代码"定下来",只有最后一个段落在跳。
三、未闭合代码块的处理
LLM 写到一半的代码块:
这是一个示例:
```javascript
function add(a, b) {
return a +在 Markdown 里这是个未闭合的代码块,markdown-it 会:
- 把
```之后的所有内容当代码(这是好的); - 但 syntax highlighter 可能因为"代码不完整"报错或不高亮。
实战做法:在 splitMarkdown 阶段检测到末尾有未闭合 ```,临时补一个闭合 ```:
ts
function patchUnclosedFence(md: string): string {
const fences = (md.match(/```/g) || []).length;
return fences % 2 === 1 ? md + '\n```' : md;
}这样高亮器看到的永远是合法 Markdown。代码块边写边高亮,不闪烁。
四、代码高亮:Shiki 是异步的,用对它
Shiki 是目前最准的 JS 端高亮(VSCode 同款词法),但它异步初始化、有 wasm 体积。流式场景下不能每个 chunk 都同步高亮。
方案 A:用 markdown-it-shiki + 增量
ts
import { codeToHtml } from 'shiki';
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({
highlight: (code, lang) => {
// 同步返回原始 code,让 Shiki 异步替换
return `<pre data-lang="${lang}"><code>${escapeHtml(code)}</code></pre>`;
},
});
// 异步增强
async function enhance(rootEl: HTMLElement) {
const blocks = rootEl.querySelectorAll('pre[data-lang]:not([data-highlighted])');
await Promise.all(
[...blocks].map(async (el) => {
const lang = el.getAttribute('data-lang') ?? 'text';
const html = await codeToHtml(el.textContent ?? '', { lang, theme: 'github-dark' });
el.outerHTML = html;
el.setAttribute('data-highlighted', 'true');
}),
);
}enhance() 只处理新出现的 <pre>,已高亮的跳过。流式过程中代码块先以"白色等宽字体"显示,写完后被替换成高亮版——视觉上是"代码颜色后到",比"卡几秒不动"好接受得多。
方案 B:CSS-only 占位
如果不想引入 Shiki,可以用一个轻量 CSS 让代码块"看起来已经高亮":
css
.md pre code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: #0f172a;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
display: block;
overflow-x: auto;
}只是没有词法着色。简单 Chat 场景这样就够。
五、Markdown 渲染的几个安全问题
1. 必须关 html: false
ts
const md = new MarkdownIt({ html: false });否则 LLM 一旦输出 <script>...</script>,你就给用户"表演了一个 XSS"。
2. 链接加 noreferrer
ts
md.renderer.rules.link_open = (tokens, idx, opts, _, self) => {
tokens[idx].attrSet('target', '_blank');
tokens[idx].attrSet('rel', 'noreferrer noopener');
return self.renderToken(tokens, idx, opts);
};3. 用户上传内容还要再走一层 DOMPurify
ts
import DOMPurify from 'dompurify';
v-html="DOMPurify.sanitize(rendered)"六、KaTeX / Mermaid / 表格的流式问题
如果你的助手会输出公式或图表:
- KaTeX:跟代码块一样,未闭合的
$$会乱。补$$闭合。 - Mermaid:复杂图表渲染极慢,建议只在最后一个块写完才渲染。判断方式:watch
isStreaming,只在变 false 时 enhance Mermaid 块。 - 表格:markdown-it 对未完成的表格容错 OK,可以放心流式渲染。
七、性能:把渲染搬到 worker
如果你的内容会到几千行(比如一篇技术文章被 LLM 一口气写完),主线程同步 markdown-it 还是会卡。把 markdown-it + DOMPurify 搬到 Web Worker:
ts
// worker.ts
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
const md = new MarkdownIt({ html: false });
self.onmessage = (e) => {
const html = DOMPurify.sanitize(md.render(e.data));
self.postMessage(html);
};主线程 debounce 到 60Hz,避免 worker 排队:
ts
let pending = false;
watch(() => props.content, (cur) => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
worker.postMessage(cur);
pending = false;
});
});八、最终的架构图
LLM SSE chunks
│
▼
content (累加字符串)
│
├──→ splitMarkdown ──→ stable / pending
│ │ │
│ ▼ ▼
│ patchUnclosed patchUnclosed
│ │ │
│ ▼ ▼
│ md.render (worker) md.render (worker)
│ │ │
│ ▼ ▼
│ stableHtml pendingHtml
│ │ │
│ └────┬─────────────┘
│ ▼
│ v-html 渲染
│ │
│ ▼
│ enhance() ← 异步 Shiki / Mermaid
└────────────┘按这个架构搭出来的 Chat UI,5000 字的 Markdown 流式输出全程顺滑,CPU 占用稳定在 10% 以下。
