实现一个动态生成并能同步定位的目录

2,584 阅读3分钟

背景

有一个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);   
}

到这里,就已经实现了一个仿掘金的目录,并且比掘金更好的是在点击标题时能滚动到指定的内容部分。