tab列表與商品列表關聯滾動-scrollTop && Intersection Observer

使用場景

多Tab展現分類的商品列表是移動端商城常見場景,下圖是手淘頁女裝類目下的多Tab列表 javascript

能夠看到,點擊某個tab,能夠跳轉到對應的服裝列表,而且隨着列表的滾動,當前激活的Tab也在隨之改變。這裏面主要包括兩個功能:

  • 點擊某個 tab ,跳轉到相應位置的商品列表 - 點擊錨點定位
  • 隨着列表滾動到某個類型的服裝,當前激活的 tab 也會相應改變

demo 搭建

這裏使用 create-react-app 腳手架進行 demo 的建立css

demo 頁面主要分爲 tab 標籤列表和每一個 tab 對應的商品列表html

  • 組件
render() {
    return (
      <div className='mobile-container'> {/* 標籤 */} {this.renderTabList()} {/* 商品列表 */} {this.renderGoodList()} </div>
    );
  }
複製代碼
  • mock 數據

這裏爲了展現方便,總共展現五個 tab,對應五個商品列表,每一個列表下各有五個商品,單排展現,數據結構以下:java

// model.js
export const TAB_TYPE = {
  JINGX_XUAN: '精選',
  NV_ZHUANG: '女裝',
  BAI_HUO: '百貨',
  XIE_BAO: '鞋包',
  SHI_PIN: '食品'
};

export const TAB_ID = {
  [TAB_TYPE.JINGX_XUAN]: 'tab_1',
  [TAB_TYPE.NV_ZHUANG]: 'tab_2',
  [TAB_TYPE.BAI_HUO]: 'tab_3',
  [TAB_TYPE.XIE_BAO]: 'tab_4',
  [TAB_TYPE.SHI_PIN]: 'tab_5'
};

export const TAB_LIST_ID = {
  [TAB_TYPE.JINGX_XUAN]: 'tab_list_1',
  [TAB_TYPE.NV_ZHUANG]: 'tab_list_2',
  [TAB_TYPE.BAI_HUO]: 'tab_list_3',
  [TAB_TYPE.XIE_BAO]: 'tab_list_4',
  [TAB_TYPE.SHI_PIN]: 'tab_list_5'
};

export const TAB_ARRAY = Object.keys(TAB_TYPE).map(key => TAB_TYPE[key]);
export const TAB_ID_ARRAY = TAB_ARRAY.map(key => TAB_ID[key]);
export const TAB_LIST_ID_ARRAY = TAB_ARRAY.map(key => TAB_LIST_ID[key]);
export const TAB_LEN = TAB_ARRAY.length;

export const ALL_LIST = TAB_ARRAY.reduce((prev, cur) => {
  const goodslist = Array.from({ length: TAB_LEN }, (item, index) => ({
    goods_name: `${cur}_${index + 1}`
  }));
  prev[cur] = goodslist;
  return prev;
}, {});
複製代碼

最後渲染出來的 DOM 結構和樣式展現以下:react

接下來就能夠開始實現這兩個功能了😝

功能實現

點擊錨點定位

點擊某個 tab 實現錨點定位,每一個 tab 標籤綁定其對應的 tab_list_id( data-anchor=${tab_list_id} ),點擊 tab 拿到當前點擊的 event.target.dataset.anchor,得到其距離文檔頂部的距離,文檔滾動相應的距離便可。api

// index.js
handleTabClick = (e, index) => {
    if (e) {
      e.preventDefault();
      e.stopPropagation();
    }
    this.setState({
      activeTabIndex: index
    });
    // 得到點擊 tab 標籤對應的商品列表容器id,獲得容器元素
    const element = document.getElementById(e.target.dataset.anchor);
    // 得到固定容器元素頂部距離文檔頂部的距離
    const offsetTopOnBody = getElementOffsetTopOnBody(element);
    // HEADER_HEIGHT 爲 Tab 欄高度,文檔滾動到 tab 對應的商品列表
    setDocumentScrollTop(offsetTopOnBody - HEADER_HEIGHT);
};
複製代碼
<div className={`tab ${this.state.activeTabIndex === index ? 'tab-active' : ''}`} key={`tab_${index}`} onClick={e => this.handleTabClick(e, index)}
    data-anchor={TAB_LIST_ID[tab]}
    id={TAB_ID[tab]}
>
    {tab}
</div>
複製代碼

這裏主要涉及到兩個與文檔距離及滾動相關的函數數組

  • getElementOffsetTopOnBody => 獲取某個元素距離 document.body 頂部的距離
// util.js
export function getElementOffsetTopOnBody(element) {
  if (!element) {
    return 0;
  }
  return getOffsetTop(element, document.body);
}

export function getOffsetTop(element, container) {
  let offset = 0;
  while (element) {
    if (element === container) {
      break;
    }
    offset += element.offsetTop;
    // 此處一直往上找 position 不爲 static 的父元素(offsetParent)的 offsetTop 距離直到 document.body 爲止,將距離疊加即爲 element 距離文檔頂部的距離
    element = element.offsetParent;
  }
  return offset;
}
複製代碼
  • setDocumentScrollTop => 設置滾動條的滾動距離
// util.js
export function setDocumentScrollTop(top) {
  document.documentElement.scrollTop = top;
  document.body.scrollTop = top;
}
複製代碼

到此處,功能一已經實現啦✅數據結構

監聽文檔的滾動激活商品列表對應的 tab 標籤

具體有兩種實現方式app

  • 對文檔的滾動進行監聽,滾動到某個距離範圍內就對應激活相應的 tabdom

    • 思路簡單,實現起來較容易
    • 監聽文檔的滾動事件是比較耗費性能的一件事,須要進行優化處理,如 節流函數
    • 同時要考慮多 tab 對應的商品列表 DOM 結構是否在文檔初始加載後就已經存在(固然,骨架屏或者佔位 DOM 能夠解決這個問題)
  • IntersectionObserver API當前激活的 tab 能夠觀測目標是否在視口內,經過斷定目標元素是否進入視口/離開視口來觸發自定義動做,達到當前激活的 tab 的相應改變

scrollTop 監聽法

隨着列表的滾動,須要激活不一樣的 tab,很容易想到去監聽頁面的滾動,到頁面滾動到某個位置,即滾動距離落在某個範圍區間內,則激活這個區間對應的 tab 標籤

  • 首先是得到頁面的滾動距離 scrollTop
// util.js
export function getDocumentScrollTop() {
  return parseInt(document.documentElement.scrollTop || document.body.scrollTop || 0, 10); 
}
複製代碼
  • 計算各個商品列表容器距離文檔頂部的距離

    demo 中總共五個 tab => tab_1 ~ tab_5, 對應五個商品列表 list_type => tab_list_1 ~ tab_list_5,對應到文檔頂部的距離爲 [d1, d2, d3, d4, d5],demo 中設定:對於 tab_1,其激活的商品列表距離範圍爲 [0, d2],依此類推以下表

    當前激活的標籤 離文檔頂部距離範圍 對應的商品列表
    tab_1 [0, d2] tab_list_1
    tab_2 [d2, d3] tab_list_2
    tab_3 [d3, d4] tab_list_3
    tab_4 [d4, d5] tab_list_4
    tab_5 [d5, Infinity] tab_list_5

    注意,這個標籤對應商品列表距離範圍的規則能夠根據須要更改

    // util.js
    export function getHeightRange(elementArr) {
      // 得到 [0, d2, d3, d4, d5]
      const rangeArr = elementArr.reduce((prev, cur, index) => {
        let startHeight = 0;
        if (index === 0) {
          prev.push(startHeight);
          return prev;
        }
        startHeight = getElementOffsetTopOnBody(cur);
        prev.push(startHeight);
        return prev;
      }, []);
      // 得到 [[0, d2], [d2, d3], [d3, d4], [d4, d5], [d5]]
      return rangeArr.map((range, index) => {
        if (index === rangeArr.length - 1) {
          return [range];
        }
        return [range, rangeArr[index + 1]];
      });
    }
    複製代碼
  • 斷定文檔滾動距離 scrollTop[[0, d2], [d2, d3], [d3, d4], [d4, d5], [d5]] 的位置 scrollIndex

    // util.js
    export function getScrollListIndex(distance, range) {
      // scrollIndex => 當前激活的 tab
      let scrollIndex = 0;
      let findScrollIndex = false;
      range.forEach((item, index) => {
        if (!findScrollIndex) {
          if (index === range.length - 1 && distance >= item[0]) {
            // 是否滾動到最後一個商品列表
            scrollIndex = index;
            findScrollIndex = true;
          } else if(distance >= item[0] && distance < item[1]) {
            scrollIndex = index;
            findScrollIndex = true;
          }
        }
      });
      return scrollIndex;
    }
    
    // index.js
    componentDidMount() {
      // 這裏用 setTimeout 是由於react 中 componentDidMount 鉤子內 dom 雖然加載完了,可是樣式還未徹底加載上,所以須要使用這樣一個 hack,加載順序 js,css/scss
      setTimeout(() => {
        const tabListElementArr = Object.keys(TAB_LIST_ID).map(key =>
          document.getElementById(TAB_LIST_ID[key])
        );
        this.listHeightRange = getHeightRange(tabListElementArr);
      }, 0);
      window.addEventListener('scroll', this.handScroll);
    }
    // HEADER_PADDING_HEIGHT 第一個商品列表頂部距離文檔頂部距離,此時文檔未滾動
    handScroll = () => {
      const scrollTop = getDocumentScrollTop();
      const listIndex = getScrollListIndex(
        scrollTop + HEADER_PADDING_HEIGHT,
        this.listHeightRange
      );
      listIndex !== this.state.activeTabIndex &&
      this.setState({
        activeTabIndex: listIndex
      });
    };
    複製代碼

    到此時,tab列表與商品列表關聯滾動的功能已經基本實現,看圖😬

仔細看整個實現過程,不難發現,在計算各商品列表距離文檔頂部距離的時候,是在 componentDidMount 這個生命週期鉤子裏獲取的,即頁面加載完成後商品列表的結構和位置就已經肯定了。若是商品列表在滾動的時候,其高度和位置是動態變化的,此方法就不適用了。固然,有的同窗說能夠在滾動的時候監聽並從新計算啊,tab 數量少的時候或許能夠,可是 tab 數多了頁面性能就。。。

不要緊,我還有辦法,請看

Intersection Observer API 法

Intersection Observer API提供了一種異步觀察目標元素與祖先元素或頂級文檔viewport的交集中的變化的方法

關於該 API 的具體使用,能夠參考

本章節介紹的使用 Intersection Observer API 監聽用戶是否已經滾動到了某個商品列表,從而激活該商品列表對應的 tab 標籤
  • 首先爲全部 tab 標籤對應的商品列表建立 IntersectionObserver 對象
// index.js
  componentDidMount() {
    const observerOptions = {
      // threshold 爲 0.5 表明只要被觀察元素範圍的一半暴露在視口當中,就會觸發該元素對應的回調函數,激活當前被觀察元素對應的 tab 標籤
      threshold: 0.5
    };
    // 建立 IntersectionObserver 對象,並傳入監聽變化的回調函數 callback 和 控制調用觀察者的回調的環境配置 observerOptions
    this.observer = new IntersectionObserver(callbacks => {
      callbacks.forEach(cb => {
        this.checkItemIn(cb, observerOptions);
      });
    }, observerOptions);
    // 監聽全部的商品列表
    Object.keys(TAB_LIST_ID)
      .map(key => document.getElementById(TAB_LIST_ID[key]))
      .forEach(element => {
        this.observer.observe(element);
    });
  }
  
  componentWillUnmount() {
    // 頁面銷燬時移除全部監聽對象 IntersectionObserver
    this.observer && this.observer.disconnect();
  }
複製代碼

IntersectionObserver 的構造函數有兩個參數: 必傳參數-回調函數 callback: IntersectionObserverCallback 和 可選參數-控制調用觀察者的回調的環境配置 options?: IntersectionObserverInit

  • callback

    IntersectionObserverCallback(void (sequence<IntersectionObserverEntry> entries, IntersectionObserver observer))

    回調函數接收兩個參數

    • entries => 一個IntersectionObserverEntry對象的數組,當被觀察者們每次觸發閾值的變化時都會觸發回調函數
    • observer => 被調用的IntersectionObserver實例
  • options

    interface IntersectionObserverInit {
      root?: Element | null;  // 默認頁面根元素
      rootMargin?: string;  // 此屬性能夠增長觀察元素的範圍,默認爲 0px 0px 0px 0px
      threshold?: number | number[]; // 規定了一個監聽目標與邊界盒交叉區域的比例值,能夠是一個具體的數值或是一組0.0到1.0之間的數組。若指定值爲0.0,則意味着監聽元素即便與根有1像素交叉,此元素也會被視爲可見.若指定值爲1.0,則意味着整個元素都是可見的
    }
    複製代碼

    具體以下:

  • 而後監聽斷定交叉區域的可見度變化,激活對應的 tab 標籤

// index.js
  checkItemIn = (params, observerOptions) => {
    const { isIntersecting, intersectionRatio } = params;
    // ifItemInView 商品列表是否在 規定的視口 內
    const ifItemInView =
      isIntersecting && intersectionRatio > observerOptions.threshold;
    if (ifItemInView) {
      // 若是在規定的視口內,激活對應的 tab 標籤
      const activeTabIndex = TAB_LIST_ID_ARRAY.findIndex(item => item === params.target.id);
      this.setState({
        activeTabIndex
      });
    }
  };
複製代碼

滾動效果以下

能夠看到,滾動到女裝列表的一半時,繼續往下一點就會觸發女裝標籤的激活,此時女裝在視口內的佔比高於50%,觸發了回調函數。往上滾動到精選列表與視口交叉範圍大於50%時,精選標籤被激活

思考

若是某個商品列表高度很大怎麼辦?

商品列表高度很大的話能夠調小 threshold 至列表與視口知足交叉條件 isIntersecting=true,交叉率 intersectionRatio>= threshold 便可。只要有一點點交叉,就認爲滑到該列表了,激活該列表對應的 tab

IntersectionObserver 其餘應用場景

  • 圖片懶加載
  • 無限加載列表
  • 元素的曝光打點
相關文章
相關標籤/搜索