小夕 | 打造无卡顿的滚动列表

2,486 阅读6分钟

稿定设计导出-20200207-111127.png
我们开发过程中经常会遇到这样的需求,一个数据量很大的列表。页面打开,加载第一页的数据,每次往下滚屏到接近底部,会加载下一屏,这样也是保证获取数据的http请求量是按需加载的。但是,随着我们一直加载,产生的DOM 会越来越多,内存占用也会越来越高。这时候如果有千上万的数据,就会产生大量的虚拟 DOM 和真实DOM,大量占用内存。

下面这几种滚动场景,都有可能造成卡顿

  1. 滚动加载,出现无限滚动时,可能给 DOM 过多页面卡死。
  2. 滚动加载的组件,DOM 过多,销毁组件的时候也会出现卡死。
  3. 任何滚动的元素,内部元素过多,都会出现卡顿现象。

针对上面的问题,我们能否有什么优化的方案?

这三类问题都有一个相似点,大量的并列的 DOM,但是这些 DOM 都在一个可滚动的容器中,无论怎么滚动,只有一部分是可见的,滚动的 DOM 元素中只展示可视区域展示的功能。如下图:

image.png

所以如果只有可见的数据才去创建虚拟 DOM,就会大量节省内存,并加快渲染速度。外元素滚动时,在非可视区域的 DOM,都暂时转存到页面的data中,但不需要渲染出来。

OK,列出问题 & 大体的解决方案后,我们来看看项目中的现有轮子

项目中的轮子: T-List

基于 ScrollView 的下拉刷新、上拉加载、无限滚动组件。
优点: 可以实现 DOM 回收,渲染速度很快。
缺点: 回收机制为新加载一条,回收最上面一条,节点限制在 12 个,对于每条高度自适应样式时,滚动条剧烈抖动,用户体验差。

现有轮子体验差,我决定对它重新优化下成为我的轮子。

我的轮子 - 限制显示页数的无限滚动列表

基于页面的下拉刷新 、 上拉加载 、无限滚动。
优点: 回收机制采用新加载一页,回收最上面一页,上滑翻页时加载上一页,回收最后一页,滚动条高度稳定且长度也稳定。且不可见区应用了骨架屏,快速滑动时,数据还未加载出来会看见骨架屏,用户体验好。

image.png

根据这个想法设计的解决方案,思路有几个关键点

  1. 记录数据区间:记录每页数据尺寸,待新一页的数据渲染完毕后,记录外盒(装载此数据的父元素)的高度,计算每页数据所占高度(此次外盒高度-上次外盒高度)。
  2. 记录滚动条位置:记录每次触底加载时滚动条的位置,在页面添加一个锚点,定位顶对齐的空元素。
  3. 判断当前页数:根据当前滚动位置,与之前记录的滚动条位置进行比较,触发上翻页或下翻页。
  4. 渲染可见页列表:只渲染出当前页和上一页的数据,不可见页用空元素占位,高度为之前记录过的当页数据的高度,这样可防止高度塌陷而抖动,且滚动条长度稳定。

解决方案的过程

记录数据区间和滚动条位置

页面结构

ID 为 anchor 的元素当做锚点,它的 position 采用绝对定位 top:0,所以它的 top 值的相反数则为页面滚动条的位置。
ID 为 scrollList 的元素为滚动列表,装载迭代数据,每一项为一页数据,可以获取它的高度。

<View>
  <View id='anchor'></View>
	<View className="pre-list-title">滚动测试</View>
	<View  id='scrollList'>
    {list.map(listitem => (
     ((listitem.group_id > 0) && 
      (listitem.group_id == page_no || listitem.group_id == page_no - 1)) ? (
       	<View id={'page_no' + listitem.group_id} key={listitem.group_id}>
       	<View className="pre-batch-title fc6">第{listitem.group_id}页</View>
          {listitem.group.map(item => {
            return <Card key={item.id} item={item} onClick={this.onPreviewImage}/>
          })}
				</View>
    ): (
      <View id={'page_no' + listitem.group_id} 
        style={{height: IsEmpty(paddingHeight[listitem.group_id]) ? 0 
        : paddingHeight[listitem.group_id].height + 'px'}}>
        {listitem.group.map((item, index) => {
        	return index < paddingHeight[listitem.group_id].height / 300 ? <Card /> : null;
        })}
      </View>
    )))}
  </View>
</View>

获取需要的元素的尺寸及位置等信息逻辑

/**
* @description 获取滚动列表的尺寸和位置,锚点的位置
* 返回参数res
* res[0]   滚动列表的尺寸和位置等信息
* res[1]   锚点的位置等信息
*/
getElement = ({
  callback
}) => {
  let query = Taro.createSelectorQuery().select('#scrollList').boundingClientRect()
  query.select('#anchor').boundingClientRect()
  query.exec(res => {
    console.log('scrollList',res)
    callback(res)
  })
}

获取结果

image.png

判断当前页数

根据滚动条位置判断上翻下翻

image.png

/**
 * @description 页面滚动事件
 * @memberof Index
 */
onPageScroll = e => {
  this.throttle({
    callback: () => {
      const {page_no, paddingHeight} = this.state
      console.log(e.scrollTop,page_no)
      if(!IsEmpty(paddingHeight[page_no - 1]) && e.scrollTop < paddingHeight[page_no - 1].top - 800) {//上翻一页
        this.up(page_no)
      }else if (!IsEmpty(paddingHeight[page_no]) && page_no < paddingHeight.length -1 && e.scrollTop > paddingHeight[page_no].top - 1000) {//下翻一页,之前加载过的一页
        this.next({})
      }
    },
    wait: 1000,
    mustRun: 1000
  });
};

这里的 throttle 方法为防抖节流函数,如果想提高反应速度可减小 wait 和 mustRun 的值。

上翻页事件

/**
 * @description 上翻一页
 * @memberof Index
 */
up = (page_no) => {
  console.log('上页',page_no,'-',page_no -1)
  this.setState({
    page_no: page_no - 1
  })
}

下翻页事件

/**
 * @description 下翻一页
 * @memberof Index
 */
next = ({
  page
}) => {
  let { page_no, page_size, noMore, paddingHeight, list } = this.state;
  if(!IsEmpty(page) && page == 1) {//如果为第一页,初始化数据
    page_no = 0
    paddingHeight = [
      {
        page_no: 0,
        paddingtop: 0,
        height: 0,
        top: 0
      }
    ]
    noMore = false
  }
  if((!noMore && page_no == list.length - 1) || page_no == 0) {//下翻一页,未加载过,请求接口
    page_no = page_no + 1;
    console.log('请求下页数据','page_no',page_no, noMore,)
    this.getList({
      page_no: page_no,
      page_size: page_size,
      callback: res => {
        let data = [];
        let list = [];
        //判空
        if (!IsEmpty(res)) {
          data = res.data;
          list = res.list;
        }
        this.getElement({
          callback: res => {
            paddingHeight[paddingHeight.length - 1].top = -res[1].top
            this.setState({
              noMore: data.length < page_no * page_size ? true : false,
              page_no,
              data,
              list,
              paddingHeight
            },() => {
              this.getElement({
                callback: ress => {
                  paddingHeight.push({
                    page_no: page_no,
                    height: ress[0].height - paddingHeight[paddingHeight.length - 1].paddingtop,
                    paddingtop: ress[0].height,
                  })
                  console.log('paddingHeight',paddingHeight)
                }
              })
            });
          }
        })
      }
    });
  }else if(page_no < list.length - 1) {//下翻一页,已加载过的一页
    console.log('下页',page_no,'-',page_no + 1)
    this.setState({
      page_no: page_no + 1
    })
  }
}

渲染可见列表

根据当前页码 page_no,渲染当前页和上一页,不可见区用骨架屏填充,防止快速滑动时,大面积空白影响视觉效果。
骨架屏放在 Card 组件中,如果不传给 Card 组件任何 props,则 Card 显示骨架屏,否则显示商品卡片

(listitem.group_id == page_no || listitem.group_id == page_no - 1)) ? (
  <View id={'page_no' + listitem.group_id} key={listitem.group_id}>
    <View className="pre-batch-title fc6">第{listitem.group_id}页</View>
    {listitem.group.map(item => {
    return <Card key={item.id} item={item} onClick={this.onPreviewImage}/>
    })}
  </View>
): (
  <View id={'page_no' + listitem.group_id} 
        style={{height: IsEmpty(paddingHeight[listitem.group_id]) ? 0 
        : paddingHeight[listitem.group_id].height + 'px'}}>
    {listitem.group.map((item, index) => {
    return index < paddingHeight[listitem.group_id].height / 300 ? <Card /> : null;
    })}
  </View>
)

效果展示

image.png

总结

滚动条无抖动,滚动条长度符合正常比例(滚动条长度 / 屏幕高度 = 屏幕高度 / 页面实际高度)。
快速滑动时显示骨架屏,优化用户等待体验,但是如果数据过多建议不使用骨架屏,因为骨架屏也占节点,过多会卡顿。
限制节点数,每页 10 条,可见区 2 页共 20 条,实现了 DOM 回收。