AI Chat 流式 Markdown 渲染实践:双阶段方案让“边输出边可读”
前言
做 AI Chat 时,最常见的体验问题之一是:
- 流式输出阶段只是“纯文本打字机”;
- Markdown(标题、列表、代码块)要等回答结束才生效;
- 用户在长回答里很难快速定位重点。
这篇文章分享一套在业务里可落地的核心方案:双阶段渲染。
- Phase A(流式阶段):轻量 Markdown 渲染,优先可读性和稳定性;
- Phase B(完成阶段):完整 Markdown + 代码高亮,一次性精修。
目标很明确:既要“实时可读”,又要“最终精致”。
0. 需求:像动图中一样实现js代码块的解析(包含代码围栏和高亮两步)
1. 问题复盘:为什么会“要等全部返回才像 Markdown”
很多项目里,流式链路其实没问题,问题在渲染策略:
onChunk已经持续拿到了fullText;- 但渲染组件在
isStreaming=true时走了纯文本分支; - 于是 Markdown 解析被延后到
done。
表现出来就是:回答结束前只有换行,没有结构化样式。
2. 核心思路:双阶段渲染
Phase A:流式轻量渲染(不做高亮)
流式阶段每个 chunk 都可能触发渲染,高亮计算很重。
因此 Phase A 的策略是:
- 保留
markdown-it的基础解析能力; - 代码块仅做转义和
<pre><code>包裹,不做highlight.js; - 对不完整语法做“最小补全”,避免 UI 抖动。
Phase B:完成后完整渲染(一次高亮)
当收到 done:
- 切换到完整渲染器;
- 执行一次
highlight.js; - 得到最终可发布级展示效果。
这能避免流式期间反复全量高亮导致的卡顿。
为了更直观看清双阶段在运行时怎么切换,可以看这张流程图:
flowchart TD
sseChunk["SSE返回Chunk"] --> appendText["onChunk累加fullText"]
appendText --> streamingCheck{"isStreaming?"}
streamingCheck -->|是| safePreprocess["toSafeStreamingMarkdown最小补全"]
safePreprocess --> phaseARender["mdStreaming渲染(无高亮)"]
phaseARender --> uiUpdateA["气泡实时更新(可读优先)"]
appendText --> doneEvent["收到done事件"]
doneEvent --> phaseBRender["mdFinal渲染(完整高亮)"]
phaseBRender --> uiUpdateB["最终展示态(精修完成)"]
图里最关键的一点是:高亮只在 done 后触发一次,流式阶段只做轻量渲染。
3. 实现细节(可直接复用)
以下为核心片段,完整实现在项目
MarkdownRenderer.vue。
3.1 两套 markdown-it 实例
const createMarkdownIt = (highlight) => new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
highlight
})
// Phase A: 流式阶段(无高亮)
const mdStreaming = createMarkdownIt((str, lang) => {
const className = lang ? ` class="language-${lang}"` : ''
return `<pre class="hljs"><code${className}>${escapeHtml(str)}</code></pre>`
})
// Phase B: 完成阶段(完整高亮)
const mdFinal = createMarkdownIt((str, lang) => {
if (lang && hljs.getLanguage(lang)) {
const result = hljs.highlight(str, { language: lang, ignoreIllegals: true })
return `<pre class="hljs"><code class="language-${lang}">${result.value}</code></pre>`
}
return `<pre class="hljs"><code>${escapeHtml(str)}</code></pre>`
})
这段代码的核心不是“写了两个变量”,而是把渲染成本按阶段拆开。可以这样理解:
-
createMarkdownIt(highlight):这是一个工厂函数。
作用是把html / breaks / linkify / typographer这些公共配置统一收口,避免维护两套实例时出现配置漂移。 -
highlight参数:这里是可插拔策略位。
同样的 Markdown 解析器,不同的highlight实现,就能得到“轻量渲染”与“完整高亮渲染”两种行为。 -
mdStreaming:流式阶段实例。
它保留代码块容器结构(pre + code + language-*),但只做escapeHtml,不做语法着色。
这意味着用户仍能看到“这是代码块、是什么语言”,同时把 CPU 成本压低。 -
mdFinal:完成阶段实例。
这里才调用hljs.highlight(...)做真正的语法高亮。
if (lang && hljs.getLanguage(lang))是保护条件,避免未知语言直接抛错。 -
ignoreIllegals: true:高亮容错开关。
代码片段不完整或语法不标准时,不会因为高亮异常导致整段渲染失败。 -
escapeHtml(str)的 fallback:兜底策略。
如果语言不存在或高亮不可用,仍然保证代码以安全文本展示,而不是把原始 HTML 注入页面。
一句话总结这段设计:
“同一套 Markdown 基线配置,按阶段切换不同 highlight 策略,换来体验和性能的平衡。”
3.2 流式“最小补全”防抖动
流式过程中最容易出问题的是未闭合语法:代码围栏和行内反引号。
为什么会抖动?因为流式文本天然是“半成品”:
- 代码块经常先收到 `````` 开头,结束围栏要过几秒才到;
- 行内代码可能先收到一个反引号,后一段才闭合;
- Markdown 解析器面对不完整结构时,会在“普通文本 / 块级元素”之间来回切换,视觉上就像跳动。
这时我们不应该强行“猜语义”,而是做一个非常克制的策略:只补语法闭合,不改原始内容意图。
也就是:
- 如果 ````` 数量是奇数,临时在末尾补一个结束围栏;
- 如果行内反引号是奇数,临时补一个反引号;
- 下一次 chunk 到来后重新计算,始终基于最新全文判断。
这样做的好处是:
- 渲染结构更稳定,代码块不会一会儿是段落、一会儿是代码;
- 补全是“临时态”,不会污染最终内容;
- 实现足够轻量,适合每个 chunk 都执行。
function toSafeStreamingMarkdown(input) {
let text = input || ''
// 代码围栏 ``` 奇数则补齐
const fenceCount = (text.match(/```/g) || []).length
if (fenceCount % 2 === 1) text += '\n```'
// 行内反引号(忽略 ```)奇数则补齐
let inlineTickCount = 0
for (let i = 0; i < text.length; i++) {
if (text.startsWith('```', i)) {
i += 2
continue
}
if (text[i] === '`') inlineTickCount++
}
if (inlineTickCount % 2 === 1) text += '`'
return text
}
简单理解:它不是在“修正文案”,而是在给流式渲染加一个“防抖层”。
3.3 渲染切换逻辑
双阶段渲染的关键不在于“写两套解析器”,而在于切换时机必须清晰且可预测。
这里的规则非常明确:
isStreaming=true:走mdStreaming,目标是低成本、可读优先;isStreaming=false:走mdFinal,目标是完整高亮、最终形态。
这意味着一次回答只会经历一次“精修”:
- 流式期间多次轻量渲染;
- 结束时一次完整渲染。
所以我们既能保证实时可读,又不会在流式阶段反复触发高亮的全量计算。
const renderedHtml = computed(() => {
if (!props.content) return ''
if (props.isStreaming) {
return mdStreaming.render(toSafeStreamingMarkdown(props.content))
}
return mdFinal.render(props.content)
})
这几行可以按“输入 -> 分流 -> 输出”来理解:
renderedHtml:这是一个 Vuecomputed,本质是“根据当前状态实时计算出来的 HTML 字符串”。模板里通常会通过v-html把它渲染出来。if (!props.content) return '':兜底分支。AI 还没返回内容时直接返回空字符串,避免渲染器做无意义计算。props.isStreaming:外部传入的流式状态开关。true代表回答还在持续到达,false代表已经结束。mdStreaming.render(...):流式阶段使用轻量渲染器(即只包含如代码标识框不包含代码的高亮功能)。它保留 Markdown 结构解析,但不做代码高亮,目的是降低每个 chunk 的计算成本。toSafeStreamingMarkdown(props.content):在真正渲染前先做“最小补全”,把未闭合代码围栏/反引号临时闭合,减少流式过程中的布局跳动。mdFinal.render(props.content):流结束后的最终渲染器。这里才启用完整高亮与最终样式,保证收尾质量。
如果把它翻译成一句话,就是:
“内容为空不渲染;流式中走轻量稳定路径;结束后走完整精修路径。”
从工程收益看,这段切换逻辑直接解决了两个核心矛盾:
- 体验上,用户不再等“最后一刻”才看到结构化内容;
- 性能上,主线程不会被高频高亮反复压垮。
4. 待优化点与踩坑复盘
这次双阶段渲染已经解决了核心体验问题,但如果要继续往“大厂生产级”演进,下面这些点建议优先处理。
4.1 待优化点(下一阶段可以做)
-
流式渲染节流调度
当前每个 chunk 都可能触发一次渲染。若后端切 chunk 很碎,主线程仍会有压力。
建议增加 50~100ms 的调度窗口(requestAnimationFrame或定时合批),把多次更新合并成一次 render。 -
超长内容分档策略
回答很长(尤其带大代码块)时,即使无高亮也可能变慢。
建议按长度分档,比如超过阈值后提升节流间隔,或者对历史片段做惰性处理。 -
更稳健的“未闭合语法”识别
目前是“最小补全”策略,已覆盖高频场景。
后续可补充更多边界:比如复杂嵌套列表、多语言围栏混排、反引号出现在字符串中的误判。
4.2 这次实际踩过的坑
-
看似是 Markdown 问题,实际是 CSS 干扰
我们遇到过“段落间隔异常大”的现象,根因是消息气泡通用样式里white-space: pre-wrap影响了 Markdown 块级排版。
修复思路:用户纯文本保留pre-wrap,AI Markdown 区域改为normal。 -
流式阶段直接高亮会导致性能抖动
一开始若对每次 chunk 都跑高亮,长回复场景下会出现明显卡顿。
这也是双阶段渲染必要性的关键依据:流式轻量,结束精修。 -
未闭合代码围栏导致结构跳变
不做最小补全时,代码块会在“普通段落/代码块”之间来回切换,阅读体验很差。
增加补全后可显著降低视觉跳动。
结语
AI Chat 的体验,不只是“能返回”,而是“返回过程是否可读”。
双阶段渲染的价值就在于:把最终质量前置到生成过程里。
一句话总结:
流式阶段先保证读得懂,完成阶段再做到看起来专业。
完整代码
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
// 只导入常用语言,减小体积
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import html from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import sql from 'highlight.js/lib/languages/sql'
import bash from 'highlight.js/lib/languages/bash'
import json from 'highlight.js/lib/languages/json'
import yaml from 'highlight.js/lib/languages/yaml'
import markdown from 'highlight.js/lib/languages/markdown'
// 注册语言
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('html', html)
hljs.registerLanguage('css', css)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('shell', bash)
hljs.registerLanguage('json', json)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('yml', yaml)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('md', markdown)
// Props 定义
const props = defineProps({
content: {
type: String,
default: ''
},
isStreaming: {
type: Boolean,
default: false
}
})
// 复用基础配置,避免两套实例配置漂移
const createMarkdownIt = (highlight) => new MarkdownIt({
html: false, // 禁用 HTML,防止 XSS
breaks: true, // 将换行符转换为 <br>
linkify: true, // 自动转换 URL 为链接
typographer: true, // 启用排版优化
highlight
})
const escapeHtml = (raw = '') =>
raw.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
// 流式阶段:只做基础 Markdown 渲染,不做语法高亮(降低每个 chunk 的计算成本)
const mdStreaming = createMarkdownIt((str, lang) => {
const className = lang ? ` class="language-${lang}"` : ''
return `<pre class="hljs"><code${className}>${escapeHtml(str)}</code></pre>`
})
// 完成阶段:做完整渲染和代码高亮
const mdFinal = createMarkdownIt((str, lang) => {
if (lang && hljs.getLanguage(lang)) {
try {
const result = hljs.highlight(str, { language: lang, ignoreIllegals: true })
return `<pre class="hljs"><code class="language-${lang}">${result.value}</code></pre>`
} catch (__) {
// 高亮失败,回退为转义文本
}
}
return `<pre class="hljs"><code>${escapeHtml(str)}</code></pre>`
})
function applySafeLinkRule(mdInstance) {
const defaultRender = mdInstance.renderer.rules.link_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options)
}
mdInstance.renderer.rules.link_open = function(tokens, idx, options, env, self) {
const token = tokens[idx]
const hrefIndex = token.attrIndex('href')
if (hrefIndex >= 0) {
const href = token.attrs[hrefIndex][1]
token.attrPush(['target', '_blank'])
token.attrPush(['rel', 'noopener noreferrer'])
if (href.startsWith('http://') || href.startsWith('https://')) {
token.attrPush(['class', 'external-link'])
}
}
return defaultRender(tokens, idx, options, env, self)
}
}
applySafeLinkRule(mdStreaming)
applySafeLinkRule(mdFinal)
// 流式文本不完整时做最小补全,避免 markdown 结构大幅抖动
function toSafeStreamingMarkdown(input) {
let text = input || ''
// 1) 围栏代码块:``` 数量为奇数时临时补齐
const fenceCount = (text.match(/```/g) || []).length
if (fenceCount % 2 === 1) {
text += '\n```'
}
// 2) 行内代码:忽略 ``` 后统计单反引号,奇数则临时补齐
let inlineTickCount = 0
for (let i = 0; i < text.length; i++) {
if (text.startsWith('```', i)) {
i += 2
continue
}
if (text[i] === '`') {
inlineTickCount += 1
}
}
if (inlineTickCount % 2 === 1) {
text += '`'
}
return text
}
// 内容容器引用
const contentRef = ref(null)
// 渲染后的 HTML
const renderedHtml = computed(() => {
if (!props.content) return ''
// Phase A: 流式阶段走轻量 Markdown 渲染(无高亮)
if (props.isStreaming) {
return mdStreaming.render(toSafeStreamingMarkdown(props.content))
}
// Phase B: 结束后仅做一次完整 Markdown + 高亮渲染
return mdFinal.render(props.content)
})
// 当流式状态变化时,滚动到最新消息
watch(() => props.isStreaming, async (newVal, oldVal) => {
if (oldVal === true && newVal === false) {
// 流式结束,等待渲染完成后通知父组件
await nextTick()
emit('rendered')
}
})
const emit = defineEmits(['rendered'])
</script>
<template>
<div ref="contentRef" class="markdown-content" v-html="renderedHtml"></div>
</template>
<style scoped>
.markdown-content {
line-height: 1.6;
word-break: break-word;
}
/* 段落 */
.markdown-content :deep(p) {
margin: 0 0 6px 0;
}
.markdown-content :deep(p:last-child) {
margin-bottom: 0;
}
/* 标题 */
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3),
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
margin: 10px 0 6px 0;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
}
.markdown-content :deep(h1) { font-size: 20px; }
.markdown-content :deep(h2) { font-size: 18px; }
.markdown-content :deep(h3) { font-size: 16px; }
.markdown-content :deep(h4) { font-size: 15px; }
.markdown-content :deep(h5) { font-size: 14px; }
.markdown-content :deep(h6) { font-size: 14px; }
/* 列表 */
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
margin: 6px 0;
padding-left: 24px;
}
.markdown-content :deep(li) {
margin: 4px 0;
}
.markdown-content :deep(li > ul),
.markdown-content :deep(li > ol) {
margin: 2px 0;
}
/* 引用块 */
.markdown-content :deep(blockquote) {
margin: 6px 0;
padding: 8px 16px;
border-left: 4px solid var(--el-border-color);
background: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
border-radius: 0 4px 4px 0;
}
.markdown-content :deep(blockquote p:last-child) {
margin-bottom: 0;
}
/* 代码块 */
.markdown-content :deep(pre.hljs) {
margin: 6px 0;
padding: 12px 16px;
background: #f6f8fa;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow-x: auto;
}
.markdown-content :deep(pre.hljs code) {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
background: transparent;
padding: 0;
border: none;
}
/* 行内代码 */
.markdown-content :deep(code:not(pre code)) {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.9em;
padding: 2px 6px;
background: var(--el-fill-color-light);
border-radius: 4px;
color: var(--el-color-danger);
}
/* 表格 */
.markdown-content :deep(table) {
width: 100%;
margin: 8px 0;
border-collapse: collapse;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
overflow: hidden;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
padding: 8px 12px;
border: 1px solid var(--el-border-color-lighter);
text-align: left;
}
.markdown-content :deep(th) {
background: var(--el-fill-color-light);
font-weight: 600;
color: var(--el-text-color-primary);
}
.markdown-content :deep(tr:nth-child(even)) {
background: var(--el-fill-color-lighter);
}
/* 链接 */
.markdown-content :deep(a) {
color: var(--el-color-primary);
text-decoration: none;
}
.markdown-content :deep(a:hover) {
text-decoration: underline;
}
.markdown-content :deep(a.external-link::after) {
content: ' ↗';
font-size: 0.8em;
}
/* 分割线 */
.markdown-content :deep(hr) {
margin: 10px 0;
border: none;
border-top: 1px solid var(--el-border-color-lighter);
}
/* 图片 */
.markdown-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
/* 高亮样式 - 使用 github 主题 */
.markdown-content :deep(.hljs) {
display: block;
color: #24292e;
}
.markdown-content :deep(.hljs-comment,
.hljs-quote) {
color: #6a737d;
font-style: italic;
}
.markdown-content :deep(.hljs-keyword,
.hljs-selector-tag,
.hljs-subst) {
color: #d73a49;
font-weight: bold;
}
.markdown-content :deep(.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable,
.hljs-tag .hljs-attr) {
color: #005cc5;
}
.markdown-content :deep(.hljs-string,
.hljs-doctag) {
color: #032f62;
}
.markdown-content :deep(.hljs-title,
.hljs-section,
.hljs-selector-id) {
color: #6f42c1;
font-weight: bold;
}
.markdown-content :deep(.hljs-subst) {
font-weight: normal;
}
.markdown-content :deep(.hljs-type,
.hljs-class .hljs-title) {
color: #458;
font-weight: bold;
}
.markdown-content :deep(.hljs-tag,
.hljs-name,
.hljs-attribute) {
color: #000080;
font-weight: normal;
}
.markdown-content :deep(.hljs-regexp,
.hljs-link) {
color: #009926;
}
.markdown-content :deep(.hljs-symbol,
.hljs-bullet) {
color: #990073;
}
.markdown-content :deep(.hljs-built_in,
.hljs-builtin-name) {
color: #0086b3;
}
.markdown-content :deep(.hljs-meta) {
color: #999;
font-weight: bold;
}
.markdown-content :deep(.hljs-deletion) {
background: #fdd;
}
.markdown-content :deep(.hljs-addition) {
background: #dfd;
}
.markdown-content :deep(.hljs-emphasis) {
font-style: italic;
}
.markdown-content :deep(.hljs-strong) {
font-weight: bold;
}
</style>