长列表优化的具体实现 !

586 阅读9分钟

前言

之前我在再来系统的回顾一下前端性能优化这篇文章里提到过长列表优化的知识点,其中提到了几种列表的常见优化方式,由于近期的线上面试过程中出现了一个代码题:要求实现一个图片懒加载的功能组件,那么这次就来看看长列表优化具体是怎么实现的吧 !!!

长列表优化

长列表优化指对于包含大量数据、需要展示成列表形式的页面进行优化的一种技术。

在这种情况下,直接将所有数据展示在页面上可能会存在性能问题,比如页面加载速度缓慢、卡顿、内存占用等,而其解决方案之一就是利用长列表的虚拟滚动技术。

对于大量数据渲染时,JS 运算并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段。

也就是说 JS 执行是很快的,页面卡顿是因为同时渲染大量 DOM 所引起的,可采用分批渲染的方式来解决。

常见的长列表优化方案有:懒加载(触底加载更多)、虚拟列表、时间分片

图片懒加载

图片懒加载可以延迟加载网页上的图片,只有当用户滚动到图片所在的位置时才开始加载,这样可以减少初始页面加载时间和带宽使用。

当然懒加载的实现可以采用 监听 scroll + 节流 的方法去实现,但是由于 scroll 事件密集发生,计算量大时容易造成性能问题,所以建议选择使用 IntersectionObserver (交叉观察器)+ 自定义指令 的方式实现。

图片懒加载实现原理:

使用 IntersectionObserver,监听元素是否进入可视区,等元素进入可视区时再为图片设置 src 属性请求图片资源。

以指令的方式为图片实现懒加载的功能,通过懒加载指令为 Dom 元素绑定 IntersectionObserver 监听器

import Vue from 'vue'

// 懒加载
const lazyLoad = (el, binding) => {
  // 初始化监听器
  const intersection = new IntersectionObserver((entries, observe) => {
        console.log('监听的',el)
        //  元素进入可视区时触发的回调 
        entries.forEach(item => {
            let target = item.target
            if(item.isIntersecting) {
               // 为图片设置传入 src 属性 
              target.src = binding.value
              // 取消观察,后续图片将不再触发 IntersectionObserver 的回调
              intersection.unobserve(item.target)
                
            }
        })
    },{root:document.getElementById('imgWarp')})
  // 为IntersectionObserver绑定监听元素 
  intersection.observe(el)
}

Vue.directive('lazy', {
    inserted: lazyLoad,
    updated: lazyLoad
})

将配置的指令引入 main.js 中。

import '@/directive/lazyLoad'

懒加载指令的使用

<template>
    <div class="imgWarp" ref="wrapper" >
      <img alt="加载"
           v-for="(item,index) of urls" :key="index"
           v-lazy="item" class="lazyload" >
    </div>
</template>

<script>
export default {
  data() {
    return {
      urls: [
        'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg',
        'https://fuss10.elemecdn.com/1/34/19aa98b1fcb2781c4fba33d850549jpeg.jpeg',
        'https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg',
        'https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg',
        'https://fuss10.elemecdn.com/d/e6/c4d93a3805b3ce3f323f7974e6f78jpeg.jpeg',
        'https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg',
        'https://fuss10.elemecdn.com/2/11/6535bcfb26e4c79b48ddde44f4b6fjpeg.jpeg'
      ]
    }
  },
}
</script>

<style lang='scss' scoped>
.imgWarp {
    height: 200px;
    overflow: auto;
    background-color: pink;
    .lazyload {
      position: relative;
      display: block;
      margin-top: 30px;
      width: 200px;
      height: 200px;
    }
    .lazyload:after {
      position: absolute;
      content: "";
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      display: block;
      background-color: #ccc;
    }
  }
</style>

图片懒加载减少初始页面加载时间,提高网页性能,同时提升了用户的体验感,只有在用户需要时才会看到图片,避免了长时间的等待。

触底加载更多

触底加载的实现效果

触底加载原理

获取滚动条的高度、可视区域的高度、页面高度

Element.scrollHeight: 是元素内容高度的度量,包括由于溢出而在屏幕上不可见的内容。

实现原理:当滚动条的距离 + 可视区域的高度 >= 滚动条长度时, 滚动条到达了当前可视区的底部

//  滚动条滚过的距离
const scrollTop = this.$refs.loadImage.scrollTop
// loadImage 组件的可视高度
const clientHeight = this.$refs.loadImage.clientHeight
// 元素内容高度的度量,包括由于溢出而在屏幕上不可见的内容
const scrollHeight = this.$refs.loadImage.scrollHeight

监听滚动条

在滚动条触底的场景下,使用节流监听滚动条事件可以避免过于频繁地触发加载更多的操作,提高性能和用户体验。

防抖是连续触发只执行一次,而节流是每到指定间隔时间执行一次。

mounted(){
    this.$refs.loadImage.addEventListener('scroll',this.throttle(this.handleScroll),1000)
  },
destroyed(){
    // 解绑监听事件,防止内存泄露
    this.$refs.loadImage.removeEventListener('scroll',this.throttle(this.handleScroll),1000)
  },
  

在页面注销时解绑监听事件是为了防止内存泄漏。

如果不解绑事件监听器,那么即使页面被销毁,事件监听器仍然会存在于内存中,导致内存占用过高,从而影响应用程序的性能。

因此,在页面注销时解绑监听器可以确保应用程序的性能得到优化。

组件的完整代码

<template>
  <div class="load-image">
    <div class="image-wrapper" ref="loadImage">
      <img
        v-for="(url, index) in imageUrl" :key="index"
        :src="url"
        class="img"
        :style="{width: imgWidth, height: imgHeight}"
      />
    </div>
  </div>
</template>

<script>
export default {
  name:'LoadImage',
  components:{
    
  },
  props: {
    urls:Array,
    imgWidth: {
      type:String,
      default: '100%'
    },
    imgHeight:{
      type:String,
      default: '500px'
    },
    preloadNumber:{
      type:Number,
      default: 3
    }
  },
  data() {
    return {
     imageUrl:[]
    }
  },
  created(){
    this.imageUrl = this.urls.slice(0,this.preloadNumber)
  },
  mounted(){
    this.$refs.loadImage.addEventListener('scroll',this.throttle(this.handleScroll),1000)
  },
  destroyed(){
    this.$refs.loadImage.removeEventListener('scroll',this.throttle(this.handleScroll),1000)
  },
  methods: {
    addImage(){
      // 添加或请求图片
      const length = this.imageUrl.length
      const addImg = this.urls[length]
      if(length<this.urls.length ){
        this.imageUrl.push(addImg)
      }
    },

    handleScroll() {
      //  滚动条滚过的距离
      const scrollTop = this.$refs.loadImage.scrollTop
      //  loadImage 组件的可视高度
      const clientHeight = this.$refs.loadImage.clientHeight
      //  元素内容高度的度量,包括由于溢出而在屏幕上不可见的内容
      const scrollHeight = this.$refs.loadImage.scrollHeight

      // 当滚动过的距离+可视区的高度>=滚动条长度时,相当于滚动到了底部
      if (scrollTop + clientHeight >= scrollHeight) {
        // 触底,添加图片
        this.addImage()
      }
    },

    // 节流 优化触底加载 定时器 + 时间戳  首尾都执行
    throttle(fn, delay) {
      let timer, context, args;
      let lastTime = 0;
      return function () {
        context = this;
        args = arguments;
  
        let currentTime = new Date().getTime();
        // 清空定时器
        clearTimeout(timer);
        
        // 时间差 大于 delay 时,清空定时器 执行 fn
        if (currentTime - lastTime > delay) {
          // 防止时间戳和定时器的重复
          
          // 清空定时器后直接 执行 fn
          fn.apply(context, args);
          lastTime = currentTime;
        } else {
          timer = setTimeout(() => {
            // 设置定时器 更新执行时间, 防止重复执行
            lastTime = new Date().getTime();
            // 执行后 清空定时器
            fn.apply(context, args);
          }, delay);
        }
  
      };
    }
  },
}
</script>

<style lang='less' scoped>
.load-image {
  .image-wrapper {
    height: 100%;
    overflow: auto;
    .img {
      display: block;
      margin: 10px 0;
    }
  }
}
</style>

懒加载组件的具体使用

<LoadImage class="demo-image" :urls="urls" :preloadNumber="2" ></LoadImage>

import LoadImage from '@/components/LoadImage/index.vue'

data() {
	 return {
      urls: [
        'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg',
        'https://fuss10.elemecdn.com/1/34/19aa98b1fcb2781c4fba33d850549jpeg.jpeg',
        'https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg',
        'https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg',
        'https://fuss10.elemecdn.com/d/e6/c4d93a3805b3ce3f323f7974e6f78jpeg.jpeg',
        'https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg',
        'https://fuss10.elemecdn.com/2/11/6535bcfb26e4c79b48ddde44f4b6fjpeg.jpeg'
      ]
	 }
 },

触底加载的缺点:触底加载通常会在页面上添加新的元素,这可能会导致页面重排和重绘。

为了最小化这种影响,可以使用CSS属性will-change来告知浏览器元素的变化,或者使用CSS3的transform属性来优化元素的动画效果。

但是实现的触底加载只对首次有效,再次进行滚动时数据是完整的不会再进行分段,在数据量大的情况下可能会对性能造成影响。

虚拟列表

虚拟列表是一种用来优化长列表的技术,将页面按照可视内容大小将列表数据进行切片,只呈现在视窗中的部分,不会直接操作和展示整个数据列表,从而避免因长列表导致的性能问题。

虚拟滚动效果如下:

通过观察发现

不论列表怎么滚动,我们改变的只是滚动条的高度和可视区的元素内容,此外并没有新增多余的元素

虚拟列表原理

将页面按照可视内容大小进行切片它可以保证在列表元素不断增加,或者列表元素很多的情况下,依然拥有很好的滚动、浏览性能。

核心思想在于:将页面按照可视内容大小进行切片,只渲染可见区域的列表元素,非可见区域不渲染,在滚动时动态更新可视区域。 即 滚动加载效果

实现虚拟列表

  1. 当列表滚动时,使用 scrollTop 获取滚动条移动的距离,计算可视区域出现的起始元素 startIndex,
  2. 更新对应列表数据,
  3. 借助 css 样式中的 transform: translateY() 属性实现列表及滚动条的移动。
<template>
  <div class="list" @scroll="scrollHandle" ref="list">
      <div class="item" v-for="(item,index) in renderList" :key="index"  :style="`height:${itemHeight}px;line-height:${itemHeight}px;transform:translateY(${top}px)`">
        {{item}}
      </div>
  </div>
</template>
<script>
export default {
name: 'App',
data() {
  return {
    list:[],//完整列表
    itemHeight:60,//每一项的高度
    renderList:[],//需要渲染的列表
    startIndex:0,//开始渲染的位置
    volume:0,//页面的容积:能装下多少个节点
    top:0,
    scroll,//用于初始化节流函数
  }
},
mounted() {
  this.initList();
  // 获取列表视口高度
  const cHeight= this.$refs.list.offsetHeight
  //  Math.ceil 向上取整 计算视口容纳的下节点个数并且设置缓存节点
  this.volume=Math.ceil(cHeight/this.itemHeight)+2;
  console.log(document.documentElement.clientHeight, cHeight,this.volume)
  //设置要渲染的列表 设置成能够容纳下的最大元素个数
  this.renderList=this.list.slice(0,this.volume);
  //初始化节流函数 最短50毫秒触发一次
  this.scroll=this.throttle(this.onScroll,50);
},
methods: {

  //初始化列表 ,循环渲染 500条
  initList(){
    for(let i=0;i<500;i++){
      this.list.push(i);
    }
  },

  scrollHandle(){
    this.scroll();
  },
  
  onScroll(){
    // scrollTop常量记录当前滚动的高度
    const scrollTop= this.$refs.list.scrollTop;
    console.log(this.$refs.list.scrollTop)
    // 获取向上滚动的列表个数,计算开始渲染的节点 
    const start=this.getCurStart(scrollTop);
    // 对比上一次的开始节点 比较是否发生变化,发生变化后便重新渲染列表
    if(this.startIndex!=start){
      // 计算列表向上移动的偏移量  被itemHeight整除的数来作为item的偏移量
      const startOffset = scrollTop - (scrollTop % this.itemHeight);
      // 使用slice拿到需要渲染的那一部分
      this.renderList=this.list.slice(start,this.startIndex+this.volume);
      // 利用css的translateY 实现列表的向上滚动及滚动条的变化
      //这里的 top 设置 translateY  transform:translateY(${top}px)
      this.top = startOffset;
    }
    this.startIndex=start;
  },
  getCurStart(scrollTop){
    // Math.floor 向下取整,获取滚动条向上滚动的列表个数
    return Math.floor(scrollTop/(this.itemHeight));
  },


   // 定时器 + 时间戳  首尾都执行
  throttle(fn, delay) {
    let timer, context, args;
    let lastTime = 0;
    return function () {
      context = this;
      args = arguments;

      let currentTime = new Date().getTime();
      // 清空定时器
      clearTimeout(timer);
      
      // 时间差 大于 delay 时
      if (currentTime - lastTime > delay) {
        // 防止时间戳和定时器重复

        // 清空定时器后直接 执行 fn
        fn.apply(context, args);
        lastTime = currentTime;
      } else {
        timer = setTimeout(() => {
          // 设置定时器 更新执行时间, 防止重复执行
          lastTime = new Date().getTime();
          // 执行后 清空定时器
          fn.apply(context, args);
          
        }, delay);
      }
    };
  }
},
}
</script>

<style>
*{
  margin: 0;
  padding: 0;
}
.list{
  height: 100vh;
  overflow: scroll;
}
.item{
  text-align: center;
  width: 100%;
  box-sizing: border-box;
  border-bottom: 1px solid lightgray;
}
</style>

虚拟列表通常使用CSS属性 transform 来实现元素的滚动和渲染,这种方式不会触发整个页面的重排和重绘。

但如果使用虚拟列表来处理大量数据,仍然可能会导致性能问题,因为需要对大量数据进行分段处理和缓存。

常用的框架也有不错的开源实现, 如:

  • 基于React的 react-virtualized
  • 基于Vue 的 vue-virtual-scroll-list
  • 基于Angular的 ngx-virtual-scroller

时间分片

时间分片也是长列表优化的常见方式,它可以将列表分成多个时间段进行渲染,从而提高页面的性能和响应速度。

将一个运行时间较长的任务分解成一块一块较小的任务,分块执行,因为在浏览器运行过程中超过 50ms 的任务就会被认定为是一个长任务( long task ),浏览器在执行长任务的过程中,可能会阻止用户与页面进行交互,导致页面响应缓慢或失去响应,造成页面渲染和交互的卡顿,所以我们可以缩短函数的连续执行时间。

这种优化方法常用于移动端或者需要处理大量数据的场景中。

在时间分片的实现中,我们可以使用一些技术来帮助我们更好地处理数据,例如虚拟列表、懒加载、缓存等。这些技术可以帮助我们在保证性能的同时,提供更好的用户体验。

可以参考一下这个小 demo :

使用 setTimout 将一个100000 条数据分页渲染,每页渲染30条数据,从而避免一次性渲染大量的数据,造成页面的卡顿。

let now = Date.now();// 记录任务开始时间
let ul = document.getElementById('dataList'); //获取容器
let total = 100000;
let pageSize = 30;
let currentPage = 1;
let endPage = Math.ceil(total/pageSize);
let currentTotal = total-pageSize*currentPage 
function load(){
  if(currentPage > endPage) {
    return false;
  }
  let currentCount = Math.min(currentTotal, pageSize)
  setTimeout(() => {
    for(let i = 1;i<=currentCount;i++) {
      let li = document.createElement('li');
      li.innerText = currentPage + ':' + i;
      ul.appendChild(li)
    }
    currentPage++;
    currentTotal = total-pageSize*currentPage 
    load()
  },0)
}
load()

在这里采用了 setTimeout ,当然我们也可以选择使用 requestAnimationFrame 去实现

使用 requestAnimationFrame 时最大的优势就是回调函数的执行时机是由系统决定的,requestAnimationFrame 会在浏览器的下一次重绘之前执行回调函数,它的刷新频率通常与系统的刷新频率保持同步。

  • 如果屏幕刷新率是60Hz,那么回调函数每 16.7ms 执行一次,1000/60=16.7ms
  • 如果刷新率是75Hz,那么这个时间间隔就变成了1000/75=13.3ms

requestAnimationFrame 跟随着系统的刷新步伐。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象,并且避免了过度绘制和卡顿的问题。

与 setTimeout、setInterval 相比,requestAnimationFrame 更加高效和稳定。

总结

懒加载、触底加载和虚拟列表都是优化大型数据集的常用技术:

  • 懒加载是指在需要时才加载数据,可以减少初始加载时间,提高页面性能。但是,如果用户需要浏览整个数据集,懒加载可能会导致用户不必要的等待时间。
  • 触底加载是指在滚动到页面底部时自动加载更多数据。这种方法可以确保用户浏览数据时不会遗漏任何内容,但是在滚动到底部之前,用户可能需要等待一段时间才能看到新的数据。
  • 虚拟列表是指只渲染当前可见区域的数据,可以大大提高大型数据集的渲染性能。虚拟列表对于需要频繁滚动和浏览大量数据的应用程序非常有用,但是实现虚拟列表需要对数据进行分段处理,并且可能需要更多的代码和组件支持。

综上所述 !!!

每种技术都有各自的优点和缺点,具体情况需要根据实际的应用场景和需求来选择适合的技术。