虚拟列表

​ 考虑列表项多的列表,最常见的优化是分页显示。但是这样的用户体验不是很好,特别是移动端。为了进一步提升用户体验,把数据做成滚动到底部自动加载。这个对用户很友好,但是会产生一个问题,页面显示的数据非常多,页面DOM的数量上升到一定的程度就会导致卡顿。针对这样场景的优化其中之一就是虚拟列表。

​ 虚拟列表,虚拟指的是用户看到的不是真实DOM结构,开发者通过计算页面的各种距离,只渲染用户当前视口的数据,其他数据不存在于DOM上,DOM上就只有用户看到的数据,从而大大减少了DOM的数量。

虚拟列表

可视的数据是2-6,那么数据2之前的和数据6之后的列表项都不必渲染到DOM上。

实现思路

​ 在每次滚动时,计算出能出现在视口的数据,记录下第一条数据的序号startIdx和最后一条可视数据的序号endIdx,根据startIdx这个序号和每条数据固定的高度,就可以算出当前第一条数据距离列表顶部的距离offsetTop,offsetTop原本是DOM数据的,但是现在不必渲染它,用一个内边距padding替代它们撑开的列表。同理,根据endIdx、原本应该有的总数据条数和每条数据的固定高度,就可以算出最后一条数据到列表底部的距离,也用一个内边距padding替代它们撑开。

虚拟列表

具体代码实现:

  1. 计算出当前可视第一条数据的序号startIdx = (滚动位置 - 列表顶部到文档顶部距离)/ 每条数据的高度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 当前滚动的位置
    const scrollTop = window.scrollTop

    // 列表顶部到文档顶部的距离
    const longListOffset = longListEl.getComputeStyle().top

    // 每条数据的高度---一般设计好即可
    const itemHeight = 64

    // 计算当前开始的可视数据序号startIdx
    let startIdx = Math.floor((scrollTop - longListOffset) / itemHeight)
  2. 算出当前位置到列表顶部的距离,用css属性padding-top撑开位置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 当前滚动的位置
    const scrollTop = window.scrollTop

    // 列表顶部到文档顶部的距离
    const longListOffset = longListEl.getComputeStyle().top

    // 当前位置到列表顶部的距离
    const paddingTop = scrollTop - longListOffset

    // 赋值给padding-top
    longListEl.style.paddingTop = paddingTop
  3. 自定义真实渲染多少条的数据visibleCount,或者获取屏幕大小再决定visibleCount,然后就可以算出真实渲染的最后一条数据序号endIdx = startIdx + visibleCount

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 当前滚动的位置
    const scrollTop = window.scrollTop

    // 列表顶部到文档顶部的距离
    const longListElOffset = longListEl.getBoundingClientRect().top

    // 每条数据的高度---一般设计好即可
    const itemHeight = 64

    // 计算当前开始的可视数据序号startIdx
    let startIdx = Math.floor((scrollTop - longListOffset) / itemHeight)

    // 可视数据的条数
    const visibleCount = 10

    // 最后一条可视数据的序号
    let endIdx = startIdx + visibleCount
  4. 算出最后一条数据endIdx到列表底部的距离,用css属性padding-bottom撑开位置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 当前滚动的位置
    const scrollTop = window.scrollTop

    // 列表顶部到文档顶部的距离
    const longListElOffset = longListEl.getBoundingClientRect().top

    // 每条数据的高度---一般设计好即可
    const itemHeight = 64

    // 计算当前开始的可视数据序号startIdx
    let startIdx = Math.floor((scrollTop - longListOffset) / itemHeight)

    // 可视数据的条数
    const visibleCount = 10

    // 最后一条可视数据的序号
    let endIdx = startIdx + visibleCount

    // 最后一条数据endIdx到列表底部的距离
    const paddingBottom = (dataSource.length - endIdx)* itemHeight

    // 赋值给padding-top
    longListEl.style.paddingBottom = paddingBottom
  5. 渲染可视数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    const fragment = new DocumentFragment()
    for(let i=startIdx;i<=endIdx;i++){
    let tmpEL = creatEL('div', JSON.stringify(data[i]))
    tmpEL.classList.add('item')
    fragment.appendChild(tmpEL)
    }

    longListEl.innerHTML = ''
    longListEl.appendChild(fragment)

    // 工具函数-创建tag标签,并且带有子标签/文本children
    function creatEL(tag, children) {
    let el = document.createElement(tag)
    if (children && Array.isArray(children)) {
    children.forEach((v) => el.appendChild(v))
    } else if (children && typeof children == 'object') {
    el.appendChild(children)
    } else if (children) {
    el.innerHTML = children
    }
    return el
    }

  6. 组织代码,封装成函数,每次滚动时调用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    // 每条数据固定高度ItemHeightpx
    const ItemHeight = 64

    // 可见数据VisiableCount条
    const VisiableCount = 10

    // 列表元素
    const longListEl = document.querySelector('#long-list')

    // 列表顶部到文档顶部的距离
    const longListElOffset = longListEl.getBoundingClientRect().top

    // 构造1000条数据
    let longArr = new Array(1000).fill(1).map((v, i) => {
    return { data: Math.floor(Math.random() * 1000), idx: i }
    })

    // 工具函数-创建tag标签,并且带有子标签/文本children
    function creatEL(tag, children) {
    let el = document.createElement(tag)
    if (children && Array.isArray(children)) {
    children.forEach((v) => el.appendChild(v))
    } else if (children && typeof children == 'object') {
    el.appendChild(children)
    } else if (children) {
    el.innerHTML = children
    }
    return el
    }

    // startIdx作为第一条数据渲染列表
    function render(startIdx) {
    const endIdx = startIdx + VisiableCount
    const data = longArr.slice(startIdx, endIdx + VisiableCount)

    const fragment = new DocumentFragment()
    for (let i = 0; i < data.length; i++) {
    let tmpEL = creatEL('div', JSON.stringify(data[i]))
    tmpEL.classList.add('item')
    fragment.appendChild(tmpEL)
    }

    longListEl.innerHTML = ''
    const lessCount = longArr.length - (endIdx + VisiableCount)
    longListEl.style.paddingBottom = `${
    (lessCount < 0 ? 0 : lessCount) * ItemHeight
    }px`

    longListEl.appendChild(fragment)
    }

    // 处理滚动时触发的事件
    function handleScroll(e) {
    // 兼容 iOS Safari/Webview
    doc = window.document.body.scrollTop
    ? window.document.body
    : window.document.documentElement
    // 当前滚动的位置
    const scrollTop = doc.scrollTop

    const dataOffset =
    scrollTop - longListELOffset > 0 ? scrollTop - longListELOffset : 0

    const startIdx = Math.floor(dataOffset / ItemHeight)
    render(startIdx)
    }



    window.addEventListener('scroll', render)

github仓库 https://github.com/IDKHTS/virsual-list

优化

  1. 因为滚动一次就需要计算一次padding-top或者padding-bottom,比较消耗性能,可以考虑一个合适的时间节流
  2. padding-top和padding-bottom改变会导致重排,所以可以考虑把页面高度固定(有滚动条),任何再给显示的列表项脱离文档流(absolute),原本的padding-top换成top即可
  3. 滚动时可以在前后多渲染几条数据这样滚动速度快时不至于出现空白(但是无论如何,过快都会导致白屏)

参考

https://github.com/dwqs/blog/issues/70