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

基于 react-markdown 实现对大模型输出展示(三):输出自定义的 echarts 报表

Markdown 里直接放 echarts 容器 div → rehype-raw 渲染成 DOM → useEffect 里 echarts.init 画图

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

0背景:从自定义标签到图表

上一篇文章我们学习了如何借助自定义 components 来实现自定义的标签和事件,这篇文章我们更进一步,学习下如何渲染 echarts 等报表,由浅入深。

📌 思路一句话:既然 rehype-raw 能把 md 里的原生 HTML 渲染成真实 DOM,那我们就能在 md 里"埋"一个 echarts 容器 div,再在 React 的 useEffect 里把它初始化成图表。

1渲染一个报表:md 里嵌容器 + useEffect 画图

直接给出代码了,相信能看懂,下面有代码解释:

import React, { useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import "github-markdown-css";
import * as echarts from 'echarts';

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

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

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

<div id="echarts-container" style="width: 600px; height: 400px;"></div>
`;

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 = () => {
  const chartRef = useRef(null);

  useEffect(() => {
    // 获取 ECharts 容器元素
    const chartContainer = document.getElementById('echarts-container');
    if (chartContainer) {
      // 初始化 ECharts 实例
      const myChart = echarts.init(chartContainer);

      // 指定图表的配置项和数据
      const option = {
        xAxis: {
          type: 'category',
          data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
        },
        yAxis: {
          type: 'value'
        },
        series: [{
          data: [820, 932, 901, 934, 1290, 1330, 1320],
          type: 'line'
        }]
      };

      // 使用刚指定的配置项和数据显示图表。
      myChart.setOption(option);

      // 组件卸载时销毁 ECharts 实例
      return () => {
        myChart.dispose();
      };
    }
  }, []);

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

export default App;
✅ 效果:md 里的 <div id="echarts-container"> 被 rehype-raw 真实渲染成 DOM;useEffect 在组件挂载后能获取到这个节点,并用 echarts.init + setOption 画出折线图。
截图位置请对照原文:页面 md 内容下方出现 600×400 的折线图(Mon~Sun 七个点)。

2渲染多个报表:两个容器两张图

一个 md 里放两个容器,用 flex 排布,两个 useEffect 各初始化一个图表(折线图 + 柱状图):

import React, { useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import "github-markdown-css";
import * as echarts from 'echarts';

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

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

### 三级标题:Markdown 丰富示例 [[3,'我是3的id']]
<div style="display:flex">
<div id="echarts-container-1" style="width: 600px; height: 400px;"></div>
<div id="echarts-container-2" style="width: 600px; height: 400px;"></div>
</div>
`;

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 = () => {
    const chartRef1 = useRef(null);
    const chartRef2 = useRef(null);

    useEffect(() => {
        // 获取第一个 ECharts 容器元素
        const chartContainer1 = document.getElementById('echarts-container-1');
        if (chartContainer1) {
            // 初始化第一个 ECharts 实例
            const myChart1 = echarts.init(chartContainer1);

            // 第一个图表的配置项和数据
            const option1 = {
                xAxis: {
                    type: 'category',
                    data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
                },
                yAxis: {
                    type: 'value'
                },
                series: [{
                    data: [820, 932, 901, 934, 1290, 1330, 1320],
                    type: 'line'
                }]
            };

            // 使用刚指定的配置项和数据显示第一个图表。
            myChart1.setOption(option1);

            // 组件卸载时销毁第一个 ECharts 实例
            return () => {
                myChart1.dispose();
            };
        }
    }, []);

    useEffect(() => {
        // 获取第二个 ECharts 容器元素
        const chartContainer2 = document.getElementById('echarts-container-2');
        if (chartContainer2) {
            // 初始化第二个 ECharts 实例
            const myChart2 = echarts.init(chartContainer2);

            // 第二个图表的配置项和数据
            const option2 = {
                xAxis: {
                    type: 'category',
                    data: ['A', 'B', 'C', 'D', 'E']
                },
                yAxis: {
                    type: 'value'
                },
                series: [{
                    data: [300, 400, 200, 500, 100],
                    type: 'bar'
                }]
            };

            // 使用刚指定的配置项和数据显示第二个图表。
            myChart2.setOption(option2);

            // 组件卸载时销毁第二个 ECharts 实例
            return () => {
                myChart2.dispose();
            };
        }
    }, []);

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

export default App;
📌 多图表关键点:每个容器一个独立 id(echarts-container-1 / -2),一个 useEffect 初始化一个,各自 setOption、各自 dispose,互不干扰。布局直接用 md 里的内联 HTML(flex)控制。
截图位置请对照原文:两个 600×400 的图表并排展示,左边是折线图,右边是柱状图。

3总结:图表与自定义标签的关系

我们可以基于自定义标签来实现报表,可以借此实现特殊的交互,对于多数其他标签有更特殊的需求,可以自己来实现。一家之言,有需要的按需取舍(原文可下载项目,dev3 分支)。

上一篇(系列 2)本篇(系列 3)
自定义 sup 标签 + 点击事件自定义 div 容器 + 图表渲染
components 接管标签渲染rehype-raw 渲染容器 + useEffect 初始化
event.target.dataset 取自定义属性document.getElementById 取容器节点
⚠️ 这个方案的核心前提:rehype-raw 必须开着,md 里的 div 才能变成真实 DOM;且图表初始化必须在 DOM 渲染完成之后(useEffect)。

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

术语表

术语定义
echarts.init(dom)在指定 DOM 容器上初始化一个 ECharts 实例
setOption(option)给图表实例设置配置项和数据,完成绘制
dispose()销毁 ECharts 实例,释放资源(组件卸载时必做)
useEffectReact 副作用钩子,适合在 DOM 挂载后操作真实节点
chartRefuseRef 创建的引用(本篇实际用 id 查找容器,ref 为辅助写法)
rehype-raw放行 markdown 中原始 HTML 的 rehype 插件,图表容器的"落地前提"

附录 A:最小可运行代码(单图表版)

// ========== 安装依赖 ==========
// npm i echarts -S(在上一篇依赖基础上)
// 已有:react-markdown + remark-gfm + rehype-raw + github-markdown-css

import React, { useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import "github-markdown-css";
import * as echarts from 'echarts';

// 1. md 内容里"埋"一个图表容器 div(rehype-raw 会把它渲染成真实 DOM)
const markdownContent = `
# 一周数据趋势

<div id="echarts-container" style="width: 600px; height: 400px;"></div>
`;

const App = () => {
  useEffect(() => {
    // 2. DOM 挂载后:找到容器 → 初始化 → setOption
    const chartContainer = document.getElementById('echarts-container');
    if (!chartContainer) return;

    const myChart = echarts.init(chartContainer);
    myChart.setOption({
      xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] },
      yAxis: { type: 'value' },
      series: [{ data: [820, 932, 901, 934, 1290, 1330, 1320], type: 'line' }]
    });

    // 3. 组件卸载时销毁实例,防止内存泄漏
    return () => myChart.dispose();
  }, []);

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

export default App;

系列预告

未完待续……下一篇(系列 4)我们学习如何展示代码及高亮。