虚拟滚动列表优化版 缓存+节流+分页

3,506 阅读3分钟

虚拟滚动列表优化版

若对虚拟滚动列表没有概念可以看上一篇基础原理篇, 这版优化包含缓存,简单节流和结合分页加载数据,下面直接进入正题。

1. 在基础版本之上添加上下缓存区

  • 初始即在尾部添加缓存区
  • 当列表向上滚到到开始位置索引大于缓存区项目个数时,需要扩大显示区域显示列表项个数,同时卷起的高度要去除缓存部分,同时修改开始位置索引 如图所示:

clipboard.png

具体实现

例如:设置上下缓存5个列表项

data() {
    return {
      boxH: 700, // 外层盒子高度
      itemH: 100, // 每个元素的高度
      ListNum: 100, // 整体个数
      list: [], // 列表整体数据
      nowList: [], // 目前显示列表
      offsetY: 0, // 显示区域动态偏移量
      cacheNum: 5, // 上下多出5个列表项用于加载缓存
    }
},

初始页面渲染个数为

pageNum() {
  return Math.ceil(this.boxH / this.itemH) + this.cacheNum
}

滚动逻辑如下:

 handleScroll(e) {
  // e.target.scrollTop 卷起高度, 需注意滑动单个元素显示一半的情况
  const scrollTop = e.target.scrollTop
  // 1.保持显示区域一直在屏幕上
  this.offsetY = scrollTop - (scrollTop % this.itemH)

  // 2.计算卷起多少个,替换更新
  let startIndex = Math.floor(scrollTop / this.itemH)
  let endIndex = startIndex + this.pageNum

  // 3.当向上卷起的数量超过缓存个数的时候,上面缓存设置,扩大显示区域
  // 卷起的高度要去除缓存部分,同时修改起始位置
  if(startIndex > this.cacheNum) {
    this.offsetY -= this.cacheNum * this.itemH
    startIndex = startIndex - this.cacheNum
  }

  // 4. 更新当前显示内容
  this.nowList = this.list.slice(startIndex, endIndex)
}

2. 简单加一个节流

  handleScroll(e) {
  // 简单节流一下
  if(Date.now() - this.lastUpdateTime <= 100) return
  // e.target.scrollTop 卷起高度, 需注意滑动单个元素显示一半的情况
  const scrollTop = e.target.scrollTop
  // 1.保持显示区域一直在屏幕上
  this.offsetY = scrollTop - (scrollTop % this.itemH)

  // 2.计算卷起多少个,替换更新
  let startIndex = Math.floor(scrollTop / this.itemH)
  let endIndex = startIndex + this.pageNum

  // 3.当向上卷起的数量超过缓存个数的时候,上面缓存设置,扩大显示区域
  // 卷起的高度要去除缓存部分,同时修改起始位置
  if(startIndex > this.cacheNum) {
    this.offsetY -= this.cacheNum * this.itemH
    startIndex = startIndex - this.cacheNum
  }

  // 4. 更新当前显示内容
  this.nowList = this.list.slice(startIndex, endIndex)
  
  //5. 更新上一次刷新时间
  this.lastUpdateTime = Date.now()
}

3. 结合分页实现

即初始页面不会获取全部列表数据

  • 注意: 每页的数据个数与初始渲染+缓存个数的关系

  • 示例中

  1. 页面显示区域可显示5个列表项 + 上下缓存各5个
  2. 每页的数据个数这里设置为15个
  3. 共105个元素, 7页内容(为了好算,这里取了理想值)

具体实现

1 ) 添加辅助数据,注释很清楚
data() {
    return {
      boxH: 500, // 外层盒子高度
      itemH: 100, // 每个元素的高度
      listNum: 105, // 整体个数,这里暂时写死
      list: [], // 列表整体数据
      nowList: [], // 目前显示列表
      offsetY: 0, // 显示区域动态偏移量
      cacheNum: 5, // 上下多出5个列表项用于加载缓存
      lastUpdateTime: Date.now(), // 用于做简单节流
      lastScrollTop: 0, // 上一次滚动的位置
      pageNo: 1, // 初始加载第一页数据
      backPageNum: 15, // 后端返回的每页项目数
    }
},
2) 初始化时加载第一页数据
 init() {
  // 1.模拟获取数据
  this.getNextPageData()

  // 2. 取得当前第一页的显示数据
  this.nowList = this.list.slice(0, this.pageNum)
},
3) 加载每一页的方法,这里是模拟数据
 // 模拟获取每页数据
getNextPageData() {
  // 1. 模拟获取的每页列表元素
  const start = (this.pageNo - 1) * this.backPageNum
  const end = start + 15
  const list = []
  for(let i = start; i < end; i++) {
    list.push(i)
  }
  this.list = this.list.concat(list)
  this.pageNo++
}
}
4) 上滑时需要判断是否要加载服务端下一页数据,这里偷懒,每次上滑都加载了
  // 4. 判断是否要加载下一页内容
  // 首先向上滚动时才需要加载下一页数据,向下滚动直接使用内存中的list中数据
  // 其次判断当前列表数据是否已经到底
  if(isScrollTop && this.list.length < this.listNum) {
    this.getNextPageData()
  }

  // 5. 更新当前显示内容
  this.nowList = this.list.slice(startIndex, endIndex)
5) 完整滚动逻辑代码
handleScroll(e) {
  // 简单节流一下
  if(Date.now() - this.lastUpdateTime <= 100) return

  // e.target.scrollTop 卷起高度, 需注意滑动单个元素显示一半的情况
  const scrollTop = e.target.scrollTop

  // 新增字段,用于判断是否是向上滚动
  const isScrollTop = this.lastScrollTop < scrollTop ? true : false
  this.lastScrollTop = scrollTop

  // 1.保持显示区域一直在屏幕上
  this.offsetY = scrollTop - (scrollTop % this.itemH)

  // 2.计算卷起多少个,替换更新
  let startIndex = Math.floor(scrollTop / this.itemH)
  let endIndex = startIndex + this.pageNum

  // 3.当向上卷起的数量超过缓存个数的时候,上面缓存设置,扩大显示区域
  // 卷起的高度要去除缓存部分,同时修改起始位置
  if(startIndex > this.cacheNum) {
    this.offsetY -= this.cacheNum * this.itemH
    startIndex = startIndex - this.cacheNum
  }

  // 4. 判断是否要加载下一页内容
  // 首先向上滚动时才需要加载下一页数据,向下滚动直接使用内存中的list中数据
  // 其次判断当前列表数据是否已经到底
  if(isScrollTop && this.list.length < this.listNum) {
    this.getNextPageData()
  }

  // 5. 更新当前显示内容
  this.nowList = this.list.slice(startIndex, endIndex)

  // 6. 更新上一次刷新时间
  this.lastUpdateTime = Date.now()
},
6) 优化空间:
1. 获取下一页的数据的时机,这里偷懒直接在每次滑动中加载了
2. 很多判断比较粗,需要细化
7) 完整实现:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.13/vue.min.js"></script>
  <style>
    * {
      padding: 0;
      margin: 0;
    }
    .container {
      overflow-y: auto;
      box-sizing: border-box;
      border: 1px solid black;
    }
    .list-item {
      list-style: none;
      border: 1px solid red;
    }
  </style>
</head>
<body>
  <div id="app">
    <!-- 外层盒子,固定高度 -->
    <div class="container" :style="`height: ${boxH}px`" @scroll="handleScroll">
      <!-- 内层滚动盒子, 高度是虚拟的数据的整体高度!!!, 这样使得滚动条更像真实的 -->
      <div class="scroll-box" :style="`height: ${allHeight}px`">
        <!-- 真正显示区域, 需要通过trasform 来让显示区域一直在屏幕中,而不是滑走 -->
        <ul :style="`transform:translateY(${offsetY}px)`">
          <li
            v-for="item,index in nowList" :key="index"
            :style="`height: ${itemH}px`"
            class="list-item"
          >{{item}}</li>
        </ul>
      </div>
    </div>
  </div>
  <script>
    new Vue({
      el:"#app",
      data() {
        return {
          boxH: 500, // 外层盒子高度
          itemH: 100, // 每个元素的高度
          listNum: 105, // 整体个数,这里暂时写死
          list: [], // 列表整体数据
          nowList: [], // 目前显示列表
          offsetY: 0, // 显示区域动态偏移量
          cacheNum: 5, // 上下多出5个列表项用于加载缓存
          lastUpdateTime: Date.now(), // 用于做简单节流
          lastScrollTop: 0, // 上一次滚动的位置
          pageNo: 1, // 初始加载第一页数据
          backPageNum: 15, // 后端返回的每页项目数
        }
      },
      created() {
        // 初始化第一页面的数据
        this.init()
      },
      computed: {
        allHeight() {
          return this.listNum * this.itemH
        },
        pageNum() {
          return Math.ceil(this.boxH / this.itemH) + this.cacheNum
        }
      },
      methods: {
        init() {
          // 1.模拟获取数据
          this.getNextPageData()

          // 2. 取得当前第一页的显示数据
          this.nowList = this.list.slice(0, this.pageNum)
        },
        handleScroll(e) {
          // 简单节流一下
          if(Date.now() - this.lastUpdateTime <= 100) return

          // e.target.scrollTop 卷起高度, 需注意滑动单个元素显示一半的情况
          const scrollTop = e.target.scrollTop

          // 新增字段,用于判断是否是向上滚动
          const isScrollTop = this.lastScrollTop < scrollTop ? true : false
          this.lastScrollTop = scrollTop

          // 1.保持显示区域一直在屏幕上
          this.offsetY = scrollTop - (scrollTop % this.itemH)

          // 2.计算卷起多少个,替换更新
          let startIndex = Math.floor(scrollTop / this.itemH)
          let endIndex = startIndex + this.pageNum

          // 3.当向上卷起的数量超过缓存个数的时候,上面缓存设置,扩大显示区域
          // 卷起的高度要去除缓存部分,同时修改起始位置
          if(startIndex > this.cacheNum) {
            this.offsetY -= this.cacheNum * this.itemH
            startIndex = startIndex - this.cacheNum
          }

          // 4. 判断是否要加载下一页内容
          // 首先向上滚动时才需要加载下一页数据,向下滚动直接使用内存中的list中数据
          // 其次判断当前列表数据是否已经到底
          if(isScrollTop && this.list.length < this.listNum) {
            this.getNextPageData()
          }

          // 5. 更新当前显示内容
          this.nowList = this.list.slice(startIndex, endIndex)

          // 6. 更新上一次刷新时间
          this.lastUpdateTime = Date.now()
        },
        // 模拟获取每页数据
        getNextPageData() {
          // 1. 模拟获取的每页列表元素
          const start = (this.pageNo - 1) * this.backPageNum
          const end = start + 15
          const list = []
          for(let i = start; i < end; i++) {
            list.push(i)
          }
          this.list = this.list.concat(list)
          this.pageNo++
        }
      }
    })
  </script>
</body>
</html>