Vue3 中的长列表优化 | 青训营笔记

784 阅读5分钟

这是我参与「第四届青训营 」笔记创作活动的的第15天,前几天在项目开发过程中遇到了一个对长列表进行优化的需求,总结一下前端开发中比较常见的两种方法:懒加载虚拟列表,以及其在 Vue3 中的实现。

一、原理概述

1. 懒加载

懒加载的核心是“”(lazy),与分页的基本思路类似(但懒加载在移动端能带来更好的用户体验,因此其应用场景更为广泛),即不一次性渲染列表中的所有数据,而是当用户滚动到当前列表的底部时再将新的一批数据追加渲染到列表尾部。

掘金社区的首页便是使用懒加载实现的无限滚动

2. 虚拟列表

所谓虚拟列表,即列表中除可视区域内的列表项是真实渲染在页面上的,其余不可见的列表项是“虚拟”的,不进行渲染,这样便能大大减少页面中 DOM 元素的数量,渲染性能自然也能得到提升。

image.png

2. 具体实现

1. 懒加载

首先我们创建一个长度为 100 的数组用来模拟长列表中的数据;

实际开发过程中可以是一次性向后端接口请求所有数据(和这里的情况一样,数据数量有限),也可以是每次滚动到列表底部才向后端接口请求新一批的数据无限滚动

interface DataItem {
    id: number
    title: string
    content: string
}

const dataSource: DataItem[] = Array.from({length: 1000}, (_, index: number) => {
    return {
        id: index + 1,
        title: `这是标题${index + 1}`,
        content: "这是内容",
    }
})

由于我们需要对长列表进行分片(也就相当于分页),因此可以定义pageIndex变量用于记录当前已加载数据的页数,以及已加载的部分数据dataList。同时定义hasMore变量用于记录是否还有未加载的数据(如果是无限滚动则不需要);

const pageSize = 10
const pageIndex = ref(0)
const dataList = ref<DataItem[]>([])
const hasMore = ref(true)

接下来我们需要监听列表是否已经滚动到底部,比较简单的方法就是在列表末尾添加一个指示元素,然后使用IntersectionObserver API 监听该元素是否可见,当该元素进入可视区域时执行加载操作

<div class="list__wrap">
    <ul class="list__content">
      <li class="list-item" v-for="item of dataList" :key="item.id">
        <h3>{{ item.title }}</h3>
        <p>{{ item.content }}</p>
      </li>
    </ul>
    <div class="list-footer" ref="listFooter">{{ hasMore ? "加载更多" : "没有更多了" }}</div>
</div>
function loadMore() {
    const newDataList = dataSource.slice(pageSize * pageIndex.value, pageSize * (pageIndex.value + 1))
    dataList.value.push(...newDataList)
    pageIndex.value++
    if (newDataList.length < pageSize) { // 判断是否还有未加载的数据
        hasMore.value = false
    }
}

const listFooter = ref<HTMLElement | null>(null)
const observer = new IntersectionObserver(([{isIntersecting}]) => {
    if (isIntersecting) {
        loadMore()
    }
})
onMounted(() => {
    if (listFooter.value !== null) {
        observer.observe(listFooter.value)
    }
})

IntersectionObserver API 提供了一种异步观察目标元素与祖先元素或根元素的视窗(viewport)的交集的方法,参考文档IntersectionObserver - Web APIs | MDN (mozilla.org)

也可以使用 VueUse 的useIntersectionObserver,是对IntersectionObserver的封装,同时还提供了 Vue 指令的使用方法,参考文档useIntersectionObserver | VueUse

接下来我们看看效果:

2. 虚拟列表

从前面的原理图可以看出,虚拟列表由三部分区域组成:可视区域、列表渲染区域、真实列表区域,其中可视区域的高度是固定的,列表渲染区域包含所有真实渲染的列表项,其高度由可视区域的高度和列表项的高度共同决定,真实列表区域的高度是计算出来的所有列表项的总高度(真实渲染的列表项 + 虚拟的列表项);

<div class="list__viewport" :style="{height: `${wrapperHeight}px`}">
    <div class="list__wrapper" :style="{height: `${itemHeight * totalCount}px`}">
        <ul class="list__content">
            <li class="list-item" :style="{height: `${itemHeight}px`}">
                <h3>{{ data.title }}</h3>
                <p>{{ data.content }}</p>
            </li>
            <li>...</li>
            ...
        </ul>
    </div>
</div>
const wrapperHeight = 600
const itemHeight = 96
const viewCount = Math.ceil(wrapperHeight / itemHeight) // 可视区域内可以渲染多少个列表项
const totalCount = 1000

其中list__viewport对应可视区域,list__wrapper对应真实列表区域,用于将高度撑开,以模拟列表的总高度,list__content对应列表渲染区域

然后我们需要监听列表的滚动事件,根据滚动的距离计算出需要渲染在可视区域内的列表项;

const startIndex = ref(0)
const virtualDataList = computed(() => {
    return dataList.slice(startIndex.value, startIndex.value + viewCount)
})

function handleScroll(event: Event) {
    const scrollTop = (event.target as HTMLElement).scrollTop
    startIndex.value = Math.floor(scrollTop / itemHeight)
}
<div class="list__viewport" :style="{height: `${wrapperHeight}px`}" @scroll="handleScroll">
    <div class="list__wrapper" :style="{height: `${itemHeight * totalCount}px`}">
        <ul class="list__content">
            <li class="list-item" :style="{height: `${itemHeight}px`}"
                v-for="(data, index) of virtualDataList" :key="data.id">
                <h3>{{ data.title }}</h3>
                <p>{{ data.content }}</p>
            </li>
        </ul>
    </div>
</div>

但是我们会发现,当列表滚动时list__content也会一起滚动而超出可视区域。因此我们需要通过计算渲染区域的偏离;

const offset = computed(() => itemHeight * startIndex.value)
<ul class="list__content" :style="{transform: `translateY(${offset}px)`}">...</ul>

我们可以看看效果:

3. 对虚拟列表的封装

我们还可以使用 Vue 中的自定义指令对虚拟列表进行封装:

export interface VirtualListOptions {
    viewportHeight: number
    itemHeight: number | ((index: number) => number)
    bufferCount?: number
}

export function useVirtualList<T>(source: Ref<T[]>, options?: VirtualListOptions) {
    const {viewportHeight, itemHeight, bufferCount = 0} = options ?? {viewportHeight: 0, itemHeight: 0}

    const startIndex = ref(0)
    const range = computed<[number, number]>(() => {
        let from = Math.max(startIndex.value - bufferCount, 0)
        let to: number
        if (typeof itemHeight === "number") {
            to = startIndex.value + Math.ceil(viewportHeight / itemHeight) + bufferCount
        } else {
            let i = startIndex.value
            let sumHeight = 0
            while (i < source.value.length) {
                sumHeight += itemHeight(i++)
                if (sumHeight >= viewportHeight) {
                    break
                }
            }
            to = i + bufferCount
        }
        to = Math.min(to, source.value.length)
        return [from, to]
    })
    const data = computed<T[]>(() => {
        const [from, to] = range.value
        return source.value.slice(from, to)
    })
    const totalHeight = computed<number>(() => {
        if (typeof itemHeight === "number") {
            return itemHeight * source.value.length
        } else {
            return source.value.reduce((sum, _, index) => sum + itemHeight(index), 0)
        }
    })
    const offset = computed<number>(() => {
        const [from] = range.value
        if (typeof itemHeight === "number") {
            return itemHeight * from
        } else {
            let sumHeight = 0
            for (let i = 0; i < from; ++i) {
                sumHeight += itemHeight(i)
            }
            return sumHeight
        }
    })

    function handleScroll(event: Event) {
        const scrollTop = (event.target as HTMLElement).scrollTop
        if (typeof itemHeight === "number") {
            startIndex.value = Math.floor(scrollTop / itemHeight)
        } else {
            let sumHeight = 0
            for (let i = 0; i < source.value.length; ++i) {
                sumHeight += itemHeight(i)
                if (sumHeight > scrollTop) {
                    startIndex.value = i
                    break
                }
            }
        }
    }

    const vVirtualList: Directive<HTMLElement> = {
        mounted(elem) {
            const viewport = document.createElement("div")
            viewport.classList.add("virtual-list__viewport")
            viewport.style.height = viewportHeight + "px"
            viewport.style.overflowY = "auto"
            viewport.addEventListener("scroll", handleScroll)

            const wrapper = document.createElement("div")
            wrapper.classList.add("virtual-list__wrapper")
            wrapper.style.height = totalHeight.value + "px"
            wrapper.style.overflowY = "hidden"

            elem.classList.add("virtual-list__content")
            elem.parentElement?.append(viewport)
            viewport.append(wrapper)
            wrapper.append(elem)
        },
        updated(elem) {
            elem.style.transform = `translateY(${offset.value}px)`
        },
        unmounted() {
            removeEventListener("scroll", handleScroll)
        },
    }

    const vVirtualListItem: Directive<HTMLElement, number> = (elem, binding) => {
        elem.classList.add("virtual-list-item")
        const [from] = range.value
        const index = binding.value
        const height = typeof itemHeight === "number" ? itemHeight : itemHeight(from + index)
        elem.style.height = height + "px"
        elem.style.boxSizing = "border-box"
        elem.style.overflowY = "hidden"
    }

    return {
        vVirtualList,
        vVirtualListItem,
        data,
        range,
    }
}
<ul v-virtual-list>
    <li v-for="(item, index) of data" :key="index" v-virtual-list-item="index">
        <h3>{{ item.title }}</h3>
        <p>{{ item.content }}</p>
    </li>
</ul>
const {vVirtualList, vVirtualListItem, data} = useVirtualList(dataSource, {
    viewportHeight: 512,
    itemHeight: 96,
    bufferCount: 5,
})

上面这种方案有以下几点改进:

  1. 使用 Vue 的自定义指令对 DOM 进行操作,不需要再手动包裹三层div
  2. 支持不同高度的列表项(通过itemHeight参数传入一个函数,根据列表项的index获取高度)
  3. 之前方案中的列表项一旦离开可视区域便立刻销毁对应的 DOM 元素,同样的只有当列表项进入可视区域时才创建对应的 DOM 元素,改进方案通过指定bufferCount参数可以实现 DOM 元素的延迟销毁提前渲染(类似于缓存和预加载)
  4. 通过指定循环渲染中的:key="index"可以告诉 Vue 只需要在已有的 DOM 元素上修改列表项的内容,避免了 DOM 元素频繁的创建和销毁

三、个人总结

懒加载和虚拟列表作为两种常见的长列表优化方案,有着各自的局限性和适用场景:

  1. 懒加载无法反馈出列表的整体长度,这是一种局限性也是一种优势,可以引导用户不断获取新的内容(联想一下抖音或者微博等平台);而虚拟列表虽然是虚拟的,但是滚动条的长度是真实的,能真实地反馈出列表的整体长度;
  2. 懒加载虽然避免了一次性渲染大量 DOM 元素带来的性能消耗,但是在一定程度上并没有解决操作大量 DOM 元素带来的性能消耗,随着用户不断获取新的内容,页面中仍会存在大量 DOM 元素;虚拟列表则保证了页面中 DOM 元素的数量是大致不变的,因此解决了这个问题;
  3. 正是因为懒加载只追加新的 DOM 元素而不改变已有的 DOm 元素,而虚拟列表需要频繁地修改(甚至销毁和创建) DOM 元素,因此当列表项需要加载静态资源或者执行耗时操作时,懒加载比虚拟列表有着更好的性能。

除此之外,虚拟列表中同样也可以实现懒加载。

四、引用参考

长列表优化之虚拟列表 - 知乎 (zhihu.com)