告别复杂:打造轻量级 Markdown 渲染弹窗组件
在日常开发中,我们经常需要以弹窗形式展示富文本内容,比如帮助文档、操作指南或系统公告。这些内容通常用 Markdown 编写,便于维护和更新。然而,市面上的 Markdown 渲染组件要么过于复杂,要么定制性不足,难以满足项目的特定需求。
经过多个项目的实践,我总结出一套轻量级的 Markdown 渲染弹窗解决方案,既保证了使用的简便性,又提供了足够的灵活性。今天就和大家分享这个组件的实现思路和具体代码。
🚀 两种调用方式,满足不同场景需求
根据实际项目中的使用体验,我设计了两种调用方式:函数式 API 和组件式 API。
函数式 API(推荐)✨
函数式 API 类似于 antd 的 Modal.show() 方法,直接调用函数即可显示内容,无需创建组件引用或管理状态,特别适合临时性的内容展示:
import { showMarkdown, hideMarkdown } from 'src/common/MarkdownRender';
// 显示 Markdown 内容
showMarkdown('# Hello World\n\n这是 **Markdown** 内容');
// 带配置选项的显示
showMarkdown('# Custom Size\n\n自定义大小的 Markdown 弹窗', {
width: '60vw',
height: '70vh',
maxWidth: 600,
maxHeight: 500,
});
// 手动隐藏(通常不需要,点击背景或关闭按钮会自动关闭)
hideMarkdown();这种用法的最大优势在于简洁直观,适合在事件处理函数或网络请求回调中快速调用,减少了组件状态管理的复杂度。
组件式 API(兼容旧项目)🔄
对于一些老项目或特定场景,可能更习惯使用组件形式,通过引用组件并使用 ref 来控制显示和隐藏:
import { useRef } from 'react';
import MarkdownRender, {
MarkdownRenderHandle,
} from 'src/common/MarkdownRender';
function MyComponent() {
const mdRef = useRef<MarkdownRenderHandle>(null);
const handleShowMarkdown = () => {
mdRef.current?.show();
};
return (
<div>
<button onClick={handleShowMarkdown}>显示 Markdown</button>
<MarkdownRender
ref={mdRef}
mdContent="# Hello World\n\n这是 **Markdown** 内容"
width="80vw"
height="80vh"
maxWidth={800}
maxHeight={600}
/>
</div>
);
}这种方式更符合 React 的组件思想,适合需要多次重用或与其他组件紧密集成的场景。
🛠️ 配置项详解
为了满足不同场景的需求,组件支持多种配置选项,两种 API 方式都可以使用:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| mdContent | string | - | Markdown 内容字符串(必需) |
| width | number | string | '80vw' | 弹窗宽度 |
| height | number | string | '80vh' | 弹窗高度 |
| maxWidth | number | string | 800 | 弹窗最大宽度 |
| maxHeight | number | string | 600 | 弹窗最大高度 |
| darkMode | boolean | true | 是否启用深色模式 |
通过这些配置项,可以灵活控制弹窗的尺寸和外观,满足不同界面设计的需求。例如,对于简短的提示信息,可以设置较小的尺寸;对于详细的帮助文档,则可以设置较大的显示区域。
💡 实现思路与技术要点
技术选型
组件的核心依赖包括:
- React Markdown:用于解析和渲染 Markdown 内容
- Remark GFM:支持 GitHub Flavored Markdown,增强表格、任务列表等功能
- Framer Motion:提供平滑优雅的动画效果
- Tailwind CSS:用于样式定制
架构设计
整个组件分为三层:
- 内部渲染组件(MarkdownRenderInternal):负责实际的 Markdown 渲染和样式应用
- 容器组件(MarkdownRenderContainer):管理弹窗状态,处理全局单例
- 对外 API 层:提供函数式和组件式两种调用方式
这种分层设计使得组件既保持了内部实现的清晰性,又提供了灵活的调用方式。
性能优化
为了避免重复渲染和内存泄漏,我采用了以下策略:
- 使用单例模式管理弹窗状态,避免多实例冲突
- 懒加载容器,只在首次调用时创建 DOM 节点
- 控制 ReactDOM.render 的调用次数,减少不必要的重渲染
📝 完整组件代码
下面是完整的组件实现代码,包含了所有功能和样式定制:
import {
useState,
useEffect,
useImperativeHandle,
forwardRef,
FC,
} from 'react';
import ReactDOM from 'react-dom';
import ReactMarkdown from 'react-markdown';
import { motion, AnimatePresence } from 'framer-motion';
import remarkGfm from 'remark-gfm';
export interface MarkdownRenderProps {
mdContent: string;
width?: number | string;
height?: number | string;
maxWidth?: number | string;
maxHeight?: number | string;
isVisible?: boolean;
onClose?: () => void;
darkMode?: boolean;
}
export interface MarkdownRenderHandle {
show: () => void;
hide: () => void;
}
const overlayVariants = {
visible: { opacity: 1 },
hidden: { opacity: 0 },
};
const contentVariants = {
visible: {
opacity: 1,
scale: 1,
transition: { type: 'spring', stiffness: 300, damping: 25 },
},
hidden: {
opacity: 0,
scale: 0.95,
transition: { duration: 0.2 },
},
};
const MarkdownRenderInternal: FC<MarkdownRenderProps> = ({
mdContent,
width = '80vw',
height = '80vh',
maxWidth = 1000,
maxHeight = 800,
isVisible = false,
onClose,
darkMode = true,
}) => {
useEffect(() => {
if (isVisible) {
document.documentElement.style.overflowY = 'hidden';
} else {
document.documentElement.style.overflowY = 'overlay';
}
}, [isVisible]);
const bgColor = darkMode ? 'bg-[#1e1e1e]' : 'bg-white';
const textColor = darkMode ? 'text-gray-200' : 'text-gray-800';
return ReactDOM.createPortal(
<AnimatePresence>
{isVisible && (
<motion.div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
initial="hidden"
animate="visible"
exit="hidden"
variants={overlayVariants}
onClick={onClose}
>
<motion.div
className={`${bgColor} relative flex flex-col overflow-hidden rounded-xl shadow-2xl`}
style={{
width,
height,
maxWidth,
maxHeight,
}}
variants={contentVariants}
onClick={(e) => e.stopPropagation()}
>
<motion.div
className={`flex-1 overflow-y-auto p-6 ${textColor}`}
initial="hidden"
animate="visible"
transition={{ staggerChildren: 0.05 }}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ node, ...props }) => (
<h1
className={`mb-6 mt-2 border-b pb-2 text-center text-3xl font-bold ${darkMode ? 'border-gray-700' : 'border-gray-200'}`}
{...props}
/>
),
h2: ({ node, ...props }) => (
<h2
className="mb-4 mt-6 text-2xl font-semibold"
{...props}
/>
),
h3: ({ node, ...props }) => (
<h3
className="mb-3 mt-5 text-xl font-semibold"
{...props}
/>
),
h4: ({ node, ...props }) => (
<h4
className="mb-2 mt-4 text-lg font-medium"
{...props}
/>
),
p: ({ node, ...props }) => (
<p
className="mb-4 leading-relaxed"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
className="mb-4 list-disc space-y-2 pl-5"
{...props}
/>
),
ol: ({ node, ...props }) => (
<ol
className="mb-4 list-decimal space-y-2 pl-5"
{...props}
/>
),
li: ({ node, ...props }) => (
<li
className="mb-1"
{...props}
/>
),
blockquote: ({ node, ...props }) => (
<blockquote
className={`border-l-4 pl-4 ${darkMode ? 'border-gray-600 bg-gray-800/50' : 'border-gray-300 bg-gray-50'} my-4 rounded px-3 py-2 italic`}
{...props}
/>
),
a: ({ node, ...props }) => (
<a
className={`text-blue-500 hover:underline`}
{...props}
target="_blank"
rel="noopener noreferrer"
/>
),
pre: ({ node, ...props }) => (
<pre
className="my-4 overflow-x-auto rounded-lg text-sm"
{...props}
/>
),
table: ({ node, ...props }) => (
<div className="my-4 overflow-x-auto">
<table
className={`min-w-full border ${darkMode ? 'border-gray-700' : 'border-gray-300'}`}
{...props}
/>
</div>
),
thead: ({ node, ...props }) => (
<thead
className={
darkMode
? 'bg-gray-800'
: 'bg-gray-100'
}
{...props}
/>
),
th: ({ node, ...props }) => (
<th
className={`px-4 py-2 text-left ${darkMode ? 'border-gray-700' : 'border-gray-300'} font-semibold`}
{...props}
/>
),
td: ({ node, ...props }) => (
<td
className={`px-4 py-2 ${darkMode ? 'border-gray-700' : 'border-gray-300'} border-t`}
{...props}
/>
),
hr: ({ node, ...props }) => (
<hr
className={`my-6 ${darkMode ? 'border-gray-700' : 'border-gray-300'}`}
{...props}
/>
),
img: ({ node, ...props }) => (
<img
className="my-4 h-auto max-w-full rounded"
{...props}
loading="lazy"
/>
),
strong: ({ node, ...props }) => (
<strong
className="font-bold"
{...props}
/>
),
em: ({ node, ...props }) => (
<em
className="italic"
{...props}
/>
),
}}
>
{mdContent}
</ReactMarkdown>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
);
};
type MarkdownModalState = {
isVisible: boolean;
mdContent: string;
config: Omit<MarkdownRenderProps, 'mdContent' | 'isVisible' | 'onClose'>;
};
let modalState: MarkdownModalState = {
isVisible: false,
mdContent: '',
config: {},
};
let forceUpdate: (() => void) | null = null;
const MarkdownRenderContainer = () => {
const [, setTick] = useState(0);
useEffect(() => {
forceUpdate = () => setTick((tick) => tick + 1);
return () => {
forceUpdate = null;
};
}, []);
const handleClose = () => {
modalState.isVisible = false;
forceUpdate?.();
};
return (
<MarkdownRenderInternal
{...modalState.config}
mdContent={modalState.mdContent}
isVisible={modalState.isVisible}
onClose={handleClose}
/>
);
};
let containerEl: HTMLDivElement | null = null;
const ensureContainer = () => {
if (typeof window === 'undefined') return;
if (!containerEl) {
containerEl = document.createElement('div');
containerEl.id = 'markdown-render-container';
document.body.appendChild(containerEl);
ReactDOM.render(<MarkdownRenderContainer />, containerEl);
}
};
// 函数式API
export const showMarkdown = (
mdContent: string,
config?: Omit<MarkdownRenderProps, 'mdContent' | 'isVisible' | 'onClose'>
) => {
ensureContainer();
modalState.mdContent = mdContent;
modalState.isVisible = true;
if (config) {
modalState.config = config;
}
forceUpdate?.();
};
export const hideMarkdown = () => {
if (!modalState.isVisible) return;
modalState.isVisible = false;
forceUpdate?.();
};
// 兼容旧API的组件
const MarkdownRender = forwardRef<MarkdownRenderHandle, MarkdownRenderProps>(
(props, ref) => {
const { mdContent, ...restProps } = props;
useImperativeHandle(ref, () => ({
show: () => showMarkdown(mdContent, restProps),
hide: () => hideMarkdown(),
}));
return null;
}
);
// 初始化容器
if (typeof window !== 'undefined') {
ensureContainer();
}
export default MarkdownRender;🔍 使用场景与实战案例
在实际项目中,这个组件已经应用在多个场景:
- 帮助文档展示:点击"帮助"按钮,快速显示功能说明
- 操作指南:在复杂操作前,显示步骤引导
- 系统公告:登录后展示重要通知
- 错误详情:展示格式化的错误堆栈和解决方案
特别适合那些需要丰富格式但又不想引入重量级编辑器的场景。
📈 后续优化方向
虽然这个组件已经满足了基本需求,但还有一些优化空间:
- 代码高亮:集成 Prism.js 或 highlight.js 增强代码块显示
- 主题定制:提供更多预设主题和自定义主题选项
- 图片预览:添加图片点击放大预览功能
- 性能优化:考虑使用虚拟滚动处理超长内容
💬 总结
通过这个轻量级 Markdown 渲染组件,我们解决了项目中频繁出现的富文本展示需求,既满足了开发效率,又保证了良好的用户体验。特别是函数式 API 的设计,大大简化了使用方式,受到团队成员的一致好评。
希望这个组件能对你的项目有所帮助!如果有任何建议或改进意见,欢迎在评论区留言交流。