原生JS编写虚拟滚动

前言

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

embed

具体思路

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

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

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

    • 需要虚拟滚动监听的 dom 元素 element
    • 需要虚拟滚动监听的 dom 的高度 height
    • 虚拟滚动列表的每一行高度 rowHeight,用来计算需要加载多少个数据
    • 新加载数据的个数 pageSize
    • 页面滚动加载新的 dom 元素,那么 dom 渲染需要时间,为此加一个缓存区域,提前渲染出需要的 dom 元素
    • 每一行渲染的回调函数 renderItem
    • 加载更多的回调函数 loadMore
      那么对应的 html 编写就是
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!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>

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //   VirtualScroller.js
    class VirtualScroller {
    constructor({
    element,
    height,
    rowHeight,
    buffer,
    renderItem,
    loadMore,
    }) {}
    }

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

    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
    //   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 = [];
    }
    }

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    //   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

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

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

    1
    2
    3
    4
    5
    constructor(){
    ......
    this.#loadInitData();
    this.scroller.addEventListener('scroll', throttle(this.#handleScroll, 150));
    }

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

    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
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    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 元素。是代码的核心。