React 之无限滚动

React 之无限滚动

pwa 在国外也火了几年了。国内虽然暂时还不重视 webapp,但是从 safari 开始支持 pwa,和国内一些公司开始尝试 pwa 的势头看来,pwa 即将会在国内流行。作为强调性能和体验的 pwa 项目中,无线滚动是每个 pwa 项目都需要解决的问题。本文从 twitter 的无线滚动的视角触发,尝试实现了一套具体的无限滚动方案。介于 twitter 的文章中只提到概念没有提到任何代码和具体实现,我会在具体实现上着重说明。


什么是无限滚动?

Infinite scrolling is a technique that allowing users to scroll through a massive chunk of content with no finishing-line in sight. This technique simply keeps refreshing a page when you scroll down it.

无限滚动简单的说就是能一直往下滑动,而且刷新滚动数据。但是随着显示的数据越来越多,dom 越来越复杂,性能也越来越低。如果用户没看到的数据不作处理就加载的话很浪费流量和性能的。要实现一个体验好的无限滚动,在已经实现无线加载的基础上,我按照难易度进行一个简单的等级划分:

  • lv0。直接 map。不管数据数组有多长,滑到底部拿到的新数组 concat 老数组继续map。这种最简单的,但是数组长了之后会卡,特别是移动端。
  • lv1。懒加载。只显示视窗可见的部分数据,其他数据动态加载。并且需要不可见部分用 padding-top, padding-bottom,或者空的 div 把高度撑起来,避免滚动条位置不正确。这里会引入缓存。
  • lv2。动态高度。这里的动态高度指的是 item 高度不一致,而且加载之后 item 的高度不会再改变。因为高度不一致,所以没办法知道上下填充 div 的高度或者padding的准确数值。这里会引入预估。
  • lv3。resize。在手机端用户可能把竖屏切换到横屏。resize 之后,lv2 的缓存和填充高度都失效了,那么如何还能做到 resize 之后看到的内容不变的情况下,平滑不带抖动的滑到顶部或者底部。这里会引入校正。
  • lv4。item高度可变。暂时没找到能优雅的检测div resize的方法,而且也没需求,推特也没有实现。放到未来规划里。

我目前只实现到 lv3。


设计

设计主要由2部分构成,分别是画布和投影仪对象。就像看一场电影。画布负责展示和调整,投影仪负责计算和输出。


画布--Scroller

"画布"就是 react 组件本身,他的主要功能是拿到容器原生 dom、提供参数和启动后面提到的投影仪,它的主要布局是这样的


因为要实现 lv1 提到的懒加载,我们只能让画布显示部分数据,比如我的实现里显示一屏(visibleItem)加3的数量的 item。这额外的三个就是屏幕之外的不可见的部分(invisibleItems),用来做缓冲,让正常滑动速度的时候不会显得那么突兀。滑动到某个位置之后,因为只显示了一屏多一点 item,滚动条的位置需要正确或者大概表达出滑动位置,所以需要一个 div 或者 padding 把内容撑起了或者踮起来(beforePadder),在我的实现里是用一个空 div 实现的。


画布代码:

<div id="c" ref={div => this.divDom = div!} style={{ "overflow": "scroll", "boxSizing": "border-box", "-webkit-overflow-scrolling": "touch", "height": this.props.containerHeight }} onScroll={this.onScroll}>
 <div ref={div => this.upperContentDom = div!} style={{ height: this.state.upperPlaceholderHeight }}></div>
 {this.state.projectedItems.map((item, index) => React.createElement(this.createChild(item, index), { key: this.props.identity ? item[this.props.identity] : index }))}
 <div style={{ height: this.state.underPlaceholderHeight }}></div>
</div>


投影仪--Projector

projector 是专门抽象出来的用于计算显示数量,填充高度等等。


Projector 伪代码

class Projector {
  // 下一帧
  next() {}
  // 手指往上滑动
  up() {}
  // 手指往下滑动
  down() {}
  // 计算填充高度
  computeUpperPlaceholderHeight() {}
  // 输出内容
  subscribe() {}
}

设计它的主要目的还是为了把计算和逻辑尽量分离出来,方便日后维护。就像一台老式投影仪,手往上摇就往下计算(up),往下摇就往下计算(down),接着计算下一帧(next),输出给 scroller(subscribe)。


实现

lv0太简单了直接从lv1开始讲怎么实现。限于篇幅的问题我只讲正常滑动速度,如果你对快速滑动感兴趣可以看结尾提到的源码链接。


lv1-懒加载

实现懒加载其实就是缓存的实现。如果能知道滑到哪,就能知道要显示哪些内容,也能知道用多少高度能撑起滚动条适合。在这之前,需要引入几个变量

  // 开始坐标
  public startIndex = 0
  // 结束坐标,endIndex 可以超过 items 最大长度
  public endIndex = 0
  // 描点,用户看到的最上面一条 item 的 坐标和偏移量。
  // index 等于 index 或者 startIndex + 3。offset 等于描点顶部到容器顶部的scrollTop
  public anchorItem = { index: 0, offset: 0 }


还需要一个能加载之后能设置缓存的子组件

public createChild = (item: any, index: number) => {
    const parent = this
    const itemIndex = parent.projector.startIndex + index
    return class Child extends React.Component {
      public dom: HTMLDivElement

      public componentDidMount() {
        this.setCache()
      }
      public render() {
        return <div ref={div => this.dom = div!}>
          {parent.props.onRenderCell(item, itemIndex)}
        </div>
      }

      public setCache = () => {
        const projector = parent.projector
        const cachedItemRect = projector.cachedItemRect
        const curItem = cachedItemRect[itemIndex]
        const prevItem = cachedItemRect[itemIndex - 1]

        if (curItem && curItem.needAdjustment === false) return
        const rect = this.dom.getBoundingClientRect()
        if (prevItem) {
           // 当前item不存在但是前一个存在
           const bottom = prevItem.bottom + rect.height
           const top = prevItem.bottom
           cachedItemRect[itemIndex] = { index: itemIndex, top, bottom, height: rect.height, needAdjustment: false }
         } else {
           // 当前 item 不存在,且前一个也不存在
           const bottom = parent.state.upperPlaceholderHeight + rect.height
           const top = parent.state.upperPlaceholderHeight
           cachedItemRect[itemIndex] = { index: itemIndex, top, bottom, height: rect.height, needAdjustment: false }
        }
      }
    }
  }

item 加载之后通过 getBoundingClientRect 方法把高宽坐标等等缓存起来。


接下来,在滑动的时候计算滑到哪里了,就可以知道接下来要渲染哪些内容了,这里以手指往上滑(屏幕往下滑)为例

  public up = () => {
    const scrollTop = this.divDom.scrollTop
    const anchorItemRect = this.cachedItemRect[this.anchorItem.index]
    // 滑动范围超过一个元素的高度之后再处理
    if (scrollTop > anchorItemRect.bottom) {
      const itemIndex = this.cachedItemRect.findIndex(item => item ? item.bottom > scrollTop : false)
      if (itemIndex === -1) {
        // 滑的太快,读不出坐标,猜一个 itemIndex
        ...... 这里省略,不是重点。
      } else {
        // 正常滑动速度
        this.startIndex = itemIndex > 2 ? itemIndex - 3 : 0
        this.endIndex = this.startIndex + this.displayCount - 1
        this.anchorItem.index = itemIndex
        this.anchorItem.offset = this.cachedItemRect[itemIndex].top
      }
      this.next()
    }
  }

当滑动距离大于描点的底部到容器顶部的距离,则我们需要计算接下来要从哪里开始裁剪原数据。理想情况下滑动速度不超过1帧1屏的速度,都会走 else 分支,这是正常滑动速度。正常滑动速度指的就是裁剪起点能在缓存中找到的情况。所以 up 和 down 的职责一目了然:算出裁剪坐标和描点坐标。


注意到 this.next() 这一行了吗,知道了坐标之后就要告诉 next 函数计算出下一帧需要的数据和参数了。


next 代码

public next(items?: any[]) {
    if (items) this.items = items

    // slice 的第二个参数表示长度,而不是坐标,所以要 + 1
    const projectedItems = this.items.slice(this.startIndex, this.endIndex + 1)

    const startItem = this.cachedItemRect[this.startIndex]

    let uponContentPlaceholderHeight = 0
    if (startItem) {
      // 正常
      uponContentPlaceholderHeight = startItem.top
    } else if (this.startIndex > 0) {
      // 滑动幅度太大, startItem 不存在, startIndex 又大于 0
      uponContentPlaceholderHeight = this.anchorItem.offset - 3 * this.averageHeight
    } else {
      // items从空到填满,这个时候是初始化,所以是0
      uponContentPlaceholderHeight = 0
    }

    const cachedItemRectLength = this.cachedItemRect.length
    const unCachedItemCount = this.items.length - cachedItemRectLength
    const lastCachedItemRect = this.cachedItemRect[cachedItemRectLength - 1]
    const lastCachedItemRectBottom = lastCachedItemRect ? lastCachedItemRect.bottom : 0
    const lastItemRect = this.endIndex >= cachedItemRectLength ? this.cachedItemRect[cachedItemRectLength - 1] : this.cachedItemRect[this.endIndex]
    const lastItemRectBottom = lastItemRect ? lastItemRect.bottom : 0
    const underContentPlaceholderHeight = lastCachedItemRectBottom - lastItemRectBottom + unCachedItemCount * this.averageHeight

    this._callback(projectedItems, uponContentPlaceholderHeight, underContentPlaceholderHeight)
  }


next 函数的职责主要是计算 scroller 需要的参数:projectedItems、upperHeight、underHeight。

  • projectedItems。知道了起点就可以从原数据中用 slice 裁剪出来。
  • upperHeight。正常滑动速度情况下,upperHeight就是 startItem 的 top。
  • underHeight。底部高度是还未显示的 items 的高度总和。

计算出这3个参数之后交给 scroller 显示就行了。


懒加载




至此,懒加载实现了。接下来实现动态高度把。


动态高度

因为类推特和微博这种项目,item 的高度不可能总是固定的。有的 item 只有一条文字,有的item 图文并茂。所以在计算底部高度的时候,没有办法用目前市面上的插件里的那种用高度乘以数量的方式。这里我引入了平均高度: averageHeight。实际上底部的高度用户并不太关注,只要滑动内容是平滑的,滚动条能告诉用户你还没有滑到底部就可以。我们可以用平均高度乘以数量的方式来填充底部。averageHeight 其实还有其他用途,比如非正常滑动速度,猜测上方填充高度,比较复杂,这里略过。


resize

resize 的需求其实很少,pc 端可以固定宽度。但是移动端有竖屏切换横屏的情况。切换的过程中,用户看到的内容不能丢失。比如竖屏情况下用户看到了第20条,切换到横屏之后尽量让用户还能看到20条左右的位置,当然最好还是第20条了。在缓存全部丢失的情况下(resize之后,之前的缓存没有参考性),如何保证位置还能在内容不闪动的情况下能滑到顶部,这是 resize 最大的难点。这里我引入了校正(adjust)机制。

下面是这个机制的流程:


setState

resize之后清空缓存,加载 resize 之前的投影数据。告诉 componentDidUpdate 需要校正。

    window.addEventListener("resize", () => {
      if (this.divDom.clientWidth !== this.width) {
        this.width = this.divDom.clientWidth
        this.resizing = true
        this.projector.cachedItemRect.length = 0
        this.projector.needAdjustment = true
        this.setState({})
      }
    })


render

子节点加载之后设置新的缓存。


componentDidUpdate

componentDidUpdate 发现需要校正,调用 computeUpperPlaceholderHeight 计算出顶部填充块的正确高度。


computeUpperPlaceholderHeight

  /**
   * @param cache 描点的缓存坐标
   * @param height 填充区的高度,也是第一个 item 的 top
   * 
   */
  public computeUpperPlaceholderHeight(cache: Cache, height: number): number {
    const scrollTop = this.divDom.scrollTop
    const prevStartIndex = this.anchorItem.index - 3
    const scrollThroughItemCount = prevStartIndex - this.startIndex
    const sliceEndIndex = scrollThroughItemCount > 3 ? 3 : scrollThroughItemCount
    const scrollThroughItem = this.cachedItemRect.slice(this.startIndex, this.startIndex + scrollThroughItemCount)
    const scrollThroughItemDistance = scrollThroughItem.reduce((acc, item) => acc + item.height, 0)
    const finalHeight = height - scrollThroughItemDistance
    this.isAdjusting = true
    // 有可能是负数
    return finalHeight
  }


之前的缓存不存在了,往上滑怎样才能让内容正好滑动到顶部呢。这个函数用缓存之前的顶部高度减去新缓存的item高度,得到正确的高度。如果高度不够的时候会出现负数的情况,高度太高提前到第0个item的时候,又太高。这部分处理本文也不打算详谈。可以参考具体代码。


setState

拿到正确的高度再做一次渲染。可能有人会觉得渲染2次是否有性能问题。幸运的是,速度很快,暂时没有发现这样的情况。


resize





流程

当你看完 resize 的实现之后,流程应该很明显了。还是拿推特的图

初次渲染一个空数组,目的主要是了拿到容器的真是dom,获取到必要的参数...

其他流程就和resize环节里差不多了。


看到哪回到哪

因为实现了缓存机制,所以还支持看到哪回到哪。例如正在看主页无限列表中的某个位置,切换到其他页面再回来之后还要回到之前看到的那个位置。这是文中没提到新功能。你可以在我的博客地址里看到效果: corol.me/slack。先在slack页面滑到某个位置,再点reddit页面,再点slack,位置还是你离开之前的位置。因为缓存了item元素坐标,所以slack页面重新构建的时候,找到那个位置需要的参数,快速渲染。


scrollTo




演示地址

corol.me/slack

可以看看 dom 是怎样的,也可以改变窗口大小试试,也可以用手机浏览器体验滑动的性能和横竖屏切换感觉。

源码地址:blog

喜欢的同学可以点个赞支持下...


其他

目前还有点bug,打算处理掉之后把我的无限滚动方案做成独立的 react 插件。希望大家支持。如果你对我的方案感兴趣的话欢迎入群:618921336


参考资料

Infinite List and React

developers.google.com/w

文章被以下专栏收录