Skip to content

告别复杂:打造轻量级 Markdown 渲染弹窗组件 ​

在日常开发中,我们经常需要以弹窗形式展示富文本内容,比如帮助文档、操作指南或系统公告。这些内容通常用 Markdown 编写,便于维护和更新。然而,市面上的 Markdown 渲染组件要么过于复杂,要么定制性不足,难以满足项目的特定需求。

经过多个项目的实践,我总结出一套轻量级的 Markdown 渲染弹窗解决方案,既保证了使用的简便性,又提供了足够的灵活性。今天就和大家分享这个组件的实现思路和具体代码。

🚀 两种调用方式,满足不同场景需求 ​

根据实际项目中的使用体验,我设计了两种调用方式:函数式 API 和组件式 API。

函数式 API(推荐)✨ ​

函数式 API 类似于 antd 的 Modal.show() 方法,直接调用函数即可显示内容,无需创建组件引用或管理状态,特别适合临时性的内容展示:

tsx
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 来控制显示和隐藏:

tsx
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 方式都可以使用:

参数类型默认值说明
mdContentstring-Markdown 内容字符串(必需)
widthnumber | string'80vw'弹窗宽度
heightnumber | string'80vh'弹窗高度
maxWidthnumber | string800弹窗最大宽度
maxHeightnumber | string600弹窗最大高度
darkModebooleantrue是否启用深色模式

通过这些配置项,可以灵活控制弹窗的尺寸和外观,满足不同界面设计的需求。例如,对于简短的提示信息,可以设置较小的尺寸;对于详细的帮助文档,则可以设置较大的显示区域。

💡 实现思路与技术要点 ​

技术选型 ​

组件的核心依赖包括:

  • React Markdown:用于解析和渲染 Markdown 内容
  • Remark GFM:支持 GitHub Flavored Markdown,增强表格、任务列表等功能
  • Framer Motion:提供平滑优雅的动画效果
  • Tailwind CSS:用于样式定制

架构设计 ​

整个组件分为三层:

  1. 内部渲染组件(MarkdownRenderInternal):负责实际的 Markdown 渲染和样式应用
  2. 容器组件(MarkdownRenderContainer):管理弹窗状态,处理全局单例
  3. 对外 API 层:提供函数式和组件式两种调用方式

这种分层设计使得组件既保持了内部实现的清晰性,又提供了灵活的调用方式。

性能优化 ​

为了避免重复渲染和内存泄漏,我采用了以下策略:

  • 使用单例模式管理弹窗状态,避免多实例冲突
  • 懒加载容器,只在首次调用时创建 DOM 节点
  • 控制 ReactDOM.render 的调用次数,减少不必要的重渲染

📝 完整组件代码 ​

下面是完整的组件实现代码,包含了所有功能和样式定制:

tsx
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;

🔍 使用场景与实战案例 ​

在实际项目中,这个组件已经应用在多个场景:

  1. 帮助文档展示:点击"帮助"按钮,快速显示功能说明
  2. 操作指南:在复杂操作前,显示步骤引导
  3. 系统公告:登录后展示重要通知
  4. 错误详情:展示格式化的错误堆栈和解决方案

特别适合那些需要丰富格式但又不想引入重量级编辑器的场景。

📈 后续优化方向 ​

虽然这个组件已经满足了基本需求,但还有一些优化空间:

  1. 代码高亮:集成 Prism.js 或 highlight.js 增强代码块显示
  2. 主题定制:提供更多预设主题和自定义主题选项
  3. 图片预览:添加图片点击放大预览功能
  4. 性能优化:考虑使用虚拟滚动处理超长内容

💬 总结 ​

通过这个轻量级 Markdown 渲染组件,我们解决了项目中频繁出现的富文本展示需求,既满足了开发效率,又保证了良好的用户体验。特别是函数式 API 的设计,大大简化了使用方式,受到团队成员的一致好评。

希望这个组件能对你的项目有所帮助!如果有任何建议或改进意见,欢迎在评论区留言交流。