[React] markdown以及markdown-navbar实现方案

1,927 阅读6分钟

[React] markdown以及markdown-navbar实现方案

1. 前言

心血来潮,想在自己的项目中实现 Markdown 文件的渲染。以下是我当前的实现方式以及遇到的一些问题的记录。

本人水平很拉,有更好的方法欢迎在下面讨论

2. markdown文件解析和渲染的实现

2.1 使用的第三方包

2.1.1 markdown 文件解析 - react-markdown

react-markdown是一个React组件,可以将Markdown文本转换为相应的HTML代码。

npm install react-markdown

2.1.2 markdown 解析扩展插件 - remark-gfm

react-markdown默认不支持自动链接、删除线、表格、任务列表等,可以使用remark-gfm插件进行扩展。

npm install remark-gfm

2.1.3 markdown 页面 css 样式github-markdown-css

这里直接在index.html文件中引入了github-markdown-css的css文件。

    <link rel="stylesheet" href="\styles\github-markdown-css\github-markdown.css">
    <link rel="stylesheet" href="\styles\github-markdown-css\github-markdown-light.css">

也可以npm的形式引入:

npm install github-markdown-css

2.2 代码以及实现

import React, { useState, useEffect } from "react";
import ReactMarkDown from "react-markdown"; // markdown渲染模块
import remarkGfm from "remark-gfm"; // markdown扩展插件
import styles from "./index.module.less";

const MarkDownNav: React.FC = () => {
    const [sourceMd, setSourceMd] = useState("");

    useEffect(() => {
        // 请求文件 (请求的是项目public目录下的文件)
        fetch("/md/React/01-projectInit.md")
            .then((res) => res.text())
            .then((text) => setSourceMd(text));
    }, []);

    return (
        <div className={`${styles["document-page"]} wrapper`}>
            <main
                id='markdown'
                className={`${styles.content} container markdown-body`}
                >
                {
                    <ReactMarkDown
                        children={sourceMd} // 传入markdown文件数据
                        remarkPlugins={[remarkGfm]} // 传入插件
                        />
                }
            </main>
        </div>
    );
};

export default MarkDownNav;

image-20230829184451213.png

3. markdown-navbar 的实现

写这篇文章其实主要还是想记录 navbar 的实现,这里踩了很多坑。下面记录了我找到的两种实现方式,个人还是推荐用第二种手动实现的方式。

第一种借助了markdown-navbar包,但是markdown-navbar好几年没维护了,有挺多bug,我就我遇到的bug进行了一下修复。

第二种是手动实现(用到了antd的Anchor组件)。在找navbar其他解决方案的时候发现的React使用react-markdown+antd实现引入渲染markdown文件。我模仿并修改了一下这篇文章的实现方式,实现方式为:

  1. 首先取出markdown dom节点中所有h1-h6标签,
  2. 在每个标题标签中加入id属性作为锚点,
  3. 然后格式化标签数组,传入antd的Anchor组件自动生成navbar并拥有锚点跳转的功能

3.1 markdown-navbar实现navbar

markdown-navbar有许多问题,这里先贴一下npm包。

npm install markdown-navbar

3.1.1 遇到的bug以及解决方案

npm 下载的版本

markdown-navbar存在挺多问题,比如npm下载的源码与github上的源码不同。npm下载的源码存在对于标题级别判断不全导致“navbar中标题序号生成错误”以及“高亮错误”的bug,如下:

image-20230829184649938.png

还有点击navbar不跳转到markdown文章相应位置的bug,这由于markdown文件异步获取,给markdown-navbar的组件设置一个setTimeout(),等待markdown获取并渲染完毕再渲染navbar就可以了

解决方案

源码错误位置(毕竟是修改node_modules目录里的,换个地方npm i就无效了)

// 源码对应 270行
// vite缓存对应 4564行

navData.forEach(function(t, i) {
    if (!t.listNo) {
        // 如果有前面有标题,且前一个标题为父级标题,则设置当前标题为子节点中第一个(例:前一个为`1.1`,则当前为`1.1.1`)
        if (navData[i - 1] && t.level === navData[i - 1].level + 1) {
            t.listNo = "".concat(navData[i - 1].listNo, ".1");
        } // 如果有前面有标题,且前一个标题为兄弟标题,则设置当前标题为下一个兄弟标题(例:前一个为`1.1.3`,则当前为`1.1.4`)
        else if (navData[i - 1] && t.level === navData[i - 1].level) {
            t.listNo = navData[i - 1].listNo.replace(/^(.+\.)(\d+)$/g, function(w, $1, $2) {
                return "".concat($1).concat(parseInt($2, 10) + 1);
            });
        } // 如果有前面有标题,且前一个标题级别低于当前标题,则设置当前标题为下一个父级标题(例:前一个为`1.1.3`,则当前为`1.2`)
        else if (navData[i - 1] && t.level === navData[i - 1].level - 1) {
            t.listNo = navData[i - 1].listNo.replace(/^(.+\.)(\d+)$/g, function(w, $1, $2) {
                return "".concat(parseInt($1, 10) + 1).concat(".1");
            });
        } else {
            t.listNo = "";
        }
    }
});

然后就渲染正常了

image-20230829185415715.png

github 下载的版本

github上的源码对上面这个bug进行了修复,但没同步到npm上(或者说同步错了?)。不过也存在其他bug,大概是因为不适配react 18导致的,直接使用会报错,提示Cannot read properties of undefined (reading 'replace')

image-20230829184729086转存失败,建议直接上传图片文件

还有componentWillReceiveProps生命周期在react新版本被弃用,会报错;以及currentNavElement变量未定义的报错。

解决方案

这里我fork了仓库,提交了修改后的版本markdown-navbar

image.png

image2.png

image3.png

这里没传npm(毕竟源码是其他大佬的,我传感觉不太好),我直接在项目中引入jsx文件进行调用。

import React, { useState, useEffect } from "react";
import ReactMarkDown from "react-markdown";
// import MarkdownNavbar from "markdown-navbar";
import remarkGfm from "remark-gfm";
import styles from "./index.module.less";
import "markdown-navbar/dist/navbar.css";
import MarkdownNavbar from "@/utils/markdown-navbar/index.jsx"; // 调用存在本地的jsx文件

/**
 * 通用markdown文章渲染页
 * 左侧aside为markdown文件navbar
 * 右侧content为markdown内容
 */

const MarkDownNav: React.FC = () => {
	const [show, setShow] = useState(false);
	const [sourceMd, setSourceMd] = useState("");
	const [isFix, setIsFix] = useState(false);

	useEffect(() => {
		// 请求文件
		fetch("/md/React/01-projectInit.md")
			.then((res) => res.text())
			.then((text) => setSourceMd(text));

		// 监听滚动事件
		window.addEventListener("scroll", handleScroll);

		return () => {
			window.removeEventListener("scroll", handleScroll);
		};
	}, []);

	useEffect(() => {
		// 获取到md数据后显示md内容和nav
		setTimeout(() => {
			setShow(true);
		}, 1000);
	}, [sourceMd, setSourceMd]);

	/**
	 * 滚动事件
     * 用于navbar定位到屏幕固定位置
	 */
	const handleScroll = () => {
		let scrollTop = document.documentElement.scrollTop; //滚动条滚动高度
		if (scrollTop > 80) {
			setIsFix(true);
		} else {
			setIsFix(false);
		}
	};

	return (
		<div className={`${styles["document-page"]} wrapper`}>
			<aside
				className={`${styles.aside} container`}
				style={isFix ? { position: "fixed", top: 5 } : {}}
			>
				{show && <MarkdownNavbar source={sourceMd}></MarkdownNavbar>}
			</aside>
			<main
				id='markdown'
				className={`${styles.content} container markdown-body`}
			>
				{(
					<ReactMarkDown
						children={sourceMd}
						remarkPlugins={[remarkGfm]}
					/>
				)}
			</main>
		</div>
	);
};

export default MarkDownNav;

实现效果

image4.png

3.2 自己手动实现markdown navbar

再提一嘴这个实现方法的出处是React使用react-markdown+antd实现引入渲染markdown文件, 实现方式为:

  1. 首先取出markdown dom节点中所有h1-h6标签,
  2. 在每个标题标签中加入id属性作为锚点,
  3. 然后格式化标签数组,传入antd的Anchor组件自动生成navbar并拥有锚点跳转的功能

下面我直接贴代码了,里面有具体的注释

import React, { useState, useEffect } from "react";
import { Anchor } from "antd";
import ReactMarkDown from "react-markdown";
import remarkGfm from "remark-gfm";
import styles from "./index.module.less";

/**
 * 通用markdown文章渲染页
 * 左侧aside为markdown文件navbar
 * 右侧content为markdown内容
 */

type Title = {
	key: string;
	href: string;
	title: string;
	children?: Title[];
	nodeName: any;
};

const Documents: React.FC = () => {
	const [show, setShow] = useState(false);
	const [sourceMd, setSourceMd] = useState("");
	const [titles, setTitles] = useState<Title[]>([]);
	const [isFix, setIsFix] = useState(false);

	useEffect(() => {
		// 请求文件
		fetch("/md/React/01-projectInit.md")
			.then((res) => res.text())
			.then((text) => setSourceMd(text));

		// 监听滚动事件
		window.addEventListener("scroll", handleScroll);

		return () => {
			window.removeEventListener("scroll", handleScroll);
		};
	}, []);

	useEffect(() => {
		// 获取到md数据后显示md内容和nav
		setTitles(addAnchor());
		setShow(true);
	}, [sourceMd, setSourceMd]);

	/**
	 * 滚动事件
	 */
	const handleScroll = () => {
		let scrollTop = document.documentElement.scrollTop; //滚动条滚动高度
		if (scrollTop > 80) {
			setIsFix(true);
		} else {
			setIsFix(false);
		}
	};

	/**
	 * 格式化markdown标题的dom节点数组
	 */
	const formatNavItem = (headerDom: NodeListOf<HTMLElement>) => {
		// 将NodeList转换为数组,并提取出需要的属性
		let headerArr = Array.prototype.slice
			.call(headerDom)
			.map((item, index) => {
				let title = {
					href: "#" + index,
					key: "" + index,
					title: headerDom[index].innerText,
					children: [],
					nodeName: item.nodeName,
				};
				return title;
			}) as Title[];

		/**
		 * (双重循环,从后往前,逐渐将子节点存入父节点children属性)
		 * 1. 从后往前,将子标题直接存入前一个父级标题的children[]中
		 * 2. 如果前一个标题与当前标题(或标题数组)无直系关系,则直接将当前标题(或标题数组解构后)放入list数组
		 * 3. 循环多次,直到result数组长度无变化,结束循环
		 */
		let result = headerArr;
		let preLength = 0;
		let newLength = result.length;
		let num = 0;
		while (preLength !== newLength) {
			num++;
			preLength = result.length; // 获取处理前result数组长度
			let list: Title[] = []; // list数组用于存储本次for循环结果
			let childList: Title[] = []; // childList存储遍历到的兄弟标题,用于找到父标题时赋值给父标题的children属性
			for (let index = result.length - 1; index >= 0; index--) {
				if (
					// 当前节点与上一个节点是兄弟节点,将该节点存入childList数组
					result[index - 1] &&
					result[index - 1].nodeName.charAt(1) ===
						result[index].nodeName.charAt(1)
				) {
					childList.unshift(result[index]);
				} else if (
					// 当前节点是上一个节点的子节点,则将该节点存入childList数组,将childList数组赋值给上一节点的children属性,childList数组清空
					result[index - 1] &&
					result[index - 1].nodeName.charAt(1) <
						result[index].nodeName.charAt(1)
				) {
					childList.unshift(result[index]);
					result[index - 1].children = [
						...(result[index - 1].children as []),
						...childList,
					];
					childList = [];
				} else {
					// 当前节点与上一个节点无直系关系,或当前节点下标为0的情况
					childList.unshift(result[index]);
					if (childList.length > 0) {
						list.unshift(...childList);
					} else {
						list.unshift(result[index]);
					}
					childList = [];
				}
			}
			result = list;
			newLength = result.length; // 获取处理后result数组长度
		}
		return result;
	};

	/**
	 * markdown锚点注入方法
	 */
	const addAnchor = () => {
		// 获取markdown标题的dom节点
		const header: NodeListOf<HTMLElement> = document.querySelectorAll(
			"h1, h2, h3, h4, h5, h6"
		);
		// 向标题中注入id,用于锚点跳转
		header.forEach((navItem, index) => {
			navItem.setAttribute("id", index.toString());
		});
		// 格式化标题数组,用于antd锚点组件自动生成锚点
		let titles = formatNavItem(header);
		return titles;
	};

	/**
	 * 锚点item点击事件
	 * 1.解决antd的Anchor组件会在导航栏显示"#锚点id"的问题,
	 * 2.以及本项目中navbar通过监听屏幕滚动进行定位,通过scrollIntoView设置页面滚动缓冲,可以一定程度上解决在页面快速滚动时navbar的定位切换造成的闪烁问题。
	 * 当然也可以不设置该点击事件
	 */
	const handleClickNavItem = (e: any, link: any) => {
		e.preventDefault();
		if (link.href) {
			// 找到锚点对应得的节点
			let element = document.getElementById(link.href);
			// 如果对应id的锚点存在,就跳滚动到锚点顶部
			element &&
				element.scrollIntoView({ block: "start", behavior: "smooth" });
		}
	};

	return (
		<div className={`${styles["document-page"]} wrapper`}>
			<aside
				className={`${styles.aside} container`}
				style={isFix ? { position: "fixed", top: 5 } : {}}
			>
				{titles.length > 0 && (
					<Anchor
						className='markdown-nav'
						affix={false}
						offsetTop={100} // 设置距离页面顶部的偏移
						onClick={handleClickNavItem}
						items={titles}
					></Anchor>
				)}
			</aside>
			<main
				id='markdown'
				className={`${styles.content} container markdown-body`}
			>
				{show && (
					<ReactMarkDown
						children={sourceMd}
						remarkPlugins={[remarkGfm]}
					/>
				)}
			</main>
		</div>
	);
};

export default Documents;

实现效果

image5.png

4. 文章引用

如何使用react-markdown安全地渲染markdown

React使用react-markdown+antd实现引入渲染markdown文件