实现Markdown的React组件

准备工作

在项目中安装以下依赖:

  • "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截图
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容