惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Y
Y Combinator Blog
有赞技术团队
有赞技术团队
J
Java Code Geeks
H
Hackread – Cybersecurity News, Data Breaches, AI and More
美团技术团队
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Hugging Face - Blog
Hugging Face - Blog
人人都是产品经理
人人都是产品经理
酷 壳 – CoolShell
酷 壳 – CoolShell
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
C
Check Point Blog
博客园 - 【当耐特】
The GitHub Blog
The GitHub Blog
Recent Announcements
Recent Announcements
The Cloudflare Blog
Microsoft Azure Blog
Microsoft Azure Blog
腾讯CDC
Vercel News
Vercel News
IT之家
IT之家
MyScale Blog
MyScale Blog
博客园_首页
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
罗磊的独立博客

博客园 - 折翼的飞鸟

React中 state值根据props传入值变化的静态方法 getDerivedStateFromProps 微信小程序分享图片显示自定义内容 Taro4.x 引入 taro-ui 组件库 Taro4.x引入redux报错:TypeError: middleware is not a function uniapp+vue3 微信小程序使用mqtt通信 uniapp+vue3 在App.vue中如何设置全局调用方法 Taro 在页面中import包、组件、样式文件的顺序自定义规则 Taro 引入moment.js打包过大 Taro 打包体积分析 React:消息订阅(subscribe)-发布(publish)机制 Taro 插件引入 Taro: chooseLocation:fail the api need to be declared in the requiredPrivateInfos field in app.json/ext.json Taro 支付宝小程序Page页获取小程序启动时的参数 Taro 主包vendors.js文件过大 Taro自定义Tabbar Taro 微信转支付宝小程序: 问题八 TypeError: Function(...) is not a function Taro微信转支付宝小程序:问题七 引入的文件图片文件名不能有@符号 Taro微信转支付宝小程序:问题六 同时编译微信小程序和支付宝小程序 Taro微信转支付宝小程序:问题五 编译时没有报错,工具中却莫名奇妙的错误提示
Taro优化VirtualList虚拟列表组件
折翼的飞鸟 · 2025-02-12 · via 博客园 - 折翼的飞鸟

在之前的虚拟列表VirtualScroll组件基础上做了优化,主要是修复一些问题,目前还没有实现骨架屏   原来的虚拟列表 :   Taro实现VirtualList虚拟列表

/**
 * author: wang.p  2022-04-15
 *
 * description:  自定义虚拟滚动列表
 *
 * */

import React, {Component} from "react"
import {ScrollView, View} from '@tarojs/components'
import PropsType from 'prop-types';
import classnames from 'classnames';
import './virtual-list.scss'; 

class VirtualScroll extends Component {

  static propTypes = { 
    refresh: PropsType.bool,  // 数据改变是否刷新
    className: PropsType.string, // 样式名
    rowCount: PropsType.number,  // 渲染的行数
    total: PropsType.number,  // 总行数,存在就是动态请求数据来渲染
    source: PropsType.array,  // 数据源数组
    rowHeight: PropsType.number,  // 行高
    scrollToIndex: PropsType.number, // 跳转到指定的位置
    getRowHeight: PropsType.func,  // 动态行高
    onScroll: PropsType.func, // 滚动处罚事件
    onRowRender: PropsType.func, // 行渲染
    onSrollTopRecommend: PropsType.func, // 触发顶部样式事件
  }

  static defaultProps = { 
    total: 0,
    rowCount: 20,
    source: [],
    rowHeight: 40,
    placeholder: false,
    refresh: true
  }

  constructor(props) {
    super(props);
    
    this.state = {
      rowCount: 20, // 显示行数
      scrollHeight: 0, // 所有内容渲染的高度
      scrollData: [],  // 渲染可视区域数据的数组
      scrollStyles: [], // 样式数组
      isCategoryToScroll: false, // 是否是分类切换定位滚动
      scrollToIndex: 0, // 跳转到指定位置
      compareHeight: 0,  // 触发渲染的高度差
      scrollTop: 0,
      source: []   //
    }
  } 
  
  componentDidMount = () => {
    let newState = this.setSourceStyle(this.props);
    this.setState({...newState, source: this.props.source});
  }

  /**
   * 设置布局
   * @data  数据
   * @fresh  是否是重新渲染数据
   * */
  setSourceStyle = (data, fresh) => {
    const {rowCount, source, rowHeight, getRowHeight, total} = data;
    let scrollStyles = [];
    let scrollData = [];
    let scrollHeight = 0;
    let compareHeight = 0;
    let lastHeight = 0;
    let newState = {};
    if (total > 0) {
      for(let i=0;i<total;i++) { 
        let styles = {position: 'absolute', left: 0, top: scrollHeight};
        scrollStyles.push(styles);
        let tempHeight = typeof getRowHeight === 'function' ? getRowHeight(source[i], i) : rowHeight;
        scrollHeight += tempHeight;
        lastHeight = tempHeight; 
      }
      newState = {scrollHeight: scrollHeight + lastHeight , scrollStyles};
      if (!fresh || this.isRenderScrollData(scrollStyles, this.state.scrollData, rowCount)) {
        // 首次渲染
        let showCount = total < rowCount ? total : rowCount;
        for (let i = 0; i < showCount; i++) {
          scrollData.push({sort: i, row: i})
        }
        compareHeight = Math.floor(scrollStyles[showCount - 1].top / showCount) * 3;

        newState['scrollData'] = scrollData;
        newState['rowCount'] = rowCount;
        newState['compareHeight'] = compareHeight;
      }
 
    } else if (source && source.length > 0) {
      source.forEach((item, idx) => {
        let styles = {position: 'absolute', left: 0, top: scrollHeight};
        scrollStyles.push(styles);
        let tempHeight = typeof getRowHeight === 'function' ? getRowHeight(item, idx) : rowHeight;
        scrollHeight += tempHeight;
        lastHeight = tempHeight;
      })

      newState = {scrollHeight: scrollHeight + lastHeight , scrollStyles};
      if (!fresh || this.isRenderScrollData(scrollStyles, this.state.scrollData, rowCount)) {
        // 首次渲染
        let showCount = source.length < rowCount ? source.length : rowCount;
        for (let i = 0; i < showCount; i++) {
          scrollData.push({sort: i, row: i})
        }
        compareHeight = Math.floor(scrollStyles[showCount - 1].top / showCount) * 3;

        newState['scrollData'] = scrollData;
        newState['rowCount'] = rowCount;
        newState['compareHeight'] = compareHeight;
      }
    } else {
      newState = {scrollData, rowCount, compareHeight, scrollHeight};
    }
    
    return newState
  }

  /**是否需要更新渲染的数组 */
  isRenderScrollData = (scrollStyles, scrollData, rowCount) => {
    if (scrollStyles.length < rowCount && scrollStyles.length != scrollData.length) {
      return true;  
    }
    if (scrollStyles.length >= rowCount && scrollData.length != rowCount) {
      return true;
    }

    let maxData = this.findMinOrMax(scrollData, true);
    if (maxData.row > scrollStyles.length) {
      return true;
    }

    return false;
  }

  componentWillReceiveProps(nextProps, nextContext) { 
    if (JSON.stringify(nextProps.source) !== JSON.stringify(this.state.source)) {
      const {scrollTop} = this.state;
      let newState = this.setSourceStyle(nextProps, true);
      if (nextProps.refresh) {
        this.setState({...newState, source: nextProps.source, scrollTop: (scrollTop > 0 ? 0 : 0.1), isCategoryToScroll: true})
      } else {
        this.setState({...newState, source: nextProps.source});
      } 
    }
  }
  

  render() {
    const {className, style} = this.props;
    const {scrollHeight, scrollData, scrollStyles, scrollTop, source} = this.state;

    return <ScrollView className={classnames('virtual-scroll', className)}
                       style={{...style}}
                       scrollTop={scrollTop}
                       scrollY={true}
                       scrollWithAnimation
                       onScroll={this.onScroll.bind(this)}>
      <View className={'virtual-scroll-body'} style={{height: scrollHeight}}>
        {scrollData.length > 0 && scrollData.map((item, idx) => {return <View className="virtual-scroll-item" key={item.row} style={scrollStyles[item.row]}>
            {this.props.onRowRender(item, scrollStyles[item.row], source)} 
          </View> 
        })} 
      </View>
      {this.props.children}
    </ScrollView>
  }

  currentScrollTop = 0;
  prevScrollTop = 0;  // 记录上次滚动的Scrolltop

  findMinOrMax = (data, isMax= false) => {
    if (isMax) {
      return data.reduce((prev, next) => {
        if (prev.row < next.row) {
          return next;
        } else {
          return prev;
        }
      })
    } else {
      return data.reduce((prev, next) => {
        if (prev.row < next.row) {
          return prev;
        } else {
          return next;
        }
      })
    }
  }

  /**
   * 滚动事件,计算渲染菜品的数据
   * 滚动到顶部时,如果顶部有推荐菜品就展示出来,如果上拉滚动,就隐藏推荐菜品
   * */
  onScroll = (event) => {
    let scrollY = event.detail.deltaY;
    const eventScrollTop = event ? event.detail.scrollTop : this.state.scrollTop;
    const {scrollStyles, rowCount, compareHeight, scrollData, isCategoryToScroll} = this.state;
    if (scrollData.length === 0) {
      return;
    }
    let styleLen = scrollStyles.length;
    if (Math.abs(this.currentScrollTop - eventScrollTop) > compareHeight || (styleLen > 4 && eventScrollTop <= scrollStyles[3].top) || ( styleLen > 5 && eventScrollTop >= scrollStyles[scrollStyles.length - 5].top)) {
      // 查询出当前scrollTop在那个范围
      this.currentScrollTop = eventScrollTop;
      let scrollIndex = -1; 
      let afterIndex = scrollStyles.findIndex(item => item.top > eventScrollTop);
      if (afterIndex != -1) { 
        if (afterIndex > 0) {
          if (scrollStyles[afterIndex - 1] && scrollStyles[afterIndex - 1].top <= eventScrollTop) {
            scrollIndex = afterIndex - 1;
          }
        } else {
          scrollIndex = 0; 
        }
      } 
      if (scrollIndex == -1) {
        return;
      }
       
      // 计算出渲染范围的最小下标和最大下标
      let minIndex = scrollIndex - parseInt(Math.floor(rowCount / 2.0));
      if (minIndex < 0) {
        minIndex = 0;
      }

      // 找出当前显示的数据范围最小值和最大值
      let minData = this.findMinOrMax(scrollData);
      let maxData = this.findMinOrMax(scrollData, true);

      let newScrollData = [...scrollData];
      if (minIndex > minData.row) {
        // 向下滑动渲染, 找出最小值,替换成最大值,循环进行替换
        let cycle = minIndex - minData.row;
        for (let i = 0; i < cycle; i++) {
          minData = this.findMinOrMax(scrollData);
          maxData = this.findMinOrMax(scrollData, true);
          // 超过最大值就不再循环
          if (maxData.row + 1 > scrollStyles.length) {
            break;
          }
          scrollData[minData.sort]['row'] = maxData.row + 1;
        }

        this.setState({scrollData: newScrollData, isCategoryToScroll: false});
      } else if(scrollY > 0){
        // 向上滑动渲染
        let cycle = minData.row - minIndex;
        for (let i = 0; i < cycle; i++) {
          minData = this.findMinOrMax(scrollData);
          maxData = this.findMinOrMax(scrollData, true);

          scrollData[maxData.sort]['row'] = minData.row - 1;
        }
        this.setState({scrollData: newScrollData, isCategoryToScroll: false});
      }

    }

    let scycelScroll = compareHeight / 3;
    // 滚动一定距离,就触发外部事件
    if (!isCategoryToScroll && Math.abs(this.prevScrollTop - eventScrollTop) > scycelScroll && this.props.onScroll) {
      this.prevScrollTop = eventScrollTop;
      let scrollIndex = 0;
      for(let i=1;i<scrollStyles.length;i++) {
        if (scrollStyles[i - 1].top <= eventScrollTop && scrollStyles[i].top > eventScrollTop) {
          scrollIndex = i;
          break;
        }
      }
      // 返回当前渲染的行,以及滚动条滚动的方向 scrollY > 0 表示下拉, scrollY > 0 表示上拉
      this.props.onScroll(scrollIndex, scrollY);
    }

    // 处理顶部隐藏的组件
    if (this.props.onSrollTopRecommend) {
      if (scrollY > 0) {
        // 下拉
        if (event.detail.scrollTop <= scycelScroll) {
          // 展开推荐菜品
          this.props.onSrollTopRecommend && this.props.onSrollTopRecommend(true);
        }
      } else {
        // 上拉
        if (event.detail.scrollTop > scycelScroll) {
          // 触发收起推荐菜品
          this.props.onSrollTopRecommend && this.props.onSrollTopRecommend(false);
        }
      }
    }
  }

}

export default VirtualScroll
.virtual-scroll {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  .virtual-scroll-body {
    position: relative;
    box-sizing: inherit;
    width: 100%;
    height: 100%;
  }
  .virtual-scroll-item {
    width: 100%;
  } 
}
 
<VirtualScroll className='scroll-content' source={[]} rowHeight={50} onRowRender={this.onRowRender} />

// 元素高度不一样,可以使用 getRowHeight 函数来动态设置列元素高度
/* 渲染的列内容 @data 列表的下标 {row: 行数, sort: 0} @style 样式 @ sourceData 所有的数据对象 */ onRowRender = (data, style, sourceData) => { const {row} = data; let itemData = sourceData[row]; if (itemData) { return <View>渲染的列内容{itemData}</View> } else { return null; } }