在 Markdown 中展示 React 组件

Nov 10, 2024
12 min read
2771

之前写过一篇如何在 Markdown 中实现 Playground 的文章,后续开发过程中,思来想去,觉得这个方案可能不是最好的展示组件的方法,因为展示组件并不需要 runtime 执行,换句话说,下面的伪代码可能更加合理:

import ReactMarkdown from 'react-markdown'

const componentMap = {
  Foo: () => <div>foo</div>
}

<ReactMarkdown
  components={{
    div(props) {
      return componentMap[props.componentName] ?? props.children
    }
  }}
>
```jsx Component="Foo" path="xxx"

```
</ReactMarkdown>

分析一下它的步骤:

  1. 首先我们需要有一个组件 Map,表示我们的自定义组件有哪些
  2. 接着配置 react-markdowndiv 属性,如果 props.componentName 命中了我们的组件 Map,那么就会展示自定义组件,否则展示 children
  3. 最后 markdown 中的自定义语法,包括组件名称(与组件 Map 对应),以及它的 path,表示代码在工程中的位置,方便展示代码

开始之前

强烈建议读者事先了解过 react-markdown 以及 remarkrehype 插件

remark 插件

这一部分做的工作主要是将 markdown 中的 code block 默认产生的 pre -> code 标签替换为 div 标签,并将自定义组件名称和代码添加到组建的 props 属性中

import path from 'node:path'
import fs from 'node:fs'
import { visit } from 'unist-util-visit'

const ComponentNameRegx = /Component="([^"]+)"/
const ComponentPathRegx = /path="([^"]+)"/
function remarkPlayground() {
  return (tree) => {
    visit(tree, 'code', (node) => {
      const meta = node.meta
      if (!meta) {
        return
      }
      const [_, componentName] = ComponentNameRegx.exec(meta)
      const [__, componentPath] = ComponentPathRegx.exec(meta)
      if (componentName && componentPath) {
        makeProperties(node)
        const props = node.data!.hProperties!
        // 给 props 添加 componentName 属性
        props['componentName'] = componentName
        const code = fs.readFileSync(componentPath, 'utf-8')
        // 给 props 添加 componentCode 属性
        props['componentCode'] = code
        // 避免与其它标签产生副作用
        node.type = 'root'
        // 设置标签名为 div
        node.data!.hName = 'div'
      }
    })
  }
}

export default remarkPlayground

// 确保 node.data.hProperties 不为空
export function makeProperties(node) {
  if (!node.data) {
    node.data = {}
  }
  if (!node.data.hProperties) {
    node.data.hProperties = {}
  }
}

react-markdown 配置

import ReactMarkdown from 'react-markdown'
import { createElement } from 'react'

const componentMap = {
  Foo: (props) => (
    // 省略样式
    <div>
      <pre>
        {/* 展示代码 */}
        <code>{props.componentCode}</code>
      </pre>
      {/* 展示组件效果,这部分可以自定义编写 */}
      <div>Preview</div>
    </div>
  ),
}

// 自定义组件
function CustomComponent(props) {
  const component = componentMap[props.componentName]
  if (component) {
    return createElement(component, props) // 调用 react 的 createElement 方法,主要是为了传递 props
  }
  return props.children
}

function Markdown({ text }) {
  return (
    <ReactMarkdown
      components={{
        div(props) {
          return <CustomComponent {...props} />
        },
      }}
    >
      {text}
    </ReactMarkdown>
  )
}

优化

动态加载

假设你配置了一万个自定义组件,那么就算一篇博客一个也没用到,那么这些组件也会加入到网络请求中,编译器并不知道你有没有用到。如果你使用 nextjs,它提供了 dynamic 功能,只有引用了这个组件,页面才会加载,当然你也可以尝试 动态导入 import()

import dynamic from 'next/dynamic'

const Foo = dynamic(() => import('./Foo'))
const componentMap = {
  Foo,
}

// ....

延迟执行

有一些自定义组件执行起来非常耗时,并且会加载大量资源,我们希望用户滚轮滚到自定义组件时再加载,这样会大大降低我们的首屏渲染时间,这里我们使用 IntersectionObserver API,修改上面的 CustomComponent 组件:

// 自定义组件
function CustomComponent(props) {
  const component = componentMap[props.componentName]
  if (component) {
    return <IntersectionCustomComponent component={component} {...props} />
  }
  return props.children
}

function IntersectionCustomComponent({ component, props }) {
  const observerRef = useRef < HTMLDivElement > null
  const [isIntersecting, setIsIntersecting] = useState(false)

  useEffect(() => {
    const observerDom = observerRef.current
    if (!observerDom) {
      return
    }
    const observer = new IntersectionObserver((entries, self) => {
      const isIntersecting = entries[0].isIntersecting
      if (isIntersecting) {
        setIsIntersecting(true)
        self.unobserve(observerDom)
      }
    })
    observer.observe(observerDom)
    return () => observer.unobserve(observerDom)
  }, [])

  return (
    <div ref={observerRef}>
      {isIntersecting ? createElement(component, props) : <Loading />}
    </div>
  )
}
CC BY-NC-SA 4.0 2024 © Plumbiu