关于长列表优化的那些事

1,236 阅读9分钟

本文首发于团队技术分享,主要探讨的是长列表的优化方案以及各自的优缺点,使用场景。

长列表性能瓶颈问题

首先为什么需要对长列表进行优化呢?主要是列表数据过多时会带来以下一些性能问题:

数据层面

  1. 对复杂数据需进行包装(数据过多时会和UI线程互斥)
  2. 从加载到渲染的时间过长

DOM 层面

  1. DOM节点复杂且量大
  2. 页面操作卡顿
  3. 节点变更就极易雪崩形成回流重绘

以上主要是日常开发中长列表带来的一些性能瓶颈,那么我们有解决方案吗?

答案无疑是有的,关于长列表的优化主要可以围绕以上几点问题进行分析和突破。

解决方案以及使用场景

因此就有了以下这些优化方案:

  • 分页加载
  • 时间分片
  • 虚拟列表
分页加载

分页加载 的实现非常简单,普通的分页加载需要用户主动点击分页器来决定要跳转到第几页,从而渲染指定范围的数据列表(可参照旧版SaaS 的项目列表)。虽然缓解了DOM渲染的虚拟瓶颈,但还是解决不了Dom过多的事实。

分页列表

Core Example

const containerHeight = ...;
const itemHeight = ...;
const originList = Array(1000).fill(0).map((_, idx) => idx);
let page = 0;
let pageSize = Math.ceil(containerHeight / itemHeight);
const list = originList.slice(page * pageSize, (page + 1) * pageSize);

以上是一段描述分页加载的核心代码,直观地了解到有且只会渲染的数据有些只有 pageSize 条数据,关于分页列表给我的感觉就是较为繁琐,每次都得通过主动交互来触发加载指定范围的数据。

这里涉及到另外一种形式的分页方式,也就是无需加载再次获取加载过的列表数据,而是将他们持续保留在页面中,并监听列表容器触底则拉取下一页数据push到以及加载的列表中,也就是无限列表。

image

Core Example

const containerHeight = ...;
const itemHeight = ...;
const LoadFlag = document.getElementById('loadFlag');
const originList = Array(1000).fill(0).map((_, idx) => idx);
const visibleList = []

let page = 0;
let pageSize = Math.ceil(containerHeight / itemHeight);

const io = new IntersectionObserver((entries) => {
    if (!entries[0].isIntersecting) return
    const fetchList = originList.slice(page++ * pageSize, page * pageSize)
    if (!fetchList.length) return io.disconnect()
    visibleList.push(...originDate.list.slice(++originData.page))
})
io.observe(entries)
时间分片

什么是时间切片?

时间切片简单来说就是将长任务分解成更小的任务,避免阻塞主进程。

Web Worker

当然,使用时间切片的目的就是为了不阻塞主进程,使用WebWorker也是一种方案,但是WebWorker使用场景比较受限。

Web Worker 的局限性

  • 存在同源策略(可使用Blob来解决)

  • 无法读取基于file://协议的本地文件(可使用Blob解决)

  • 无法直接操作 DOM(因为worker所在的GO不存在window,documentparent

  • 脚本限制 (无法使用 alertcomfirm,因为 GO 中没有 window)

  • 通信限制(worker所在的子线程与主线程是隔离的,无法直接通行,只能通过消息进行通信)

同时worker脚本执行完毕后需要主动关闭,不然会耗费不必要资源的(当然你多开的时候也一样,亲,建议采用 Pool 思想),同时还有另外一个问题,你们自己想吧,我写不动了 就是worker一旦执行就没法中断等待主线程完成某个操作后再继续执行(通俗易懂点就是存档)。

关于 Web Worker 的讨论编不下去了点到为止,如果要了解更多关于Web Worker 的同学可以戳这里

时间分片 本质就是将长任务切割为多个小任务后逐一执行(再唠叨一遍),例如React就是利用时间分片来解决CPU瓶颈的,因为浏览器的GUI渲染线程JS线程是互斥的,所以脚本的执行和页面的绘制是不能同时支持的(除了使用webwork),而时间分片的关键就是将同步的更新更改为异步可中断的更新,不过虽然解决了渲染的问题,可是依然解决不了DOM节点过多的问题(单纯使用的前提,如果混搭就是另外一回事了嘿嘿嘿)。

番外小补

React三层架构中的的Scheduler(调度器)Scheduler是一个独立的包,不依赖于React且和平台没有关系)用于调度任务优先级,高任务优先进入Reconciler(协调器),而 Reconciler 的主要目标之一就是将把可中断的任务切片处理,当所有组件都完成Reconciler的工作后才会统一Renderer(渲染器)

React 15 的架构分为2层:

  • Reconciler(协调器):负责找出组件的变化 传送门
  • Renderer(渲染器):负责将变化的组件渲染到页面中 传送门

而 React 16 比 15 版本多了一层 Scheduler(调度器) 用于调度和分解任务派发给 Reconciler 进而实现异步的,可中断的任务调度。因为在 15 版本中的 Reconciler 没有 Scheduler的协调情况下是采用递归的方式处理虚拟DOM的,而递归的损耗是巨大的,所以才有后面的三层架构。不能再扯了如果对这方面感兴趣的同学可以私底下讨论...

主要的技术栈API为 requestAnimationFramerequestIdleCallback

我们可以对上面的无限列表在进行一次渲染的优化,CV大法~

Core Example(无限列表+时间切片)

const containerHeight = ...;
const itemHeight = ...;
const LoadFlag = document.getElementById('loadFlag');
const originList = Array(1000).fill(0).map((_, idx) => idx);
const visibleList = []

let page = 0;
let pageSize = Math.ceil(containerHeight / itemHeight);

// 写得比较多,可以简化很多
const handleRenderList = (function () {
    const list = []
    const id = null
    const render = () => {
       if (!list.lengh) return window.cancelAnimationFrame(id)
       const item = list.shift
       // 处理数据...
       id = window.requestAnimationFrame(render)
    }
    return function (rendList) {
        list.concat(rendList)
        render()
    }
})()

const io = new IntersectionObserver((entries) => {
    if (!entries[0].isIntersecting) return
    const fetchList = originList.slice(page++ * pageSize, page * pageSize)
    if (!fetchList.length) return io.disconnect()
    visibleList.push(...originDate.list.slice(++originData.page))
    // 采用时间切片对列表进行异步渲染,减低阻塞主线程的风险
    handleRenderList(visibleList)
})

io.observe(entries)

关于时间切片实际到Event Loop的概念,可以看下这张非常非常简单的图做个了解,需要了解详情的点击这里

image

虚拟列表

相较于全量渲染全部数据到视图中不如采用只需要渲染当前可视视图的数据的,非可视区域的视图数据可以在用户滚动过程中做监听判断是否即将达到可视区域在做渲染,这种方案既保证了巨量数据的渲染以及DOM过度占用内存的问题,也就是下文的虚拟列表方案。

虚拟列表简版描述图(哼!憋住,笔者的画风可是直击灵魂的)

虚拟列表

了解了什么是虚拟列表以及使用场景后接下来就是小试牛刀了解如何实现吧~

实现逻辑

虚拟列表的逻辑主要如下:

  • 给每个列表项设置固定的高度,并根据列表项个数 * 单个列表项高度得出列表容器的可滚动高度
  • 计算列表容器的高度,并结合当个元素高度计算可挂载的元素数量;
  • 监听容器滚动,计算已加载的元素总高度当前视图所渲染的元素总高度以及可视区域底部未加载元素的总高度

纸上谈兵终觉浅,咱们还是直接用码来说吧~

import React, {useState, useCallback, useEffect, useRef} from "react"
import './index.less'
// 定义每个元素的高度
const HEIGHT = 50
// 缓冲个数
const BUFF_SIZE = 5
// 每次请求拉取的数据量
const FETCH_COUNT = 100
// 当前可视区域出示的元素个数
let VISIBLE_COUNT = 0

const fetchList = pageSize => Array(pageSize).fill(0).map(_ => ({}))

const VirtualizedList = () => {
  const container = useRef(null)
  // 记录列表容器上回滚动的高度
  const [lastScrollTop, setLastScrollTop] = useState(0)
  // 记录当前列表可滚动的高度
  const [listTotalHeight, setTotalHeight] = useState(0)
  // 头下标
  let [startIndex, setStartIndex] = useState(0)
  // 尾下标
  let [endIndex, setEndIndex] = useState(0)
  // 加载的数据列表
  const [list, setList] = useState([])
  // 可视区域渲染的数据(包括缓冲区)
  const [visibleList, setVisibleList] = useState([])
  // 记录锚点元素的位置信息
  const [anchorItem, setAnchorItem] = useState({
    index: 0, // 当前锚点下标
    offset: 0 // 偏移量
  })

  // 请求列表
  const handleFetchList = useCallback((callback) => {
    const freshList = [...list, ...handleMarkListIndex(fetchList(FETCH_COUNT))]
    setList(freshList)
    setTotalHeight(freshList.length * HEIGHT)
    if (callback instanceof Function) callback(freshList)
    console.log('[handleFetchList]', freshList.length)
  }, [VISIBLE_COUNT, list, container, setList, setTotalHeight])

  // 对每个列表项的位置信息以及锚点序号
  const handleMarkListIndex = useCallback(dataList => {
    const lastIndex = list.length
    dataList.forEach((item, index) => Object.assign(item, {
      index: lastIndex + index,
      scrollY: listTotalHeight + index * HEIGHT
    }))
    return dataList
  }, [list, listTotalHeight])

  // 更新锚点缓存
  const updateAnchorItem = useCallback(() => {
    const CONTAINER = container.current
    const index = Math.floor(CONTAINER.scrollTop / HEIGHT);
    const offset = CONTAINER.scrollTop - index * HEIGHT;
    return { index, offset }
  }, [container])

  // 滚动监听
  const handleScroll = useCallback(() => {
    const CONTAINER = container.current
    // 滚动差值
    const delta = CONTAINER.scrollTop - lastScrollTop
    // 判断滚动方向
    const isPositive = delta >= 0
    anchorItem.offset += delta
    // 判断滚动方向
    if (isPositive) {
      // 当锚点偏移量大于等于固定高度时,说明向下滚动超过一个元素的高度则更新锚点缓存
      if (anchorItem.offset >= HEIGHT) Object.assign(anchorItem, updateAnchorItem())
      // 当前锚点距离头下标的差值大于缓冲个数时则更新头下标
      if (anchorItem.index - startIndex >= BUFF_SIZE) startIndex = Math.min(list.length - VISIBLE_COUNT, anchorItem.index - BUFF_SIZE)
    } else {
      if (CONTAINER.scrollTop <= 0) {
        // 容器滚动至顶部时则初始化锚点缓存
        Object.assign(anchorItem, {index: 0, offset: 0})
      } else if (anchorItem.offset < 0) {
        // 锚点向上滚动超过一个元素则更新锚点缓存
        Object.assign(anchorItem, updateAnchorItem())
      }
      // 判断锚点向上滚动的距离距离头下标是否大于缓冲区,成立则更新头下标
      if (anchorItem.index - startIndex < BUFF_SIZE) startIndex = Math.max(0, anchorItem.index - BUFF_SIZE)
    }
    // ============状态更新===============
    // 记录滚动高度
    setLastScrollTop(CONTAINER.scrollTop)
    // 更新头下标
    setStartIndex(startIndex)
    // 更新尾下标
    setEndIndex(endIndex = Math.min(startIndex + VISIBLE_COUNT + BUFF_SIZE * 2, list.length))
    // 更新可视区列表
    setVisibleList(list.slice(startIndex, endIndex))
    // 更新锚点
    setAnchorItem(anchorItem)
    if (endIndex === list.length) handleFetchList()
  }, [container, visibleList, list, anchorItem, startIndex, endIndex, lastScrollTop, setAnchorItem, setVisibleList, setStartIndex, setEndIndex, setLastScrollTop, handleFetchList])

  // 组件挂载阶段
  useEffect(() => {
    // 获取可视区可渲染的个数
    VISIBLE_COUNT = Math.ceil(container.current.offsetHeight / HEIGHT)
    const freshEndIndex = VISIBLE_COUNT + BUFF_SIZE
    // 更新尾下标(含缓存区)
    setEndIndex(freshEndIndex)
    handleFetchList(freshList => {
      setVisibleList(freshList.slice(startIndex, freshEndIndex))
      console.log('[VISIBLE_COUNT]', VISIBLE_COUNT, freshList, container)
    })
  }, [])

  return (
    <div className="v-list" ref={container} onScroll={handleScroll}>
      <div
        className="v-list__scroll-height"
        style={{transform: `translate(0, ${listTotalHeight}px) translateZ(0px)`}}
      ></div>
      {
        visibleList.map(item => (
          <div
            className="v-list__item"
            style={{
              transform: `translate(0, ${item.scrollY}px)`,
            }}
            key={item.index}>{item.index}</div>
        ))
      }
    </div>
  )
}

export default VirtualizedList
.v-list {
    position: relative;
    overflow: hidden auto;
    // width: 300px;
    height: 500px;
    margin: auto;
    border: 1px solid #6c6c6c;
    &__scroll-height { 
        position: absolute;
        width: 1px;
        height: 1px;
        transition: transform 0.2s;
    }
    &__item {
        position: absolute;
        width: 100%;
        margin: 20px 0;
        contain: layout;
        will-change: transform;
        border: 1px solid #ccc;
        line-height: 50px;
        text-align: center;
    }
}

以上就是虚拟列表的实现,那么这个时候就会有可爱的小伙伴会问虚拟列表是不是都得固定高度,假如我的列表可以展开伸缩,或者添加或者删除数据的这种动态高度的列表项场景是不是就没啥用了,针对这种场景该如何解决或者优化呢?

动态高度虚拟列表难点分析

动态高度虚拟列表相较于固定高度虚拟列表那么简单只需要在滚动过程中监听锚点以及头尾哨兵下标截取可视区域列表数据,更多的是考虑列表高度的动态情况,例如展开可伸缩,又或者可动态添加数据亦或是存在媒体资源(如图片懒加载)的场景。

最简单的做法就是给每个列表元素预估一个固定的高度,待列表元素渲染可视区域时通过浏览器API监听节点的动态变动动态更新每个节点元素的位置以及所处位置,高度等信息同时修正滚动容器的可滚动高度。

关于节点变动的监听可以使用 ResizeObserver 或者 MutationObserver,不过偷懒的代价就是兼容性,但是又对应的 polyfill 来兼容噗哈哈哈

由于 产品大大正在催需求 时间关系,关于动态高度虚拟列表笔者就以抛砖引玉的方式说出自己的理解,小伙伴如果感兴趣的话可以私底下一起讨论或者分享有趣的技术实现方案哟~

最后谢谢各位小伙伴的捧场,愿在即将到来的新的一年共勉!