掘金文章归档 · 第 3 篇 react-markdown 系列 2/4 React / JavaScript

基于 react-markdown 实现对大模型输出展示(二):自定义标签及事件触发

正则把约定格式转成 sup 上标 → 借助 rehype-raw 渲染 → 用 components 自定义渲染器接管点击事件

原文作者:随意_(掘金) | juejin.cn/post/7478881352694726690 | 发布于 2025-03-07 | 阅读 1,611 · 约 4 分钟

0背景:上一篇文章打的地基

上一篇文章我们基于 react-markdown 并引用 remark-gfm、rehype-raw、github-markdown-css 来处理复杂 md 格式、处理 html 标签和美化样式。本篇章我们来实现自定义标签和事件触发

📌 上一篇是"能渲染、能美化、能放行 HTML",这一篇是"让 Markdown 里长出你自己定义的富组件,并且能点"。

1场景:引用上标怎么来的

举例:例如后端跟我们约定,他们流式返回数据,我们接到后拼装展示,但是有一些特殊标识,比如说引用,类似于参考文献,一般是右上角上标,点击会跳转详情或者其他操作。

这里我们就需要:自定义标签 + 触发事件。暂定我们约定后端返回的数据特殊格式如下,第一个是引用的顺序,第二个是引用的 id:

[[1,'引用1的id']]

我们需要判定此格式:

  1. 转为 <sup> 标签,并自定义样式;
  2. 点击的时候,需要将 id 传过去,触发事件。

2实现方案

2.1 原始效果及展示:不处理就按字符串展示

以上规则,demo 代码及展示如下:

import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import "github-markdown-css"

const richMarkdownContent = `
# 一级标题:Markdown 丰富示例 [[1,'我是1的id']]

## 二级标题:Markdown 丰富示例 [[1,'我是1的id']]

### 三级标题:Markdown 丰富示例 [[1,'我是1的id']]
`;

const App = () => {
  return (
    <div className="markdown-body">
      <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
        {richMarkdownContent}
      </ReactMarkdown>
    </div>
  );
};

export default App;
⚠️ 如果我们不处理,[[1,'我是1的id']] 则按照普通字符串展示。下面我们按需截取特殊约定字符。
截图位置请对照原文:标题后面原样显示 [[1,'我是1的id']] 字符串。

2.2 正则截取:把约定格式替换成带 data 属性的 sup 标签

正则截取,我们将顺序回显id 作为自定义属性添加到元素上,借助 rehype-raw 渲染出来自定义的标签。注意自定义属性 data-supid

import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import "github-markdown-css"

const richMarkdownContent = `
# 一级标题:Markdown 丰富示例 [[1,'我是1的id']]

## 二级标题:Markdown 丰富示例 [[1,'我是1的id']]

### 三级标题:Markdown 丰富示例 [[1,'我是1的id']]
`;

const replaceReferences = (str) => {
  // 定义正则表达式
  const regex = /\[\[(\d+),'(.*?)'\]\]/g;

  // 使用 replace 方法进行全局替换
  return str.replace(regex, (match, num, id) => {
      return `<sup className="text-active cursor-pointer" data-supid="${id}">[${num}]</sup>`;
  });
}

const regStr = replaceReferences(richMarkdownContent)

const App = () => {
  return (
    <div className="markdown-body">
      <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
        {regStr}
      </ReactMarkdown>
    </div>
  );
};

export default App;
📌 核心机制:正则替换生成"带自定义属性的 HTML 字符串" → 因为开着 rehype-raw,这个 HTML 被真正渲染成 DOM。data-supid 是自定义属性,React 也能透传(注意 React 中要用驼峰 className 而非 class)。
截图位置请对照原文:标题后面的引用已变成上标 [1] 样式,不再显示原始字符串。

2.3 美化样式:和普通 HTML 一样用 Tailwind

对于创建的元素,可以使用 tailwindcss 修改样式,或者其他自定义样式均可,和普通 html 样式无异:

const replaceReferences = (str) => {
  // 定义正则表达式
  const regex = /\[\[(\d+),'(.*?)'\]\]/g;

  // 使用 replace 方法进行全局替换
  return str.replace(regex, (match, num, id) => {
      return `<sup className="text-blue-600 cursor-pointer" data-supid="${id}">[${num}]</sup>`;
  });
}
✅ 注意 text-blue-600 颜色设置,上标现在变成蓝色、可点击外观。
截图位置请对照原文:上标 [1] 变成蓝色(text-blue-600)效果。

2.4 自定义事件触发:用 components 接管 sup 渲染

借助 react-markdown 的 components 来自定义标签:

import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import "github-markdown-css"

const richMarkdownContent = `
# 一级标题:Markdown 丰富示例 [[1,'我是1的id']]

## 二级标题:Markdown 丰富示例 [[2,'我是2的id']]

### 三级标题:Markdown 丰富示例 [[3,'我是3的id']]
`;

const replaceReferences = (str) => {
  // 定义正则表达式
  const regex = /\[\[(\d+),'(.*?)'\]\]/g;

  // 使用 replace 方法进行全局替换
  return str.replace(regex, (match, num, id) => {
    return `<sup className="text-blue-600 cursor-pointer" data-supid="${id}">[${num}]</sup>`;
  });
}

const regStr = replaceReferences(richMarkdownContent)

// 自定义渲染器
const components = {
  sup: ({ children, ...rest }) => {
    return (
      <sup className="text-active" onClick={(event) => handleSupClick(event)} {...rest}>
        {children}
      </sup>
    );
  },
};

// 点击事件处理函数
const handleSupClick = (event) => {
  const supid = event.target.dataset.supid;
  console.log("Clicked sup data-supid:", supid);
  // 你可以在这里进行其他操作,比如将内容传递给父组件等
};

const App = () => {
  return (
    <div className="markdown-body">
      <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={components}>
        {regStr}
      </ReactMarkdown>
    </div>
  );
};

export default App;

两个关键点:

这样,当你点击 sup 元素时,就可以获取到自定义属性 data-supid 的值了。

⚠️ 注意顺序问题:React 的 onClick 会先执行,然后 ...rest 把 data-supid 透传到 DOM 上,所以事件里通过 dataset 一定能读到。
截图位置请对照原文:点击上标 [1],控制台打印 Clicked sup data-supid: 我是1的id。

3总结:自定义节点的通用套路

这篇文章我们基于自定义的 sup 标签,借助自定义 components,结合正则来实现了文章前期的需求。可以发现功能很强大,对于多数其他标签有更特殊的需求,可以自己来实现。一家之言,有需要的按需取舍(原文可下载项目,dev2 分支)。

步骤做什么用到的能力
1后端约定特殊格式(如 [[序号,'id']])协议约定
2正则把约定格式替换成带自定义属性的 HTMLString.replace + 正则捕获组
3让这段 HTML 真正渲染出来rehype-raw(放行原生 HTML)
4自定义样式Tailwind 类名(text-blue-600 等)
5接管该标签的渲染与点击components 自定义渲染器 + dataset 取自定义属性
📌 通用套路:约定格式 → 正则替换成 HTML → rehype-raw 渲染 → components 接管。这个套路可以用在任意自定义标签上(高亮、气泡、按钮、卡片……)。

4术语表 & 附录:完整可运行代码

术语表

术语定义
componentsreact-markdown 的组件映射对象,key 是标签名(如 sup),value 是自定义渲染组件
rehype-raw允许 markdown 中的原始 HTML 被解析渲染,本方案的"HTML 落地"依赖它
data-* 属性HTML5 自定义属性,可存任意字符串,前端通过 dataset 读取
event.target.dataset读取元素上所有 data-* 自定义属性的 DOM API
正则捕获组正则中 () 分组匹配的内容,replace 回调里依次作为参数传入
...restJS 扩展运算符,把剩余 props 透传给子元素,保证 data-supid 等属性不丢

附录 A:完整可运行代码(自定义 sup 上标 + 点击事件)

// ========== 安装依赖 ==========
// 基于上一篇:react-markdown + remark-gfm + rehype-raw + github-markdown-css

import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import "github-markdown-css";

// 1. 后端约定的特殊格式:[[序号,'引用id']]
const richMarkdownContent = `
# 一级标题:Markdown 丰富示例 [[1,'我是1的id']]

## 二级标题:Markdown 丰富示例 [[2,'我是2的id']]

### 三级标题:Markdown 丰富示例 [[3,'我是3的id']]
`;

// 2. 正则替换:把约定格式转成带 data-supid 的 sup 标签
const replaceReferences = (str) => {
  const regex = /\[\[(\d+),'(.*?)'\]\]/g;
  return str.replace(regex, (match, num, id) => {
    return `<sup className="text-blue-600 cursor-pointer" data-supid="${id}">[${num}]</sup>`;
  });
};

const regStr = replaceReferences(richMarkdownContent);

// 3. components 接管 sup 的渲染:挂 onClick,透传 ...rest
const components = {
  sup: ({ children, ...rest }) => {
    return (
      <sup className="text-active" onClick={(event) => handleSupClick(event)} {...rest}>
        {children}
      </sup>
    );
  },
};

// 4. 点击时通过 dataset 取出自定义属性
const handleSupClick = (event) => {
  const supid = event.target.dataset.supid;
  console.log("Clicked sup data-supid:", supid);
  // 可以在这里把 id 传给父组件 / 跳转详情等
};

const App = () => {
  return (
    <div className="markdown-body">
      <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={components}>
        {regStr}
      </ReactMarkdown>
    </div>
  );
};

export default App;

系列预告

未完待续……下一篇(系列 3)我们学习如何渲染 echarts 等报表,由浅入深;第 4 篇输出代码及高亮展示。