基于 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)
    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 的核心目标:

  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 灵活控制渲染效果

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

相关推荐
帆张芳显几秒前
前端表格公式能力新选择|智表 ZCell 公式引擎深度剖析
前端·excel·canva可画·excel插件
清水迎朝阳1 分钟前
Edge 在线语音 403 问题修复
前端·edge
一碗白开水一9 分钟前
Day 5 · 弹性 | 微观
java·前端·数据库
Lsx_17 分钟前
微信支付接入全流程:JSAPI支付、小程序支付与H5支付
前端·javascript·全栈
OpenTiny社区34 分钟前
React 正式登场!GenUI SDK v1.4.0 三框架渲染、出码与 Agent 生态重磅升级~
前端·ai编程
试图让你心动44 分钟前
【无标题】
前端·javascript·vue.js
Hilaku1 小时前
为什么每个前端都应该拥有一个自己的独立博客?
前端·javascript·程序员
燕落南枝1 小时前
CSS 后代选择器与子代选择器:从入门到实战
前端·css
Amos_Web1 小时前
Rspack 源码解析(二十八):HtmlRspackPlugin 如何把模板变成带资源标签的最终产物
前端
马可家的菠萝1 小时前
个人知识库怎么建?从一个正在学的技术主题开始
前端·笔记·知识管理·个人知识库