原生JS实现一个虚拟列表

2,904 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

虚拟列表是前端解决海量数据展示的一种解决方案。
当我们需要展示万条,百万条数据时。如果使用传统的分页向下展示。随着数据量的增多,HTML节点也会增加,HTML节点越多,重绘重排的花销也会增大,慢慢地会让你的页面变得非常的慢。
那么为什么使用虚拟列表就能解决这个问题呢?

虚拟列表的原理

虚拟列表的原理其实就是:当我们查询出大量数据时,只展示当前可视区域的数据,其他的数据只在滚动到数据的页数时才展示。如图所示:

image.png

我们可以预先加载几页数据,当我们滚动到预加载的页面时,加载下(上)一页数据,并删除上(下)一页数据。这样无论我们怎么滚动,页面中展示的数据量始终保持固定的。

实现虚拟列表

了解完它的原理,接下来就是需要实现这个虚拟列表,这里可以提供两种方式解决问题~

  • 监听列表容器的滚动,通过滚动对可视列表进行控制
  • 使用ResizeObserver监听内容区域的改变,从而控制可视列表。但其方法是实验性的。

因为ResizeObserver是实验性的API,不推荐在生产环境中使用,所以我们这里第一种方法:监听滚动来实现。

具体的实现步骤为:

  1. 创建容器并监听容器滚动
  2. 获取容器高度和每个列表的高度
  3. 计算触发下(上)一页触发的滚动距离
  4. 计算留白的高度(重要)

其中,计算留白的高度是虚拟列表最重要的一环,因为当元素隐藏时,为了保证展示列表不出现塌陷,需要使用padding将容器撑高。

虚拟列表有定高不定高两种。

  • 定高就是每一个item固定高度,我们在计算滚动距离的时候就会比较轻松。
  • 但是很多情况下,列表的item高度是不固定的,这时我们就要比定高多一步:计算每一页渲染后动态计算的总高度。

定高虚拟列表

首先新建一个html文件,创建一个box容器,固定容器高度并监听滚动

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>虚拟列表</title>
    <meta charset="utf8" />
    <style>
      * {
        padding: 0;
        margin: 0;
      }
      body {
        width: 100vw;
        height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      #box {
        width: 300px;
        height: 500px;
        background: purple;
        overflow-y: scroll;
        box-sizing: border-box;
      }
    </style>
  </head>
  <body>
    <div id="box">
      <div id="box_container"></div>
    </div>
  </body>
  <script lang="text/javascript" src="./script.js"></script>
</html>
    // script.js
    (() =>{
        // 定义初始化数据
        let page = 1; // 页码
        let size = 20; // 每页条数
        const height = 50; // 每条高度
        const  preLoadNum = 3; // 同时展示载页数
        const boxHeight = 500; // 容器高度
        let paddingBottom = 50; // 底部留白高度
        let listArr = []; // 用于存放列表数据
        
        const box = document.querySelector("#box");
        // 监听滚动
        box.addEventListener("scroll", (e) => {}, false)
    })()

定义完一些基础的初始信息之后,接下来写一个获取数据的方法

// script.js

function createItem(page = 1, size = 10) {
    const fragment = document.createDocumentFragment()
    const box = document.createElement("div");
    
    box.className = `page_${page}`; // 给每页容器定一个类名,后面根据类名进行容器删除
    for (let i = 0; i < size; i++) {
      const element = document.createElement("div");

      element.style.width = "100%";
      element.style.height = "50px";
      element.style.color = "#fff";
      element.className = `item_${page * (i + 1)}`;
      element.innerText = `我是item——${((page - 1) * size) + i + 1}`;
      box.appendChild(element);
    }
    fragment.appendChild(box);
    
    return {fragment, box};
}

回到立即执行函数中,进入页面时立即执行一遍

// script.js

(() =>{
    // 定义初始化数据
    let page = 1; // 页码
    let size = 20; // 每页条数
    const height = 50; // 每条高度
    const  preLoadNum = 3; // 同时展示载页数
    const boxHeight = 500; // 容器高度
    let listArr = []; // 用于存放列表数据

    const box = document.querySelector("#box");
    const boxContainer = document.querySelector("#box_container");
    
    const {fragment, box: boxList} = createItem(page, size)
    listArr.push(boxList); // 将数据放入列表中
    boxContainer.appendChild(fragment); // 载入初始数据
    // 监听滚动
    box.addEventListener("scroll", (e) => {
    
    }, false)
 })()

接下来就需要将计算滚动高度去获取并展示数据

    box.addEventListener("scroll", (e) => {
        const scrollTop = e.target.scrollTop
        if (scrollTop >= nextHeight) {
            page++;
            // 顶部留白高度
            paddingTop = (page - preLoadNum) * (size * height) + paddingBottom;
            // 触发下一页的高度
            nextHeight = (page - 1) * (size * height) + paddingBottom + boxHeight;
            let fragment;
            // 判断该页数据是否已经存在列表中,存在则无须获取新的
            if (!listArr[page - 1]) {
              const {fragment: element, box: boxList} = createItem(page, size)
              fragment = element;
              listArr.push(boxList);
            } else {
              fragment = listArr[page - 1]
            }
            boxContainer.appendChild(fragment);
            // 判断是否存在需要删除的列表
            const hideElem = document.querySelector(`.page_${page - preLoadNum}`);
            if (hideElem) {
              // 如果有则删除列表并新增留白高度
              boxContainer.removeChild(hideElem);
              boxContainer.style.paddingTop = `${paddingTop}px`;
            }
        } else if (
            scrollTop <= (page - preLoadNum + 1) * size * height + paddingBottom && page > preLoadNum
        ) {
            page--;
            // 顶部留白高度
            paddingTop = (page - preLoadNum) * (size * height) + paddingBottom;
            // 触发下一页的高度
            nextHeight = (page - 1) * (size * height) + paddingBottom + boxHeight;
            // 取出上一页的数据并插入
            const fragment = listArr[page - preLoadNum];
            boxContainer.insertBefore(fragment, boxContainer.childNodes[0]);
            const hideElem = document.querySelector(`.page_${page + 1}`);
            if (hideElem) {
              // 删除下面的列表并减去顶部留白
              boxContainer.removeChild(hideElem);
              boxContainer.style.paddingTop = `${paddingTop}px`;
            }
        }
    }, false)
    

大功告成!来测试一下效果

不定高虚拟列表

对于不定高的虚拟列表,我们需要计算每一页的总高度,或者将每一页的每一项高度都加上然后累加去,因为我们每一页都用了一个容器来将他们保存起来,并且容器是自动撑高的,所以,只需要获取每一页容器的高度即可。修改一下上面的代码:

function createItem(page = 1, size = 10) {
const fragment = document.createDocumentFragment()
const box = document.createElement("div");
box.className = `page_${page}`;
for (let i = 0; i < size; i++) {
  const element = document.createElement("div");
  // 高度随机
  let height = Math.ceil(Math.random() * 5) * 50
  element.style.width = "100%";
  element.style.height = `${height}px`;
  element.style.color = "#fff";
  element.className = `item_${page * (i + 1)}`;
  element.innerText = `我是item——${((page - 1) * size) + i + 1} \n 高度——${height}`;
  box.appendChild(element);
}
fragment.appendChild(box);
return {fragment, box};
}
(() => {
let page = 1;
let size = 20;
let height = 50;
let preLoadNum = 3;
let boxHeight = 500;
let paddingBottom = 50;
const box = document.querySelector("#box");
const boxContainer = document.querySelector("#box_container");
let listArr = [];
let isGetting = false;


const {fragment, box: boxList} = createItem(page, size)
boxContainer.appendChild(fragment);
// 获取第一页的总高度
const listHeight = document.querySelector(`.page_${page}`).clientHeight;
listArr.push({boxList, height: listHeight});

let paddingTop = 0;
// 触发渲染下一页的条件
let nextHeight = paddingTop + listHeight + paddingBottom - boxHeight;

boxContainer.style.paddingBottom = `${paddingBottom}px`;
boxContainer.style.paddingTop = `${paddingTop}px`;

box.addEventListener("scroll", (e) => {

  const scrollTop = e.target.scrollTop
  if (scrollTop >= nextHeight) {
    if (isGetting) return;
    isGetting = true;
    page++;
    let fragment;
    let pushObj;
    if (!listArr[page - 1]) {
      const {fragment: element, box: boxList} = createItem(page, size)
      fragment = element;
      pushObj = { boxList };
    } else {
      const { boxList, height } = listArr[page - 1]
      fragment = boxList;
    }

   boxContainer.appendChild(fragment);
   // 更新顶部留白
   paddingTop = (listArr.filter((_, index) => index < page - preLoadNum)).map((val) => val.height).reduce((a,b) => (a + b), 0);
   // 更新下一页触发条件
   nextHeight = boxContainer.clientHeight - boxHeight - paddingBottom;
   if (pushObj) {
    const listHeight = document.querySelector(`.page_${page}`).clientHeight;
    pushObj.height = listHeight;
    listArr.push(pushObj)
   }

    const hideElem = document.querySelector(`.page_${page - preLoadNum}`);
    if (hideElem) {
      boxContainer.removeChild(hideElem);
      boxContainer.style.paddingTop = `${paddingTop}px`;
    }
    // 渲染上一页条件
  } else if (scrollTop <= nextHeight - listArr[listArr.length - 1].height && page > preLoadNum) {
    page--;
    
    // 拉取上一页数据并渲染
    const { boxList } = listArr[page - preLoadNum];
    boxContainer.insertBefore(boxList, boxContainer.childNodes[0]);

    const hideElem = document.querySelector(`.page_${page + 1}`);
    // 删掉最下面一页
    if (hideElem) {
      // 更新顶部留白
      paddingTop = (listArr.filter((_, index) => index < page - preLoadNum)).map((val) => val.height).reduce((a,b) => (a + b), 0);
      boxContainer.style.paddingTop = `${paddingTop}px`;
      boxContainer.removeChild(hideElem);
      // 更新下一页渲染条件
      nextHeight = boxContainer.clientHeight - boxHeight - paddingBottom;
    }
  }
  isGetting = false;
}, false);
})()

最后实现效果

总结

本文主要介绍了

  • 虚拟列表的原理
  • 虚拟列表的实现步骤
  • 定高的虚拟列表实现
  • 不定高的虚拟列表实现

参考