等高项无限滚动方案

132 阅读4分钟

需求背景

  • 大量的列表展示数据
  • 用户滚动界面进行浏览

传统方案

  1. 后端分页
  2. 前端监听 dom 容器 scroll 事件,滚动到列表末尾加载下一页数据进行渲染

优化方案

传统方案在大数据量情况下存在的问题: dom 元素量过大时,浏览器渲染的时间就会急剧增多,页面中大量 dom 也将占据很大内存空间,滚动将变得滞后,甚至导致页面卡顿崩溃

优化方向: 减少非必要的 dom 渲染,因为视口大小是固定的,处于视口之外的元素没有必要渲染

优化手段:

  1. 只渲染处于视口中的 dom 元素 -> 需要计算哪些元素需要渲染 -> a. 通过 容器scrollTop / 单个项目高度 计算出视口中第一个元素索引;b. 通过 视口高度 / 单个项目高度 计算出视口能够容纳多少个元素

  2. 只渲染视口中的元素怎么解决滚动高度塌陷 -> 通过 单个元素高度 * 元素总数 计算出容器总高度(这里是计算总高度,也可以计算已经获取到的元素应该占据的高度)

  3. 怎么布局渲染元素使得它们刚好处于视口中(处于正确的位置)-> a. 每个元素设置 position: absolute; top: 0;left:0; 使其脱离文档流;b. 每个元素设置transform: translateY(<元素在总数据中的索引 * 元素高度>px),根据元素在总数据中的索引计算其y方向的偏移量,使得元素刚好显示在容器的正确位置

原理:

  1. 让每个元素脱离文档流,然后根据其索引计算y方向的偏移量,通过 transform 属性让其处于正确的位置
  2. 根据容器 scrollTop 计算需要渲染的元素
  3. 通过比较已经获取到的元素数量和需要渲染的元素索引来决定是否需要向远端获取下一页的数据

代码

以下 demo 基于 Vue3 ,原生和 React 大同小异:

style:

html,
body {
    height: 100vh;
}

.wrap {
    height: 100vh;
    overflow: auto;
}

.list {
    overflow: hidden;
    position: relative;
}

.li {
    position: absolute;
    box-sizing: border-box;
    top: 0;
    left: 0;
    width: 100%;
    border: 1px solid #cdcdcd;
    padding: 20px 30px;
}

template:

import { computed, defineComponent, nextTick, onMounted, reactive } from 'vue'
import './styles'

interface OrderItem {
    orderId: string
    orderStatusStr: string
    customer: string
    reserveDateStr: string
}

const updateList = (
    pageSize: number
): (() => Promise<{ list: OrderItem[]; total: number }>) => {
    let pageNo = 0
    let pending = false
    const updator = (): Promise<{ list: OrderItem[]; total: number }> => {
        // 正在更新,则返回(这里这样做了简单处理,实际情况这样做会有问题)
        if (pending) return
        pending = true

        return new Promise((resolve) => {
            setTimeout(() => {
                pageNo++
                pending = false
                resolve({
                    list: new Array(pageSize).fill(0).map((v, i) => ({
                        orderId: String((pageNo - 1) * pageSize + i),
                        orderStatusStr: (function () {
                            switch (i % 5) {
                                case 0:
                                    return '已支付'
                                case 1:
                                    return '已发货'
                                case 2:
                                    return '已收货'
                                case 3:
                                    return '退款中'
                                case 4:
                                    return '已退款'
                            }
                        })(),
                        customer: String.fromCharCode((pageNo - 1) * pageSize + i + 100),
                        reserveDateStr: new Date((pageNo - 1) * pageSize + i).toLocaleDateString()
                    })),
                    total: 500
                })
            }, 300)
        })
    }
    return updator
}

// 节流
function throttle(fn, delay = 200) {
    let timer = null
    return (...args) => {
        if (timer === null) {
            timer = setTimeout(() => {
                fn.apply(null, args)
                timer = null
            }, delay)
        }
    }
}

export default defineComponent({
    setup() {
        const updator = updateList(20)
        const state = reactive<{
            list: OrderItem[]
            total: number
            startIndex: number
            itemHeight: number
        }>({
            list: null, // 列表数据
            total: null, // 列表总数
            startIndex: 0, // 头部元素索引
            itemHeight: null // 每个列表项的高度
        })

        // 屏幕中需要展示的列表
        const activeList = computed<OrderItem[]>(() => {
            const start = state.startIndex
            const end = state.startIndex === 0 ? Math.ceil(window.innerHeight / state.itemHeight) + 5 : state.startIndex + Math.ceil(window.innerHeight / state.itemHeight) + 11

            return state.list?.slice(
                // 向前额外渲染 5 条数据
                start,
                // 向后额外渲染 5 条数据
                end
            )
        })

        // 列表总高度
        const totalHeight = computed<number>(() => state.itemHeight ? state.itemHeight * state.total : null)

        const updateStateList = throttle(async () => {
            const data = await updator()
            if (data) {
                state.list = [...state.list, ...data.list]
            }
        })

        const handleScroll = throttle(async (e) => {
            const scrollTop = e.target.scrollTop
            // 已经完全滚动到屏幕外的项目数量
            // 也就是屏幕中应该展示的第一个项目的索引
            const scrolledCount = Math.floor(scrollTop / state.itemHeight)

            requestAnimationFrame(() => {
                // 屏幕中应该展示的第一个项目的索引
                const nextIndex = scrolledCount - 5 >= 0 ? scrolledCount - 5 : 0
                if (
                    nextIndex < Math.ceil(window.innerHeight / state.itemHeight) ||
                    Math.abs(nextIndex - state.startIndex) >
                    Math.ceil(window.innerHeight / state.itemHeight / 2)
                ) {
                    state.startIndex = scrolledCount - 5 >= 0 ? scrolledCount - 5 : 0
                }
            })
            // 结束位置
            const endIndex = scrolledCount + Math.ceil(window.innerHeight / state.itemHeight) + 1
            // 检查数据列表中有没有该项目和该项目在现有数据列表中的位置
            if (state.list.length < endIndex + 10) {
                updateStateList()
            }
        })

        onMounted(async () => {
            const data = await updator()
            state.list = data.list
            state.total = data.total
            await nextTick()
            state.itemHeight = document.querySelector('li').offsetHeight
        })

        return () => (
            <div class='wrap' onScroll={handleScroll}>
                <ul
                    class='list'
                    style={totalHeight.value ? `height: ${totalHeight.value}px` : ''}
                >
                    {activeList.value?.map((item, index) => (
                        <li
                            class='li'
                            style={{transform: `translateY(${(state.startIndex + index) * state.itemHeight}px)`}}
                        >
                            <div>订单号:{item.orderId}</div>
                            <div>订单状态:{item.orderStatusStr}</div>
                            <div>客户姓名:{item.customer}</div>
                            <div>预约时间:{item.reserveDateStr}</div>
                        </li>
                    ))}
                </ul>
            </div>
        )}
    })

注意点

  1. 视口 dom 起始点的更新需要设置合适的阈值,具体情况具体斟酌,demo 中并没有特别好得计算边界
  2. 视口 dom 起始点的更新放在了requestAnimationFrame回调中
  3. scroll-listener并没有被节流函数 throttle 函数整个包裹,而只是节流了更新数据的逻辑,目的是能及时得更新起始 dom 索引,降低白屏频率
  4. 对于分页请求部分做了防抖处理,避免滚动到阈值的时候频繁触发,但是方案还不是很完善,如果远程响应较慢可能出现白屏(其实应该在scroll-listener中计算需要更新的页码,而不是任何情况都逐页更新)

总结

在项目高度固定的大数据列表渲染的场景下渲染优化的核心思想就是尽可能只渲染用户可见的项目,其中有很多边界条件需要仔细斟酌,同时需要考虑远程响应时间带来的影响。

该笔记的目的是记录一下这种场景下的处理思路,并没有对很多边界条件做特定优化和考虑。

不等高项的无限滚动并不适用,需要对该方案进行改进