字
字节笔记本
2026年7月20日
微信小程序/UniApp 中实现 Markdown 流式解析与渲染
API中转
¥120
微信小程序/UniApp 中实现 Markdown 流式解析与渲染
在开发 AI 对话类小程序时,经常需要将后端返回的 Markdown 格式内容实时渲染出来。微信小程序的 rich-text 组件对 HTML 节点格式有严格限制,加上流式输出(SSE/WebSocket)场景下内容是逐块到达的,这使得 Markdown 的实时解析成为一个需要特别处理的问题。
rich-text 组件的节点规范
微信小程序的 rich-text 组件接受一个 nodes 数组,每个节点有两种类型:
元素节点:
json
{
"type": "node",
"name": "p",
"attrs": { "style": "line-height: 1.5; margin: 8rpx 0;" },
"children": [...]
}文本节点:
json
{
"type": "text",
"text": "普通文本"
}需要注意的几个限制:
- 支持的 HTML 标签是白名单制的(
p/h1-h6/strong/em/code/pre/ul/ol/li/a/img/hr等) - 只支持
class和style属性,不支持id - 图片用
src,表格用colspan/rowspan,这些特殊属性按标签限定 - 内联样式用
style属性最可靠
流式解析器的核心设计
流式场景的关键难点在于:每次收到的文本片段可能不完整。比如一行内容可能分多次到达,或代码块的结束标记还没到。
解决方案是维护一个 buffer,暂存不完整的最后一行,等下次追加内容后再解析:
javascript
class UniMarkdownParser {
constructor(options = {}) {
this.options = {
baseStyles: {
paragraph: 'line-height: 1.5; margin: 8rpx 0;',
heading: 'font-weight: bold; line-height: 1.5; margin: 16rpx 0;',
list: 'margin: 8rpx 0 8rpx 32rpx;',
code: 'background-color: #f6f8fa; border-radius: 4rpx; font-family: monospace;',
link: 'color: #0366d6; text-decoration: none;',
},
headerSizes: {
h1: '28px', h2: '26px', h3: '24px',
h4: '22px', h5: '20px', h6: '18px'
},
...options
};
this.reset();
}
reset() {
this.buffer = '';
this.nodes = [];
this.currentBlock = [];
this.inCodeBlock = false;
this.codeLanguage = '';
this.inList = false;
this.listType = '';
this.listItems = [];
this.lastLineIncomplete = false;
}
}append 方法:处理增量输入
javascript
append(text) {
// 拼接到缓冲区
this.buffer += text;
// 按换行符拆分
const lines = this.buffer.split('\n');
// 最后一行如果不完整(没有以换行结束),暂存到 buffer
this.lastLineIncomplete = !text.endsWith('\n');
if (this.lastLineIncomplete) {
this.buffer = lines.pop() || '';
} else {
this.buffer = '';
}
// 解析完整的行
return this.parseLines(lines);
}finish 方法:收尾处理
javascript
finish() {
let finalNodes = [];
if (this.buffer) {
finalNodes = this.parseLines([this.buffer]);
this.buffer = '';
}
// 处理未完成的段落
if (this.currentBlock.length > 0) {
finalNodes.push(this.createParagraph(this.currentBlock.join(' ')));
this.currentBlock = [];
}
// 处理未完成的列表
if (this.inList && this.listItems.length > 0) {
finalNodes.push(this.createList(this.listItems, this.listType));
this.listItems = [];
this.inList = false;
}
return finalNodes;
}Markdown 解析逻辑
parseLines 主循环
javascript
parseLines(lines) {
const newNodes = [];
for (const line of lines) {
const trimmedLine = line.trimEnd();
// 代码块处理
if (trimmedLine.startsWith('```')) {
if (this.inCodeBlock) {
newNodes.push(this.createCodeBlock(
this.currentBlock.join('\n'), this.codeLanguage
));
this.currentBlock = [];
this.inCodeBlock = false;
this.codeLanguage = '';
continue;
} else {
this.inCodeBlock = true;
this.codeLanguage = trimmedLine.slice(3).trim();
continue;
}
}
if (this.inCodeBlock) {
this.currentBlock.push(line);
continue;
}
// 列表结束检测
if (this.inList && !this.isListItem(trimmedLine)) {
newNodes.push(this.createList(this.listItems, this.listType));
this.listItems = [];
this.inList = false;
}
// 空行 → 输出段落
if (!trimmedLine) {
if (this.currentBlock.length > 0) {
newNodes.push(this.createParagraph(this.currentBlock.join(' ')));
this.currentBlock = [];
}
continue;
}
// 标题
if (trimmedLine.startsWith('#')) {
const match = trimmedLine.match(/^(#{1,6})\s+(.+)$/);
if (match) {
if (this.currentBlock.length > 0) {
newNodes.push(this.createParagraph(this.currentBlock.join(' ')));
this.currentBlock = [];
}
newNodes.push(this.createHeader(match[2], match[1].length));
continue;
}
}
// 列表项
const listMatch = this.isListItem(trimmedLine);
if (listMatch) {
if (!this.inList) {
this.inList = true;
this.listType = listMatch.ordered ? 'ol' : 'ul';
}
this.listItems.push(this.parseInline(listMatch.content));
continue;
}
// 水平线
if (trimmedLine.match(/^[-*_]{3,}$/)) {
newNodes.push(this.createHorizontalRule());
continue;
}
// 普通文本 → 累积
this.currentBlock.push(trimmedLine);
}
return newNodes;
}行内样式解析
javascript
parseInline(text) {
// 行内代码
text = text.replace(/`([^`]+)`/g, (_, code) => {
return `<code style="${this.options.baseStyles.code}; padding: 4rpx 8rpx;">${code}</code>`;
});
// 加粗
text = text.replace(/\*\*(.+?)\*\*|__(.+?)__/g, (_, m1, m2) => {
return `<strong>${m1 || m2}</strong>`;
});
// 斜体
text = text.replace(/\*(.+?)\*|_(.+?)_/g, (_, m1, m2) => {
return `<em>${m1 || m2}</em>`;
});
// 链接
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
return `<a style="${this.options.baseStyles.link}" href="${url}">${text}</a>`;
});
return [{ type: 'text', text: text }];
}节点创建方法
javascript
createParagraph(text) {
return {
type: 'node', name: 'p',
attrs: { style: this.options.baseStyles.paragraph },
children: this.parseInline(text)
};
}
createHeader(text, level) {
return {
type: 'node', name: `h${level}`,
attrs: {
style: `${this.options.baseStyles.heading}; font-size: ${this.options.headerSizes[`h${level}`]};`
},
children: this.parseInline(text)
};
}
createCodeBlock(code, language) {
return {
type: 'node', name: 'pre',
attrs: {
style: `${this.options.baseStyles.code}; padding: 16rpx; margin: 8rpx 0; white-space: pre-wrap;`
},
children: [{ type: 'text', text: code }]
};
}
createList(items, type) {
return {
type: 'node', name: type,
attrs: { style: this.options.baseStyles.list },
children: items.map(item => ({
type: 'node', name: 'li', children: item
}))
};
}在 UniApp 页面中使用
vue
<template>
<view class="chat-container">
<scroll-view scroll-y :scroll-top="scrollTop" class="chat-content">
<block v-for="(msg, index) in messages" :key="index">
<view class="message" :class="msg.role">
<rich-text :nodes="msg.nodes"></rich-text>
</view>
</block>
</scroll-view>
</view>
</template>
<script>
import UniMarkdownParser from './markdownParser.js'
export default {
data() {
return {
messages: [],
scrollTop: 0,
parser: new UniMarkdownParser()
}
},
methods: {
// 处理流式内容
onStreamChunk(text) {
const msg = this.messages[this.messages.length - 1]
if (!msg) return
const newNodes = this.parser.append(text)
if (newNodes.length > 0) {
msg.nodes.push(...newNodes)
this.$forceUpdate()
this.scrollToBottom()
}
},
// 流式结束
onStreamEnd() {
const msg = this.messages[this.messages.length - 1]
if (!msg) return
const finalNodes = this.parser.finish()
if (finalNodes.length > 0) {
msg.nodes.push(...finalNodes)
}
this.$forceUpdate()
this.parser.reset()
},
scrollToBottom() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this)
query.select('.chat-content').boundingClientRect(data => {
if (data) this.scrollTop = data.height * 2
}).exec()
})
}
}
}
</script>WebSocket / SSE 数据源接入
javascript
// WebSocket 方式
uni.connectSocket({ url: 'wss://your-server/ws' })
uni.onSocketMessage(res => {
const data = JSON.parse(res.data)
if (data.content) {
this.onStreamChunk(data.content)
}
if (data.done) {
this.onStreamEnd()
}
})
// SSE 方式
const es = new EventSource('/api/chat/stream')
es.onmessage = (event) => {
if (event.data === '[DONE]') {
this.onStreamEnd()
es.close()
return
}
const data = JSON.parse(event.data)
if (data.content) {
this.onStreamChunk(data.content)
}
}实际开发中的注意事项
1. 代码块内容的完整性
代码块是最容易出问题的部分。开始标记 ``` 和结束标记可能在不同批次到达。解析器必须用 inCodeBlock 状态标记来正确处理。
2. 样式单位
小程序中推荐使用 rpx 作为尺寸单位,它会根据屏幕宽度自动缩放。
3. rich-text 的性能
大量节点会带来性能问题。建议单条消息的 Markdown 内容不要过长,复杂文档考虑分段加载。
4. 占位符方案处理嵌套行内元素
当需要处理复杂的嵌套行内样式(如加粗文本中包含代码)时,可以先占位符替换再二次解析。上面的简化版直接用 HTML 标签混排已经覆盖了大部分常见场景。
5. 不支持的 Markdown 特性
小程序 rich-text 不支持表格(table/tr/td)、引用块(blockquote)、任务列表(- [ ])等。如果需要这些特性,需要自己用 view 组件组合实现,而不是依赖 rich-text。
分享: