Skip to content

Markdown 流式渲染 + 代码高亮的踩坑

流式渲染 那篇解决了"字怎么吐到屏幕上",这一篇接着解决一个更恶心的问题:LLM 一边吐 Markdown,前端一边渲染,怎么做到不闪、不抖、不卡?

三个核心问题

  1. 每来一个 chunk 就重新解析整段 Markdown 是 O(n²) ——会卡。
  2. 代码块还没闭合时,半截 ```javascript 是个语法错误——会闪。
  3. 高亮代码用 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% 以下。


本站总访问量 --heart 本站访客数 -- 人次