长列表优化 vue 版

长列表优化 vue 版

长列表在各种数据列场景下经常使用,一旦数据量非常大,就会出现卡段,先看下长列表一步一步进化

一次性加载

最开始使用长列表时,会这样写

<div id="container"></div>
<script>
  let total = 100000
  let timer = Date.now()
  // 新版浏览器优化,当 js 执行完成后一并插入到页面
  for (let i = 0; i < total; i++) {
    let li = document.createElement('li')
    li.innerHTML = i;
    container.appendChild(li)
  }
  console.log(Date.now() - timer) // js 执行的时间 == 执行很快
  setTimeout(() => {
    console.log(Date.now() - timer) // 渲染完成用的时间 == 时间太长了
  })
</script>

新版浏览器对数据 dom 插入做了优化,当 js 执行完成后一并插入到页面,这样会导致页面空白页很久

使用分片加载

我们知道 js 是单线程的,异步事件是基于 EventLoop 机制,执行顺序为

  • 执行代码时,遇到宏任务(setTimeout,setInterval,Ajax,DOM 事件)或微任务(promise、async/await),都推入到相应的队列
  • 当同步代码执行完,开始清空微任务队列
  • 微任务执行完后,就会尝试进行 dom 渲染
  • 以上完成,这时 EventLoop 开始工作,从宏任务队列取出一个宏任务(可能包含同步代码,promise 等)执行
  • 然后继续循环下一次

从以上知道,可以 dom 渲染是在宏任务之前执行,那么可以通过改进下

<div id="container"></div>
<script>
  let total = 100000
  let timer = Date.now()
  // 新版浏览器优化,当 js 执行完成后一并插入到页面
  let index = 0 // 偏移量
  let id = 0 // 递增的内容
  function load() {
    index += 50
    if (index < total) {
      // setTimeout 和 requestAnimationFrame 都是宏任务
      // requestAnimationFrame 可以配合浏览器的刷新频率,效果可能好点
      setTimeout(() => { // 分片渲染,因为定时器是一个宏任务,会等待 ui 渲染完成后执行
        let fragment = document.createDocumentFragment() // ie 浏览器 需要使用文档碎片
        // 先渲染 50 个,等待渲染完成后,再渲染 50 个
        for (let i = 0; i < 50; i++) {

          let li = document.createElement('li')
          li.innerHTML = id++
          fragment.appendChild(li)
        }
        container.appendChild(fragment)
        load()
      }, 0)
    }
  }

  load()  
</script>

使用 setTimeout 宏任务先渲染 50 个 dom,这样就能进行分片加载,页面快速显示内容,但依然存在页面卡顿的问题

使用虚拟列表

页面卡顿的根本原因,还是 dom 太多了,使用虚拟列表,只渲染当前的可视化区域,这里采用 vue 来实现

参考实现:https://github.com/tangbc/vue-virtual-scroll-list/tree/v1.4.7

文件 App.vue

  • 包含两个组件 VirtualListItem
  • 通过 mockjs 生产 mock 数据
<template>
  <div id="app">
    <!-- 只显示可视区域-->
    <!-- 1、列表每一项多高,算出一个滚动条来-->
    <!-- 2、variable 这个高度不一定多高了 -->
    <VirtualList :size="100" :remain="8" :items="items" :variable="true">
      <Item slot-scope="{item}" :item="item"></Item>
    </VirtualList>
  </div>
</template>

<script>

import VirtualList from './components/virtual-list'
import Item from './components/item'
import mock from 'mockjs'

let items = []
for (let i = 0; i < 10000; i++) {
  items.push({id: i, value: mock.Random.sentence(5, 50)})
}

export default {
  name: 'App',
  components: {
    VirtualList,
    Item
  },
  data() {
    return {items}
  }
}
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
</style>

组件 components/Item.vue

  • 接收 item 对象,显示值
<template>
  <div style="border: 1px solid red; padding: 20px 0">
    {{ item.value }}
  </div>
</template>

<script>
export default {
  name: "item",
  props: {
    item: Object
  }
}
</script>

最核心的 components/virtual-list.vue

  • 接收四个参数

    • size: 每一项的高度
    • remain: 可视区域显示多少个
    • items: 数据源
    • variable: 每项高度是否固定
  • html 结构:可视区域 ref="viewport" , 滚动条 ref="viewport" 和数据列表 class="scroll-list"

  • 加载完成后,调用 cacheList 方法先缓存每个 dom 的高度,顶部,底部位置;每次渲染后,更新缓存为实际的高度,顶部,底部位置

  • variabletrue 时,利用二分查找算法找到滚动条的位置

  • 可视区域显示的实际 dom 数量应大于 remain 个数,防止出现空白

<template>
  <!--  能滚动的盒子-->
  <div class="viewport" ref="viewport" @scroll="scrollFn">
    <!--    自己做一个滚动条-->
    <div class="scroll-bar" ref="scrollBar"></div>
    <!--    渲染的内容-->
    <div class="scroll-list" :style="{transform:`translate3d(0,${offset}px,0)`}">
      <!--    <div class="scroll-list" :style="{top:`${offset}px`}">-->
      <div v-for="item in visibleData" :vid="item.id" :key="item.id" ref="items">
        <slot :item="item"></slot>
      </div>
    </div>
  </div>
</template>

<script>
import throttle from 'lodash/throttle'

export default {
  name: "virtual-list",
  props: {
    size: Number, // 每一项的高度
    remain: Number, // 可见多少个
    items: Array,
    variable: Boolean, //
  },
  data() {
    return {
      start: 0,
      end: this.remain, // 默认显示 8 个
      offset: 0,
    }
  },
  created() {
    this.scrollFn = throttle(this.handleScroll, 200, {leading: false})
  },
  computed: {// 渲染三个屏幕
    prevCount() { // 前面预留几个
      return Math.min(this.start, this.remain)
    },
    nextCount() { // 后面预留几个
      return Math.min(this.remain, this.items.length - this.end)
    },
    // 可见数据有哪些
    visibleData() {
      // 根据 start 和 end 截取
      let start = this.start - this.prevCount
      let end = this.end + this.nextCount
      return this.items.slice(start, end)
    }
  },
  mounted() {
    // 可视区域高度
    this.$refs.viewport.style.height = this.size * this.remain + 'px'
    // 实际滚动条高度
    this.$refs.scrollBar.style.height = this.items.length * this.size + 'px'

    // 如果加载完毕,需要缓存每一项的高度
    // 1、先记录好,等一会滚动的时候,去渲染页面是获取真实 dom 的高度,来更新缓存内容
    // 2、再重新计算滚动条的高度
    this.cacheList()
  },
  updated() {
    // 页面渲染完成后,需要根据当前展示的数据,更新缓存区的内容
    this.$nextTick(() => {
      // 根据当前显示的,更新缓存中的 height bottom top,最终更新滚动条的高度
      let nodes = this.$refs.items // 获取真实的节点
      if (!(nodes && nodes.length > 0)) {
        return
      }
      nodes.forEach(node => {
        let {height} = node.getBoundingClientRect() // 真实的高度
        // 更新缓存中老的高度
        let id = node.getAttribute('vid') - 0
        let oldHeight = this.positions[id].height
        let val = oldHeight - height // 计算当前的高度和之前的高度是否变化
        if (val) { // 没变化的话,就不用任何操作了
          this.positions[id].height = height
          this.positions[id].bottom = this.positions[id].bottom - val // 底部增加了
          // 链表 将后续的所有人 都要向后移动
          for (let i = id + 1; i < this.positions.length; i++) {
            this.positions[i].top = this.positions[i - 1].bottom
            this.positions[i].bottom = this.positions[i].bottom - val
          }
        }
      })
      // 只要更新过,会计算出滚动条的最新高度
      this.$refs.scrollBar.style.height = this.positions[this.positions.length - 1].bottom + 'px'
      // 就是动态计算滚动条的高度
    })
  },
  methods: {
    getStartIndex(value) { // 查找当前滚动的需要找到的值
      let start = 0 // 开始
      let end = this.positions.length - 1 // 结束位置
      let temp = null
      while (start <= end) {
        let middleIndex = parseInt((start + end) / 2);
        let middleValue = this.positions[middleIndex].bottom // 找到当前的中间的那个人的结尾点
        if (middleValue == value) { // 如果直接找到了,就返回当前的下一个人
          return middleIndex + 1
        } else if (middleValue < value) { // 当前要查找的人,在右边
          start = middleIndex + 1
        } else if (middleValue > value) { // 当前要查找的人,在左边
          if (temp == null || temp > middleIndex) {
            temp = middleIndex // 找到范围
          }
          end = middleIndex - 1
        }
      }
      return temp
    },
    cacheList() { // 缓存当前项的高度和 top 值,还有 bottom
      this.positions = this.items.map((item, index) => ({
        height: this.size,
        top: index * this.size,
        bottom: (index + 1) * this.size
      }))
    },
    handleScroll() {
      // 1、先算出当前滚动过去几个了,应该从第几个开始显示
      let scrollTop = this.$refs.viewport.scrollTop

      if (this.variable) {
        // 如果有 variable 使用二分查找找到对应的记录
        // 二分查找: 在一个有序的数据列表,先分成两半,从中间开始,判断在哪个一半,然后继续分成两半查找
        this.start = this.getStartIndex(scrollTop)
        this.end = this.start + this.remain
        // 设置偏移量
        this.offset = this.positions[this.start - this.prevCount] ? this.positions[this.start - this.prevCount].top : 0
      } else {
        // 2、获取当前应该从第几个开始渲染
        this.start = Math.floor(scrollTop / this.size)  // 已滚动的高度/每一项的高度 = 已滚动的个数, 需要取整
        // 3、计算当前结尾的位置
        this.end = this.start + this.remain // 当前可渲染的区域
        // 定义当前可视区域,让当前渲染的内容显示在当前 viewport 的可视区域里
        // 如果有预留渲染,应该把这个位置再向上移动当前这么多 this.size * this.prevCount
        this.offset = this.start * this.size - this.size * this.prevCount // 让可视区域调整偏移位置
      }
    }
  },
}
</script>

<style>
.viewport {
  overflow-y: scroll;
  position: relative;
}

.scroll-list {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
</style>

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注