React + MarkdownIt 完整 Markdown 渲染器(支持自定义 Map + 长按复制)
在跨端应用中(小程序、H5、React Native),我们经常需要将 Markdown 文本渲染为页面内容。如果要支持图片、列表、引用、自定义 Map 标签以及长按复制文本,需求会比较复杂。本文将带你实现一个完整的 Markdown 渲染器,解析 Markdown 文本 → 生成 AST → 渲染 React 组件,并支持自定义 Map 与文本复制。
一、功能实现
该渲染器实现功能如下:
-
支持 Markdown 基础语法:
- 段落(paragraph)
- 标题(heading)
- 引用(blockquote)
- 有序列表(ordered_list)
- 无序列表(bullet_list)
- 列表项(list_item)
- 行内样式:加粗(strong)、斜体(em)、删除线(del)、标记(mark)、行内代码(code_inline)
- 链接(link)和换行(break)
-
支持图片(image)
-
支持自定义
<map>标签(block 或 inline) -
支持长按复制文本
-
样式可配置化,支持主题定制
-
使用 AST 渲染,方便扩展与维护
二、工具函数和基础类型
// 判断节点是否为行内类型
function isInline(type: string) {
return [
"text", "strong", "em", "del", "mark", "code_inline", "link", "break",
].includes(type);
}
// 生成唯一节点 ID
let nodeIdCounter = 0;
function genNodeId() {
return `node-${nodeIdCounter++}`;
}
// AST 节点类型
export type ASTNode = {
id: string;
type: string;
tag?: string;
attrs?: Record<string, any>;
text?: string;
children?: ASTNode[];
};
算法逻辑:
isInline用于判断节点类型是否是行内元素,决定后续渲染时是否需要包装genNodeId保证 AST 节点唯一性ASTNode类型定义,包含节点类型、属性、子节点等,用于统一渲染
三、MarkdownIt Map 插件
function markdownItMapPlugin(md: MarkdownIt) {
// Block 形式 <map ...></map>
md.block.ruler.before("html_block", "map_block", (state, startLine, endLine, silent) => {
const line = state.src.slice(state.bMarks[startLine], state.eMarks[startLine]).trim();
const match = line.match(/^<map\b([^>]*)>(.*?)</map>/i);
if (!match) return false;
if (!silent) {
const token = state.push("map_block", "map", 0);
token.block = true;
token.content = line;
}
state.line = startLine + 1;
return true;
});
// Inline 形式 <map .../>
md.inline.ruler.before("html_inline", "map_inline", (state, silent) => {
const src = state.src.slice(state.pos);
const match = src.match(/^<map\b([^>]*)>(.*?)</map>/i);
if (!match) return false;
if (!silent) {
const token = state.push("map_inline", "map", 0);
token.content = match[0];
}
state.pos += match[0].length;
});
}
算法逻辑:
- 拦截 Markdown 中
<map>标签,生成自定义 Token - 支持 block 和 inline 两种形式
- 解析完成后,Token 会被
mdToAST转换为 AST 节点
四、属性解析函数
function parseAttrs(attrStr: string) {
const attrs: Record<string, string> = {};
const regex = /(\w+)\s*=\s*(['"])(.*?)\2/g;
let match;
while ((match = regex.exec(attrStr))) {
attrs[match[1]] = match[3];
}
return attrs;
}
function attrsToMap(attrs?: [string, string][]): Record<string, any> | undefined {
if (!attrs) return undefined;
const map: Record<string, any> = {};
for (const [k, v] of attrs) map[k] = v;
return map;
}
算法逻辑:
- 将
<map latitude="xx" longitude="yy" name="zzz">的字符串属性解析成对象 attrsToMap将 MarkdownIt Token 的[key, value][]属性数组转对象,方便后续渲染
五、AST 生成
1. 内联节点转换
function inlineTokensToAST(tokens: any[]): ASTNode[] {
const result: ASTNode[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === "text") result.push({ id: genNodeId(), type: "text", text: token.content });
else if (token.type === "softbreak" || token.type === "hardbreak") result.push({ id: genNodeId(), type: "break" });
else if (token.type.endsWith("_open") && isInline(token.type.replace(/_open$/, ""))) {
let childrenTokens: any[] = [];
let level = 1;
i++;
while (i < tokens.length && level > 0) {
const t = tokens[i];
if (t.type === token.type) level++;
else if (t.type === token.type.replace("_open", "_close")) level--;
if (level > 0) childrenTokens.push(t);
i++;
}
i--;
result.push({ id: genNodeId(), type: token.type.replace(/_open$/, ""), children: inlineTokensToAST(childrenTokens) });
} else if (token.type === "link_open") {
let childrenTokens: any[] = [];
let level = 1;
i++;
while (i < tokens.length && level > 0) {
const t = tokens[i];
if (t.type === "link_open") level++;
else if (t.type === "link_close") level--;
if (level > 0) childrenTokens.push(t);
i++;
}
i--;
result.push({ id: genNodeId(), type: "link", attrs: attrsToMap(token.attrs), children: inlineTokensToAST(childrenTokens) });
} else if (token.type === "image") result.push({ id: genNodeId(), type: "image", attrs: attrsToMap(token.attrs) });
else if (token.type === "map_inline") {
const attrMatch = token.content.match(/<map\s+([\s\S]*?)(?:/?>|>)/i);
const attrs = attrMatch ? parseAttrs(attrMatch[1]) : {};
result.push({ id: genNodeId(), type: "map", attrs });
}
}
return result;
}
算法逻辑:
- 递归解析行内 Token
- 支持文本、换行、加粗、斜体、删除线、标记、行内代码、链接、图片和 Map
- 保持节点层级关系,用于渲染
2. 包装连续内联节点
function wrapInlineNodes(nodes: ASTNode[]): ASTNode[] {
const result: ASTNode[] = [];
let buffer: ASTNode[] = [];
const flush = () => {
if (buffer.length) { result.push({ id: genNodeId(), type: "inline", children: buffer }); buffer = []; }
};
for (const node of nodes) {
if (isInline(node.type)) buffer.push(node);
else { flush(); result.push(node); }
}
flush();
return result;
}
算法逻辑:
- 将连续的行内节点合并为一个
inline节点 - 减少渲染层级,提高性能
3. mdToAST 主函数
export function mdToAST(markdown: string): ASTNode[] {
nodeIdCounter = 0;
const md = new MarkdownIt({ html: true, breaks: true }).enable("table");
md.use(markdownItMapPlugin);
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") {
const attrs = parseAttrs(token.content.match(/<map\s+([\s\S]*?)(?:/?>|>)/i)?.[1] || "");
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 || [];
}
算法逻辑:
- 使用 MarkdownIt 解析文本 → Token
- Token 按顺序转换为 AST
_open/_close维护嵌套结构- 内联节点递归处理
- Map 与图片特殊处理
六、AST 渲染(带长按复制)
1. 内联节点渲染
function renderInlineNode(node: ASTNode, styleConfig: MarkdownStyleConfig, parentStyle: React.CSSProperties = {}) {
let style: React.CSSProperties = { ...parentStyle };
switch (node.type) {
case "strong": style = { ...style, ...styleConfig.strong }; break;
case "em": style = { ...style, ...styleConfig.em }; break;
case "del": style = { ...style, ...styleConfig.del }; break;
case "mark": style = { ...style, ...styleConfig.mark }; break;
case "code_inline": style = { ...style, ...styleConfig.codeInline }; break;
}
if (node.type === "text") return <Text key={node.id} userSelect style={{ ...styleConfig.inlineText, ...style }}>{node.text}</Text>;
if (node.type === "break") return <Text key={node.id} />;
if (node.children) return <>{node.children.map(c => renderInlineNode(c, styleConfig, style))}</>;
return null;
}
算法逻辑:
- 递归渲染行内节点
- 根据节点类型叠加样式
userSelect实现长按复制
2. 段落、列表渲染
<Text key={node.id} userSelect style={styleConfig.paragraph}>
{node.children?.map(c => renderInlineNode(c, styleConfig))}
</Text>
列表项:
<Text key={child.id} userSelect style={styleConfig.paragraph}>
{prefix}
{child.children?.map(cc => renderInlineNode(cc, styleConfig))}
</Text>
算法逻辑:
- 根据 AST 节点类型渲染对应组件
- 段落、标题、列表项、行内节点统一支持长按复制
3. Map 渲染
const MapNode = ({ node, styleConfig }) => {
if(!node.attrs?.latitude || !node.attrs?.longitude) return null;
const latitude = parseFloat(node.attrs.latitude);
const longitude = parseFloat(node.attrs.longitude);
const title = node.attrs.name || "位置";
const markers = [{ id: Number(node.id.replace(/\D/g,"")), latitude, longitude, width:20, height:26 }];
const handleTap = () => wx.openLocation({ latitude, longitude, name: title, scale: 18 });
return (
<View key={node.id} style={styleConfig.mapContainer}>
<View style={styleConfig.mapHeader}>
<Image src="https://img-ys011.didistatic.com/static/picplace_app_imgs/map-address.png" style={{ width:14,height:16,marginRight:8 }}/>
<Text style={styleConfig.mapTitle} numberOfLines={1}>{title}</Text>
</View>
<View style={styleConfig.mapImageWrapper}>
<Map latitude={latitude} longitude={longitude} scale={14} markers={markers} enableScroll={false} enableZoom={false} style={{ width:"100%", height:"100%" }} onTap={handleTap}/>
</View>
</View>
);
};
算法逻辑:
- 将 Map AST 节点渲染为小程序地图组件
- 设置标记点 (
markers) - 点击地图跳转小程序原生地图
- 支持 block/inline Map
七、MarkdownRenderer 组件
export default function MarkdownRenderer({ source, styleConfig = defaultMarkdownStyle }) {
const normalizedSource = source.replace(/↵/g, "\n");
const ast = React.useMemo(() => mdToAST(normalizedSource), [normalizedSource]);
return <>{ast.map(node => renderNode(node, styleConfig))}</>;
}
算法逻辑:
- 归一化输入文本
- 生成 AST(只在 source 改变时重新生成)
- 渲染 AST 到 React 组件树
八、总结
- Markdown → Token → AST → React 组件
- 支持图片、列表、行内样式、Map
- 长按复制通过
Text userSelect实现 - 样式可配置化,便于主题定制
- 使用 AST 渲染逻辑清晰,易于扩展