一. 需求:类似于美团的商家页面,左边是可点击选择的商品品类,右边是按各商品品类分类的商品列表。点击左边某品类时,右边商品列表自动滚动定位到该品类下商品第一个;滚动右边商品列表时,当滚动到某个品类名时自动定位高亮相对应的左边的品类名列表。如果是微信小程序开发的话,在ColorUI组件库有现成的组件可以用,但是现在需要用h5实现,就必须自己上撸代码了。
二. 所用到的插件:基于滚动插件better-scroll完成。用之前需要先了解一下better-scroll文档。
三. 所遇到的问题:在实际运用中,但商品品类和商品列表数据量特别大的时候,尤其是商品列表项目中有图片加载和事件绑定时,右侧的商品列表滚动会非常的卡顿。
四. 解决问题思路:在渲染商品项目时,通过判断只加载当前品类前两个和后两个,总共5个品类的数据,这样会解决滚动时卡顿的问题。
五. 组件代码(基于React):
import React, { FunctionComponent, useEffect, useRef, useState } from 'react';
import BScroll from 'better-scroll';
import styles from './index.less';
import shuiguo0 from '@/assets/my/shuiguo0.jpg'
import shuiguo1 from '@/assets/my/shuiguo1.jpg'
import { throttle } from '@/utils';
interface IProps {
}
// 是否点击Tab滚动右边内容
let isClickTab = false;
const Component: FunctionComponent<IProps> = props => {
// 选择的当前品类
const [current, setCurrent] = useState<number>(0);
// 滚动实例
const [tabScroll, setTabScroll] = useState<any>(null);
const [contentScroll, setContentScroll] = useState<any>(null);
// 滚动区域Dom节点
const sideRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLDivElement>(null);
// 滚动内容高度节点记录
const [contentHeightNode, setContentHeightNode] = useState<any>([]);
// 滚动tab高度节点记录
const [tabHeightNode, setTabHeightNode] = useState<any>([]);
const list = [
{
key: '1',
title: '热销',
children: ['', ''],
}, {
key: '2',
title: '折扣',
children: ['', '', ''],
}, {
key: '3',
title: '工作餐',
children: ['', '', '', '', ''],
}, {
key: '4',
title: '双人餐',
children: ['', '', ''],
}, {
key: '5',
title: '水产',
children: [''],
}, {
key: '6',
title: '王炸招牌',
children: ['', '', ''],
}, {
key: '7',
title: '海鲜烧饭',
children: [''],
}, {
key: '8',
title: '店长推荐',
children: ['', ''],
}, {
key: '9',
title: '时光咖啡',
children: ['', '', ''],
}, {
key: '10',
title: '乐享生活',
children: ['', '', '', '', ''],
}, {
key: '11',
title: '下午茶',
children: ['', '', ''],
}, {
key: '12',
title: '蛋包饭',
children: ['', ''],
}, {
key: '13',
title: '煲仔饭',
children: ['', ''],
}, {
key: '14',
title: '牛肉粉',
children: ['', ''],
}, {
key: '15',
title: '饭后甜点',
children: ['', '', '', ''],
}, {
key: '16',
title: '水果',
children: ['', '', '', '', '', ''],
}];
// 进入页面初始化数据
useEffect(() => {
// 记录content每个滚动节点
let contentHeightCount = 0;
list && list.forEach((item: any) => {
contentHeightCount += 24 + item.children.length * 130 + 26
contentHeightNode.push(contentHeightCount)
})
setContentHeightNode(contentHeightNode);
// 记录tab每个滚动节点
let tabHeightCount = 0;
list && list.forEach((item: any, index: number) => {
tabHeightCount += 56
tabHeightNode.push(index > 0 ? tabHeightCount : 0);
})
setTabHeightNode(tabHeightNode);
}, []);
// 进入页面实例化better-scroll
useEffect(() => {
if (sideRef.current && mainRef.current) {
if (!tabScroll) {
// 左边tab滚动配置
setTabScroll(new BScroll(sideRef.current as Element, { probeType: 3, scrollY: true, mouseWheel: true, click: true, useTransition: false, /* 防止iphone微信滑动卡顿 */ }));
}
if (!contentScroll) {
// 右边content滚动配置
setContentScroll(new BScroll(mainRef.current as Element, { probeType: 3, scrollY: true, mouseWheel: true, click: true, useTransition: false, preventDefault: true /* 防止iphone微信滑动卡顿 */ }));
}
}
}, [mainRef.current]);
// 滚动监听
useEffect(() => {
const onScroll = throttle((pos: BScroll.Position) => {
const maxY = Number(Math.abs(contentScroll?.maxScrollY as number));
const currentY = Number(Math.abs(pos.y).toFixed(0));
if (contentHeightNode && !isClickTab) {
setTabsIndex(currentY, maxY);
}
}, 30);
// 监听右边content滚动结束事件
const onScrollEnd = (pos: BScroll.Position) => {
isClickTab = false;
};
// 设置滚动Dom
// 监听右边content滚动
if (contentScroll) {
contentScroll.off('scroll', onScroll);
contentScroll.on('scroll', onScroll);
// 监听右边content滚动结束事件
contentScroll.off('scrollEnd', onScrollEnd);
contentScroll.on('scrollEnd', onScrollEnd);
}
return () => {
contentScroll && contentScroll.off('scroll', onScroll);
contentScroll && contentScroll.off('scrollEnd', onScrollEnd);
};
}, [contentScroll]);
// 设置tab高亮
const setTabsIndex = (currentY: number, maxY: number) => {
if (currentY <= maxY / 2) {
const arr = contentHeightNode.filter((item: number) => {
return item <= currentY;
});
const val = Math.max(...arr);
const indexOf = contentHeightNode.indexOf(val);
if (indexOf > 1) { tabScroll?.scrollToElement(sideRef.current as HTMLElement, 300, 0, tabHeightNode[indexOf - 2]); }
setCurrent(indexOf + 1);
} else {
const arr = contentHeightNode.filter((item: number) => {
return item >= currentY;
});
const val = Math.min(...arr);
const indexOf = contentHeightNode.indexOf(val);
if (indexOf > 2) { tabScroll?.scrollToElement(sideRef.current as HTMLElement, 300, 0, tabHeightNode[indexOf - 3]); }
setCurrent(indexOf);
}
if (currentY === 0) {
setCurrent(0);
tabScroll?.scrollTo(0, 0);
}
if (currentY === maxY) { setCurrent(contentHeightNode.length - 1); }
};
// 点击左边选项卡
const tabItemChange = (index: number) => {
isClickTab = true;
// 设置Tab高亮
setCurrent(index);
if (index > 1) { tabScroll?.scrollToElement(sideRef.current as HTMLElement, 300, 0, tabHeightNode[index - 3]); }
// 触发content滚动到响应位置
contentScroll?.scrollToElement(mainRef.current?.children[0].children[index] as HTMLElement, 300, 0, 0);
}
// 获取左边tab类目
const getTabList = () => {
return (
list && list.map((items: any, itemsIndex: number) => {
return (
<li key={items.key} className={[current === itemsIndex ? styles.tabs_item_checked : '', current === itemsIndex + 1 ? styles.tabs_item_prev : '', current === itemsIndex - 1 ? styles.tabs_item_next : '',].filter(v => v).join(' ')} onClick={() => { tabItemChange(itemsIndex) }}>
<div className={styles.tabs_title}><span>{items.title}</span></div>
</li>
);
})
);
}
// 获取右边商品列表
const getContentList = () => {
return (
list && list.map((items: any, itemsIndex: number) => {
/** 计算只显示商品类目框的索引范围*/
const itemSize: number = 2;
let leftIndexLast: number = 0;
if (current === list.length - 1) { leftIndexLast = 2; }
if (current === list.length - 2) { leftIndexLast = 1; }
const leftIndex = current - itemSize - leftIndexLast;
const rightIndex = current + itemSize + (current - itemSize < 0 ? Math.abs(current - itemSize) : 0);
return (
<div key={'content' + itemsIndex} className={styles.content_item}>
<label>{items.title}</label>
{items.children.map((item: any, itemIndex: number) => {
return ((itemsIndex >= leftIndex && itemsIndex <= rightIndex) ?
<div key={'content-children' + itemIndex} className={styles.content_item_card}>
<img src={itemIndex % 2 === 0 ? shuiguo0 : shuiguo1} alt="" />
</div> :
<div key={'content-children' + itemIndex} className={styles.content_item_card}></div>
);
})
}
</div>
);
})
)
}
return (
<>
<span className={styles.var_title}>移动端锚点定位垂直选项卡</span>
<div className={styles.ver_tabs_wrapper}>
<div ref={sideRef} className={styles.wrapper_tabs}>
<ul className={styles.tabs}>
{getTabList()}
</ul>
</div>
<div ref={mainRef} className={styles.wrapper_content}>
<div className={styles.content}>
{getContentList()}
</div>
</div>
</div>
</>
);
};
export default Component;
六. 样式代码:
.var_title {
font-size: 18px;
&::before,
&::after {
content: '**';
margin: 5px;
}
}
.ver_tabs_wrapper {
width: 100%;
height: 90%;
margin: 15px 0 0 0;
display: flex;
.wrapper_tabs {
width: 88px;
height: 100%;
background: rgba(255, 255, 255, 1);
overflow: hidden;
//border: 1px solid red;
.tabs {
width: 88px;
padding: 0;
margin: 0;
background: #fff;
.tabs_item_checked {
background: #fff;
.tabs_title {
&>span {
color: rgba(227, 83, 44, 1);
font-weight: 700;
}
}
}
.tabs_item_prev {
border-radius: 0 0 8px 0;
&:after {
content: none !important;
}
}
.tabs_item_next {
border-radius: 0 8px 0 0;
}
&>li {
&:not(tabs_item_prev):not(.tabs_item_checked):not(:last-child):after {
width: 72px;
content: "";
display: block;
position: absolute;
left: 8px;
right: 8px;
border-top: 1px dashed rgba(0, 0, 0, .1);
}
display: flex;
justify-content: center;
align-items: flex-end;
width: 100%;
height: 55px;
background:rgba(245, 245, 245, 0.8);
//border-bottom: 1px dashed rgba(0,0,0,.1);
.tabs_title {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
&>span {
font-size: 13px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: rgba(0, 0, 0, 1);
line-height: 17px;
}
}
}
}
}
.wrapper_content {
height: 100%;
flex: 1;
background: #fff;
overflow: hidden;
//border: 1px solid green;
.content {
width: 100%;
.content_item {
padding: 12px;
&>label {
font-size: 13px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: rgba(0, 0, 0, 1);
line-height: 26px;
}
.content_item_card {
width: 100%;
height: 120px;
background: rgba(0, 0, 0, .3);
margin-bottom: 10px;
&>img {
width: 100%;
height: 100%;
border-radius: 5px;
object-fit: cover;
}
}
}
}
}
}
七. 实现效果:https://live.csdn.net/v/118254
八. 最后说的话:这个思路实现了功能,但也有一些不足的地方。希望有更好的处理方法或思路的同学能够指正,谢谢。
最后附上滚动监听方法用到的一个节流函数:
/**
* 节流
* @param callback
* @param delay
*/
export function throttle(callback: (...rest: any[]) => void, delay: number) {
let previous = 0;
return function (...args: any[]) {
const now = +new Date();
if (now - previous > delay) {
callback.call(null, ...args);
previous = now;
}
}
}