在 Taro 小程序中实现完整 Markdown 渲染器的实践
在小程序开发中,展示 Markdown 内容是一项常见需求。特别是当 Markdown 内容既包含纯文本、行内格式(加粗、斜体、链接等),又包含块级元素(图片、地图、表格、代码块、列表等)时,渲染顺序、布局和文本复制问题就变得复杂。本文分享一个完整的 Taro Markdown 渲染器实现方案。
一、功能概览
本方案实现了以下功能:
-
Markdown → AST 转换
- 使用
markdown-it
解析 Markdown,生成 Token - 转换为 AST(抽象语法树),每个节点包含类型、属性和子节点
- 使用
-
完整 Markdown 渲染
- 支持段落、标题、列表、表格、代码块、图片、Map
- 支持行内格式:加粗、斜体、链接、行内代码、软换行/硬换行
-
文本优化
- 段落内纯文本 → 单个
<Text userSelect="text">
包裹 - 整篇 Markdown 全是纯文本 → 顶层直接
<Text>
渲染 - 支持长按复制
- 段落内纯文本 → 单个
-
块级元素正确布局
- Map 自动映射为小程序
<Map>
- 图片、表格、代码块、列表按顺序渲染
- 避免显示错位
- Map 自动映射为小程序
二、实现思路
1. 将 Markdown 转为 AST
ini
import MarkdownIt from 'markdown-it';
import { mdToAST } from './mdToAST';
const md = new MarkdownIt();
const tokens = md.parse(markdownString, {});
const ast = mdToAST(tokens);
说明:
- Token → AST 转换时,保留节点类型、属性和子节点
- 支持常见 Markdown 语法,包括行内和块级元素
2. AST 渲染原则
- 块级元素 → 使用
<View>
或对应组件(如<Map>
、<Image>
)包裹 - 行内元素 → 使用
<Text>
渲染 - 段落优化 → 段落内部全是行内元素 → 用单
<Text>
,减少嵌套 - 顶层纯文本优化 → 整篇 Markdown 都是文本 → 顶层直接
<Text>
3. 判断块级和行内元素
scss
function isBlockNode(node: ASTNode) {
return [ 'map', 'image', 'code_block', 'fence', 'hr', 'heading', 'paragraph', 'bullet_list', 'ordered_list', 'list_item', 'table', 'thead', 'tbody', 'tr', 'th', 'td' ].includes(node.type);
}
function isAllTextNodes(nodes: ASTNode[]): boolean {
return nodes.every(n => !isBlockNode(n) && (!n.children || isAllTextNodes(n.children)));
}
4. AST 渲染器核心逻辑
typescript
import React from 'react';
import { Text, View, Image, Map } from '@tarojs/components';
import { ASTNode } from './mdToAST';
interface RendererProps { nodes: ASTNode[]; keyPrefix?: string; }
export const ASTRenderer: React.FC<RendererProps> = ({ nodes, keyPrefix = 'node' }) => {
if (isAllTextNodes(nodes)) {
// 顶层纯文本
return <Text userSelect="text">{nodes.map((n, i) => renderInlineNode(n, `${keyPrefix}-${i}`))}</Text>;
}
return <>{nodes.map((n, i) => renderNode(n, `${keyPrefix}-${i}`))}</>;
};
function renderNode(node: ASTNode, key: string): React.ReactNode {
switch (node.type) {
case 'map':
return <Map key={key} latitude={+node.attrs?.latitude} longitude={+node.attrs?.longitude} style={{ width: '100%', height: 200 }} />;
case 'image':
return <Image key={key} src={node.attrs?.src} style={{ width: 200, height: 200 }} />;
case 'paragraph':
if (node.children.every(c => !isBlockNode(c))) {
return <Text key={key} userSelect="text">{node.children.map((c,i) => renderInlineNode(c, `${key}-${i}`))}</Text>;
} else {
return <View key={key}>{node.children.map((c,i) => isBlockNode(c) ? renderNode(c, `${key}-${i}`) : renderInlineNode(c, `${key}-${i}`))}</View>;
}
default:
return node.text ? <Text key={key}>{node.text}</Text> : null;
}
}
function renderInlineNode(node: ASTNode, key: string): React.ReactNode {
switch(node.type){
case 'text': return <Text key={key}>{node.text}</Text>;
case 'strong': return <Text key={key} style={{ fontWeight: 'bold' }}>{node.children?.map((c,i)=>renderInlineNode(c, `${key}-${i}`))}</Text>;
case 'em': return <Text key={key} style={{ fontStyle:'italic' }}>{node.children?.map((c,i)=>renderInlineNode(c, `${key}-${i}`))}</Text>;
case 'softbreak':
case 'hardbreak': return <Text key={key}>{'\n'}</Text>;
default: return node.text ? <Text key={key}>{node.text}</Text> : null;
}
}
5. 核心亮点
-
顶层纯文本优化
- 整篇 Markdown 直接
<Text>
渲染,轻量且可复制
- 整篇 Markdown 直接
-
段落内部纯文本优化
- 减少
<View>
包裹,段落轻量化
- 减少
-
块级元素顺序保持
- Map、图片、表格、列表、代码块按 AST 顺序渲染
- 避免显示错位
-
支持文本长按复制
- 使用
userSelect="text"
替代selectable
- 使用
-
避免 React #130 错误
- 顶层渲染逻辑合理,段落和文本分离
六、使用示例
ini
const markdownAST = mdToAST(md.parse(markdownString, {}));
<ASTRenderer nodes={markdownAST} />
- 整篇纯文本 → 顶层
<Text>
- 段落内含 Map / 图片 →
<View>
按顺序渲染 - 文本可长按复制
- Map 自动渲染为小程序 Map 组件
七、总结
通过将 Markdown 转为 AST,再递归渲染为 Taro 组件,本方案实现了:
- 完整 Markdown 支持(文本、行内格式、块级元素)
- 纯文本段落与整篇文本优化
- 块级元素顺序保持,避免错位
- 小程序文本可长按复制
这是一套既通用又实用的 Markdown 渲染方案,非常适合在 Taro 小程序中使用。