前言

在开发项目的过程中,偶尔会遇到很大的数据,然后设计图上又是列表还不分页的情况。为此研究了下虚拟滚动的方案。虚拟滚动大致的思路是当你往下滚动,但最后一个计算的元素出现的时候,替换上面不见了的 DOM 元素,将它们从渲染的 HTML 中剔除,同理往上滚动,一个计算的元素出现在最上面的时候,表明需要加载上面的元素信息,并隐藏下面的 DOM 元素。

具体思路

  1. 页面结构,当然只需要一个 DIV 元素就可以。然后将手动生成 DOM 元素添加我们需要虚拟滚动的 DOM 元素中。

  2. 代码结构,利用 JS 的 Class 来创建一个对象,然后对象里做操作方法。这样把 dom 和 JS 操作分开,有利于代码的维护。

  3. 对象上属性的考虑需要哪些呢?

    • 需要虚拟滚动监听的 dom 元素 element
    • 需要虚拟滚动监听的 dom 的高度 height
    • 虚拟滚动列表的每一行高度 rowHeight,用来计算需要加载多少个数据
    • 新加载数据的个数 pageSize
    • 页面滚动加载新的 dom 元素,那么 dom 渲染需要时间,为此加一个缓存区域,提前渲染出需要的 dom 元素
    • 每一行渲染的回调函数 renderItem
    • 加载更多的回调函数 loadMore
      那么对应的 html 编写就是
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>虚拟滚动</title>
        <!-- 一个自己的样式 -->
        <link rel="stylesheet" href="./style.css" />
      </head>
      <body>
        <div id="virtual-scroller"></div>
        <script src="./VirtualScroller.js"></script>
        <script src="./index.js"></script>
      </body>
    </html>
    

目前主要一个对象需要这几个属性。对应代码的编写就如下

//   VirtualScroller.js
class VirtualScroller {
  constructor({
    element,
    height,
    rowHeight,
    buffer,
    renderItem,
    loadMore,
  }) {}
}

构造函数中传入需要的对象属性值,然后构造函数检验值的正确性,并赋值

//   VirtualScroller.js
class VirtualScroller {
  constructor({ element, height, rowHeight, buffer, renderItem, loadMore }) {
    if (typeof element === "string") {
      this.scroller = document.querySelector(element);
    } else if (element instanceof HTMLElement) {
      this.scroller = element;
    }

    if (!this.scroller) {
      throw new Error("Invalid element");
    }

    if (
      !height ||
      (typeof height !== "number" && typeof height !== "string")
    ) {
      throw new Error("invalid height value");
    }

    if (!rowHeight || typeof rowHeight !== "number") {
      throw new Error("rowHeight should be a number");
    }

    if (typeof renderItem !== "function") {
      throw new Error("renderItem is not a function");
    }

    if (typeof loadMore !== "function") {
      throw new Error("renderItem is not a function");
    }

    // set props
    this.height = height;
    this.rowHeight = rowHeight;
    this.pageSize =
      typeof pageSize === "number" && pageSize > 0 ? pageSize : 50;
    this.buffer = typeof buffer === "number" && buffer >= 0 ? buffer : 10;
    this.renderItem = renderItem;
    this.loadMore = loadMore;
    this.data = [];
  }
}

这个时候来看看如何生成一个对象

//   index.js

let scroller = new VirtualScroller({
  element: "#virtual-scroller",
  height: "100vh",
  rowHeight: 60, // px
  pageSize: 100,
  buffer: 10,
  renderItem: function (dataItem) {
    const div = document.createElement("div");
    div.classList.add("row-content");
    div.textContent = dataItem;
    return div;
  },
  loadMore: function (pageSize) {
    const data = [];
    for (let i = 0; i < pageSize; i++) {
      const dataItem = `当前元素下标${this.data.length + i}`;
      data.push(dataItem);
    }
    return data;
  },
});

赋值操作弄好之后,还需要再每次创建新对象的时候就创建一个 dom 元素来包裹渲染 rowItem

constructor(){
  ......
    // 每次创建新的对象的时候就创建一个dom元素来包裹渲染rowItem
    const contentBox = document.createElement('div');
    this.contentBox = contentBox;
    this.scroller.append(contentBox);
  }

还需要每次新建对象的的时候先渲染一批数据,然后还要再上面创建的包裹层中添加滚动监听。相当于虚拟 dom 的根节点。然后挂载到需要的 dom 节点上。

constructor(){
    ......
    this.#loadInitData();
    this.scroller.addEventListener('scroll', throttle(this.#handleScroll, 150));
}

这里就遇到了 2 个函数一个初始化加载函数,渲染第一次渲染的数据,第二个是防抖函数用来处理滚动的性能,第三个就是每次滚动到指定位置就渲染的数据的渲染函数

class VirtualScroller{
    ......
#loadInitData () {
    // 拿到被挂载的dom元素,获取它的显示高度,然后看最少需要渲染多少个数据,然后把个数放入到回调函数中获取数据
    const scrollerRect = this.scroller.getBoundingClientRect();
    const minCount = Math.ceil(scrollerRect.height / this.rowHeight);
    // const page = Math.ceil(minCount / this.pageSize);
    // const newData = this.loadMore(page * this.pageSize);
    // const page = Math.ceil(minCount / this.pageSize);
    const newData = this.loadMore(minCount);
    this.data.push(...newData);
    // 拿到了数据之后就是渲染挂载到指定的contentBox容器上。
    this.#renderNewData(newData);
  }

  //渲染每一行,把它包裹在一个div中,并且这个dom元素也可以设置一些数据还不干扰到renderItem渲染的dom
  #renderRow (item) {
    const rowContent = this.renderItem(item);
    const row = document.createElement('div');
    row.dataset.index = item
    row.style.height = this.rowHeight + 'px';
    row.appendChild(rowContent)
    return row;
  }

  #renderNewData (newData) {
    newData.forEach(item => {
      this.contentBox.append(this.#renderRow(item));
    });
  }

#handleScroll = (e) => {
    const { clientHeight, scrollHeight, scrollTop } = e.target;
    if (scrollHeight - (clientHeight + scrollHeight) < 40) {
      // 到底加载更多

      const newData = this.loadMore(this.pageSize);
      this.data.push(...newData)
    }
    //记录当前的滚动距离,然后对比上一次保存的距离,知道了向上滚动还是向下滚动
    const direction = scrollTop > this.#scrollTop ? 1 : -1


    this.#toggleTopItems(direction)
    this.#toggleBottomItems(direction)

    this.#scrollTop = scrollTop;
  }

    //替换上面的dom
  #toggleTopItems = (direction) => {
    const { scrollTop } = this.scroller;
    const firstVisibleItemIndex = Math.floor(scrollTop / this.rowHeight);
    const firstExistingItemIndex = Math.max(0, firstVisibleItemIndex - this.buffer);
    const rows = this.contentBox.children;
    // 替换上面不可见的元素
    if (direction === 1) {
      for (let i = this.#topHiddenCount; i < firstExistingItemIndex; i++) {
        if (rows[0]) rows[0].remove();
      }
    }
    // 恢复上面隐藏的元素
    if (direction === -1) {
      for (let i = this.#topHiddenCount - 1; i >= firstExistingItemIndex; i--) {
        const item = this.data[i];
        const row = this.#renderRow(item);
        this.contentBox.prepend(row);
      }
    }
    this.#topHiddenCount = firstExistingItemIndex;
    this.#paddingTop = this.#topHiddenCount * this.rowHeight;
    this.contentBox.style.paddingTop = this.#paddingTop + 'px';
  }

  //替换下面的dom
  #toggleBottomItems = (direction) => {
    const { scrollTop, clientHeight } = this.scroller;
    const lastVisibleItemIndex = Math.floor((scrollTop + clientHeight) / this.rowHeight);
    const lastExistingItemIndex = lastVisibleItemIndex + this.buffer;
    this.#lastVisibleItemIndex = lastVisibleItemIndex;
    const rows = [...this.contentBox.children];
    // 替换下面不可见的元素
    if (direction === -1) {
      for (let i = lastExistingItemIndex + 1; i <= this.data.length; i++) {
        const row = rows[i - this.#topHiddenCount];
        if (row) row.remove();
      }
    }
    // 恢复下面不可见的元素
    if (direction === 1) {
      for (let i = this.#topHiddenCount + rows.length; i <= lastExistingItemIndex; i++) {
        const item = this.data[i];
        if (!item) break;
        const row = this.#renderRow(item);
        this.contentBox.append(row);
      }
    }
    this.#bottomHiddenCount = Math.max(0, this.data.length - (this.#topHiddenCount + this.contentBox.children.length) - this.buffer);
    this.#paddingBottom = this.#bottomHiddenCount * this.rowHeight;
    this.contentBox.style.paddingBottom = this.#paddingBottom + 'px';
  }
}

最主要的函数操作是this.#toggleTopItems(direction); this.#toggleBottomItems(direction)这 2 个函数,他们主要就是替换消失在屏幕展示区域中的 dom 元素。是代码的核心。