长列表渲染实践

长列表渲染实践

场景描述

在 h5 下拉加载更多的场景当中,经常会碰到这种情况:由于加载数据量过大, dom 节点不断累加,从而导致滚动卡顿等用户体验下降的问题。

为了解决因为 dom 过多而导致的卡顿问题,当务之急便是减少页面 dom 的渲染。由于手机屏幕高度固定,可见区域只有屏幕大小的地方,因此最理想的方式,便是仅仅渲染屏幕范围内的 dom 节点。

长列表 item 动态渲染,尽可能减少总 dom 节点数量

提出问题

  1. 如果只渲染展示可见范围内的 dom ,原来的滚动条就没了,如何重现原有的滚动条?
  2. 滚动条的问题解决后,如何根据当前滚动位置来知道该渲染列表的哪部分?

解决方案

1. 将 dom 结构设计成以下结构

<div className='infinite-list-wrapper'>
  <div className='infinite-list-ghost'/>
  <div className='infinite-list'/>
</div>
  • infinite-list-wrapper 为列表的外层包裹,高度为屏幕高度。
  • infinite-list-ghost 在外层包裹内,其高度设为总列表的高度,从而形成滚动条。
  • infinite-list 为可见渲染区域。

2. 根据 scrollTop 获取当前位置

监听 infinite-list-ghostscroll 事件,根据当前 scrollTop 值,从第一个 item 开始累加高度,当总高度大于 scrollTop 时,则当前的 index 为 startIndex。接着从序号为 startIndex 的 item 开始累加,当累加高度大于屏幕高度时,则此时的序号为 endIndex。有了 startIndexendIndex,即可获取需要渲染的列表片段。

接下来,我们需要确定截取片段在整个列表中的位置。

假设当 startIndex 的值为 n ,这表示截取片段从列表中第 n 项(记作 item_{n} )开始渲染。但是为了得到 item_{n} 在列表中的位置,则需要 item_{1}item_{n-1} 的高度累加,并在 item_{n} 上方顶开。

<div
  class='infinite-list' 
  style={{ transform: `translate3d(0, ${top}px, 0)` }} 
/>
其中 top_{n}=item_{1}+item_{2}+...+item_{n-1}

由触发滚动事件到重新渲染完成,步骤大致分为以下 5 步:

  • 触发 scroll 事件
  • 得到当前 scrollTop
  • 根据 scrollTop 和每个 item 高度的累加,计算 startIndex
  • 根据 startIndex 计算获得 endIndextop
  • 定位后重新渲染列表片段

至此,简单的虚拟滚动长列表就完成了。

改进

1. endIndex 与 top 值的缓存

startIndex 一定时,则 endIndextop 值一定(因为屏幕高度不变,且 startIndex 之前的 item 的总高度不变)。因此,在当滚动到某个位置时,根据 startIndex 计算得到的 endIndextop 可以缓存,等到下次滚动到相同位置时,就不用重复计算 endIndextop 值。

例如上图,当 startIndex 为 2 时,计算的 endIndextop 分别为 16 和 180,则将其值缓存进对应 startIndex 的属性中,在下次滚动到 startIndex 为 2 时, endIndextop 值直接取缓存。

观察可发现,当前 startIndextop 值可以根据以下公式计算获得

top_{n}=height_{n}+height_{n-1}+...+height_{1}
=> top_{n}=height_{n}+top_{n-1}
将n = 1代入
top_{1}=top_{0}+height_{1}=height_{1} (其中 top_{0}=0

可见当前 item 的 top 值等于上一个 item 的 top 值加上一个 item 的高度,从而避免了重复累加 startIndex 之前所有的 item 的高度,节省了计算资源。

2. 上下 offset

碍于手机浏览器计算能力的限制,在页面滚动过程中,可能出现当前需要渲染的部分尚未计算完毕的情况,从而导致页面出现部分白屏的现象。(在改进 1 中,通过缓存计算值,节省了部分计算资源的情况下依然会出现白屏现象)


于是想到在渲染时,在屏幕可见范围之外,预渲染更多 item,来弥补计算不足而导致的白屏问题。

例如当 offset 设为 10,则在屏幕上下各多渲染 10 个 item 作为滚动缓冲。(红色框内表示屏幕可见,红色框外表示预加载,上下各预加载 10 个 item )

3. interval

interval 意为重新渲染间隔。

通常情况下,interval 为 1 。例如初始 startIndex 为 0 ,在滑动开始时, startIndex 变为 1 后重新渲染。

但需求可能要求 item 间背景颜色间隔展示,此时如果 interval 为 1 ,则会如下图所示:

由于不知道当前 startIndex 是单数还是双数, 而程序设定奇数行白色背景、偶数行灰色背景,从而导致滚动过程中,单个 item 的背景颜色在白色和灰色间来回闪烁。

此时只要将 interval 值改为 2 ,便能防止闪烁。

原因如下:

startIndex = interval*n (其中n为正整数)

interval 为 2 时,startIndex 总是偶数,而偶数行的背景颜色始终未灰色,于是避免了背景色的闪烁。

Todo

不依赖 item 的高度进行计算

为了做到在 item 高度各不相同的情况下,依然能做到部分渲染和准确定位,在现有的计算方法中,必须用到 item 的高度作为函数入参。但是在生产环境中,item 高度可能无法在渲染前得知,从而会导致现有的计算方法无法正常定位。


Plasma Spark Towerblog.leonzh.cc
leonzhang1108/leon-ts-appgithub.com图标

编辑于 2019-06-20

文章被以下专栏收录

    只看代码的话,上 https://github.com/ElemeFe 。这一群人,关心的不是「如何写前端」而是「如何很好地运行一个 ( web ) APP」;这一群人,会在监控屏上加上弹幕,会让实习生自主招聘,会设计、编写、监控整个 APP 的生命周期;这一群人,玩的时候... 更卖力,就像从来没来过那般卖力,卖力地热爱生活。所以这些创作大多基于 ❤️