多Tab展現分類的商品列表是移動端商城常見場景,下圖是手淘頁女裝類目下的多Tab列表 javascript
能夠看到,點擊某個tab,能夠跳轉到對應的服裝列表,而且隨着列表的滾動,當前激活的Tab也在隨之改變。這裏面主要包括兩個功能:這裏使用 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;
}
複製代碼
到此處,功能一已經實現啦✅數據結構
具體有兩種實現方式app
對文檔的滾動進行監聽,滾動到某個距離範圍內就對應激活相應的 tab
。dom
節流函數
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
});
}
};
複製代碼
滾動效果以下
能夠看到,滾動到女裝列表的一半時,繼續往下一點就會觸發女裝標籤的激活,此時女裝在視口內的佔比高於50%,觸發了回調函數。往上滾動到精選列表與視口交叉範圍大於50%時,精選標籤被激活若是某個商品列表高度很大怎麼辦?
商品列表高度很大的話能夠調小 threshold
至列表與視口知足交叉條件 isIntersecting=true
,交叉率 intersectionRatio>= threshold
便可。只要有一點點交叉,就認爲滑到該列表了,激活該列表對應的 tab