React + MarkdownIt 完整 Markdown 渲染器(支持自定义 Map + 长按复制)

39 阅读3分钟

React + MarkdownIt 完整 Markdown 渲染器(支持自定义 Map + 长按复制)

在跨端应用中(小程序、H5、React Native),我们经常需要将 Markdown 文本渲染为页面内容。如果要支持图片、列表、引用、自定义 Map 标签以及长按复制文本,需求会比较复杂。本文将带你实现一个完整的 Markdown 渲染器,解析 Markdown 文本 → 生成 AST → 渲染 React 组件,并支持自定义 Map 与文本复制。


一、功能实现

该渲染器实现功能如下:

  1. 支持 Markdown 基础语法:

    • 段落(paragraph)
    • 标题(heading)
    • 引用(blockquote)
    • 有序列表(ordered_list)
    • 无序列表(bullet_list)
    • 列表项(list_item)
    • 行内样式:加粗(strong)、斜体(em)、删除线(del)、标记(mark)、行内代码(code_inline)
    • 链接(link)和换行(break)
  2. 支持图片(image)

  3. 支持自定义 <map> 标签(block 或 inline)

  4. 支持长按复制文本

  5. 样式可配置化,支持主题定制

  6. 使用 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 渲染逻辑清晰,易于扩展