虚拟滚动(原生js实现)

4,503 阅读5分钟

虚拟滚动使用场景

当在一个页面或者某个功能中,需要展现大量的数据(如:移动端新闻列表、移动端地区列表)时,如果全部渲染会导致页面的性能变得很差,这时候就可以使用虚拟滚动来对页面进行优化。

什么是虚拟滚动

只给用户渲染需要看到的数据,其他数据不渲染。

如果有1w条数据,但是屏幕中最多只能看到20条数据,这种时候除了这20条数据之外其他都是没有意义的,只会消耗浏览器的性能。所以虚拟滚动中,把不需要渲染的数据使用了空白填充。当用户进行滚动操作时,渲染出对应区域的数据即可。

dom结构区别

不使用虚拟滚动的dom结构:

image.png

使用虚拟滚动:

动画.gif

首次渲染的性能区别:

左侧为渲染全部数据,右侧为虚拟滚动

image.png

实现虚拟滚动

HTML结构分析

因为虚拟滚动需要把视觉效果和普通渲染保持一致,所以我们需要一个占位元素将父容器撑开,这个占位元素的高度需要和普通渲染中列表的高度相同,这样滚动条的表现才会一致。

image.png

当我们向下滚动的时候,因为表格中数据的实际高度只有一点,所以很快就会出现空白部分,如图所示:

image.png

所以这时候我们需要将列表元素与可视区域一样向下移动(可以设置top属性或者使用transform属性),这样就可以一致保持用户所看到的是有数据的:

image.png

样式实现

css文件

.screen {
    position: absolute;
    width: 300px;
    height: 200px;
}
#scroll {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background-color: #4DC0EB;
    overflow-y: scroll;
}
.background {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    z-index: -1;
}
.list {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
}
.line {
    color: #24305E;
    line-height: 2;
    padding-left: 10px;
}

html

screen为视口区域,可自定义,background为占位元素,list为需要渲染的列表数据

<div class="screen">
    <div id="scroll">
        <div class="background"></div>
        <div class="list"></div>
    </div>
</div>

js实现

数据处理

初始化数据

首先我们需要初始化一些基本变量,后续的效果实现需要这些变量支持


    const ITEM_HEIGHT = 32 // 每项列表元素的高度(不一样的高度取最小值)
    
    const COUNT = 10000 // 列表的数据量(一般由接口获取)
    
    // 一个可视区域最多可以出现的数据个数
    const MAX_COUNT = Math.ceil(document.querySelector('.screen').clientHeight / ITEM_HEIGHT)
    
    const listData = [] // 存放全部的列表数据
    
    let runData = [] // 实际渲染的列表数据
    
    const container = document.querySelector('.list')
    
    setBackgroundHeight()
    
    runData = listData.slice(0, MAX_COUNT * 2) // 设置初始渲染数据
    
    // 列表中的数据元素
    const line = createElement('div', {
      className: 'line'
    })
    appendElement(runData, container, line)
    

工具函数:

    // 设置background(占位元素)的高度
    function setBackgroundHeight () {
          document.querySelector('.background').style.height 
              = getListHeight(ITEM_HEIGHT, COUNT) + 'px'
    }
    
    // 创建元素
    function createElement (tag, options = { style: {} }) {
      const dom = document.createElement('div')
      changeDomStyle(dom, options)
      return dom
    }
    
    // 将元素添加到指定dom上
    function appendElement (dataList, container, child) {
      container.innerHTML = ''
      const fragment = document.createDocumentFragment()
      dataList.forEach(item => {
        child = initInnerHTMLData(child.cloneNode(true), item.text)
        fragment.appendChild(child)
      })
      container.append(fragment)
    }

    // 生成假数据
    function initInnerHTMLData (dom, text) {
      dom.innerHTML = text
      return dom
    }
    
    // 改变元素样式
    function changeDomStyle (dom, options) {
      const { className, style } = options
      dom.className = className || ''
      for (const key in style) {
        dom.style[key] = style[key]
      }
    }

列表数据处理

因为在列表滚动的时候我们需要精确的定位到当前可视区域第一个元素的索引,所以需要给每个数据添加一个标记,标记为该元素的scrollTop,在移动列表数据的时候也需要用到这个数据。

    // 如果元素高度不一样,则需要定义一个变量进行累加
    listData.forEach((item, index) => {
      item.top = index * ITEM_HEIGHT
    })

listData处理完后:

image.png

渲染列表

根据业务需求自行定义元素样式生成并且挂载

逻辑实现

通过监听父元素的scroll事件触发相关逻辑,为了节省性能,这里使用了节流,requestAnimationFrame函数简单来说就是一个定时器(setTimeout),触发的时间不同

点击链接:(深入理解 requestAnimationFrame_前端大全的博客-CSDN博客)。

    let tick = false // 节流开关
    document.querySelector('#scroll').addEventListener('scroll', (e) => {
      if (!tick) {
        tick = true
        window.requestAnimationFrame(() => {
          tick = false
        })
        getRunDataList(getScrollDistance(e))
      }
    })

getScrollDistance:

function getScrollDistance (event) {
  return event.target.scrollTop
}

getRunDataList 最关键的函数:

beforeList,afterList这两个数组的意义是为了增加渲染列表的上下衔接的数据。这样会避免因为用户滚动太快而数据还没加载出现的空白列表现象。

function getRunDataList (distance) {
    // distance为传入的scrollTop,通过scrollTop精确定位到当前视口顶部的元素索引
    const startIndex = getStartIndex(distance)
    
    // 兜底数据
    const beforeList = listData.slice(getBeforeIndex(startIndex), startIndex)
    const nowList = listData.slice(startIndex, startIndex + MAX_COUNT)
    const afterList 
        = listData.slice(getAfterIndex(startIndex), getAfterIndex(startIndex) + MAX_COUNT)
        
    // 移动list元素至对应的scrollTop
    changeListTop(startIndex, beforeList[0] || listData[startIndex])
    
    runData = [...beforeList, ...nowList, ...afterList]
    
    // 将数据渲染到页面上
    appendElement(runData, container, line)
}

优化后的函数:


function getRunDataList (distance) {
  // 因为有beforeList和afterList的数据兜底
  // 所以可以在scrollTop处于nowList的数据上的时候不渲染数据
  // 等到滚动出了安全区的时候再渲染数据
  if (!switchScroll(distance)) {
    const startIndex = getStartIndex(distance)
    
    const beforeList = listData.slice(getBeforeIndex(startIndex), startIndex)
    const nowList = listData.slice(startIndex, startIndex + MAX_COUNT)
    const afterList 
        = listData.slice(getAfterIndex(startIndex), getAfterIndex(startIndex) + MAX_COUNT)
    
    changeListTop(startIndex, beforeList[0] || listData[startIndex])
    
    // 改变安全区
    changeSwitchScale(startIndex, getBeforeIndex(startIndex), getAfterIndex(startIndex))
    
    runData = [...beforeList, ...nowList, ...afterList]
    
    appendElement(runData, container, line)
  }
}

工具函数:

// 移动list元素到指定位置
function changeListTop (startIndex, { top }) {
  document.querySelector('.list').style.transform = `translate3d(0, ${top}px, 0)`
}

// 判断是否在安全区
function switchScroll (scrollTop) {
  return scrollTop > switchScrollScale[0] && scrollTop < switchScrollScale[1]
}

// 改变安全区
function changeSwitchScale (startIndex, beforeIndex, afterIndex) {
  const beforeScale = Math.ceil(startIndex) * ITEM_HEIGHT
  const afterScale = Math.floor((afterIndex)) * ITEM_HEIGHT
  switchScrollScale = [beforeScale, afterScale]
}

// 二分法查找
function getStartIndex (scrollTop) {
  let start = 0
  let end = listData.length - 1
  while (start < end) {
    const mid = Math.floor((end + start) / 2)
    const { top } = listData[mid]
    if (scrollTop >= top && scrollTop < top + ITEM_HEIGHT) {
      start = mid
      break
    } else if (scrollTop >= top + ITEM_HEIGHT) {
      start = mid + 1
    } else if (scrollTop < top) {
      end = mid - 1
    }
  }
  return start < 0 ? 0 : start
}

function getBeforeIndex (startIndex) {
  return startIndex - MAX_COUNT < 0 ? 0 : startIndex - MAX_COUNT
}

function getAfterIndex (startIndex) {
  return startIndex + MAX_COUNT > COUNT ? COUNT : startIndex + MAX_COUNT
}

实现效果

代码以及实现效果:

文章部分引用:vue轻松实现虚拟滚动 - 知乎 (zhihu.com)

实际应用场景:element-ui transfer组件优化