Vue虚拟列表

虚拟列表

什么是进程?什么是线程?

进程是系统进行资源分配和调度的一个独立单位,一个进程包含多个线程

渲染进程

  • GUI 渲染线程(页面渲染)
  • JS 引擎线程(执行 js 脚本)
  • 事件触发线程(EventLoop 轮询处理线程)
  • 事件(onClick)、定时器(setTimeout)、ajax(xhr)(独立线程)

浏览器 EventLoop

JS 引擎线程(执行栈、微任务、宏任务)

清空微任务队列(Promise.then、MutataionObserver)

GUI 渲染

取出宏任务执行(ajax、setTimeout、event)

requestAnimationFrame 在重新渲染屏幕之前执行

超长列表渲染性能优化

  • 分片渲染(通过浏览器事件环机制,分割渲染时间)
  • 不腻列表(只渲染可视区域)

计算长列表渲染时间

假设需要渲染100000条数据,计算渲染完成的时间,根据浏览器事件环的渲染机制可知,等到 GUI 渲染完成后,才会执行宏任务里面的队列,所以真正的渲染完成时间需要在setTimeout中获取。

<div id="container"></div>
<script>
  let total = 100000
  let timer = Date.now()
  for (let i = 0; i < total; i++) {
    let li = document.createElement('li')
    li.innerHTML = i
    container.appendChild(li)
  }

  // 脚本执行完成时间
  console.log(Date.now() - timer)

  setTimeout(() => {
    // 页面渲染完成时间(在GUI渲染完成后执行)
    console.log(Date.now() - timer)
  })
</script>

分片渲染

假设需要渲染100000条数据,将数据分片渲染,每次先渲染50条,直到全部渲染完成。

let index = 0
let total = 100000
const RENDER_NUM = 50
const el = document.getElementById('container')
function load() {
  index += RENDER_NUM
  if (index < total) {
    setTimeout(() => {
      for (let i = 0; i < RENDER_NUM; i++) {
        let li = document.createElement('li')
        li.innerHTML = i++
        el.appendChild(li)
      }
      load()
    })
  }
}
load()

requestAnimationFrame可以配合浏览器刷新频率,重新渲染屏幕之前执行,更适合游戏或者频繁的渲染操作:

let total = 100000
let index = 0
const RENDER_NUM = 50

function load() {
  index += RENDER_NUM
  if (index < total) {
    requestAnimationFrame(() => {
      const fragment = document.createDocumentFragment()
      for (let i = 0; i < RENDER_NUM; i++) {
        let li = document.createElement('li')
        li.innerHTML = i++
        fragment.appendChild(li)
      }
      container.appendChild(fragment)
      load()
    })
  }
}
load()

分片渲染缺点

分片加载会导致页面 dom 元素过多,造成页面卡顿

虚拟列表

虚拟列表,只渲染当前的可视区域,参考案例:vue-virtual-scroll-list

实现原理

实现虚拟列表,首先需要明确下面几点:

  • 计算虚拟列表总高度
  • 计算可视区域高度
  • 监听列表滚动事件

总结下来是:监听列表滚动事件,动态渲染可视区域的列表

虚拟列表

  • scroll-bar 滚动条(列表总高)
  • scroll-list 可视区域列表

组件参数

虚拟列表组件参数:

  • size: Number 列表每项高度
  • remain: Number 可视区域列表数量
  • items: Array 列表数据

子组件 slot 预留作用域参数 item,组件结构如下:

<template>
    <VirtualList :size="40" :remain="8" :items="items">
        <Item slot-scope="{item}" :item="item" />
    </VirtualList>
</template>

Item组件:

<template>
  <div class="item">
      {{item.value}}
  </div>
</template>

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

<style>
.item{
    height: 40px;
    border-bottom: 1px solid #ddd;
}
</style>

VirtualList

组件结构

首先明确VirtualList组件结构:

  • 滚动条
  • 虚拟列表
  • 滚动事件监听

template 结构如下:

<div class="viewport" ref="viewport" @scroll="handleScroll">
  <div class="scroll-bar" ref="scrollbar"></div>
  <div class="scroll-list" :style="{transform: `translate3d(0,${offset}px,0)`}">
    <div v-for="item in visibleData" :data-index="item.id" :key="item.id">
      <slot :item="item"></slot>
    </div>
  </div>
</div>

计算高度和样式布局

  • 首先需要计算父元素.viewport的高度,让子元素处于可滚动状态。
  • 计算.scroll-bar滚动条高度(列表总高度)
  • .scroll-list相对父元素的绝对定位

计算高度:

viewport = 每项高度 - 可视区域显示个数
scrollbar = 列表总数 - 每项高度

mounted(){
    this.$refs.viewport.style.height = this.size * this.remain + "px";
    this.$refs.scrollbar.style.height = this.items.length * this.size + "px";
}

样式布局:

.viewport {
  overflow-y: scroll;
  position: relative;
}
.scroll-list {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

visibleData

visibleData 是可视区域的渲染数据,通过计算可视区域的列表起始值start和结束值end获取:

data(){
    return {
        start:0,
        end: this.remain, // 初始值 end 是可视区域列表数量
    }
},
computed:{
    visibleData(){
        return this.items.slice(this.start, this.end)
    },
    // 距离列表顶部的滚动偏移量
    offset() {
      return this.start * this.size
    },
}

监听滚动事件

监听列表的滚动事件,通过滚动高度计算startend的变化:

 methods:{
    handleScroll(){
        const scrollTop = this.$refs.viewport.scrollTop;
        this.start = Math.floor(scrollTop/this.size);
        this.end = this.start + this.remain;
    }
}

解决滚动白屏问题

如果用户滚动过快,导致计算渲染未能跟上用户的滚动速度,会导致白屏的出现,解决方式如下:

  • 渲染三屏,可视区域的列表,可视区域前面预渲染的列表,可视区域前面后面预渲染的列表
computed: {
    //  前面预留几个列表项
    prevCount() {
        return Math.min(this.start, this.remain);
    },
    //  后面预留几个列表项
    nextCount() {
        return Math.min(this.remain, this.items.length - this.end);
    },
    offset() {
        // 偏移量需要再减去前面的预渲染的几个列表项
        return this.start * this.size - this.size * this.prevCount;
    },
    visibleData() {
        const start = this.start - this.prevCount;
        const end = this.end + this.nextCount;
        return this.items.slice(start, end);
    },
},

不确定的列表项高度

有时候列表每项的高度也是不确定的,列表项的高度随着内容高度的变化而变化。

解决途径:

  • 新增 variable 参数,判断是否需要动态计算每项高度
  • 初始化缓存每项高度
  • 滚动时,获取列表每项真实高度 更新缓存高度
  • 重新计算滚动条高度
<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
        v-for="item in visibleData"
        :data-index="item.id"
        :key="item.id"
        ref="items"
      >
        <slot :item="item" />
      </div>
    </div>
  </div>
</template>

<script>
import throttle from "lodash/throttle";

export default {
  props: {
    size: Number,
    remain: Number,
    items: Array,
    variable: Boolean,
  },
  data() {
    return {
      start: 0,
      end: this.remain,
    };
  },
  updated() {
    this.$nextTick(() => {
      let nodes = this.$refs.items;
      if (!(nodes && nodes.length)) return;
      nodes.forEach((node) => {
        let { height } = node.getBoundingClientRect();
        let index = Number(node.dataset.index);
        let oldHeight = this.positions[index].height;
        let val = oldHeight - height; // 比较新旧高度是否有变化
        if (val) {
          this.positions[index].height = height;
          this.positions[index].bottom = this.positions[index].bottom - val;
          // 将后面的值向后移动
          for (let i = index + 1; i < this.positions.length; i++) {
            this.positions[i].top = this.positions[i - 1].bottom;
            this.positions[i].bottom = this.positions[i].bottom - val;
          }
        }
      });
      const lastItem = this.positions[this.positions.length - 1]
      this.$refs.scrollbar.style.height = lastItem.bottom + "px";
    });
  },
  computed: {
    prevCount() {
      return Math.min(this.start, this.remain);
    },
    nextCount() {
      return Math.min(this.remain, this.items.length - this.end);
    },
    offset() {
      if (this.variable && this.positions) {
        let item = this.positions[this.start - this.prevCount];
        return item ? item.top : 0;
      } else {
        return this.start * this.size - this.size * this.prevCount;
      }
    },
    visibleData() {
      const start = this.start - this.prevCount;
      const end = this.end + this.nextCount;
      return this.items.slice(start, end);
    },
  },
  created(){
    this.scrollFn = throttle(this.handleScroll, 200, {leading: false})  
  },
  mounted() {
    this.$refs.viewport.style.height = this.size * this.remain + "px";
    this.$refs.scrollbar.style.height = this.items.length * this.size + "px";

    // 如果加载完毕,需要缓存每一项的高度
    this.cacheList();
  },
  methods: {
    cacheList() {
      // 缓存当前项的高度和top值还有bottom值
      this.positions = this.items.map((item, index) => ({
        height: this.size,
        top: index * this.size,
        bottom: (index + 1) * this.size,
      }));
    },
    getStartIndex(scrollTop) {
      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 === scrollTop) {
          return middleIndex + 1;
        } else if (middleValue < scrollTop) {
          start = middleIndex + 1;
        } else if (middleValue > scrollTop) {
          if (temp === null || temp > middleIndex) {
            temp = middleIndex;
          }
          end = middleIndex - 1;
        }
      }
      return temp;
    },
    handleScroll() {
      const scrollTop = this.$refs.viewport.scrollTop;

      if (this.variable) {
        this.start = this.getStartIndex(scrollTop);
        this.end = this.start + this.remain;
      } else {
        this.start = Math.floor(scrollTop / this.size);
        this.end = this.start + this.remain;
      }
    },
  },
};
</script>

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