使用场景
多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 的具体使用,可以参考
- MDN Intersection Observer API
- 阮一峰大大的 IntersectionObserver API 使用教程
- cnblogs Intersection Observer 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 其他应用场景
- 图片懒加载
- 无限加载列表
- 元素的曝光打点