如何在 React 中渲染 Markdown 文档

    Oct 18, 2024

    实现在 React.js 中渲染你的 Markdown 文本,并从 Markdown 编译过程中的一些概念入手,说明 unified.js 和一些插件的用法和用例。

    概念

    Markdown 是一种轻量级的标记性流式文本格式,具备标题、加粗下划线斜体、引用块、代码块、链接等基础文档元素。非常适宜写笔记、文档和博客。但是 Markdown 文档必须先经过编译器分析出文档内的元素、格式,然后再经过阅读器渲染后才能呈现出文档格式。

    假如我想使用 React 开发我的个人博客网站,博客使用 Markdown 写。那么如何做才能在 React 中渲染我的 Markdown 文档呢?

    本文使用 unified.js 为例,unified.js 是一个开源的内容处理系统,它的功能之一就是把 Markdown、HTML 等标记型文本编译成对于机器来说可读的抽象语法树,并支持插件对这些文本进行修改,把标记文本编译出其他机器可渲染的形式(如 DOM,React 对象等)。

    在开始之前首先要明确一些概念

    • 标记性文本(Markup Text): 是一种将文本内容与其结构和格式信息结合起来的文本形式。它使用特定的标记(markup)来描述文本的结构、样式和其他属性。这些标记通常以标签的形式出现,嵌入在文本中,用于指示如何显示或处理文本内容。比如 Markdown 和 HTML 都是标记性文本。
    • 抽象语法树 (Abstract Syntax Tree,AST): 为了让机器理解 Markdown 或 HTML 文本中标记的含义,就需要把它转换成机器可以理解的形式,这一个过程是文本的编译,这个转换结果即抽象语法树。
    • hast (HTML Abstract Syntax Tree): 即 HTML 的抽象语法树。
    • mdast (Markdown Abstract Syntax Tree): 即 Markdown 文本的抽象语法树。
    • 语法树转换: 把一种语法树转换成另一种语法树,比如 mdast 转为 hast 或把 hast 转为 mdast,就是语法树之间的转换。由于 HTML 和 Markdown 都是标记性文本,所以它们由很多相似之处,语法树在一定条件下可以相互转化,即 mdast 和 hast 在一定条件下可以相互转化。
    • 编译输入: 把标记文本转换成抽象语法树的过程称为编译输入。例如 markdown 到 mdast 的过程就是编译输入。
    • 编译输出: 把抽象语法树转换成可渲染的数据结构称为编译输出。例如 hast 是 HTML 的抽象语法树,详细描述了 HTML 代码的构造。根据 hast 生成 HTML 代码字符串的过程就是编译输出,得到最终编译产物。

    在浏览器中不支持直接渲染 Markdown 文档,所以一般是先把 Markdown 文本转换成 HTML DOM 才能渲染在浏览器中。

    所以编写出 Markdown 文本,再渲染到浏览器中实际上流程是这样的。

    Markdown Text => mdast => hast => HTML => Render HTML
    

    unified.js 是一个“流水线”集成器,本身不支持处理文本内容,需要借助插件来处理文本。

    在开始使用 unified.js 之前,还需要明确 unified.js 中的概念。

    1. 入口插件 (Entry Plugin): 实现编译输入,把纯文本的标记文本转换为 mdast 或 hast 语法树的插件。比如 remark-parse 是把 markdown 文本转换为 mdast 的插件;rehype-parse 是把 html 文本转换为 hast 的插件。入口插件输入文本内容,输出 mdast 或 hast。
    2. 语法树转换插件 (AST Convert Plugin): 实现 mdast 和 hast 两种语法树之间转换的插件。比如 remark-rehype 是把 mdast 转换为 hast 的插件;rehype-remark 是把 hast 转换为 markdown 的插件。这种插件输入 mdast,转换并输出 hast,或者是输入 hast 然后输出转换成 mdast。
    3. remark 插件 (Remark Plugin): 是在编译过程中修改 mdast 的插件。通过修改 mdast,实现对 markdown 结构进行再处理的插件。并输出修改后的 mdast。
    4. rehype 插件 (Rehype Plugin): 是在编译过程中修改 hast 的插件。通过修改 hast,实现对 html 结构进行再处理的插件。并输出修改后的 hast。
    5. 出口插件 (Output Plugin): 实现编译输出,把 mdast 或者 hast 转换为编译结果的插件。比如 rehype-stringify 是把 hast 转换为 HTML 文本的插件,输出 HTML 代码,就可以直接被浏览器识别渲染。rehype-react 是把 hast 转换为 React JSX Element 的插件,输出一个可被 Render 的 React 节点元素。

    以上插件中,入口插件、语法树转换插件、出口插件都是特殊插件。

    实现

    在 React 中,再结合刚才 Markdown 到浏览器中渲染的流程,就不难理解在 React 中如何处理 Markdown 文本了。

    我用下面的流程图来依次演示编译过程中的各个插件的调用顺序。

                          Markdown 文本
                             ↓
                        +----------------+
                        | Entry Plugin   |
    +------------------ | (Remark-parse) |----------------+
    |  unified.js       +----------------+                |
    |                        ↓ mdast                      |
    |                   +-----------------------+         |
    |                   | Syntax Convert Plugin |         |
    |                   |    (remark-rehype)    |         |
    |                   +-----------------------+         |
    |                        ↓ hast                       |
    |                   +------------------+              |
    |                   |  Output Plugin   |              |
    +-------------------|  (Rehype-react)  |--------------+
                        +------------------+
                             ↓
                        React JSX Element
    

    最终 Markdown 文本会被编译成一个可以直接渲染的 React JSX 组件。

    我们首先初始化一个 React 项目。用 next.js 或者 CRA 都可以。在本项目中使用 next.js 为例。初始化一个 next.js 项目。创建项目的选项如下。

    √ What is your project named? ... react-markdown-example
    √ Would you like to use TypeScript? ... Yes
    √ Would you like to use ESLint? ... No
    √ Would you like to use Tailwind CSS? ... Yes
    √ Would you like to use `src/` directory? ... No
    √ Would you like to use App Router? (recommended) ... No
    √ Would you like to customize the default import alias (@/*)? ... Yes
    √ What import alias would you like configured? ... @/*
    

    为被渲染的 Markdown 添加 CSS 排版类支持,再安装 tailwindcss 的 typography 插件。

    pnpm install -D @tailwindcss/typography
    

    再把 tailwind.config.js 文件中添加这个 typography 插件。

    /** @type {import('tailwindcss').Config} */
    module.exports = {
      theme: {
        // ...
      },
      plugins: [
    +   require('@tailwindcss/typography'),
        // ...
      ],
    }
    

    然后我们安装相关依赖

    pnpm i unified remark-parse remark-rehype rehype-react
    

    unified.js 使用的是中间件模式,任何插件都可以被视为一个中间件,可以用unified.use()方法来链式调用中间件,调用时顺着 use 链依次向后传递,上一个插件的输出结果作为下一个插件的输入,在中间件的调用尽头再用 process() 方法来执行处理。注意,在这里 process()是异步函数,需要 await。

    写出的代码大概是这样的:

    import remarkParse from "remark-parse";
    import remarkRehype from "remark-rehype";
    import rehypeReact from "rehype-react";
    import { unified } from "unified";
    import * as production from "react/jsx-runtime";
    
    const processMarkdown = async () => {
      const pipeline = await unified()
        // Entry Plugin, string to mdast
        .use(remarkParse)
        // Syntax convert plugin, mdast to hast
        .use(remarkRehype)
        // Output plugin, hast to React JSX Element
        .use(rehypeReact, production)
        .process(markdownText);
      return pipeline.result;
    };
    

    注意以上代码中插件的调用顺序。首先是输入 markdown 字符,入口插件 remark-parse 接受的是字符,输出的是 mdast。然后把输出的 mdast 作为语法树转换插件 remark-rehype 的输入结果,再输出 hast。以此类推。注意各个插件的输入、输出对象,不能搞混。

    我们用以下 markdown 文档做测试:

    ## This is a heading
    
    hello **world**, _this is italic sentence_
    [This is a link](https://example.com)
    

    我们把它集成到 next.js 页面中。以首页为例,我们把首页 index.ts 文件中写入如下

    import remarkParse from "remark-parse";
    import remarkRehype from "remark-rehype";
    import rehypeReact from "rehype-react";
    import { unified } from "unified";
    import * as production from "react/jsx-runtime";
    import { Fragment, createElement, useEffect, useState } from "react";
    
    const markdownText = `
    ## This is a heading
    hello **world**, _this is italic sentence_
    [This is a link](https://example.com)
      `;
    
    export default function Home() {
      const [element, setElement] = useState(createElement(Fragment));
    
      const processMarkdown = async () => {
        const pipeline = await unified()
          // Entry Plugin, string to mdast
          .use(remarkParse)
          // Syntax convert plugin, mdast to hast
          .use(remarkRehype)
          // Output plugin, hast to React JSX Element
          .use(rehypeReact, production)
          .process(markdownText);
        return pipeline.result;
      };
    
      useEffect(() => {
        processMarkdown().then((elem) => setElement(elem));
      }, [markdownText]);
    
      return (
        <div className="p-5">
          <main className="prose">
            {/* The compiled element will display here. */}
            {element}
          </main>
        </div>
      );
    }
    

    然后再运行查看效果。

    可以看出来,我们的 markdown 文档的基本要素,如标题、加粗、斜体、链接等已经齐全,成功编译成了 React JSX 元素,渲染正常。

    使用 Remark,Rehype 插件

    在以上的工作中我们很清楚地演示了 Markdown 文本编译成 React 组件的过程。但是 remark-parse 入口插件在处理 Markdown 文本时,只支持基础的 Markdown 格式特征,而 Markdown 中还有很多其他的高级特性,比如代码块高亮、公式、表格等,并不自带支持。

    我们尝试在 markdownText 中加入一些公式和代码块和表格语法。

    const markdownText = `
    
    ## This is a heading
    
    hello **world**, _this is italic sentence_
    [This is a link](https://example.com)
    
    $$
    a^{2} + b^{2} = c^{2}
    $$
    
    \`\`\`javascript
    console.log(hello world)
    \`\`\`
    
    | name    | age | job      |
    | ------- | --- | -------- |
    | Alice   | 30  | engineer |
    | Bob     | 25  | designer |
    | Charlie | 28  | techer   |
    
    `;
    

    发现公式和表格没有被渲染出来,代码块也没有高亮。

    接下来演示的是为我们的 markdown 文本添加上对代码块高亮、公式和表格渲染的支持。

    想要支持公式、代码块高亮、表格这些高级特征。还需要添加一些插件来提供支持。这就引入我在上面提到的 Remark 和 Rehype 插件了。

    在上面的例子中,公式、表格没有被成功渲染的原因就是因为入口插件remark-parse在解析文档中,只能识别最基础的 markdown 语法,而不支持识别公式块和表格等高级特征的语法,所以在生成的语法树中,没有为公式块、表格生成对应的语法结构。

    所以 remark-parse 转换出的 mdast 不完整。为了让 mdast 生成对应的公式块、表格的语法结构,我们需要对 mdast 稍加修改,就需要借助 remark-gfmremark-math 这两个插件来扩展语法识别能力。它可以识别出 remark-parse 未识别出的语法,并且生成对应的语法结构。

    其中 remark-gfm 可以识别表格语法,remark-math 可以识别公式块语法。

    生成带有代码块、公式和表格语法结构的 mdast 后,再使用语法树转换插件转换为 hast。 但是,此时 hast 虽然有了代码块、表格和公式的结构,但是它们对应的 html class 属性结构中还没有 CSS 样式。而代码块和公式需要依赖外部 CSS 定义的 class 才能正确显示样式。

    要想让生成的 HTML 结构能被正确渲染,还需要对 hast 再做进一步处理。所以接下来我们就用到 rehype 插件,它可以修改 hast,为代码块和公式对应的 html 结构中加上 CSS 类。就要用到 rehype-katexrehype-highlight,前者对公式块的 html 结构加上用于显示数学公式的样式类,后者是给代码块的高亮元素加上样式类。

    综合以上内容,可以知道,文档的编译流程大概是这样的。

                          Markdown 文本
                             ↓
                        +----------------+
                        | Entry Plugin   |
    +------------------ | (remark-parse) |----------------+
    |  unified.js       +----------------+                |
    |                        ↓ mdast                      |
    |  +-------------------- ↓ ---------------------+     |
    |  | Remark Plugins      ↓ mdast                |     |
    |  |               +----------------+           |     |
    |  |               | remark-gfm     |           |     |
    |  |               +----------------+           |     |
    |  |                     ↓ mdast                |     |
    |  |               +----------------+           |     |
    |  |               | remark-math    |           |     |
    |  |               +----------------+           |     |
    |  +-------------------- ↓ ---------------------+     |
    |                        ↓ mdast                      |
    |                  +-----------------------+          |
    |                  | Syntax Convert Plugin |          |
    |                  |    (remark-rehype)    |          |
    |                  +-----------------------+          |
    |                        ↓ hast                       |
    |  +-------------------- ↓ ---------------------+     |
    |  | Rehype Plugins      ↓ hast                 |     |
    |  |               +----------------+           |     |
    |  |               | rehype-katex   |           |     |
    |  |               +----------------+           |     |
    |  |                     ↓ hast                 |     |
    |  |               +------------------+         |     |
    |  |               | rehype-highlight |         |     |
    |  |               +------------------+         |     |
    |  +-------------------- ↓ ---------------------+     |
    |                        ↓ hast                       |
    |                  +------------------+               |
    |                  |  Output Plugin   |               |
    +------------------|  (rehype-react)  |---------------+
                       +------------------+
                             ↓
                        React JSX Element
    

    我们安装这些 remark, rehype 插件,以及其他必要的元素

    pnpm i remark-gfm remark-math rehype-katex rehype-highlight katex highlight.js
    

    然后再把我们的代码改成如下内容。

    import remarkParse from "remark-parse";
    import remarkRehype from "remark-rehype";
    import rehypeReact from "rehype-react";
    import remarkGfm from "remark-gfm";
    import remarkMath from "remark-math";
    import rehypeHighlight from "rehype-highlight";
    import rehypeKatex from "rehype-katex";
    import { unified } from "unified";
    import * as production from "react/jsx-runtime";
    import { Fragment, createElement, useEffect, useState } from "react";
    import "highlight.js/styles/dark.css"; // import css file for code highlight theme.
    import "katex/dist/katex.css"; // import css file for formula blocks.
    
    const markdownText = `
    
    ## This is a heading
    
    hello **world**, _this is italic sentence_
    [This is a link](https://example.com)
    
    $$
    a^{2} + b^{2} = c^{2}
    $$
    
    \`\`\`javascript
    console.log(hello world)
    \`\`\`
    
    | name    | age | job      |
    | ------- | --- | -------- |
    | Alice   | 30  | engineer |
    | Bob     | 25  | designer |
    | Charlie | 28  | techer   |
    
    `;
    
    export default function Home() {
      const [element, setElement] = useState(createElement(Fragment));
    
      const processMarkdown = async () => {
        const pipeline = await unified()
          // Entry Plugin, string to mdast
          .use(remarkParse)
          // Add remark-gfm to recognize table syntax
          .use(remarkGfm)
          // Add remark-math to recognize the formula-block syntax
          .use(remarkMath)
          // Syntax convert plugin, mdast to hast
          .use(remarkRehype)
          // Use rehype-katex to add formula styles to formula block.
          .use(rehypeKatex)
          // Use rehype-highlight to add css classes for elements in code block to make them stylified.
          .use(rehypeHighlight, { detect: true })
          // Output plugin, hast to React JSX Element
          .use(rehypeReact, production)
          .process(markdownText);
        return pipeline.result;
      };
    
      useEffect(() => {
        processMarkdown().then((elem) => setElement(elem));
      }, [markdownText]);
    
      return (
        <div className="p-5">
          <main className="prose">
            {/* The compiled element will display here. */}
            {element}
          </main>
        </div>
      );
    }
    

    最后,运行程序预览结果。

    表格、公式、代码高亮等都正常渲染了。

    自定义元素

    以上基本介绍了编译、渲染的流程和实现。接下来不妨尝试一个更高级的特征。在编写 markdown 文档插入图片时,有时候我希望给图片加上一段说明文字。

    就像这样,在图片底部加上一行小字作为说明。

    在 markdown 的图片语法中可以输入 alt 作为图片的说明文字。

    ![This is the description for image](https://link-to-image.com)
    

    然而 markdown 被编译并渲染后我们看不到这行说明文字。我们知道,markdown 的图片语法经过编译后会转换为 <img /> 元素,alt 属性也会被传递到这个 img 元素中。但是我们可以编写一个自定义元素,在编译时把<img />转换为自定义的图片组件。

    rehype-react 就提供了一个 components 选项,支持将默认的 html 元素替换为自定义的元素。

    我们建立一个 components/customImg.tsx,编写一个自定义 Image 组件。接收 props 中的 alt 文字并展示出来。

    export const customImg = (props: JSX.IntrinsicElements["img"]) => {
      // Now you can receive origin img attributes by props.
      return (
        <div className="flex flex-col">
          <img className="mx-auto my-0" src={props.src} />
          <figcaption className="p-0 text-center mx-auto text-sm text-gray-500">
            {props.alt}
          </figcaption>
        </div>
      );
    };
    

    在以上代码中,你可以使用 props 来接收原 img 的srcalt等属性。

    然后在对 rehype-react 的调用中设置 components 选项。这样在编译 hast 中,会自动把 img 组件替换为我们自定义的 customImg 组件。

    重写代码如下:

    import remarkParse from "remark-parse";
    import remarkRehype from "remark-rehype";
    import rehypeReact from "rehype-react";
    import remarkGfm from "remark-gfm";
    import remarkMath from "remark-math";
    import rehypeHighlight from "rehype-highlight";
    import rehypeKatex from "rehype-katex";
    import { unified } from "unified";
    import * as production from "react/jsx-runtime";
    import { Fragment, createElement, useEffect, useState } from "react";
    import { customImg } from "@/components/customImg";
    import "highlight.js/styles/dark.css"; // import css file for code highlight theme.
    import "katex/dist/katex.css"; // import css file for formula blocks.
    
    const markdownText = `
    ![This is a Qomolangma mountain, the highest mountain around the world](https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTdzhPIE2I8agW_N3Cl2hNuwy9xSUiDfKtFFQ&s)
    
    Mount Everest attracts many climbers, including highly experienced mountaineers. There are two main climbing routes, one approaching the summit from the southeast in Nepal (known as the "standard route") and the other from the north in Tibet.
    `;
    
    export default function Home() {
      const [element, setElement] = useState(createElement(Fragment));
    
      const processMarkdown = async () => {
        const pipeline = await unified()
          // Entry Plugin, string to mdast
          .use(remarkParse)
          // Add remark-gfm to recognize table syntax
          .use(remarkGfm)
          // Add remark-math to recognize the formula-block syntax
          .use(remarkMath)
          // Syntax convert plugin, mdast to hast
          .use(remarkRehype)
          // Use rehype-katex to add formula styles to formula block.
          .use(rehypeKatex)
          // Use rehype-highlight to add css classes for elements in code block to make them stylified.
          .use(rehypeHighlight, { detect: true })
          // Output plugin, hast to React JSX Element
          .use(rehypeReact, { ...production, components: { img: customImg } })
          .process(markdownText);
        return pipeline.result;
      };
    
      useEffect(() => {
        processMarkdown().then((elem) => setElement(elem));
      }, [markdownText]);
    
      return (
        <div className="p-5">
          <main className="prose">
            {/* The compiled element will display here. */}
            {element}
          </main>
        </div>
      );
    }
    

    运行代码,大功告成。

    其他实现

    在 React 中使用 unified.js 相对来说比较麻烦,你也自己可以把它封装成一个独立的组件。社区中也有一个更简单的 React 库 react-markdown,它帮你省去了 unified 对入口插件、语法树转换插件和出口插件的处理流程。你只需要传递 Remark 插件和 Rehype 插件即可,开箱即用。

    npm i react-markdown
    

    然后直接调用 React 组件即可

    import remarkMath from "remark-math";
    import remarkGfm from "remark-gfm";
    import rehypeKatex from "rehype-katex";
    import rehypeHighlight from "rehype-highlight";
    import ReactMarkdown from "react-markdown";
    
    const Post = (props: { markdownText: string }) => {
      return (
        <ReactMarkdown
          remarkPlugins={[remarkMath, remarkGfm]}
          rehypePlugins={[
            () => rehypeKatex({ strict: false }),
            () => rehypeHighlight({ detect: true }),
          ]}
        >
          {props.markdownText}
        </ReactMarkdown>
      );
    };
    
    export default Post;
    

    就可以渲染出 Markdown 文本了。

    其他插件

    除了以上本文提到的插件,Github 上还有很多现成的 remark,rehype 插件。这里分别是官方提供的 remark 插件列表Rehype 插件列表

    下面介绍一些 remark 和 rehype 插件。

    • remark-lint - Markdown linter. 可以自动修正 Markdown 里面的一些语法错误。

    • remark-toc - 为 Markdown 添加 Table of Contents 标题目录。

    • remark-gfm - 支持识别更多的 Markdown 语法,如 todo list,表格,脚注等。

    • remark-math - 支持识别 Markdown 中的公式块语法。

    • remark-frontmatter - 支持识别 Markdown 中的 frontmatter 语法。

    • rehype-raw - 支持 Markdown 中夹杂的自定义 HTML

    • rehype-slug - 为标题添加 id

    • rehype-autolink-headings - 为标题添加指向自身的链接 rel = "noopener noreferrer"

    • rehype-sanitize - 清理 HTML,用于确保 HTML 安全避免 XSS 攻击

    • rehype-external-link - 自定义插件,给外部链接添加 target="_blank" 和 rel="noopener noreferrer"

    • rehype-mermaid - 自定义插件,渲染绘图和制表工具 Mermaid,本文的架构图就是通过 Mermaid 渲染的

    • rehype-embed - 自定义插件,用于根据链接自动嵌入 YouTube、Twitter、GitHub 等卡片

    • rehype-remove-h1 - 自定义插件,用于把 h1 转为 h2

    优化

    unified.js 处理文档的速率和性能取决于使用的插件。一般来说,使用的 remark 插件或 rehype 插件越多,那么处理速率就越慢。所以对于 remark、rehype 插件应当按需使用,裁剪不必要的插件。另外编写高性能的 remark 或 rehype 也是有必要的。

    remark、rehype 插件大多数是同步性插件,少数是异步性插件,使用异步插件可以避免阻塞,提高性能。

    后续我会记录下如何编写一个 remark、rehype 插件。

    源码参考

    本文中的项目示例已经上传到 github,读者可以自行下载、运行、学习。

    除了本文外,你还可以参考以下文章。

    Share with the post url and description