多Tab展现分类的商品列表是移动端商城常见场景,下图是手淘页女装类目下的多Tab列表 javascript
这里使用 create-react-app
脚手架进行 demo
的建立css
demo 页面主要分为 tab 标签列表和每一个 tab 对应的商品列表html
render() { return ( <div className='mobile-container'> {/* 标签 */} {this.renderTabList()} {/* 商品列表 */} {this.renderGoodList()} </div> ); } 复制代码
这里为了展现方便,总共展现五个 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; } 复制代码
到此处,功能一已经实现啦✅markdown
具体有两种实现方式数据结构
对文档的滚动进行监听,滚动到某个距离范围内就对应激活相应的 tab
。app
节流函数
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提供了一种异步观察目标元素与祖先元素或顶级文档viewport的交集中的变化的方法
关于该 API 的具体使用,能够参考
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))
回调函数接收两个参数
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 }); } }; 复制代码
滚动效果以下
若是某个商品列表高度很大怎么办?
商品列表高度很大的话能够调小 threshold
至列表与视口知足交叉条件 isIntersecting=true
,交叉率 intersectionRatio>= threshold
便可。只要有一点点交叉,就认为滑到该列表了,激活该列表对应的 tab