项目需求中有一个功能是支持Markdown渲染,尽量仿照ChatGPT、Claude的效果。

该文章的目的是记录我在实现这个功能时遇到的问题和解决方案。

react-markdown

先安装 react-markdown

1
npm install react-markdown

此处运用 react-markdown 库的官方示例中的文本来进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# A demo of `react-markdown`

`react-markdown` is a markdown component for React.

👉 Changes are re-rendered as you type.

👈 Try writing some markdown on the left.

## Overview

* Follows [CommonMark](https://commonmark.org)
* Optionally follows [GitHub Flavored Markdown](https://github.github.com/gfm/)
* Renders actual React elements instead of using `dangerouslySetInnerHTML`
* Lets you define your own components (to render `MyHeading` instead of `'h1'`)
* Has a lot of plugins

## Contents

Here is an example of a plugin in action
([`remark-toc`](https://github.com/remarkjs/remark-toc)).
**This section is replaced by an actual table of contents**.

## Syntax highlighting

Here is an example of a plugin to highlight code:
[`rehype-highlight`](https://github.com/rehypejs/rehype-highlight).

```js
import React from 'react'
import ReactDOM from 'react-dom'
import Markdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'

const markdown = `
# Your markdown here
`

ReactDOM.render(
<Markdown rehypePlugins={[rehypeHighlight]}>{markdown}</Markdown>,
document.querySelector('#content')
)
\```

> Pretty neat, eh?

## GitHub flavored markdown (GFM)

For GFM, you can *also* use a plugin:
[`remark-gfm`](https://github.com/remarkjs/react-markdown#use).
It adds support for GitHub-specific extensions to the language:
tables, strikethrough, tasklists, and literal URLs.

These features **do not work by default**.
👆 Use the toggle above to add the plugin.

| Feature | Support |
| ---------: | :------------------- |
| CommonMark | 100% |
| GFM | 100% w/ `remark-gfm` |

~~strikethrough~~

* [ ] task list
* [x] checked item

https://example.com

## HTML in markdown

⚠️ HTML in markdown is quite unsafe, but if you want to support it, you can
use [`rehype-raw`](https://github.com/rehypejs/rehype-raw).
You should probably combine it with
[`rehype-sanitize`](https://github.com/rehypejs/rehype-sanitize).

<blockquote>
👆 Use the toggle above to add the plugin.
</blockquote>

## Components

You can pass components to change things:

```markdown
import React from 'react'
import ReactDOM from 'react-dom'
import Markdown from 'react-markdown'
import MyFancyRule from './components/my-fancy-rule.js'

const markdown = `
# Your markdown here
`

ReactDOM.render(
<Markdown
components={{
// Use h2s instead of h1s
h1: 'h2',
// Use a component instead of hrs
hr(props) {
const {node, ...rest} = props
return <MyFancyRule {...rest} />
}
}}
>
{markdown}
</Markdown>,
document.querySelector('#content')
)
/```

测试文本中也有代码块,但是因为我用的是Hexo写的博客,这些原本应该在(我自己设置的)代码块内的文本都被渲染成了代码块。

因此我在每个代码块的后面都加了个斜杠,以使其不被渲染成代码块。你们在测试时可以将这些斜杠去掉

1
2
3
4
5
6
7
8
9
10
11
import ReactMarkdown from "react-markdown";

function App() {
const markdownContent = `{刚才说的Markdown测试文本}`;

return (
<>
<ReactMarkdown>{markdownContent}</ReactMarkdown>
</>
);
}

显示出来的效果(记得将markdownContent的值替换掉):

img.png

可以看出和Typora或者GitHub的渲染效果有一些差异,比方说代码块和普通文字的样式过于贴近,我们还是更适用于有着明显背景色以及代码高亮的代码块。

并且像是表格、任务列表、删除线等特殊样式也没有被渲染出来。

remark-gfm

其实测试文本都告诉了我们为什么:那些是GFM,也就是GitHub Flavored Markdown的特性,而 react-markdown 默认是不支持GFM的。

所以我们需要安装 remark-gfm 插件来支持GFM:

1
npm install remark-gfm

react-markdown 引用插件的方式也很简单:

1
2
3
4
5
import remarkGfm from "remark-gfm";

// ...

<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdownContent}</ReactMarkdown>

展现出来的效果:

img.png

看到这里,你肯定很是疑惑:表格的边框呢??

如果我们用开发者工具查看这个表格的样式,会发现表格确确实实被渲染成 table 标签,但是User Agent Stylesheet中的样式对 table 标签做了一些处理。 所以我们需要自己写一些样式来覆盖这些默认样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* index.css */

table {
border-spacing: 0 !important;
border-collapse: collapse !important;
border-color: inherit !important;
display: block !important;
width: max-content !important;
max-width: 100% !important;
overflow: auto !important;
}

tbody, td, tfoot, th, thead, tr {
border-color: inherit !important;
border-style: solid !important;
border-width: 2px !important;
}

把所有的样式统统加上 !important,这样就可以覆盖掉User Agent Stylesheet中的样式了。

再次展现出来的效果:

img.png

HTML支持因为安全性考虑,我决定不使用,所以这里就不再展示了。

这样我们就完成了一个简单的Markdown渲染功能。不过在这个需求中有一个最为重要的功能:代码块的高亮。

代码块的高亮

因为项目的要求,代码块必须是突显出来、语句高亮的,这对行内代码块也一样。

这里我们可以使用 react-syntax-highlighter

1
npm install react-syntax-highlighter

react-syntax-highlighter 具有两个引擎:prismhighlight.js。两者的区别详细可以直接去网上搜索。

这里我们使用 prism 引擎以及 oneDark 主题:

1
2
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";

ReactMarkdowncomponents 属性中,我们可以自定义代码块的渲染方式。如果你看过网上的其他教程,近乎清一色的都是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<SyntaxHighlighter
style={nightOwl}
language={match[1]}
PreTag="div"
children={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props} children={children} />
)
}
}}
>
{markdownContent}
</ReactMarkdown>

这里我们自定义了 code 组件的渲染行为。

code({ node, inline, className, children, ...props }) {} 中的参数们分别表示了:

  • node:当前节点
  • inline:是否是行内代码块
  • className:类名
  • children:子节点(代码块中的内容)
  • ...props:其他属性

接着我们使用正则表达式来匹配 className 中的 language-xxx,如果匹配到并且不是行内代码块,就使用 SyntaxHighlighter 来渲染代码块,否则使用默认的 code 标签。

但是!!!!

inline 属性在新版本的 react-markdown 中已经被废弃、不会作为参数传入了!!!

这让当时的我非常头疼,网上的资料翻了翻也没有找到解决方案,一时选择去解决另一个问题:没有定义语言的代码块的渲染。

代码块被渲染后,和行内代码块最大的区别是外面贴了一套 pre 标签。由于 code 标签作为子标签无法从 props 中获取到父标签的样式,但是反过来想,pre 标签是可以获取到 code 标签的样式的呀!

于是我嘎嘎乱写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre({node, className, children, ...props}) {
if (children["type"] === "code") {
try {
const match = children["props"]["className"].match(/language-(\w+)/)
return (
<pre>
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
showLineNumbers
wrapLongLines
children={String(children["props"]["children"]).replace(/\n$/, '')}
/>
</pre>
)
} catch (e) {
return (
<pre>
<SyntaxHighlighter
style={oneDark}
language="python"
PreTag="div"
showLineNumbers
wrapLongLines
children={String(children["props"]["children"]).replace(/\n$/, '')}
/>
</pre>
)
}
}
}
}}
>
{markdownContent}
</Markdown>

直接在 pre 标签中判断 children 的类型,如果是 code 那就肯定是代码块了,然后再去匹配 className 中的语言类型。

如果匹配不到就会导致错误,所以我临时加了一个 try...catch 语句,匹配不到的话就默认渲染成 python 语言的代码块吧。

效果还是不错的:

img.png

后话

这个需求的实现还是有一些问题的,比方说行内代码块的渲染就没有解决、 try...catch 语句并不是一个好的解决方案,以及没有定义语言就默认渲染成 python 语言的代码块的技术债太鬼畜了!

但是由于项目的时间紧迫,我也没有再去深究这个问题。或许以后时间充裕了我会再去解决这个问题。以后再说吧。