在 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>
分析一下它的步骤:
- 首先我们需要有一个组件 Map,表示我们的自定义组件有哪些
- 接着配置
react-markdown
的div
属性,如果props.componentName
命中了我们的组件 Map,那么就会展示自定义组件,否则展示children
- 最后 markdown 中的自定义语法,包括组件名称(与组件 Map 对应),以及它的
path
,表示代码在工程中的位置,方便展示代码
开始之前
强烈建议读者事先了解过 react-markdown
以及 remark、rehype 插件
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>
)
}