滚动革命:深入浅出玩转虚拟滚动列表

虚拟滚动列表,虚拟滚动的关键在于只渲染当前视口内可见的数据项,而不是一次性渲染所有数据项。这可以显著提高性能,尤其是在处理大量数据时。

以下是一个完整的虚拟滚动列表的示例代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Virtual Scrolling</title>
    <style>
      #container {
        width: 500px;
        height: 700px;
        margin: 30px;
        overflow: auto;
        position: relative;
        border: 1px solid #ccc;
      }
      .item {
        position: absolute;
        width: 100%;
        background-color: #bfc;
        border-radius: 10px;
        margin: 2px 0;
        padding: 10px;
        box-sizing: border-box;
      }
    </style>
  </head>
  <body>
    <div id="container"></div>

    <script>
      const container = document.getElementById("container");
      const itemHeight = 50;
      const containerHeight = 700;
      const buffer = 5;

      let data = [];
      let visibleData = [];
      let startIndex = 0;

      function loopFun(num) {
        data = Array.from({ length: num }, (_, index) => index);
        handleScroll();
      }

      function handleScroll() {
        const scrollTop = container.scrollTop;
        const visibleStart = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
        const visibleEnd = Math.min(
          data.length,
          visibleStart + Math.ceil(containerHeight / itemHeight) + buffer * 2
        );
        startIndex = visibleStart;
        visibleData = data.slice(visibleStart, visibleEnd);
        renderVisibleData();
      }

      function renderVisibleData() {
        container.innerHTML = "";
        visibleData.forEach((item, index) => {
          const div = document.createElement("div");
          div.className = "item";
          div.style.top = (startIndex + index) * itemHeight + "px";
          div.textContent = `数据-${item}`;
          container.appendChild(div);
        });
      }

      function throttle(func, limit) {
        let inThrottle;
        return function () {
          const args = arguments;
          const context = this;
          if (!inThrottle) {
            func.apply(context, args);
            inThrottle = true;
            setTimeout(() => (inThrottle = false), limit);
          }
        };
      }

      loopFun(1000);

      const throttledHandleScroll = throttle(handleScroll, 100);
      container.addEventListener("scroll", throttledHandleScroll);

      window.addEventListener("beforeunload", () => {
        container.removeEventListener("scroll", throttledHandleScroll);
      });
    </script>
  </body>
</html>

上面一大串代码,看起来比较难看,在下边我会拆开了一点点来说明。先来看一下在页面上的展示效果,如下:

在这里插入图片描述

在这里插入图片描述

对比两张效果图可以看到,不论怎么滚动,页面实际上展示的永远是 23 条数据。

声明的变量:

const container = document.getElementById("container"); // 获取元素
const itemHeight = 50; // 每个数据项的高度
const containerHeight = 700; // 容器的高度
const buffer = 5; // 缓冲区,提前加载的数据项数量

let data = []; // 获取元素
let visibleData = []; // 存储当前可视区域的数据项
let startIndex = 0; // 当前可视区域的起始索引

loopFun() 函数的作用:

// 拿到 num 条数据的索引,生成新数组。
function loopFun(num) {
  data = Array.from({ length: num }, (_, index) => index);
  handleScroll();
}
  • Array.from() 静态方法从可迭代或类数组对象创建一个新的浅拷贝的数组实例。

handleScroll() 函数的作用:

function handleScroll() {
        /** scrollTop 用于获取或设置一个元素的垂直滚动距离 */
        const scrollTop = container.scrollTop;
        /**
         * Math.max() 静态方法返回作为输入参数给出的数字中的最大值
         * Math.floor() 静态方法始终向下舍入并返回小于或等于给定数字的最大整数
         *
         * scrollTop / itemHeight 得到当前滚动条所处数据位置
         * buffer 意味着在可视区域上方和下方各提前加载5个数据项
         */
        const visibleStart = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);

        /**
         * Math.min() 静态方法返回作为输入参数给出的数字中的最小者
         * Math.ceil() 静态方法始终向上舍入并返回大于或等于给定数字的最小整数
         */
        const visibleEnd = Math.min(
          data.length,
          visibleStart + Math.ceil(containerHeight / itemHeight) + buffer * 2
        );
        startIndex = visibleStart;
        visibleData = data.slice(visibleStart, visibleEnd);
        renderVisibleData();
      }
  1. scrollTop
    scrollTop 是当前滚动条的位置,表示从容器顶部到滚动条当前位置的距离(以像素为单位)。

  2. itemHeight
    itemHeight 是每个数据项的高度(以像素为单位)。假设每个数据项的高度是固定的,例如 50 像素。

  3. scrollTop / itemHeight
    这个表达式计算当前滚动条位置对应的数据项索引。结果是一个浮点数,表示滚动条位置对应的数据项的大致位置。

  4. Math.floor(scrollTop / itemHeight)
    使用 Math.floor 将浮点数向下取整,得到一个整数索引,表示当前滚动条位置对应的数据项的起始索引。

  5. Math.floor(scrollTop / itemHeight) - buffer
    减去 buffer,确保在可视区域上方提前加载 buffer 个数据项。buffer 是一个缓冲区,表示在可视区域外提前加载的数据项数量。例如,buffer 设为 5,意味着在可视区域上方提前加载 5 个数据项。

  6. Math.max(0, …)
    使用 Math.max 确保 visibleStart 不小于 0,防止负索引。如果 Math.floor(scrollTop / itemHeight) - buffer 小于 0,则 visibleStart 会被设置为 0。


renderVisibleData() 函数的作用:

// 处理标签结构及文本的展示
function renderVisibleData() {
  container.innerHTML = "";
  visibleData.forEach((item, index) => {
    const div = document.createElement("div");
    div.className = "item";
    div.style.top = (startIndex + index) * itemHeight + "px";
    div.textContent = `数据-${item}`;
    container.appendChild(div);
  });
}

相当于生成了一个 div 标签,加上 class 属性 item

<div class="item">数据-1</div>

然后对这个标签进行循环展示。

{
  visibleData.map((item, index) => (
    <div class="item" key={item}>
      数据-{item}
    </div>
  ));
}

这一行代码,可以详细说一说:

div.style.top = (startIndex + index) * itemHeight + "px";
  1. div.style.top

    div.style:这是 DOM 元素的 style 属性,它允许你直接访问和修改元素的内联样式。
    top:这是 style 对象的一个属性,用于设置元素的 top CSS 属性。top 属性定义了元素相对于其父容器的顶部边缘的距离。


  2. (startIndex + index) * itemHeight

    startIndex:这是当前可视区域的起始索引,表示第一个可见数据项在整个数据数组中的位置。
    index:这是当前数据项在其可视区域内的索引。例如,如果 visibleData 包含 10 个数据项,index 会从 0 到 9。


  3. itemHeight:这是每个数据项的高度(以像素为单位)。

准确的来说这是用来计算数据项的垂直位置的。

  • startIndex + index:计算当前数据项在整个数据数组中的实际索引。startIndex 是当前可视区域的起始索引,index 是当前数据项在其可视区域内的索引。

  • (startIndex + index) \* itemHeight:将实际索引乘以每个数据项的高度,得到当前数据项的垂直位置(以像素为单位)。

  • 设置 top 属性: div.style.top = (startIndex + index) * itemHeight + ‘px’:将计算得到的垂直位置设置为 div 元素的 top 属性值。这样,每个数据项都会根据其在数据数组中的位置被正确地定位在容器中。

示例:

startIndex = 10;
index = 2;
itemHeight = 50;

计算过程:

startIndex + index = 10 + 2 = 12

(startIndex + index) _ itemHeight = 12 _ 50 = 600

div.style.top = 600 + 'px' = 600px

因此,这个数据项的 top 属性会被设置为 600px,表示它距离容器顶部 600 像素。

作用:通过这种方式,你可以确保每个数据项在容器中的位置是正确的。即使容器滚动,每个数据项也会根据其在数据数组中的位置被正确地定位,从而实现虚拟滚动的效果。


throttle() 函数的作用:

// 节流处理
function throttle(func, limit) {
  let inThrottle;
  return function () {
    const args = arguments;
    const context = this;
    if (!inThrottle) {
      func.apply(context, args);
      inThrottle = true;
      setTimeout(() => (inThrottle = false), limit);
    }
  };
}

throttle 函数是一种常用的节流技术,用于限制函数的调用频率。它在处理高频事件(如滚动、窗口调整大小等)时非常有用,可以显著提高性能并减少不必要的计算。

示例:

  • func:需要节流的函数。
  • limit:节流的时间间隔(以毫秒为单位),表示在多少毫秒内只能执行一次 func。
// 100 毫秒后执行函数 handleScroll
throttle(handleScroll, 100);

底部函数调用:

loopFun(1000);

调用 loopFun 函数,生成 1000 个数据项。


const throttledHandleScroll = throttle(handleScroll, 100);

使用 throttle 函数对 handleScroll 进行节流处理,确保 handleScroll 每 100 毫秒最多执行一次。


container.addEventListener("scroll", throttledHandleScroll);

向容器元素添加滚动事件监听器,当容器滚动时,throttledHandleScroll 函数会被调用。


window.addEventListener("beforeunload", () => {
  container.removeEventListener("scroll", throttledHandleScroll);
});
  • window\.addEventListener('beforeunload', ...):在窗口即将卸载时(例如用户关闭页面或导航到其他页面)触发的事件。

  • container.removeEventListener('scroll', throttledHandleScroll):在页面卸载前移除滚动事件监听器,避免内存泄漏。


上述代码问题: 有时候滚动到第23个的时候会卡住,导致无法再滚动.


原因:问题在于设置的延时长度会导致滑动过快后卡顿。


解决方法:插入的时候可以用createDocumentFragment。

改进后

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Virtual Scrolling</title>
    <style>
        #container {
            width: 500px;
            height: 700px;
            margin: 30px;
            overflow: auto;
            position: relative;
            border: 1px solid #ccc;
        }
        .item {
            position: absolute;
            width: 100%;
            background-color: #bfc;
            border-radius: 10px;
            margin: 2px 0;
            padding: 10px;
            box-sizing: border-box;
        }
    </style>
</head>
<body>
    <div id="container"></div>

    <script>
        const container = document.getElementById("container");
        const itemHeight = 50;
        const containerHeight = 700;
        const buffer = 5;

        let data = [];
        let visibleData = [];
        let startIndex = 0;

        function loopFun(num) {
            data = Array.from({ length: num }, (_, index) => index);
            handleScroll();
        }

        function handleScroll() {
            const scrollTop = container.scrollTop;
            const visibleStart = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
            const visibleEnd = Math.min(
                data.length,
                visibleStart + Math.ceil(containerHeight / itemHeight) + buffer * 2
            );
            startIndex = visibleStart;
            visibleData = data.slice(visibleStart, visibleEnd);
            renderVisibleData();
        }

        function renderVisibleData() {
            const fragment = document.createDocumentFragment();

            const existingItems = container.querySelectorAll('.item');
            existingItems.forEach(item => {
                if (!visibleData.includes(parseInt(item.textContent.split('-')[1]))) {
                    container.removeChild(item);
                }
            });

            visibleData.forEach((item, index) => {
                const div = document.createElement('div');
                div.className = 'item';
                div.style.top = (startIndex + index) * itemHeight + 'px';
                div.textContent = `数据-${item}`;
                fragment.appendChild(div);
            });

            container.appendChild(fragment);
        }

        function throttle(func, limit) {
            let inThrottle;
            return function () {
                const args = arguments;
                const context = this;
                if (!inThrottle) {
                    func.apply(context, args);
                    inThrottle = true;
                    setTimeout(() => (inThrottle = false), limit);
                }
            };
        }

        loopFun(1000);

        const throttledHandleScroll = throttle(handleScroll, 100);
        container.addEventListener('scroll', throttledHandleScroll);

        window.addEventListener('beforeunload', () => {
            container.removeEventListener('scroll', throttledHandleScroll);
        });
    </script>
</body>
</html>

关键改进点

  1. 使用 createDocumentFragment

    • renderVisibleData 函数中,使用 document.createDocumentFragment() 创建一个文档片段,并将所有新创建的元素添加到这个片段中,最后一次性插入到容器中。
  2. 避免不必要的 DOM 清空

    • renderVisibleData 中,首先移除不再需要的元素,然后再添加新的元素。这样可以避免每次都清空整个容器。
  3. 优化 handleScroll 函数

    • 确保 handleScroll 函数只在必要的时候触发,并且通过节流函数限制其执行频率。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值