tab列表与商品列表关联滚动-scrollTop && Intersection Observer

3,467 阅读8分钟

使用场景

多Tab展示分类的商品列表是移动端商城常见场景,下图是手淘页女装类目下的多Tab列表

可以看到,点击某个tab,可以跳转到对应的服装列表,并且随着列表的滚动,当前激活的Tab也在随之改变。这里面主要包括两个功能:

  • 点击某个 tab ,跳转到相应位置的商品列表 - 点击锚点定位
  • 随着列表滚动到某个类型的服装,当前激活的 tab 也会相应改变

demo 搭建

这里使用 create-react-app 脚手架进行 demo 的创建

demo 页面主要分为 tab 标签列表和每个 tab 对应的商品列表

  • 组件
render() {
    return (
      <div className='mobile-container'>
        {/* 标签 */}
        {this.renderTabList()}
        {/* 商品列表 */}
        {this.renderGoodList()}
      </div>
    );
  }
  • mock 数据

这里为了展示方便,总共展示五个 tab,对应五个商品列表,每个列表下各有五个商品,单排展示,数据结构如下:

// 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 结构和样式展示如下:

接下来就可以开始实现这两个功能了😝

功能实现

点击锚点定位

点击某个 tab 实现锚点定位,每个 tab 标签绑定其对应的 tab_list_id( data-anchor=${tab_list_id} ),点击 tab 拿到当前点击的 event.target.dataset.anchor,获得其距离文档顶部的距离,文档滚动相应的距离即可。

// 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 标签

具体有两种实现方式

  • 对文档的滚动进行监听,滚动到某个距离范围内就对应激活相应的 tab

    • 思路简单,实现起来较容易
    • 监听文档的滚动事件是比较耗费性能的一件事,需要进行优化处理,如 节流函数
    • 同时要考虑多 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 其他应用场景

  • 图片懒加载
  • 无限加载列表
  • 元素的曝光打点