react+react-markdown实现markdown预览、代码高亮、目录生成等

7,774 阅读3分钟

1. 预览md

md预览的插件选用react-markdown ,react-markdown 是一款非常优秀的 markdown 转 html 的 react 组件。

说明:文章最后有完整代码

1.1 安装依赖

pnpm install react-markdown

1.2 基本使用

import Markdown from 'react-markdown'

const markdown = '# Hi, *react-markdown*!'

createRoot(document.body).render(<Markdown>{markdown}</Markdown>)

1.3 实现代码高亮

要实现代码高亮,我门需要借助react-syntax-highlighter这个库:

pnpm install react-syntax-highlighter

如何使用?,下面直接上主要代码,来实现一个最简单的代码高亮效果:

import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";

<ReactMarkdown
components={{
  code({ children, className, inline }) {
    // 匹配否指定语言
    const match: any = /language-(\w+)/.exec(className || "");
    return (
      <>
        {!inline ? (
          <SyntaxHighlighter
            showLineNumbers={true}
            language={match && match[1]}
          >
            {String(children).replace(/\n$/, "")}
          </SyntaxHighlighter>
        ) : (
          <code className={className} style={inlineCodeStyle}>
            {children}
          </code>
        )}
      </>
    );
  },
}}
>
{content}
</ReactMarkdown>

我们只需要传入变量content,这个库就能自动帮我们匹配指定语言的代码进行高亮了,我们将会得到:

图5.jpg 如何切换主题?:我们只需要在原有的基础上引入你想要的主题,最后通过style属性就可以完成主题的切换了

// 代码高亮主题风格
import { darcula, oneDark, prism, vs } from "react-syntax-highlighter/dist/esm/styles/prism";

<ReactMarkdown
components={{
  code({ children, className, inline }) {
    // 匹配否指定语言
    const match: any = /language-(\w+)/.exec(className || "");
    return (
      <>
        {!inline ? (
          <SyntaxHighlighter
            showLineNumbers={true}
            language={match && match[1]}
            style={oneDark}
          >
            {String(children).replace(/\n$/, "")}
          </SyntaxHighlighter>
        ) : (
          <code className={className} style={inlineCodeStyle}>
            {children}
          </code>
        )}
      </>
    );
  },
}}
>
{content}
</ReactMarkdown>

如何实现代码折叠功能?

在很多博客或者社区网站我们都能注意到一些代码块有折叠和复制的功能,那么要如何实现呢?下面直接贴代码:

<ReactMarkdown
  components={{
    code({ children, className, inline }) {
      // 匹配否指定语言
      const match: any = /language-(\w+)/.exec(className || "");
      let [isShowCode, setIsShowCode] = useState(true);
      let [isShowCopy, setIsShowCopy] = useState(false);
      return (
        <>
          {!inline ? (
            <>
              {/* 代码头部 */}
              <div className="code-header">
                <div
                  style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
                  className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
                  onClick={() => setIsShowCode(!isShowCode)}
                >
                  <DownSvg />
                </div>
                <div>{match && match[1]}</div>
                <div
                  className="preview-code-copy"
                  onClick={() => {
                    setIsShowCopy(true);
                    ClipboardUtil.writeText(String(children));
                    setTimeout(() => {
                      setIsShowCopy(false);
                    }, 1500);
                  }}
                >
                  {isShowCopy && <span className="opacity-0-1-0 copy-success">复制成功</span>}
                  <CopySvg />
                </div>
              </div>
              {isShowCode && (
                <SyntaxHighlighter
                  showLineNumbers={true}
                  style={theme}
                  language={match && match[1]}
                >
                  {String(children).replace(/\n$/, "")}
                </SyntaxHighlighter>
              )}
            </>
          ) : (
            <code className={className} style={inlineCodeStyle}>
              {children}
            </code>
          )}
        </>
      );
    },
  }}
>
  {content}
</ReactMarkdown>;

我们做的处理是为每个code标签代码块添加一个code-header类名的div作为顶部区域,通过编写样式来达成想过要的布局。那么我门来依次来说明,折叠和复制这两个功能的实现思路。

  • 代码折叠功能

首先,我们定义一个变量isShowCode来判断是否展示代码块,通过点击折叠图标来修改isShowCode的值从而控制代码块的隐藏

  • 代码复制功能

主要通过Clipboard Api来实现粘贴板的写入功能,从而达到复制效果,这边我们使用封装好的工具类ClipboardUtil,调用他的writeText将代码块中的内容写入,从而达到代码复制功能,注意,这api只能在localhost或https环境下使用,最终我们就能得到这样的效果了:smiley::

图6.jpg

1.4 集成插件

react-markdown这个库的基本使用可以说非常简单,只需要将md语法传给引入的组件即可实现md语法的预览,我们无需任何处理。无奈对一些特殊语法的支持不太好,比如表格,分割线,任务列表,表情,删除线等,不过不用担心,作为一款github10k+stars:star:的组件库,他的插件也是及其丰富的,下面针对我开发中的需求介绍几款我用到的插件。

remark-gfm

remark-gfm是remark的一个插件,专门用于支持GitHub Flavored Markdown(GFM)的一系列特性,如自动链接、表格和待办列表等,下面帖出官方示例:

import React from 'react'
import {createRoot} from 'react-dom/client'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'

const markdown = `Just a link: www.nasa.gov.`

createRoot(document.body).render(
  <Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
)

先说总结:只需要进行简单的配置即可"实现"对上述md语法特性的支持,这里的实现用了引号,就我实验来看,他能实现大部分语法的解析,但是它对表格的解析也不是全自动的,下面我讲贴出使用该插件和不使用改插件解析结果进行对比。

  • 使用插件前:
import React from 'react'
import {createRoot} from 'react-dom/client'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'

const markdown = 
`- [X]  html
- [X]  css
- [ ]  js

---

**下面是一个表格,有3列4行**


| A    | B    | C    |
| ---- | ---- | ---- |
| a1   | b1   | c1   |
| a2   | b2   | c2   |
| a3   | b3   | c3   |
| a4   | b4   | c4   |
`

createRoot(document.body).render(
  <Markdown>{markdown}</Markdown>
)

图1.jpg

这里可以发现,如果使用remark-gfm插件,react-markdown是无法帮我们解析表格,待办列表等这些语法的。

  • 使用插件后:
import React from 'react'
import {createRoot} from 'react-dom/client'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'

const markdown = 
`- [X]  html
- [X]  css
- [ ]  js

---

**下面是一个表格,有3列4行**


| A    | B    | C    |
| ---- | ---- | ---- |
| a1   | b1   | c1   |
| a2   | b2   | c2   |
| a3   | b3   | c3   |
| a4   | b4   | c4   |
`

createRoot(document.body).render(
  <Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
)

图2.jpg

可以发现,待办列表可以正常解析,表格也被解析成了html表格的标签,但是并没有默认样式,这也就是上面所说的,对表格的解析并不是全自动的。当然也可能是我的打开方式不对。如何解决?只需要在css文件中为表格标签添加样式即可:

table {
  display: block;
  overflow: auto;
  width: 100%;
  border-collapse: collapse;
  empty-cells: show;
  border-spacing: 0;
  word-break: keep-all;
}

th {
  border: 1px solid #dfe2e5;
  padding: 6px 13px;
  white-space: nowrap;
  word-break: normal;
}

td {
  border: 1px solid #dfe2e5;
  padding: 6px 13px;
  white-space: nowrap;
  word-break: normal;
}

thead {
  tr {
    background: rgb(248 248 248);
  }
}

tbody {
  tr:nth-child(even) {
    background: rgb(248 248 248);
  }
}

*注意:为了防止直接通过标签选择器的方式污染全局标签样式,要在外部加一层类名进行包裹

做完这些,就可以站在巨人的肩膀上愉快的解析上面所列举的一些语法了:smile:

图3.jpg

**说明:**剩下的插件使用起来均为全自动插件,下面直接贴出代码:

remark-gemoji

解析表情插件:pnpm install remark-gemoji

import React from 'react'
import {createRoot} from 'react-dom/client'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkGemoji from "remark-gemoji";

const markdown = `Just a link: www.nasa.gov.`

createRoot(document.body).render(
  <Markdown remarkPlugins={[remarkGfm,remarkGemoji]}>{markdown}</Markdown>

rehype-raw

解析md语法中的html标签:pnpm install rehype-raw

import React from 'react'
import {createRoot} from 'react-dom/client'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkGemoji from "remark-gemoji";

const markdown = `Just a link: www.nasa.gov.`

createRoot(document.body).render(
  <Markdown remarkPlugins={[remarkGfm,remarkGemoji]}   rehypePlugins={[rehypeRaw]}>{markdown}</Markdown>

还有一些插件像进行数学运算的remark-math,绘制图像的rehype-katex,由于本人没有这些需求,这些实现就不一一列举了。

2. 生成目录

配套reack-markdown生成目录结构的也有一个库markdown-navbar,由于这个库很久前就没更新了,也可能是本人操作不当,生成的目录一言难尽。。。但是看源代码生成的思路也是通过锚点和锚链的方式。于是我们采取plan B,通过Ant Design的Anchor组件实现目录,下面贴上Ant Design官方的使用示例:

import React from 'react';
import { Anchor } from 'antd';

const handleClick = (
  e: React.MouseEvent<HTMLElement>,
  link: {
    title: React.ReactNode;
    href: string;
  },
) => {
  e.preventDefault();
  console.log(link);
};

const App: React.FC = () => (
  <Anchor
    affix={false}
    onClick={handleClick}
    items={[
      {
        key: '1',
        href: '#anchor-demo-basic',
        title: 'Basic demo',
      },
      {
        key: '2',
        href: '#anchor-demo-static',
        title: 'Static demo',
      },
      {
        key: '3',
        href: '#api',
        title: 'API',
        children: [
          {
            key: '4',
            href: '#anchor-props',
            title: 'Anchor Props',
          },
          {
            key: '5',
            href: '#link-props',
            title: 'Link Props',
          },
        ],
      },
    ]}
  />
);

export default App;

可以分析出来,我们重点关注的items这个数组,通过结构就可以很清晰的知道,里面的一个对象就是一个锚链接,如果有多层结构的话,通过children指定,href属性表示锚点,title就是标题了,和react-router路由表的方式简直是一个摸子里刻出来的。只需要这么一套cv大法,你就能得到:

图4.jpg 我们知道,md中的各个标题会被转成html中的h1-h6标签,那么我们改如何从那么长一大串解析后的结果提取出h1-h6标签再分别给他们加上锚点呢?

那么接下来就是要我们手动实现的代码了,思考下如果我们的文章结构复杂,那么多标题标签,让我门筛选出每个h1-h6标签,作为cv工程师来说,这比鲨了我还难受。既然我们考虑到了这点,很开心,官方为了组件的灵活性为我们提供了更多选项,可以帮助我们直接定位全部的h1-h6标签,下面直接上代码:

const MdPreview: React.FC<Props> = ({ content }) => {
  let index = 0;
  return (
    <ReactMarkdown
      components={{
        code() {
          return "这里用伪代码表示还是之前上面写过的内容";
        },
        h1({ children }) {
          return (
            <h1 id={"heading-" + ++index} className="heading">
              {children}
            </h1>
          );
        },
        h2({ children }) {
          return (
            <h2 id={"heading-" + ++index} className="heading">
              {children}
            </h2>
          );
        },
        h3({ children }) {
          return (
            <h3 id={"heading-" + ++index} className="heading">
              {children}
            </h3>
          );
        },
        h4({ children }) {
          return (
            <h4 id={"heading-" + ++index} className="heading">
              {children}
            </h4>
          );
        },
        h5({ children }) {
          return (
            <h5 id={"heading-" + ++index} className="heading">
              {children}
            </h5>
          );
        },
        h6({ children }) {
          return (
            <h6 id={"heading-" + ++index} className="heading">
              {children}
            </h6>
          );
        },
      }}
    >
      {content}
    </ReactMarkdown>
  );
};

可以看到,组将提供我们更多选项帮助我们直接匹配到对应的标签,我的做法是定义一个索引,为每个标签加id来作为这个元素的锚点,这样我们就能很简单的为h1-h6标签打上锚点了。

锚点有了,接下来我们就要来实现我们的目录结构了,根据上面的分析可以知道,我们只需要将数据处理成Ant Design 的Anchor接收的数据结构我们就能直接得到目录结构了,该来的最终还是要来,终究是逃不过自己写代码的命运,这边粘上我的实现代码:

// 生成锚点列表
let [anchorList, setAnchorList] = useState([]);
const generateAnchorList = (hNodeList: Array<HTMLElement>) => {
  if (hNodeList.length == 0) return [];
  // 最终生成的列表
  let anchorList: IAnchor[] = [];
  // 当前处理的dom元素索引
  let index = 0;
  // 保存当前锚点信息
  let currentAnchor: any = {};

  // 逻辑主函数
  function transform(item: HTMLElement) {
    let anchor = createAnchor(item);
    if (anchorList.length == 0) {
      anchorList.push(anchor);
      currentAnchor = anchor;
      return;
    }
    // 如果标签的大小递增,则往children中添加
    if (anchor.level > currentAnchor.level) {
      currentAnchor.children = currentAnchor?.children ?? [];
      recursionFn(currentAnchor.children, anchor);
    }
    // 如果当前处理的anchor和保存的anchor层级相同,则判断为当前保存anchor层级的元素处理完毕
    else {
      anchorList.push(anchor);
      currentAnchor = anchor;
    }
  }

  // 递归查询到相同的level,并处理
  function recursionFn(curChildren: IAnchor[] | any[], anchor: IAnchor) {
    if (curChildren.length == 0 || curChildren[0].level == anchor.level) {
      curChildren.push(anchor);
    } else if (curChildren[0].level < anchor.level) {
      // 顺序遍历,永远是往最后加
      let lastIndex = curChildren.length - 1;
      curChildren[lastIndex].children = curChildren[lastIndex]?.children ?? [];
      recursionFn(curChildren[lastIndex].children, anchor);
    }
  }

  // 创建锚点信息
  function createAnchor(item: HTMLElement) {
    let level = Number(item.nodeName.split("")[1]);
    let anchor: IAnchor = {
      key: "",
      href: "",
      title: "",
      level, // h标签的层级
    };
    anchor.title = item.innerHTML;
    anchor.href = `#heading-${++index}`;
    anchor.key = anchor.href;
    return anchor;
  }

  for (let item of hNodeList) {
    transform(item);
  }
  return anchorList;
};

useEffect(() => {
  let hNodeList = document.querySelectorAll(".heading");
  setAnchorList(generateAnchorList(hNodeList));
}, []);

写完这个方法我们就能成功的到我们想要的数据结构了,最后把我们得到的列表传给Anchor组件我们就能得到:

图7.jpg

3. 最终代码

3.1 预览md组件代码

  • 安装依赖

pnpm install react-markdown rehype-raw remark-gemoji remark-gfm remark-math rehype-katex react-syntax-highlighter

  • MdPreview组件
import ReactMarkdown from "react-markdown"; // 解析 markdown
import remarkGfm from "remark-gfm"; // markdown 对表格/删除线/脚注等的支持
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import remarkGemoji from "remark-gemoji";
import rehypeRaw from "rehype-raw";
import DownSvg from "@/assets/icon/down.svg";
import CopySvg from "@/assets/icon/copy.svg";

import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
// 代码高亮主题风格
import { darcula, oneDark, prism, vs } from "react-syntax-highlighter/dist/esm/styles/prism"; 
import "./MdPreview.less";
import React, { useState } from "react";
import ClipboardUtil from "@/utils/clipboardUtil";

// 主题枚举
export enum ThemeEnum {
  DEFAULT = prism,
  ONEDARK = oneDark,
  Darcula = darcula,
  VS = vs,
}

interface Props {
  content: string;
  theme?: ThemeEnum;
}

const inlineCodeStyle = {
  background: "rgba(243, 244, 244)",
  padding: "2px 5px",
  fontSize: "15px",
  color: "rgba(51, 51, 51)",
};

const MdPreview: React.FC<Props> = ({ content, theme }) => {
  let index = 0;
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm, remarkMath, remarkGemoji]}
      rehypePlugins={[rehypeKatex, rehypeRaw]}
      components={{
        code({ children, className, inline }) {
          // 匹配否指定语言
          const match: any = /language-(\w+)/.exec(className || "");
          let [isShowCode, setIsShowCode] = useState(true);
          let [isShowCopy, setIsShowCopy] = useState(false);
          return (
            <>
              {!inline ? (
                <>
                  {/* 代码头部 */}
                  <div className="code-header">
                    <div
                      style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
                      className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
                      onClick={() => setIsShowCode(!isShowCode)}
                    >
                      <DownSvg />
                    </div>
                    <div>{match && match[1]}</div>
                    <div
                      className="preview-code-copy"
                      onClick={() => {
                        setIsShowCopy(true);
                        ClipboardUtil.writeText(String(children));
                        setTimeout(() => {
                          setIsShowCopy(false);
                        }, 1500);
                      }}
                    >
                      {isShowCopy && <span className="opacity-0-1-0 copy-success">复制成功</span>}
                      <CopySvg />
                    </div>
                  </div>
                  {isShowCode && (
                    <SyntaxHighlighter
                      showLineNumbers={true}
                      style={theme}
                      language={match && match[1]}
                    >
                      {String(children).replace(/\n$/, "")}
                    </SyntaxHighlighter>
                  )}
                </>
              ) : (
                <code className={className} style={inlineCodeStyle}>
                  {children}
                </code>
              )}
            </>
          );
        },
        h1({ children }) {
          return (
            <h1 id={"heading-" + ++index} className="heading">
              {children}
            </h1>
          );
        },
        h2({ children }) {
          return (
            <h2 id={"heading-" + ++index} className="heading">
              {children}
            </h2>
          );
        },
        h3({ children }) {
          return (
            <h3 id={"heading-" + ++index} className="heading">
              {children}
            </h3>
          );
        },
        h4({ children }) {
          return (
            <h4 id={"heading-" + ++index} className="heading">
              {children}
            </h4>
          );
        },
        h5({ children }) {
          return (
            <h5 id={"heading-" + ++index} className="heading">
              {children}
            </h5>
          );
        },
        h6({ children }) {
          return (
            <h6 id={"heading-" + ++index} className="heading">
              {children}
            </h6>
          );
        },
      }}
    >
      {content}
    </ReactMarkdown>
  );
};

export default MdPreview;
  • MdPreview 组件样式MdPreview.less
pre {
  margin: 0 0 20px !important;
  min-height: 0;
}

hr {
  border: none;
  height: 2px;
  background-color: #eaecef;
}

blockquote {
  margin: 0;
  border-left: 4px solid rgb(223 226 229);
  padding-left: 16px;
}

img {
  max-width: 100%;
}

table {
  display: block;
  overflow: auto;
  margin-bottom: 16px;
  width: 100%;
  border-collapse: collapse;
  empty-cells: show;
  border-spacing: 0;
  word-break: keep-all;
}

th {
  border: 1px solid #dfe2e5;
  padding: 6px 13px;
  white-space: nowrap;
  word-break: normal;
}

td {
  border: 1px solid #dfe2e5;
  padding: 6px 13px;
  white-space: nowrap;
  word-break: normal;
}

thead {
  tr {
    background: rgb(248 248 248);
  }
}

tbody {
  tr:nth-child(even) {
    background: rgb(248 248 248);
  }
}

.code-header {
  display: flex;
  align-items: center;
  padding: 0 16px;
  height: 38px;
  font-size: 18px;
  font-weight: 600;
  color: #90a4ae;
  background: #e6ebf1;
  line-height: 38px;

  .preview-code-copy {
    margin-left: auto;
    cursor: pointer;
    transition: all 0.3s;

    .copy-success {
      margin-right: 10px;
      font-size: 14px;
      color: rgb(73 177 245);
      line-height: 1rem;
    }

    &:hover {
      color: rgb(73 177 245);
    }
  }
}

.code-rotate-right {
  animation: rotate-right 0.2s linear forwards;
}

.code-rotate-down {
  animation: rotate-down 0.2s linear forwards;
}

@keyframes rotate-right {
  from {
    transform: rotate(0);
  }

  to {
    transform: rotate(-90deg);
  }
}

@keyframes rotate-down {
  from {
    transform: rotate(-90deg);
  }

  to {
    transform: rotate(0);
  }
}
  • ClipboardUtil
export default class ClipboardUtil {
  private static clipboard = navigator.clipboard;

  static readText(): Promise<any> {
    return this.clipboard.readText();
  }

  static writeText(str: string): Promise<any> {
    return this.clipboard.writeText(str);
  }
}
  • 使用
import MdPreview from "@/components/MdPreview/MdPreview";
 <MdPreview content={articleInfo.articleContent} />

3.2 目录组件

由于本人项目的目录组件并不需要复用,这里抽出主要实现代码的阉割版,保证能够正常使用。

import React, { useEffect, useState } from "react";
import { Anchor } from "antd";

export const ArticleNav: React.FC = () => {
  // 生成锚点列表
  let [anchorList, setAnchorList] = useState([]);
  const generateAnchorList = (hNodeList: Array<HTMLElement>) => {
    if (hNodeList.length == 0) return [];
    // 最终生成的列表
    let anchorList: any[] = [];
    // 当前处理的dom元素索引
    let index = 0;
    // 保存当前锚点信息
    let currentAnchor: any = {};

    // 逻辑主函数
    function transform(item: HTMLElement) {
      let anchor = createAnchor(item);
      if (anchorList.length == 0) {
        anchorList.push(anchor);
        currentAnchor = anchor;
        return;
      }
      // 如果标签的大小递增,则往children中添加
      if (anchor.level > currentAnchor.level) {
        currentAnchor.children = currentAnchor?.children ?? [];
        recursionFn(currentAnchor.children, anchor);
      }
      // 如果当前处理的anchor和保存的anchor层级相同,则判断为当前保存anchor层级的元素处理完毕
      else {
        anchorList.push(anchor);
        currentAnchor = anchor;
      }
    }

    // 递归查询到相同的level,并处理
    function recursionFn(curChildren: any | any[], anchor: any) {
      if (curChildren.length == 0 || curChildren[0].level == anchor.level) {
        curChildren.push(anchor);
      } else if (curChildren[0].level < anchor.level) {
        // 顺序遍历,永远是往最后加
        let lastIndex = curChildren.length - 1;
        curChildren[lastIndex].children = curChildren[lastIndex]?.children ?? [];
        recursionFn(curChildren[lastIndex].children, anchor);
      }
    }

    // 创建锚点信息
    function createAnchor(item: HTMLElement) {
      let level = Number(item.nodeName.split("")[1]);
      let anchor: any = {
        key: "",
        href: "",
        title: "",
        level, // h标签的层级
      };
      anchor.title = item.innerHTML;
      anchor.href = `#heading-${++index}`;
      anchor.key = anchor.href;
      return anchor;
    }

    for (let item of hNodeList) {
      transform(item);
    }
    return anchorList;
  };

  useEffect(() => {
    let hNodeList: any = document.querySelectorAll(".heading");
    //@ts-ignore
    setAnchorList(generateAnchorList(hNodeList));
    generateAnchorList(hNodeList);
  }, []);

  return (
    <Anchor
      items={anchorList}
      affix={false}
      // 这里把滚动的容器的类名改成自己的
      getContainer={() => document.querySelector(".basic_layout_container")}
    ></Anchor>
  );
};

export default ArticleNav;
  • 使用
import ArticleNav from "./modules/articleNav";
<ArticleNav></ArticleNav>

说明:

本文章用于记录自己的开发实现过程,如果你有更好的实现思路可以一起讨论,代码虽好也要有一定的修改,本文章所写的代码仅适用本人项目,如果需要服用可能部分地方需要结合你的项目进行修改,如果对文章有疑问欢迎讨论:laughing: