基于 React + MarkdownIt 的 Markdown 渲染器实践:支持地图标签和长按复制

基于 React + MarkdownIt 的 Markdown 渲染器实践:支持地图标签和长按复制

在 React 小程序中,Markdown 内容不能直接渲染成组件。本文分享一个 基于 React + MarkdownIt 的 Markdown 渲染器实践,支持自定义样式、地图标签、列表渲染,以及长按复制功能。


功能概览

实现的核心功能如下:

  1. Markdown 渲染

    支持标题、段落、引用、列表、内联样式(加粗、斜体、删除线、标记、行内代码)、图片等。

  2. 自定义样式

    通过 MarkdownStyleConfig 定义段落、标题、列表、内联文本、图片和地图样式,实现灵活排版。

  3. 地图标签解析

    支持 <map latitude="xx" longitude="yy" name="位置" /> 标签。

    MarkdownIt 默认不识别 <map>,所以通过自定义插件生成 token,再在 AST 渲染阶段生成 Map 组件。

  4. 长按复制

    微信小程序中 <Text> 支持 userSelect 长按复制,但内联 <Text> 嵌套 Image/Map 会导致样式错乱。

    解决方式:

    • AST 构建阶段:内联和块级内容分开,Image/Map 不嵌套在 Text 内。
    • 渲染阶段 :尽量用 Text 包裹内联内容,在最外层加 userSelect

MarkdownIt 和 Token

MarkdownIt 是高性能可扩展 Markdown 解析器,特点:

  • 输出 token,方便二次处理
  • 支持插件扩展
  • 支持 HTML 标签

Token 类型

MarkdownIt 输出 token,主要分为:

  • 块级元素(Block Tokens)
    paragraphheadingblockquotebullet_listordered_listlist_item
  • 行内元素(Inline Tokens)
    textstrongemdelmarkcode_inlinelinkimagesoftbreakhardbreak
    自定义插件生成的 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_blockmap_inline token,并保留属性用于 AST 构建。


Token → AST 核心算法

将 MarkdownIt token 转成 AST 的核心目标:

  1. 保留块级元素嵌套关系
  2. 支持内联多层嵌套(strong、em、del、mark、code_inline、link)
  3. 分离块级节点和 Image/Map,保证长按复制不乱

内联 Token 转 AST

arduino 复制代码
function inlineTokensToAST(tokens: any[]): ASTNode[] { ... }

逻辑

  • 遍历内联 token:

    • text → text 节点
    • softbreak / hardbreak → break 节点
    • 内联样式 _open / _close → 递归解析子 token
    • link_open → 解析链接子 token
    • image → image 节点
    • map_inline → map 节点
  • 递归处理嵌套,支持任意深度的内联样式


包装连续内联节点

matlab 复制代码
function wrapInlineNodes(nodes: ASTNode[]): ASTNode[] { ... }

逻辑

  • 初始化 buffer 存放连续内联节点

  • 遇到块级节点 / Image / Map:

    • flush buffer,把连续内联节点合并成一个 inline 节点
    • 块级节点直接加入结果数组
  • 避免内联节点与块级节点混合,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 || [];
}

核心设计思想

  1. stack 管理块级嵌套_open 入栈,_close 出栈
  2. 内联节点包装 :连续内联节点合并成 inline 节点,阻止 Image/Map 嵌套在 Text 内
  3. Map/Image 独立节点:保证渲染和复制不会乱
  4. 唯一 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>
  );
};

总结

这套渲染器特点:

  1. Markdown 全量渲染:段落、标题、列表、内联样式、图片、地图
  2. 长按复制优化 :AST 构建阶段分离内联与块级元素,渲染阶段最外层 Text 加 userSelect
  3. 地图标签支持 :自定义 MarkdownIt 插件解析 <map>,生成小程序 Map 组件
  4. 自定义样式 :通过 MarkdownStyleConfig 灵活控制渲染效果

逻辑清晰、可扩展性强,适合小程序富文本展示和交互场景。

相关推荐
源猿人2 小时前
企业级文件浏览系统的Vue实现:架构设计与最佳实践
前端·javascript·数据可视化
红红大虾2 小时前
Defold引擎中关于CollectionProxy的使用
前端·游戏开发
最后一个农民工2 小时前
vue3实现仿豆包模版式智能输入框
前端·vue.js
xw52 小时前
uni-app中v-if使用”异常”
前端·uni-app
!win !3 小时前
uni-app中v-if使用”异常”
前端·uni-app
IT_陈寒3 小时前
Java 性能优化:5个被低估的JVM参数让你的应用吞吐量提升50%
前端·人工智能·后端
南囝coding3 小时前
《独立开发者精选工具》第 018 期
前端·后端
小桥风满袖4 小时前
极简三分钟ES6 - ES9中for await of
前端·javascript