多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