本文通过一个具体的案例,展示了如何使用 react-markdown 实现 Markdown 的个性化渲染,并用 useRef 解决动态数据更新和组件重渲染的问题。
Markdown 是 AIGC 工作流中的重要工具,主要用于文档生成、内容展示等各个方面。在页面上将 Markdown 格式的数据呈现给用户,是 AIGC 产品的主要交互方式。在开发这类产品功能时,通常需要将 Markdown 内容渲染到页面上。
渲染 Markdown 的工具类库有很多,在这里我们选择使用 react-markdown,它能够将 Markdown 文本转换为 React 组件,便于在网页中展示格式化的内容。react-markdown 用起来也比较方便,比如:
import React from 'react'
import {createRoot} from 'react-dom/client'
import Markdown from 'react-markdown'
const markdown = '# Hi, *Pluto*!'
createRoot(document.body).render(
<Markdown>{markdown}</Markdown>
)
对于正常的 Markdown 渲染,react-markdown 是完全能够满足的。但是如果有个性化的需求,则需要做额外的开发。在 Markdown 中的链接([文本](URL))默认会被渲染为 <a> 标签,但是在下面的 H5 应用示例中需要将 [文本](URL) 渲染成引用角标,并且点击角标会弹出一个抽屉,并给出引用来源等信息。具体如下图所示:
这个功能就是将 Markdown 的<a> 标签做了一些个性化处理,react-markdown 是支持这种自定义渲染的,比如:
<Markdown
components={{
// Map `h1` (`# heading`) to use `h2`s.
h1:'h2',
// Rewrite `em`s (`*like so*`) to `i` with a red foreground color.
em(props){
const{node,...rest}= props
return<i style={{color: 'red'}} {...rest} />
}
}}
/>
接下来,我们来讨论一下如何实现这个功能。首先,后端服务返回的数据是以下面的格式来组织的:
{
content: 'Markdown 是一种... [1](citation:abc)',
citations: {
'abc': {detail: '本段文字参考了...', goto: 'a.b.c/example' }
}
}
content 字段是 Markdown 要进行渲染的內容,[1](citation:abc)
要渲染成引用角标 ①,abc
是字段 citations
中的索引,因此该角标引用的具体数据是citations.abc
。要渲染的数据并不是一次生成好的,需要轮询请求后端服务,以获取和更新数据。
在组件的实现上,我们可以按下面的方式进行设计:
对于 Citation 和 Drawer 组件在这里只做示意,不具体介绍它们的实现。Link 组件实现起来也比较简单,下面是 Link 的代码实现:
const Link = (props) => {
const { detail, goto, children } = props;
const [showDrawer, setShowDrawer] = useState(false);
return (
<>
<Citation onClick={() => setShowDrawer(true)}> {children}</Citation>
{showDrawer ?(<Drawer detail={detail} goto={goto} onClose={() => setShowDrawer(false)} />):null}
</>
);
}
接下来,为了能渲染 Markdown 中的特定元素 <a> 标签,需要将 Link 组件以对象的形式给到 react-markdown 使用。但是 Link 组件又要消费 citations 中的数据,所以需要定义一个函数来做处理:
const getComponents = (citations) => {
const components = {
a(props: any) {
const {href, children} = props;
const index = href.split(':')[1];
const {detail, goto} = citations.current?.[index] || {};
return <Link goto={goto} detail={detail}>{children}</Link>;
},
};
return components;
};
如果仔细阅读这部分代码,不难发现代码中的 citations.current
,很明显这里 citations 是一个 ref,为什么这里用 ref 到后面再讲。
最后介绍 App 的代码实现。在 App 中我们轮询请求后端服务,并把要渲染的內容和引用数据给到 react-markdown 使用:
import Markdown from 'react-markdown';
const defaultUrlTransform = (url: any) => url;
const App = () => {
const [data, setData] = useState({});
const citationsRef = useRef(data.citations);
useEffect(() => {
const interval = setInterval(() => {
fetchData().then(({content, citations, status}) => {
setData({ content, citations });
})
if (status === 'finish') {
clearInterval(interval);
}
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
const components = useMemo(() => {
return getComponents(citationsRef);
},[citationsRef]);
citationsRef.current = data.citations || {};
return (
<Markdown
urlTransform={defaultUrlTransform}
components={components}
>
{data.content || ''}
</Markdown>
);
};
在上面的代码中,使用 fetchData()
不断的请求数据,并把数据更新到 data 中,而 citationsRef 引用了 data.citations。
在回答为什么这里使用 useRef 之前,我们思考一下,如果不用 useRef 这里要如何实现呢?正常情况下,我们会把 data.citations 直接给到 getComponents 函数:
const components = useMemo(() => {
return getComponents(data.citations);
}, [data.citations]);
但是由于轮询请求数据会不断的更新 data.citations,导致 getComponents 返回新的 components,而新的 components 给到 Markdown 后会造成旧的 Link 被卸载,然后再渲染出一个新的 Link。如果用户在 Markdown 渲染未结束之前点击了角标,弹出的 Drawer 会随着旧 Link 的卸载而消失不见,造成了不好的用户体验。
众所周知,useRef 可用于在组件的生命周期内保存一个可变的值,且不会触发重新渲染。在这个场景里 useRef 避免了每次 data.citations 更新时,getComponents 的重新计算和组件的重新渲染。useRef 能够确保 Link 组件在渲染时能够访问到最新的 citations 数据,从而当点击事件发生时能够渲染出正常的 Drawer 组件。
当然,整个功能的实现方案并不唯一,我们也可以把 Drawer 组件放在 App 组件中,而不是 Link 中,并在 App 中定义 onElementClick 方法用于响应 Link 的点击事件;当 Link 组件中的 Citation 被点击时,调用 onElementClick 方法,示意代码如下:
import Markdown from 'react-markdown';
const defaultUrlTransform = (url: any) => url;
const getComponents = (onElementClick) => {
const components = {
a(props: any) {
const {children} = props;
const onClick = () => onElementClick(props)
return <Link onClick={onClick}>{children}</Link>;
},
};
return components;
};
const App = () => {
const [data, setData] = useState({});
const citationsRef = useRef(data.citations);
...
useEffect(() => {
fetchData()
...
}, []);
const onElementClick = useCallback(() => {
// 使用 citationsRef 获取数据 ...
}, [citationsRef]);
const components = useMemo(() => {
return getComponents(onElementClick);
}, [onElementClick]);
citationsRef.current = data.citations || {};
return (
<>
<Markdown
urlTransform={defaultUrlTransform}
components={components}
>
{data.content || ''}
</Markdown>
<Drawer ... />
</>);
};
不难发现,上面的代码实现中依然使用到了 useRef。