基于React的垂直选项卡(含锚点定位功能)

一. 需求:类似于美团的商家页面,左边是可点击选择的商品品类,右边是按各商品品类分类的商品列表。点击左边某品类时,右边商品列表自动滚动定位到该品类下商品第一个;滚动右边商品列表时,当滚动到某个品类名时自动定位高亮相对应的左边的品类名列表。如果是微信小程序开发的话,在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;
    }
  }
}

 

React Markdown本身并不直接提供锚点定位功能,但可以通过结合其他第三方库来实现。下面介绍一种使用react-scroll组件实现在滚动的div中进行锚点定位的方法。 1. 安装react-scroll库 ```bash npm install react-scroll --save ``` 2. 在React组件中引入react-scroll库 ```jsx import { Link } from 'react-scroll'; ``` 3. 在Markdown文本中为需要添加锚点的标题设置id属性 ```markdown # 第一部分 {#section1} 这是第一部分的内容。 # 第二部分 {#section2} 这是第二部分的内容。 # 第三部分 {#section3} 这是第三部分的内容。 ``` 4. 在React组件中使用Markdown组件并添加锚点链接 ```jsx import ReactMarkdown from 'react-markdown'; class ScrollableDiv extends React.Component { render() { return ( <div className="scrollable"> <ReactMarkdown source={markdownText} renderers={{ heading: props => { const { level, children } = props; const id = children[0].props.value.toLowerCase().replace(/\s/g, '-'); return ( <Link to={id} smooth={true} duration={500}> {React.createElement(`h${level}`, { id }, children)} </Link> ); } }} /> </div> ); } } ``` 在该例子中,我们使用了ReactMarkdown组件,并且为其设置了一个自定义的渲染器。该渲染器用于将Markdown文本中的标题转换成带有锚点链接的HTML标签,并且使用react-scroll库提供的Link组件实现锚点定位功能。Link组件的to属性指向标题的id属性,smooth属性表示滚动是否平滑,duration属性表示滚动持续的时间。 通过以上步骤,我们就可以在滚动的div中使用React Markdown并且实现锚点定位功能
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值