侧边栏壁纸
博主头像
MicroMatrix博主等级

曲则全,枉则直,洼则盈,敝则新,少则得,多则惑。是以圣人抱一为天下式。不自见,故明;不自是,故彰;不自伐,故有功;不自矜,故长。夫唯不争,故天下莫能与之争。古之所谓“曲则全”者,岂虚言哉!诚全而归之。

  • 累计撰写 80 篇文章
  • 累计创建 21 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

原生JS编写虚拟滚动

蜗牛
2022-06-13 / 0 评论 / 0 点赞 / 9 阅读 / 10089 字 / 正在检测是否收录...

前言

在开发项目的过程中,偶尔会遇到很大的数据,然后设计图上又是列表还不分页的情况。为此研究了下虚拟滚动的方案。虚拟滚动大致的思路是当你往下滚动,但最后一个计算的元素出现的时候,替换上面不见了的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编写就是
    <!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元素。是代码的核心。

0

评论区