基于 React + MarkdownIt 的 Markdown 渲染器实践:支持地图标签和长按复制
在 React 小程序中,Markdown 内容不能直接渲染成组件。本文分享一个 基于 React + MarkdownIt 的 Markdown 渲染器实践,支持自定义样式、地图标签、列表渲染,以及长按复制功能。
功能概览
实现的核心功能如下:
-
Markdown 渲染
支持标题、段落、引用、列表、内联样式(加粗、斜体、删除线、标记、行内代码)、图片等。
-
自定义样式
通过
MarkdownStyleConfig
定义段落、标题、列表、内联文本、图片和地图样式,实现灵活排版。 -
地图标签解析
支持
<map latitude="xx" longitude="yy" name="位置" />
标签。MarkdownIt 默认不识别
<map>
,所以通过自定义插件生成 token,再在 AST 渲染阶段生成 Map 组件。 -
长按复制
微信小程序中
<Text>
支持userSelect
长按复制,但内联<Text>
嵌套 Image/Map 会导致样式错乱。解决方式:
- AST 构建阶段:内联和块级内容分开,Image/Map 不嵌套在 Text 内。
- 渲染阶段 :尽量用 Text 包裹内联内容,在最外层加
userSelect
。
MarkdownIt 和 Token
MarkdownIt 是高性能可扩展 Markdown 解析器,特点:
- 输出 token,方便二次处理
- 支持插件扩展
- 支持 HTML 标签
Token 类型
MarkdownIt 输出 token,主要分为:
- 块级元素(Block Tokens)
paragraph
、heading
、blockquote
、bullet_list
、ordered_list
、list_item
等 - 行内元素(Inline Tokens)
text
、strong
、em
、del
、mark
、code_inline
、link
、image
、softbreak
、hardbreak
自定义插件生成的map_inline
内联元素通过 isInline(type)
判断。
解析 <map>
标签
MarkdownIt 默认不识别 <map>
标签,我们通过插件生成 token:
javascript
function markdownItMapPlugin(md: MarkdownIt) {
md.block.ruler.before("html_block", "map_block", (state, startLine, endLine, silent) => { ... });
md.inline.ruler.before("html_inline", "map_inline", (state, silent) => { ... });
}
插件会匹配 <map ...>
,生成 map_block
或 map_inline
token,并保留属性用于 AST 构建。
Token → AST 核心算法
将 MarkdownIt token 转成 AST 的核心目标:
- 保留块级元素嵌套关系
- 支持内联多层嵌套(strong、em、del、mark、code_inline、link)
- 分离块级节点和 Image/Map,保证长按复制不乱
内联 Token 转 AST
arduino
function inlineTokensToAST(tokens: any[]): ASTNode[] { ... }
逻辑:
-
遍历内联 token:
text
→ text 节点softbreak
/hardbreak
→ break 节点- 内联样式
_open
/_close
→ 递归解析子 token link_open
→ 解析链接子 tokenimage
→ image 节点map_inline
→ map 节点
-
递归处理嵌套,支持任意深度的内联样式
包装连续内联节点
matlab
function wrapInlineNodes(nodes: ASTNode[]): ASTNode[] { ... }
逻辑:
-
初始化 buffer 存放连续内联节点
-
遇到块级节点 / Image / Map:
- flush buffer,把连续内联节点合并成一个
inline
节点 - 块级节点直接加入结果数组
- flush buffer,把连续内联节点合并成一个
-
避免内联节点与块级节点混合,Image/Map 不在 Text 内
✅ 这样做的好处:
- 渲染阶段可以安全地在最外层
<Text>
添加userSelect
,支持长按复制 - 保持内联节点样式继承
Tokens → AST 核心流程
ini
export function mdToAST(markdown: string): ASTNode[] {
const tokens = md.parse(markdown, {});
const root: ASTNode = { id: genNodeId(), type: "root", children: [] };
const stack: ASTNode[] = [root];
for (const token of tokens) {
const parent = stack[stack.length - 1];
if (token.type.endsWith("_open")) {
// 块级节点开始
const node: ASTNode = {
id: genNodeId(),
type: token.type.replace(/_open$/, ""),
tag: token.tag,
attrs: attrsToMap(token.attrs),
children: [],
};
parent.children!.push(node);
stack.push(node);
} else if (token.type.endsWith("_close")) {
// 块级节点结束
stack.pop();
} else if (token.type === "inline") {
// 内联节点递归解析
parent.children!.push(...wrapInlineNodes(inlineTokensToAST(token.children || [])));
} else if (token.type === "map_block" || token.type === "map_inline") {
// Map 节点独立处理
const attrs = parseAttrsFromToken(token);
parent.children!.push({ id: genNodeId(), type: "map", attrs });
} else if (token.type === "image") {
parent.children!.push({ id: genNodeId(), type: "image", attrs: attrsToMap(token.attrs) });
} else if (token.type === "text") {
parent.children!.push({ id: genNodeId(), type: "text", text: token.content });
} else if (token.type === "softbreak" || token.type === "hardbreak") {
parent.children!.push({ id: genNodeId(), type: "break" });
}
}
return root.children || [];
}
核心设计思想
- stack 管理块级嵌套 :
_open
入栈,_close
出栈 - 内联节点包装 :连续内联节点合并成
inline
节点,阻止 Image/Map 嵌套在 Text 内 - Map/Image 独立节点:保证渲染和复制不会乱
- 唯一 ID :每个 AST 节点生成唯一
id
,React 渲染时安全使用key
AST 渲染
javascript
function renderNode(node: ASTNode, styleConfig: MarkdownStyleConfig) {
switch (node.type) {
case "paragraph":
case "heading":
return (
<Text key={node.id} userSelect style={styleConfig[node.type]}>
{node.children?.map(c => renderInlineNode(c, styleConfig))}
</Text>
);
case "bullet_list":
return <View key={node.id}>{node.children?.map((c, i) => renderListItem(c, styleConfig, i + 1, "bullet"))}</View>;
case "ordered_list":
return <View key={node.id}>{node.children?.map((c, i) => renderListItem(c, styleConfig, i + 1, "ordered"))}</View>;
case "image":
return <Image key={node.id} src={node.attrs?.src} style={styleConfig.image} />;
case "map":
return <MapNode node={node} styleConfig={styleConfig} />;
default:
return null;
}
}
MapNode 核心实现
ini
const MapNode = ({ node, styleConfig }: { node: ASTNode; styleConfig: MarkdownStyleConfig }) => {
const latitude = parseFloat(node.attrs?.latitude);
const longitude = parseFloat(node.attrs?.longitude);
const markers = [{ id: 0, latitude, longitude, width: 20, height: 26 }];
const handleTap = () => {
wx.openLocation({ latitude, longitude, name: node.attrs?.name, scale: 18 });
};
return (
<View key={node.id} style={styleConfig.mapContainer}>
<Text style={styleConfig.mapTitle}>{node.attrs?.name}</Text>
<Map
latitude={latitude}
longitude={longitude}
markers={markers}
enableScroll={false}
enableZoom={false}
style={{ width: "100%", height: "100%" }}
onTap={handleTap}
/>
</View>
);
};
总结
这套渲染器特点:
- Markdown 全量渲染:段落、标题、列表、内联样式、图片、地图
- 长按复制优化 :AST 构建阶段分离内联与块级元素,渲染阶段最外层 Text 加
userSelect
- 地图标签支持 :自定义 MarkdownIt 插件解析
<map>
,生成小程序 Map 组件 - 自定义样式 :通过
MarkdownStyleConfig
灵活控制渲染效果
逻辑清晰、可扩展性强,适合小程序富文本展示和交互场景。