这个长列表优化渲染,值得学一波(通俗易懂,学不懂你找我)

8,661 阅读9分钟

背景

公司里的PHP后台项目,上一任的工程师对于很长列表的处理,都是直接全部进行渲染,导致的一个问题就是,加载页面都要加载比较久的时间,这里只能庆幸这个后台项目只是给公司里边的大佬用,用的机器还可以,除了忍受一下加载速度比较慢之外,没有很大的影响。但他们能忍,作为前端工程师,我实在是忍不了了,之前每天打开,看看数据,等那几秒至十几秒的时间,好像是过了半个世纪一样,难受!最近终于抽空不偷懒了,想要好好的优化一下,于是我就瞄准了 IntersectionObserver 这个API,期待能够给我一个比较好的解决方案,能让我好好的摆个地摊,赚取第一桶金

最终效果(在edge的chrome内核版测试)

好紧张!!!当然是先要温柔一点试试水啦

光温柔,不能暴力使用,那就没法面对很多场景了,中看不中用,来张暴力图

舒服了,总算是让它入得厨房,出得厅堂了。

第一次尝试 (致敬网易云音乐团队的 一个简洁、有趣的无限下拉方案

核心思想:

  • 利用 intersectionObserver监听已渲染的DOM列表首末两个 item,在首末两个item进入视口的时候渲染上一页或下一页的列表项
  • 为了能够保持当前所占据的位置,通过设置padding,来替代已经渲染过的列表上方的item,假装上方有DOM已经渲染。

实践:

具体流程为

重新渲染之后的结果为

1. 监听元素是否已进入可视区域

  • intersectionObserver 在监听的元素刚进入可视区域的时候,会触发一次回调,在完全进入可视区域的时候,还会触发一次回调,因此,在进行监听的时候,需要对是否是刚进入的状态进行筛选,这样可以避免多次渲染。
    // 创建 intersectionObserver 对象
    this.intersectionObserver = new IntersectionObserver(entries => {
      console.log(entries)
      for (const entry of entries) {
        // 如果为true,则证明是进入的状态,如果为false,则是完全进入的状态,取刚进入的状态即可
        if (!entry.isIntersecting) return
        if (entry.target.classList.contains(firstItemClass)) {
          console.log('向上滑动')
          this.handleScrollUp(false)
        } else if (entry.target.classList.contains(lastItemClass)) {
          console.log('向下滑动')
          this.handleScrollDown(true)
        }
      }
    })
  • 在我个人看来,只要监听到entry.isIntersecting为true,则可以说明是刚进去的状态。

2. 渲染更新下一页的数据

  • 在渲染下一页的数据之前,需要拿到下一页数据的首item索引,也就是item-first对应的索引,这里我直接沿用网易云音乐的方案,缓存当前的首item索引,然后在下一次监听的元素进入视口触发回调的时候,拿到上次的首item索引,对其进行半数的页容量增加作为新的首item索引。

  • 就如上图中每次需要渲染的元素个数(页容量)为10个,初始首item索引为0,然后在监听到 item-last 元素进入视口之后,取5作为最新的首item索引,这样来渲染下一页(5-14)的元素。

    getContainerFirstIndex(isScrollDown) {
        // 获取缓存的currentIndex
        const { currentIndex } = this.dataCache
        // 获取半数的页容量作为增量
        const { halfListSize } = this.staticData
        // 一次进行半数列表项的递增
        return isScrollDown
          ? currentIndex + halfListSize
          : (currentIndex - halfListSize < 0 ? 0 : currentIndex - halfListSize)
    }
  • 然后就从首item索引开始渲染下一页的数据

3. 绑定新的首末项

  • 当渲染完新的数据列表之后,之前的数据列表已经被移除掉了,此时新的数据列表还没有重新设定首末项元素监听
  • 因此需要在渲染完数据之后取消监听将之前监听的首末项元素,监听新的首末项元素
  bindNewItems() {
    const { firstItemClass, lastItemClass, container } = this.staticData
    const { firstItem, lastItem } = this.dataCache
    // 解绑旧的
    firstItem && this.intersectionObserver.unobserve(firstItem)
    lastItem && this.intersectionObserver.unobserve(lastItem)
    // 先给新的数据列表添加class
    container.children[0].classList.add(firstItemClass)
    container.children[container.children.length - 1].classList.add(lastItemClass)
    // 然后获取新的首末项元素并开启监听
    const newFirstItem = container.querySelector('.' + firstItemClass)
    const newLastItem = container.querySelector('.' + lastItemClass)
    this.intersectionObserver.observe(newFirstItem)
    this.intersectionObserver.observe(newLastItem)
    // 将当前新的首末项元素缓存起来,可以用作下次绑定新的首末项元素之前取消绑定旧的首末项元素
    this.updateDataCache({
      firstItem: newFirstItem,
      lastItem: newLastItem
    })
  }

4. 进行padding的设置

  • 关于padding的设置,我的方案比较简单粗暴,具体就是先拿到全部item列表项的高度(比如1个item的高度为150,有100个item,总高度为 150 x 100 = 15000),减去一个页容量的高度(页容量为10个item,对应的页容量的高度 10 x 150 = 1500),就作为总padding的值(15000 - 1500 = 13500)。
  • 那么对应的 paddingTop 的高度就是 这个首item索引前面所有元素的高度总和(比如在上图中首item索引为5的时候,paddingTop 为5个item的高度, 5 x 150 = 750)
  • 对应的 paddingBottom 的高度就是总 padding - paddingTop(13500 - 750 = 12750‬)
  adjustPaddings(firstIndex) {
    // 半数页容量,一个item的高度,item全部列表项,页容量,容器
    const { halfListSize, itemHeight, listLen, listSize, container } = this.staticData
    // 获取渲染listSize一半对应的高度
    const halfListSizeHeight = halfListSize * itemHeight
    // 将 listLen - listSize 的高度作为总padding
    const totalPadding = (listLen - listSize) / halfListSize * halfListSizeHeight
    // 将超出当前firstIndex以上的部分作为paddingTop
    const newCurrentPaddingTop = firstIndex <= 0 ? 0 : (firstIndex / halfListSize) * halfListSizeHeight
    // paddingBottom则等于 totalPadding - paddingTop
    const newCurrentPaddingBottom = totalPadding - newCurrentPaddingTop < 0 ? 0 : totalPadding - newCurrentPaddingTop
    // 修改container的padding
    container.style.paddingTop = `${newCurrentPaddingTop}px`
    container.style.paddingBottom = `${newCurrentPaddingBottom}px`

    console.log('firstIndex, paddingTop, paddingBottom', firstIndex, newCurrentPaddingTop, newCurrentPaddingBottom)
  }

  • 此时的效果如图(以页容量20来做的测试),是不是超棒的?

5. 缺陷

  • 当我们正常的用鼠标去滚动的时候,效果都是棒棒的。我差点就以为我已经成功了
  • 然而,当我们尝试去拉动滚动条的时候,尴尬的事情就发生了,页面直接白屏~,当场去世???

6. 原因

  • 再来说一下 intersectionObserver这个API,是监听元素刚进入视口以及完全进入视口
  • 当我们快速去滑动滚动条的时候,直接就略过了那些需要监听的元素,此时,就没法触发回调,那就没法进行数据渲染啦
  • 所以我们就只能很悲伤的看着白屏,我还以为可以实现了我的做法了???

第二次尝试(最终版本)

  • 作为当代前端选手,怎么可能那么容易就放弃呢?至少也得进行 收藏 = 学会 的操作吧?
  • 上述的方案虽然有缺陷,除了白屏导致了这个问题之外其它都还好,但有困难还是需要去解决的,不然就没法用在实际项目中去了,只能这样玩玩~
  • 其实解决方案也很简单,当检测到用户正在大幅度进行滚动条滚动的时候,根据当前的滚动条位置来获取当前区域内应该出现的元素,并进行首末项监听绑定,渲染
  • 没错,也就是 onscroll的方案,先在这里说一下自己的看法,虽然这个会每次都触发回调,但是在做了是否大幅度滚动的检测之后,拿到的效果就是,正常上下滑动的时候,不会触发 onscroll里边的渲染。这就相当于润滑剂polyfill的存在了
  • 虽然是传统的onscroll方案,但其中涉及的计算还是挺多的,不过,和在intersectionObserver监听回调那里做的一样,都是为了拿到首项item元素,然后渲染对应的元素列表

1. 往下滚动时,需要先拿到baseHeight

    // 获取往下滑动时,必要的阈值 相对于  叶容量 20 以及 itemHeight 为 150, 容器盒子为500 来说就是 850
    /**
     * eg: listSize: 20, itemHeight: 150, boxHeight: 500
     * firstIndex   bottom   top    scrollTop                           firstBaseHeight
     *    0          12000   0
     *    10         10500   1500      2350         1500  -  150  -  500  =  850  =  2350 - 1500
     *    20         9000    3000      3850         1500  -  150  -  500  =  850  =  3850 - 3000
     *    30         7500    4500 ...
     */
  • 先贴出注释, 在上面注释对应的例子中, 首项item与paddingTop的值是一一对应的关系
  • 我在对总item数为100, 叶容量 20 以及 itemHeight 为 150, 容器盒子为500上下进行滑动测试
  • 发现在向下滑动时,当我滚动条滑动距离在 2350 的时候就触发了intersectionObserver对末项item第一次响应
  • 在向下滑动时,当我滚动条滑动距离在 3850 的时候就触发了intersectionObserver对末项item第二次响应
  • 然后我神奇的发现,经过我瞎 jier 的计算之后,有那么一个值(这里是 850 ),可以作为精准获取首项item的标志(有请评论区的大神给小弟解释一下😄)

2. 拿到首项item元素

  • 当我拿到当前的滑动距离 - baseHeight 之后,就可以一一对应着firstIndex的padding值,也就是说我可以精确的拿到firstIndex并进行这一块的渲染
// 获取当前的firstIndex(向下滑动时)
let firstIndex = Math.floor(Math.abs(currentScrollTop - baseHeight) / (itemHeight * halfListSize)) * halfListSize
  • 向上滑动时,也有这么个baseHeight,就是一个itemHeight的高度
let firstIndex = Math.floor((currentScrollTop - itemHeight) / (itemHeight * halfListSize)) * halfListSize
  • 拿到了首项item, 就可获取对应视口需要渲染的元素,然后按照之前的逻辑进行渲染 => 解绑旧元素 => 绑定新元素 => 给新的padding即可

  • 然后就随意的爱怎么玩就怎么玩,就是不白屏了,舒服~,是我喜欢的类型❤

思考总结

1. 方案

  • 这个长列表优化渲染方案优先是基于 intersectionObserver + padding进行异步加载数据,以及润滑剂基于scroll + padding进行同步加载数据来完成的,具体步骤除了一开始获取首项item的方式不同,其它的步骤一致,均为取消监听 旧的首末项, 绑定监听新的首末项,然后对容器添加padding。在实现正常的滑动的基础上,能够让用户的一些大幅度的举动也能满足要求

2. 缺陷

  • 目前只能固定item的高度,我没有想到过能够渲染动态高度的方案,适用于表格列表、同等高度的列表等
  • 如果页容量太小,会导致首末元素同时出现在视口中,这样将没法进行渲染,必须要让页容量大一点

3. 后续优化

  • 可以实现异步加载数据(这是可行的,暂时没去实现)
  • 可以考虑添加对动态高度的拓展(暂时没有什么灵感,评论区请大佬指点)

代码实现

完整代码