「前端性能优化」十万条数据丝滑查看(虚拟列表)

2,340 阅读2分钟

面试官:给你10万条数据,你怎么处理?

今天跟大家来唠唠嗑,如果后端真的返回给前端10万条数据,咱们前端要怎么优雅地展示出来呢?(哈哈假设后端真的能传10万条数据到前端)天呐,不会真的有人会直接在页面绘制10万个 DOM 节点来渲染10万条数据吧,这人指定疯了吧!

其实对于十万条数据,我们前端可以采用 虚拟列表 + 分页 的方式来展示

那到底什么是虚拟列表呢?让我们来一探究竟

什么是虚拟列表?

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。来看一下渲染的效果吧:

虚拟列表在线体验地址:www.xyst.club:4888/

ezgif-2-a33557f27d.gif

一个虚拟列表一共分为上空白区、上缓冲区、可视区、下缓冲区、下空白区,废话不多说,直接上图

假设页面可视区最多能渲染5条数据,这是页面首次加载完渲染的样子:

img-06-18-27.png 这是页面发生滚动之后渲染的样子

img-06-18-10.png 这是页面已经滚动到底部该渲染的样子:

img-06-18-42.png

代码实现虚拟列表

下面我们用 Vue2 来写前端代码,后端用 express + mockjs 模拟数据返回给前端

这里是后端模拟数据返回给前端,没什么好解释的,直接拷贝用就行

 const Mock = require("mockjs")
 const express = require("express")
 const app = express()
 ​
 // 设置跨域
 app.use(require('cors')())
 let length = 0
 ​
 // 根据传入得 num ,生成 num 条模拟数据
 function generatorList(num) {
   const mockList = Mock.mock({
     [`Mocklist|${num}`]: [{
       // 模拟ID 自增方式追加
       "id|+1": Number(length),
       // 模拟数据内容,中文字符串长度5-10位
       info: "@ctitle(20,50)",
     }]
   })
   length += (num - 0)
   return mockList
 }
 ​
 // 截取路由发送数据给前端页面
 app.get("/getData", function (req, res) {
   const { num } = req.query
   const data = generatorList(num)
   return res.send(data)
 })
 ​
 app.get('/created', function () {
   length = 0
 })
 ​
 app.listen(1888, () => {
   console.log("本地mock启动,接口地址为:http://localhost:1888/getData?num=请求数量");
 })

前端html结构,我们需要写一个滚动盒子和一个上下填充空白的盒子(列表可视区外不需要渲染,我们采用上下内边距padding来填充虚拟节点)

 <template>
   <div class="VList">
     <div class="scroll-box" ref="scrollbox" @scroll.prevent="handlescroll">
       <div :style="blankStyle">
         <div v-for="item of DOMList" :key="item.id" :index="item.id" class="msg-box">
           <div>{{ item.id }}</div>
           <div>{{ item.info }}</div>
         </div>
       </div>
     </div>
   </div>
 </template>

scroll-box: 监听滚动事件的盒子,必须要给此盒子设置高度

blankStyle: 这个盒子主要是用来设置上下内边距的(填充上下空白),style样式为动态绑定

DOM-box: 需要在页面渲染的真实DOM

虚拟列表需要设置的一些数据:

dataList: 总数据列表

DOMList: 需要渲染成DOM的数据列表

oneHeight: 单条数据的高度

maxSize: 可视区最大容积数

firstIndex: DOM节点里第一条数据的索引

endIndex: DOM节点里最后一条数据的索引

blankStyle: 页面上下空白区域的样式

实现思路:

1.计算容器最大容积数量

2.监听滚动事件获取可视区第一条数据的索引firstIndex

3.计算属性计算出最后一条数据的索引endIndex

4.根据 firstIndex 和 endIndex 截取出页面需要渲染的DOM节点数据

优化思路:

  • 监听滚动事件设置上下缓冲区消除快速滚动白屏
  • 计算属性根据 firstIndex 和 endIndex 动态设置上下空白区域占位
  • 监听滚动事件判断是否滚动到底部,这里是采用分页(懒加载)来获取数据,即不用一次性从后端获取十万条数据

最后附上所有代码:

 <script>
 import axios from 'axios'
 export default {
   name: 'VirtualList',
   data() {
     return {
       dataList: [],        // 总数据列表
       oneHeight: 120,      // 单条数据的高度                  
       maxSize: '',         // 可视区最大容积数   
       firstIndex: '',      // 可视区第一条数据的索引                  
     }
   },
 ​
   methods: {
     // 计算列表可视区最大容积
     // 思路:用列表可视区高度除以单条数据高度取整加 2
     getMaxSize() {
       this.maxSize = ~~(this.$refs.scrollbox.offsetHeight / this.oneHeight) + 2
     },
 ​
     // 计算可视区第一条数据的索引
     // 思路:用列表向上滚动的距离除以单条数据的高度取整
     getFirstIndex() {
       this.firstIndex = ~~(this.$refs.scrollbox.scrollTop / this.oneHeight)
     },
 ​
     // 判断页面是否滚动到底部了
     isEnd() {
       if (this.firstIndex + this.maxSize > this.dataList.length) {
         this.getMockData(30)   // 滑到底了就拿下一页数据
       }
     },
 ​
     // 列表滚动时的回调
     handlescroll() {
       this.getFirstIndex()
       this.isEnd()
     },
 ​
     // 发送请求获取模拟数据
     getMockData(num) {
       axios({
         url: `http://localhost:1888/getData?num=${num}`,
         method: 'get'
       }).then(res => {
         const data = res.data.Mocklist
         this.dataList = [...this.dataList, ...data]
       })
     }
   },
 ​
   mounted() {
     this.getMockData(30)   // 发送请求获取数据
     this.getMaxSize()    // 计算列表可视区最大容积
     window.onresize = () => this.getMaxSize()   // 屏幕大小发生变化时计算一下可视区最大容积数量
   },
 ​
   computed: {
     // 页面需要渲染DOM的数据列表
     // 思路:DOM列表 = 可视区数据列表 + 上下缓冲区数据列表
     DOMList() {
       let startIndex = 0
       if (this.firstIndex <= this.maxSize) {
         startIndex = 0     //  DOM节点第一条数据还在缓冲区
       } else {
         // DOM节点第一条数据超过缓冲区,为虚拟DOM,用空白填充不需要渲染了
         startIndex = this.firstIndex - this.maxSize
       }
       return this.dataList.slice(startIndex, this.endIndex)
     },
 ​
     // DOM数据列表最后一条数据的索引
     endIndex() {
       let endIndex = this.firstIndex + this.maxSize * 2  // 列表容积 ×2 是为了留下一个下缓冲区
       if (!this.dataList[endIndex]) {
         endIndex = this.dataList.length     // 如果最后一条数据不存在,则直接等于列表所有数据的长度
       }
       return endIndex
     },
 ​
     // 页面上下空白区域
     // 虚拟列表总高度 === 可视区高度 + 上下空白高度
     blankStyle() {
       let startIndex = 0
       if (this.firstIndex <= this.maxSize) {
         startIndex = 0    // DOM节点第一条数据还在缓冲区
       } else {
         startIndex = this.firstIndex - this.maxSize    // DOM节点第一条数据超过缓冲区,即要用空白代替
       }
       return {
         paddingTop: startIndex * this.oneHeight + 'px',    // 上空白
         paddingBottom: (this.dataList.length - this.endIndex) * this.oneHeight + 'px'   // 下空白
       }
     },
   },
 ​
   created() {
     axios({
       url: 'http://localhost:1888/created',
       method: 'get'
     })
   },
 }
 </script>

最后最后,再附上样式吧,感兴趣的小伙伴直接Ctrl + C / V 即可直接运行

 <style scoped>
 .VList {
   width: 100vw;
   height: 100vh;
 }
 ​
 .scroll-box {
   width: 100%;
   height: 100%;
   overflow-y: auto;
 }
 ​
 .DOM-box {
   width: 100%;
   height: 120px;
   border-bottom: 1px solid #ccc;
 }
 ​
 .DOM-box div {
   padding: 0 20px;
 }
 </style>