實現一個動態生成並能同步定位的目錄

背景

有一個markdown文檔,須要前端進行markdown解析,同時要能生成一個目錄。總體效果相似掘金。html

解析markdown的開源庫挺多的,可是生成的目錄並不知足個人預期。因而只好本身來作一個。前端


使用庫node

  • react v16.2.0
  • marked v0.7.0

開發

獲取解析後的根節點

本文采用 marked 對markdown進行解析。 設置ref來獲取解析後的根節點react

<section className="doc-container">
  <div
    dangerouslySetInnerHTML={{__html: markdown}}
    className="markdown-body" 
    ref={(ref) => {this.markdown = ref;}} // 用來獲取文檔解析後的根節點
  ></div>
</section>
複製代碼

獲取目錄信息

當文檔解析成html內容後,下一步就須要獲取全部的標題(h1-h6)信息markdown

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(); // 檢測滾動,稍後會講解
  });
}

複製代碼

完成這一步後,能獲取到大概以下的數據ide

{
  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>
複製代碼

如今咱們已經根據文檔的內容生成了對應的標題。ui

接下來咱們須要對頁面滾動和標題點擊進行處理this

監聽頁面滾動

// 檢測頁面滾動函數
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>

複製代碼

作完這一步,就已經實現了頁面滾動監聽,當咱們滾動頁面時,目錄能實時反饋用戶正在閱讀哪個標題下的內容。spa

目錄切換

最後,當點擊標題時,能自動切換到對應標題下的內容中

// 點擊目錄切換
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);   
}
複製代碼

到這裏,就已經實現了一個仿掘金的目錄,而且比掘金更好的是在點擊標題時能滾動到指定的內容部分。

相關文章
相關標籤/搜索