原生js实现虚拟列表-无固定高度

800 阅读4分钟

前言:长列表滚动卡顿问题属于比较常见的性能问题,在写这篇文章之前已有很多前辈写过解决方案,这里我只做记录当做自己笔记,有问题可以随时提出建议或意见。 虚拟列表主要解决长列表滚动卡顿问题,通过虚拟列表可以轻松实现数十万条数据的流畅滚动!在上一篇文章中同样也实现虚拟列表滚动效果,缺点是列表项高度必须固定。

实现效果

虚拟列表-无高度.gif

从上图可以看到,列表内容高度是不固定的,当列表滚动的时候dom数量会发生一些变量,这是因为当列表项高度不同的时候一屏展示的数量也会跟着发生变化,但这种变化时在一定范围内的不会一直增加。

实现思路

1、整体思路
    动态计算每一屏能够展示的列表项数量,和动态计算每一屏展示数据的起始和结束位置,然后根据这三个数据渲染对应的内容。

2、如何动态获取列表项高度?
我的解决方法是:
    我们可以先假设列表项的高度都一样,比如50px,并用一个数组positions把这些列表项的高度都缓存起来;然后实现渲染一屏的数据,渲染完成后立刻计算已经渲染好的列表项的真实高度,并把高度更新到数组positions中。
    当所有的列表项都渲染完一次后,就把所有的列表项高度都保存起来了,再次渲染的时候就可以直接拿到这些数据进行相关计算无需再计算高度。
    
3、如何根据滚动距离动态计算列表项离开可视区域的数量?
    通过while循环动态计算滚动到屏幕外的列表项高度,根据这个高度和滚动距离判断当前列表项是否离开了可视区域。
    

布局

结构

    <!-- 滚动容器 -->
    <div class="virtual-box">
        <!--  虚拟滚动条  -->
        <div class="virtual-scrollbar"></div>
        <!-- 滚动列表容器 -->
        <div class="virtual-list">
            <!-- 列表项 -->
            <div class="virtual-item">
                <p>标题</p>
                <p>内容</p>
            </div>
        </div>
    </div>

样式

* {
    margin: 0;
    padding: 0;
}
.virtual-box {
    width: 200px;
    height: 360px;
    border: 1px solid #ccc;
    margin: 10px auto;
    position: relative;
    overflow: auto;
}
.virtual-list {
    position: absolute;
    width: 100%;
    height: 100%;
    top: 0;
    left: 0;
}
.virtual-item {
    padding: 10px;
    border-bottom: 1px solid #ccc;
}

功能实现

准备数据

// 初始化数据
let data  = []
for(let i = 1; i <= 1000; i++){
    data.push({
        id: i,
        title: "item-"+i,
        content: getRandomText(5, 30) // 获取随机5~30条数据的内容 
    })
}

注意getRandomText这个函数是自己定义的,为了生成不同数量的内容从而达到生成高度的列表项,小伙伴们使用的时候可以自己写些内容代替即可。

获取相关容器

const virtualBox = document.querySelector(".virtual-box")
const scrollbar = document.querySelector(".virtual-scrollbar")
const virtualList = document.querySelector(".virtual-list")

初始化相关变量和数据

// 假设列表项高度50px
let itemHeight = 50
// 设置全局变量 维护每一个列表项的高度等信息
let positions = data.map((item,index)=>{
    return {
        index,
        height: itemHeight
    }
})
// 滚动容器高度
let listHeight = virtualList.offsetHeight
// 计算一屏渲染数量
function calcViewCount(){
    let viewCount = 0
    let height = positions[0].height
    // 通过while循环计算一屏可渲染数量
    while(height<virtualList.offsetHeight){
        viewCount++;
        height+=positions[viewCount].height
    }
    return viewCount
}
// 初始化一屏可渲染列表项数量
let count = calcViewCount()
let start = 0 // 渲染的开始位置
let end = count // 渲染的结束位置

// 虚拟滚动条的高度计算
let scrollBarHeight = 0;
function setScrollBarHeight(){
    let height = positions.reduce((total, item)=>{
        return total+=item.height
    },0)
    scrollBarHeight = height // 另存一份滚动条高度
    scrollbar.style.height = height+"px"
}
setScrollBarHeight()

渲染初始列表内容

// isRenderEnd 判断是否已经渲染完毕所有列表内容
let isRenderEnd = false
// 渲染内容
function render(start, end) {
    if(end >data.length){
        end = data.length
        isRenderEnd = true
    }
    let str = ""
    for(let i = start; i<end;i++){
        str+=`
        <div class="virtual-item">
            <p>${data[i].title}</p>
            <p>${data[i].content}</p>
        </div>
        `
    }
    virtualList.innerHTML = str
    // 计算子内容真实高度 -- 滚动完所有数据后 不再需要重新计算
    if(!isRenderEnd){ 
        let children = [...virtualList.children]
        children.forEach((item,index)=>{
            if(index>=start &&index<=end){
                let height = item.offsetHeight
                positions[index].height = height
            }
        })
    }
    // 重新计算一屏可显示的数量
    calcViewCount()
    // 虚拟滚动条的高度重新计算 - 滚动完所有数据后不需要再重新计算
    if(!isRenderEnd){
        setScrollBarHeight()
    }
}
render(start, end)

滚动监听-更新开始和结束位置

virtualBox.addEventListener("scroll", function(){
    // console.log(positions);
    let scrollTop = Math.floor(this.scrollTop);

    // 计算滚动离开屏幕的数量 --- 重点
    let scrollCount = 0
    function calcLeaveViewCount(){
        let itemsHeight = positions[scrollCount].height
        // scrollTop/itemsHeight 大于1 说明已经滚动到下一项内容了
        while(scrollTop/itemsHeight >=1){
            scrollCount++
            itemsHeight+=positions[scrollCount]?.height;
        }
    }
    calcLeaveViewCount()
    // 计算开始和结束的位置
    start = scrollCount
    end = count + start
    render(start, end)

    // 滚动条计算偏移量 让列表回到原来的位置
    startOffset = this.scrollTop;
    virtualList.style.transform = `translateY(${startOffset}px)`

})

到此,虚拟滚动列表完成!

完整代码

// 初始化数据
console.time("run time:")

let data  = []
for(let i = 1; i <= 1000; i++){
    data.push({
        id: i,
        title: "item-"+i,
        // content: "测试"
        content: getRandomText(5, 30)
    })
}

// 获取相关容器
const virtualBox = document.querySelector(".virtual-box")
const scrollbar = document.querySelector(".virtual-scrollbar")
const virtualList = document.querySelector(".virtual-list")


// 初始化相关变量和数据
// 假设高度
let itemHeight = 50
// 设置全局变量 维护每一个子项的高度等信息
let positions = data.map((item,index)=>{
    return {
        index,
        height: itemHeight
    }
})
// 滚动容器高度
let listHeight = virtualList.offsetHeight
// 计算一屏渲染数量
function calcViewCount(){
    let viewCount = 0
    let height = positions[0].height
    while(height<virtualList.offsetHeight){
        viewCount++;
        height+=positions[viewCount].height
    }
    return viewCount
}
let count = calcViewCount()
let start = 0 // 渲染的开始位置
let end = count // 渲染的结束位置

// 虚拟滚动条的高度计算
let scrollBarHeight = 0;
function setScrollBarHeight(){
    let height = positions.reduce((total, item)=>{
        return total+=item.height
    },0)
    scrollBarHeight = height // 另存一份滚动条高度
    scrollbar.style.height = height+"px"
}
setScrollBarHeight()

// 渲染内容
let isRenderEnd = false
function render(start, end) {
    if(end >data.length){
        end = data.length
        isRenderEnd = true
    }
    let str = ""
    for(let i = start; i<end;i++){
        str+=`
        <div class="virtual-item">
            <p>${data[i].title}</p>
            <p>${data[i].content}</p>
        </div>
        `
    }
    virtualList.innerHTML = str
    // 计算子内容真实高度 -- 滚动完所有数据后 不再需要重新计算
    if(!isRenderEnd){ 
        let children = [...virtualList.children]
        children.forEach((item,index)=>{
            if(index>=start &&index<=end){
                let height = item.offsetHeight
                positions[index].height = height
            }
        })
    }
    // 重新计算一屏可显示的数量
    calcViewCount()
    // 重新 虚拟滚动条的高度计算 - 滚动完所有数据后不需要再重新计算
    if(!isRenderEnd){
        setScrollBarHeight()
    }
}
render(start, end)
// console.log(positions);


// 监听滚动
virtualBox.addEventListener("scroll", function(){
    // console.log(positions);
    let scrollTop = Math.floor(this.scrollTop);

    // 计算滚动离开屏幕的数量
    let scrollCount = 0
    function calcLeaveViewCount(){
        let itemsHeight = positions[scrollCount].height
        // scrollTop/itemsHeight 大于1 说明已经滚动到下一项内容了
        while(scrollTop/itemsHeight >=1){
            scrollCount++
            itemsHeight+=positions[scrollCount]?.height;
        }
    }
    calcLeaveViewCount()
    // 计算开始和结束的位置
    start = scrollCount
    end = count + start
    render(start, end)

    // 滚动条计算偏移量 让列表回到原来的位置
    startOffset = this.scrollTop;
    virtualList.style.transform = `translateY(${startOffset}px)`

})