[TOC]

最近项目中需要用到列表的展示,且不分页。当数据加载太多时会造成性能问题。因此采用虚拟列表来优化

一、虚拟列表

真实列表:每条数据都展示到html上,数据越多,DOM元素也就越多,性能也就越差。
虚拟列表:只展示部分数据(可见区域展示数据),当屏幕滚动时替换展示的数据,DOM元素的数量是固定的,相比较真实列表更高效。

二、实现思路

在vue中实现虚拟列表组件 - 图1

难点与思考:

  1. 如何计算需要渲染的数据
    1. 数据可分为总数据,与需要渲染的数据,需要渲染的数据包括了可见区域与缓冲区域的数据
    2. 通过单条数据占位的高度与可见区域的高度,算出可见区域的列表条数,再往上和往下扩展几条缓冲区域的数据(本次代码是以3倍可见区域的条数作为需要渲染的数据条数)
  2. 何时替换数据
    1. 监听滚动事件,渲染元素的第一条数据滚动出缓冲区域后,也就是可见区域第一个元素的index 大于 缓冲区域的条数时,就开始替换数据了,每次往上滑动一个元素,就替换一次数据。
  3. 计算空白区域占位的高度
    1. 由于列表在滚动过程中会替换数据,如果没有空白占位的话,会导致第一个元素替换后,第二个元素立马替换了第一个元素的位置,会导致错位。如下图所示:

虚拟列表.gif

  1. 因此滚动时,需要在元素消失后,补一个相同高度的空白占位
  2. 上方的空白占位 = 消失的元素个数(也就是第一个渲染元素的index) * 单个元素的高度
  3. 下方的空白占位 = 剩下需要渲染的元素个数(也就是最后一个元素的index与总数据条数的差值)* 单个元素的高度

    其他注意事项:

  1. 在使用v-for遍历渲染数据时,key的值使用index,不用item的id,可以避免该dom元素被重新渲染,只替换数据。
  2. 下拉加载更多时,不要将整个数据替换了,而是追加到数据的后面,避免之前展示的数据被替换了。
  3. 空白占位可以使用padding来占位,也可以使用DOM元素占位,使用DOM元素占位监听滚动事件时,应使用touchmove或mousemove监听,避免dom元素高度变化后,又触发了scroll滚动事件。
  4. 监听滚动事件应该采用节流的方式,避免程序频繁执行。
  5. 监听滚动时加上passive修饰符,可以提前告知浏览器需要执行preventDefault,使滚动更流畅,具体功能可以参考vue官网。
  6. 外层包裹的元素需要有固定高度,并且overflow为auto,才能监听scroll滚动事件。

    三、实现

    实现效果

    虚拟列表效果.gif

    实现代码

    ```html
``` ### 模拟数据的后端代码 - 这是本次用于模拟后端数据的代码,采用mock和express。 ```json const Mock = require('mockjs') const express = require('express') const app = express() let sum = 1 // mock的ID // 根据入参生成num条模拟数据 function generatorList(num) { return Mock.mock({ [`list|${num}`]: [ { 'id|+1': sum, title: "@ctitle(15,25)", from: "@ctitle(3,10)", } ] }) } // 允许跨域 app.all('*', function (req, res, next) { res.setHeader("Access-Control-Allow-Origin", '*'); res.setHeader("Access-Control-Allow-Headers", '*'); res.setHeader("Access-Control-Allow-Method", '*'); next() }) app.get('/data', function (req, res) { const { num } = req.query const data = generatorList(num) sum += parseInt(num) return res.send(data) }) const server = app.listen(4000, function () { console.log('4000端口正在监听~~') }) ``` ## 四、封装为组件 > 也可以封装为插件,此处为了方便就封装为组件 ### props: 1. allList : 所有数据 1. oneHeight : 单条元素的高度 1. lower : 距离底部多远时触发触底事件,默认50 ### event: - scrollLower : 触底时触发 ### 虚拟列表组件代码 ```html
<a name="mPU9O"></a>
### 使用代码
```html
<template>
  <div id="app">
    <VScroll :allList="allList" :oneHeight="150" :lower="150" @scrollLower="scrollLower">
      <!-- 作用域插槽,使用slot-scope取出在组件中遍历的数据 -->
      <template slot-scope="{item}">
        <div class="box">
          <h2>{{ item.title }} - {{ item.id }}</h2>
          <h3>{{ item.from }}</h3>
        </div>
      </template>
    </VScroll>
  </div>
</template>

<script>
import axios from "axios";
import VScroll from "./components/VScroll.vue";
export default {
  name: "App",
  data() {
    return {
      allList: [], // 所有数据
      isRequest: false  // 是否正在请求数据
    };
  },
  created() {
    this.getData(); // 请求数据
  },
  methods: {
    // 请求数据
    getData() {
      this.isRequest = true; // 正在请求中
      axios.get("http://localhost:4000/data?num=10").then((res) => {
        // 将结果追加到allList
        this.allList = [...this.allList, ...res.data.list];
        this.isRequest = false;
      });
    },
    // 滚动到底部
    scrollLower() {
      if (!this.isRequest) this.getData()
    }
  },
  components: { VScroll }
};
</script>

<style>
#app {
  height: 100vh;
}
.box {
  width: 96vw;
  height: 150px;
  background: #eee;
  border: 2px navajowhite solid;
  box-sizing: border-box;
}
</style>

五、在uniapp中封装组件

由于最近在开发一个app项目,uniapp的list长列表组件只能支持nvue,且app存在兼容性问题,无法通过event获取滚动高度,所以结合了uniapp内置的scroll-view,与上述功能,完成了一个简单的长列表展示组件 scroll-view请用法参考uniapp官网,如需要使用scroll-view中的其他功能,可以自行增加,比如下拉刷新,上拉加载更多等

<template>
    <!-- @scrolltolower触底事件,@scroll滚动事件 -->
    <scroll-view scroll-y="true" :style="{height: containerHeight+'px'}" @scroll="scrollTop = $event.detail.scrollTop"
    @scrolltolower="$emit('@scrolltolower')">
        <!-- 监听滚动事件使用passive修饰符 -->
        <view :style="paddingStyle">
            <!-- key使用index,可避免多次渲染该dom -->
            <view id="box" v-for="(item, index) in showList" :key="index">
                <!-- 使用作用域插槽,将遍历后的数据item和index传递出去 -->
                <slot :item="item" :$index="index"></slot>
            </view>
        </view>
    </scroll-view>
</template>

<script>
    export default {
        name: "App",
        props: {
            // 所有数据
            allList: {
                type: Array,
                default () {
                    return []
                }
            },
            // 容器高度
            containerHeight: {
                type: Number,
                default: 0
            },
        },
        data() {
            return {
                oneHeight: 200, // 单个元素的高度
                scrollTop:0 // 滚动距离
            };
        },
        mounted() {
            // 计算单个元素的高度
            this.$nextTick(() => {
                // uniapp中获取高度的兼容写法
                const query = uni.createSelectorQuery();
                query.select('#box').boundingClientRect(data => {
                    this.oneHeight = data.height
                }).exec();
            })
        },
        computed: {
            // 一屏多少条数据
            showNum() {
                return ~~(this.containerHeight / this.oneHeight) + 2;
            },
            // 开始索引
            startIndex() {
                // 可见区域,第一个元素的index
                const curIndex = ~~(this.scrollTop / this.oneHeight)
                // console.log(this.showNum, this.oneHeight)
                // 渲染区域第一个元素的index,这里缓冲区域的列表条数使用的是this.showNum
                return curIndex < this.showNum ? 0 : curIndex - this.showNum
            },
            // 渲染元素最后的index
            endIndex() {
                // 可见区域,第一个index
                const curIndex = ~~(this.scrollTop / this.oneHeight)
                let end = curIndex + this.showNum * 2; // 2倍是需要预留缓冲区域
                let len = this.allList.length
                return end >= len ? len : end; // 结束元素大于所有元素的长度时,就取元素长度
            },
            // 需要渲染的数据
            showList() {
                return this.allList.slice(this.startIndex, this.endIndex)
            },
            // 空白占位的高度
            paddingStyle() {
                return {
                    paddingTop: this.startIndex * this.oneHeight + 'px',
                    paddingBottom: (this.allList.length - this.endIndex) * this.oneHeight + 'px'
                }
            }
        },
    };
</script>