项目需求中有一个功能是支持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 `react-markdown ` is a markdown component for React. 👉 Changes are re-rendered as you type. 👈 Try writing some markdown on the left. * 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 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**. 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? For GFM, you can *also* use a plugin: [`remark-gfm `](https://github.com/remarkjs/react-markdown 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 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> 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
的值替换掉):
可以看出和Typora或者GitHub的渲染效果有一些差异,比方说代码块和普通文字的样式过于贴近,我们还是更适用于有着明显背景色以及代码高亮的代码块。
并且像是表格、任务列表、删除线等特殊样式也没有被渲染出来。
其实测试文本都告诉了我们为什么:那些是GFM,也就是GitHub Flavored Markdown的特性,而 react-markdown
默认是不支持GFM的。
所以我们需要安装 remark-gfm
插件来支持GFM:
react-markdown
引用插件的方式也很简单:
1 2 3 4 5 import remarkGfm from "remark-gfm" ;<ReactMarkdown remarkPlugins ={[remarkGfm]} > {markdownContent}</ReactMarkdown >
展现出来的效果:
看到这里,你肯定很是疑惑:表格的边框呢??
如果我们用开发者工具查看这个表格的样式,会发现表格确确实实被渲染成 table
标签,但是User Agent Stylesheet中的样式对 table
标签做了一些处理。 所以我们需要自己写一些样式来覆盖这些默认样式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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中的样式了。
再次展现出来的效果:
HTML支持因为安全性考虑,我决定不使用,所以这里就不再展示了。
这样我们就完成了一个简单的Markdown渲染功能。不过在这个需求中有一个最为重要的功能:代码块的高亮。
代码块的高亮
因为项目的要求,代码块必须是突显出来、语句高亮的,这对行内代码块也一样。
这里我们可以使用 react-syntax-highlighter
。
1 npm install react-syntax-highlighter
react-syntax-highlighter
具有两个引擎:prism
和 highlight.js
。两者的区别详细可以直接去网上搜索。
这里我们使用 prism
引擎以及 oneDark
主题:
1 2 import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" ; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism" ;
在 ReactMarkdown
的 components
属性中,我们可以自定义代码块的渲染方式。如果你看过网上的其他教程,近乎清一色的都是这样写的:
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
语言的代码块吧。
效果还是不错的:
后话
这个需求的实现还是有一些问题的,比方说行内代码块的渲染就没有解决、 try...catch
语句并不是一个好的解决方案,以及没有定义语言就默认渲染成 python
语言的代码块的技术债太鬼畜了!
但是由于项目的时间紧迫,我也没有再去深究这个问题。或许以后时间充裕了我会再去解决这个问题。以后再说吧。