[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;
3. markdown-navbar 的实现
写这篇文章其实主要还是想记录 navbar 的实现,这里踩了很多坑。下面记录了我找到的两种实现方式,个人还是推荐用第二种手动实现的方式。
第一种借助了
markdown-navbar包,但是markdown-navbar好几年没维护了,有挺多bug,我就我遇到的bug进行了一下修复。第二种是手动实现(用到了antd的
Anchor组件)。在找navbar其他解决方案的时候发现的React使用react-markdown+antd实现引入渲染markdown文件。我模仿并修改了一下这篇文章的实现方式,实现方式为:
- 首先取出markdown dom节点中所有h1-h6标签,
- 在每个标题标签中加入id属性作为锚点,
- 然后格式化标签数组,传入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,如下:
还有点击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 = "";
}
}
});
然后就渲染正常了
github 下载的版本
github上的源码对上面这个bug进行了修复,但没同步到npm上(或者说同步错了?)。不过也存在其他bug,大概是因为不适配react 18导致的,直接使用会报错,提示Cannot read properties of undefined (reading 'replace')

还有componentWillReceiveProps生命周期在react新版本被弃用,会报错;以及currentNavElement变量未定义的报错。
解决方案
这里我fork了仓库,提交了修改后的版本markdown-navbar
这里没传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;
实现效果
3.2 自己手动实现markdown navbar
再提一嘴这个实现方法的出处是React使用react-markdown+antd实现引入渲染markdown文件, 实现方式为:
- 首先取出markdown dom节点中所有h1-h6标签,
- 在每个标题标签中加入id属性作为锚点,
- 然后格式化标签数组,传入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;
实现效果