准备工作
在项目中安装以下依赖:
- "rehype-raw": "^5.1.0"
- "remark-gfm": "^1.0.0"
- "remark-parse": "^9.0.0"
- "remark-rehype": "^8.1.0"
- "unified": "^9.0.0"
工作目标
组件接受Markdow语法的字符串,将其转移为React组件
工作内容
编写md字符串转移为HTML AST树的方法
import React from 'react'
import unified from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import remarkGfm from 'remark-gfm'
export function mdTextToHTMLAst(text: string): Promise<RootNode> {
return new Promise(resolve => {
// FIXME: 这边 this 的实际类型为 Processor<void, Input, Input, Output>,
// 但是改为实际类型比较麻烦,所以先 as any 了
function getHTMLAstPlugin(this: any) {
Object.assign(this, { Compiler: compiler })
function compiler(root: RootNode) {
resolve(root)
}
}
unified()
.use(remarkParse) // md text -> md ast
.use(remarkGfm) // 解决非CommonMark语法不能解析的问题
.use(remarkRehype) // md ast -> html ast
.use(getHTMLAstPlugin)
.process(text)
})
}
将HTML AST树转为React的虚拟DOM
export type RootNode = {
type: 'root'
children: Array<TextNode | ElementNode>
}
type TextNode = {
type: 'text'
value: string
}
type ElementNode = {
type: 'element'
children: Array<TextNode | ElementNode>
properties: object
tagName: keyof ReactHTML
}
function childrenToReactNode(children: Array<TextNode | ElementNode>,parent?: ElementNode | RootNode) {
children = [...children]
const res: ReactNode[] = []
let key = 0
for (let i = 0; i < children.length; i++) {
const current = children[i]
if (current.type === 'text') {
const text = renderTextNode(current, parent)
if (text !== null) {
res.push(text)
}
continue
}
if (current.type === 'element') {
res.push(renderElementNode(current, key++))
continue
}
}
return res
}
function renderTextNode(child: TextNode, parent?: ElementNode | RootNode) {
if (
child.value === '\n' ||
(parent && parent.type === 'element' && tableElements.has(parent.tagName))
) {
// 去除不必要的空白文本,React does not permit whitespace text elements as children of table
return null
}
return child.value
}
function renderElementNode(element: ElementNode,key: number): ReactNode {
const children = element.children
const len = children.length
const tagName = element.tagName.toLowerCase()
if (tagName === 'style' || tagName === 'script') {
return null
}
const reactElement = React.createElement(
tagName,
{ key, ...element.properties, style: undefined, className: undefined },
len !== 0 ? childrenToReactNode(children, element) : null
)
if (tagName === 'table') {
// 表格外面包一层 div,防止宽度超出
return <div key={key}>{reactElement}</div>
}
return reactElement
}
绘制HTML AST树
export function renderHTMLAst(htmlAst: RootNode) {
return React.createElement(
Fragment,
null,
childrenToReactNode(htmlAst.children)
)
}
制作一个React组件用来展示HTML AST树
export function HtmlAst(props: { htmlAst: RootNode; className?: string }) {
const { htmlAst, className } = props
const element = useMemo(() => renderHTMLAst(htmlAst), [htmlAst])
return (
<>
{/** 这边之所以使用自定义标签,是为了保证这边样式的独立性(不会影响到其他页面)之外,
* 又降低了优先级(防止覆盖渲染 md 所替换的组件里面的样式) */}
{React.createElement('markdown-container', { class: className }, element)}
</>
)
}
制作一个React组件作为封装的最外层
function Markdown(props: { text: string; className?: string }) {
const { text, className } = props
const [articleHtmlAst, setArticleHtmlAst] = useState<RootNode | null>(null)
useEffect(() => {
mdTextToHTMLAst(text).then(res => setArticleHtmlAst(res))
}, [text])
if (articleHtmlAst == null) {
return <></>
}
return <HtmlAst htmlAst={articleHtmlAst} className={className} />
}
工作结果
代码实例
function App() {
const text = [
'## ewrwewr',
'---',
'俄方温哥华娃陪我佛文件噢i人家范围普及共轭分为恶狗和烹饪法人家哦我i俄加入微软近日挥金如土口味看空间哦文件人品就感觉哦入耳几个鹅各位赶紧哦额日记更可怕人间极品微积分i文件',
'**14234**',
'> 23123',
'| 人玩儿玩儿完 | 人玩儿而为 | tyre已) |',
'| ------------ | --------------------- | ---------------- |',
'| 一天如一日 | 人特人特人图 | 100 |',
'| 3而特人 | 的黑寡妇恢复的很发达| 50 |',
'| 而特特 | 好的风格很烦很烦他 | 30 |',
].join('\n')
return (
<div>
<Markdown text={text} />
</div>
);
}
实际效果
页面截图DOM截图
网友评论