背景
有一个markdown文档,需要前端进行markdown解析,同时要能生成一个目录。整体效果类似掘金。
解析markdown的开源库挺多的,但是生成的目录并不满足我的预期。于是只好自己来做一个。
使用库
- react v16.2.0
- marked v0.7.0
开发
获取解析后的根节点
本文采用 marked
对markdown进行解析。 设置ref来获取解析后的根节点
<section className="doc-container">
<div
dangerouslySetInnerHTML={{__html: markdown}}
className="markdown-body"
ref={(ref) => {this.markdown = ref;}} // 用来获取文档解析后的根节点
></div>
</section>
获取目录信息
当文档解析成html内容后,下一步就需要获取所有的标题(h1-h6)信息
constructor(props) {
super(props);
this.state={
menuData: [], // 用来存储目录结构
menuState: '' // 用来存储当前命中的标题
};
}
// 因为要计算高度,预留1秒等待文档加载
componentDidMount() {
setTimeout(() => {
this.getAPs(['H1', 'H2', 'H3', 'H4']);
}, 1000);
}
// 获取标题锚点
// 参数 nodeArr 表示需要解析到目录的内容标题
getAPs = (nodeArr) => {
let nodeInfo = []; // 存储目录信息
// 对文档根节点的每一个子节点进行遍历,选出所有需要解析的目录标题
this.markdown.childNodes.forEach((item) => {
if (nodeArr.includes(item.nodeName)) {
nodeInfo.push({
type: item.nodeName, // 存储该标题的类型
txt: item.getAttribute('id'), // 存储该标题的文本 [ps:marked解析出来的h1-h6标题会在id里填上对应的标题文本]
offsetTop: item.offsetTop // 存储该标题离页面顶部的距离
});
}
});
this.setState({
menuData: nodeInfo,
menuState: nodeInfo[0].txt
}, () => {
this.checkMenuScroll(); // 检测滚动,稍后会讲解
});
}
完成这一步后,能获取到大概如下的数据
{
menuData: [
{
type: 'H1',
txt: '标题1',
offsetTop: 200
},
{
type: 'H2',
txt: '标题2',
offsetTop: 300
}
],
menuState: '标题1'
}
由目录信息生成目录
对menuData进行遍历,生成对应的li标签, class样式单独设置,并赋予点击事件
<section className="doc-aside">
<ul>
{
this.state.menuData.map((item) => {
return (
<li
className={`${item.type}type`}
key={item.txt}
onClick={() => {this.scrollPage(item);}} // 当点击时执行页面移动,稍后会讲解该函数
>
<a
className={menuState === item.txt ? 'on' : ''}
// 这里用来处理该标题是否命中,a标签不是必须
>{item.txt}</a>
</li>
);
})
}
</ul>
</section>
现在我们已经根据文档的内容生成了对应的标题。
接下来我们需要对页面滚动和标题点击进行处理
监听页面滚动
// 检测页面滚动函数
checkMenuScroll = () => {
// this.scroll 为整个页面的根节点,用来监听滚动
this.scroll.addEventListener('scroll', () => {
let scrollTop = this.scroll.scrollTop; // 获取当前页面的滚动距离
let menuState = this.state.menuData[0].txt; // 设置menuState对象默认值为第一个标题的文字
// 对menuData循环检测,
// 如果当前页面滚动距离 大于 一个标题离页面顶部 的距离,则将该标题的文字赋值给menuState,循环继续
// 如果当前页面滚动距离 小于 一个标题离页面顶部 的距离,说明页面还没滚动到该标题的位置,当前标题尚未命中,之后的标题也不可能命中。 循环结束
for(let item of this.state.menuData) {
if (scrollTop >= item.offsetTop) {
menuState = item.txt;
} else {
break;
}
}
// 如果滑动到了页面的底部,则命中最后一个标题
if (this.scroll.clientHeight + scrollTop === this.scroll.scrollHeight) {
menuState = this.state.menuData[this.state.menuData.length - 1].txt;
}
// 如果当前命中标题和前一个命中标题的文本不一样,说明当前页面处于其他标题下的内容,切换menuState
if (menuState !== this.state.menuState) {
this.setState({menuState});
}
});
}
...
// 这里需要设置一个ref,保存整个页面的根节点,用来监听滚动, 其他内容保持不变
<div ref={(ref) => {this.scroll = ref;}}>
<section className="doc-aside">
<ul>
{
menuData.map((item) => {
return (
<li
className={`${item.type}type`}
key={item.txt}
onClick={() => {this.scrollPage(item);}}
>
<a className={menuState === item.txt ? 'on' : ''}>{item.txt}</a>
</li>
);
})
}
</ul>
</section>
<section className="doc-container">
<div
dangerouslySetInnerHTML={{__html: markdown}}
className="markdown-body"
ref={(ref) => {this.markdown = ref;}}
></div>
</section>
</div>
做完这一步,就已经实现了页面滚动监听,当我们滚动页面时,目录能实时反馈用户正在阅读哪一个标题下的内容。
目录切换
最后,当点击标题时,能自动切换到对应标题下的内容中
// 点击目录切换
scrollPage = (item) => {
// 创建一个setInterval,每16ms执行一次,接近60fps
let scrollToTop = window.setInterval(() => {
let currentScroll = this.scroll.scrollTop;
if (currentScroll > item.offsetTop) {
// 当页面向上滚动时操作
this.scroll.scrollTo(0, currentScroll - Math.ceil((currentScroll - item.offsetTop) / 5));
} else if (currentScroll < item.offsetTop) {
// 页面向下滚动时的操作
if (this.scroll.clientHeight + currentScroll === this.scroll.scrollHeight) {
// 如果已经滚动到了底部,则直接跳出
this.setState({menuState: item.txt});
window.clearInterval(scrollToTop);
} else {
this.scroll.scrollTo(0, currentScroll + Math.ceil((item.offsetTop - currentScroll) / 5));
}
} else {
window.clearInterval(scrollToTop);
}
}, 16);
}
到这里,就已经实现了一个仿掘金的目录,并且比掘金更好的是在点击标题时能滚动到指定的内容部分。