在 React 中渲染 Markdown

在我的博客中使用的是unified来渲染markdown, 使用react-markdown的插件生态.

这主要和我的需求有关, 我的按照我自己的习惯, 比较喜欢使用Markdown来记录, 所以后端的数据库里存的也是Markdown形式的字符串.

为什么不直接使用后端渲染 Markdown 呢?

我的后端用的是python因为pythonMarkdown生态属实一般, 之前试过python的一个Markdown模块, 那个模块渲染出来的Markdown有一点问题, 不能被MathJAX渲染成数学公式. 所以这部分工作交由前端来完成.

使用 unified

unified项目开源地址: github.com/unifiedjs/unified . unified 最有特色的就是他丰富的插件生态了, 我目前所需要的功能基本上装上对应的插件就能够解决, 使用插件的方式也很简单, 直接在unified()后面加上.use(<插件>)就可以了. 比如说我的渲染markdown, 并返回渲染后的 HTML 字符串的代码就是:

export async function markdownToHtml(markdown: string): Promise<string> {
  const result = await unified()
    .use(remarkParse) // 解析为AST(抽象语法树)
    .use(remarkMath) // 解析数学公式
    .use(remarkGfm) // Github Flavored Markdown支持 表格,删除线等
    .use(remarkRehype, { allowDangerousHtml: true }) // 将 Markdown AST 转换为 HTML AST
    .use(rehypeRaw) // 允许在 Markdown 中使用 HTML
    .use(rehypeKatex) // 数学公式渲染为 HTML
    .use(rehypeHighlight) // 代码语法高亮
    .use(rehypeHighlightCodeLines, { showLineNumbers: true }) // 代码段添加行号
    .use(rehypeStringify) // 将 HTML AST 转换为HTML
    .process(markdown);

  return result.toString();
}

想要直接输出渲染的HTML字符串的话就是:

export function Page() {
  const html = await markdownToHtml(markdownContent);
  return <article dangerouslySetInnerHTML={{ __html: html }} />
}

之后按照自己的需求添加样式即可, 比如说我使用的样式就是Github的, 来自github-markdown-css这个包.

自定义标签属性

上述流程虽然用起来很方便, 但是如果需要自定义的话就比较麻烦, 具体的实现的话可以使用unist-util-visit这个包, 这个包会遍历HTML抽象语法树上的节点, 按照需要修改自己需要的部分就可以了:

import { Element } from "mdx/types";
import { visit } from "unist-util-visit";

export function rehypeCustomAttrs() {
  return (tree: any) => {
    visit(tree, "element", (node: Element) => {
      if (node.tagName === "a") {
        const internalLinkPattern =
          /^(data-footnote-backref|user-content-fnref)/;

        // 这个标签的 class
        const className = node.properties?.className as string[] | undefined;
        // 这个标签的 id
        const id = node.properties?.id as string | undefined;

        // 判断是否是内部链接
        const isInternalLink =
          className?.some((cls) => internalLinkPattern.test(cls)) ||
          internalLinkPattern.test(id ?? "");

        // 如果是就重写部分属性
        node.properties = {
          ...node.properties,
          ...(!isInternalLink && {
            target: "_blank",
            rel: "noopener noreferrer",
          }),
        };
      }
    });
  };
}

有了这个自定义插件之后直接添加到unified中的处理链中就可以了. 就像这样:

export async function markdownToHtml(markdown: string): Promise<string> {
  const result = await unified()
    .use(remarkParse) // 解析为AST(抽象语法树)
    .use(remarkMath) // 解析数学公式
    .use(remarkGfm) // Github Flavored Markdown支持 表格,删除线等
    .use(remarkRehype, { allowDangerousHtml: true }) // 将 Markdown AST 转换为 HTML AST
    .use(rehypeRaw) // 允许在 Markdown 中使用 HTML
    .use(rehypeCustomAttrs) // 自定义a标签
    .use(rehypeKatex) // 数学公式渲染为 HTML
    .use(rehypeHighlight) // 代码语法高亮
    .use(rehypeHighlightCodeLines, { showLineNumbers: true }) // 代码段添加行号
    .use(rehypeStringify) // 将 HTML AST 转换为HTML
    .process(markdown);

  return result.toString();
}

题外话

为什么不直接使用MDX呢, 因为这个东西我也没搞明白, 虽然MDXNext.js中也是有原生支持的, 但是渲染结果貌似有点区别. 并且, 因为我的前端也是使用了turbopack, 这个东西是Rust写的"增量捆绑器"(incremental bundler), 本地编译确实很快. 但是他是Rust, 这也就意味着不能直接传TypeScript的模块给他, 详见Configuring: MDX | Next.js, 然后就有一个尴尬的事情, 我本地运行的时候需要使用字符串, 而构建的时候需要改为 TS 模块...并且使用MDX的话Markdown文件就需要放在前端作为一个模块了(其实about页面就是用MDX写的).