ByteNoteByteNote

字节笔记本

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 等)
  • 只支持 classstyle 属性,不支持 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

分享: