之前写过一篇如何在 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> )}
评论区