一文拿下长列表渲染(分页、懒加载、虚拟列表)

202 阅读3分钟

讲解

分页

  • 优点:快速跳转,方便回溯(更多用在 B 端)
  • 缺点:用户体验不好,需要手动切换页码

懒加载

  • 优点:用户体验好,不像分页那样需要手动切换(更多用在 C 端)
  • 缺点:不方便跳转回溯,而且scroll事件可能会被高频度的触发,并且在scroll事件里又去操作dom,页面便会重排重绘,造成卡顿

虚拟列表

  • 优点:dom 节点数不变,动态替换,解决了懒加载造成回流重绘的性能问题(更多用在 C 端)

实现

最终测试,除了普通长列表渲染,其他渲染都使得 10w 条数据渲染时间由 4s 变为 0.4 s。

以下长列表渲染均通过代码示例进行讲解。

image.png

代码如下:

App.vue

<template>
  <button @click="toPage('/demo')">demo</button>
  <button @click="toPage('/page-demo')">page-demo</button>
  <button @click="toPage('/virtual-demo')">virtual-demo</button>
  <button @click="toPage('/lazy-demo')">lazy-demo</button>
  <button @click="toPage('/virtual-plus-demo')">virtual-plus-demo</button>
  <div>
    <router-view></router-view>
  </div>
</template>

<script setup lang="ts">
import {useRouter} from "vue-router";

const router = useRouter()
const toPage = (path) => {
  router.push(path)
}
</script>

<style scoped>

</style>

main.js

虚拟列表库使用(Vue3版本)vue-virtual-scroller/packages/vue-virtual-scroller at master · Akryum/vue-virtual-scroller (github.com)


import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import VueVirtualScroller from 'vue-virtual-scroller'


const app = createApp(App)

app.use(router)
app.use(VueVirtualScroller)
app.mount('#app')

router/index.js

import {createRouter, createWebHistory} from 'vue-router'
import HomeView from '../views/PageDemoView.vue'

const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [
        {
            path: '/',
            name: 'home',
            component: HomeView
        },
        {
            path: '/demo',
            name: 'demo',
            component: () => import('../views/DemoView.vue')
        },
        {
            path: '/page-demo',
            name: 'pageDemo',
            component: () => import('../views/PageDemoView.vue')
        },
        {
            path: '/lazy-demo',
            name: 'lazyDemo',
            component: () => import('../views/LazyDemoView.vue')
        },
        {
            path: '/virtual-demo',
            name: 'virtualDemo',
            component: () => import('../views/VirtualDemoView.vue')
        },
        {
            path: '/virtual-plus-demo',
            name: 'virtualPlusDemo',
            component: () => import('../views/VirtualPlusDemoView.vue')
        },
    ]
})

export default router

长列表渲染

DemoView.vue

<template>
  <div>
    <h1>长列表 Demo</h1>
    <ul>
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.name }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

// 使用 ref 定义响应式数据
const items = ref([]);

// 定义生成数据的方法
const generateItems = (count) => {
  for (let i = 0; i < count; i++) {
    items.value.push({ id: i, name: `Item ${i}` });
  }
};

// 在组件挂载时生成数据 假定十万条数据
onMounted(() => {
  generateItems(100000);
});
</script>

<style scoped>
.list-item {
  padding: 10px;
  border-bottom: 1px solid #ddd;
}
</style>

长列表渲染 - 懒加载

LazyDemeView.vue

<template>
  <div class="scroll-container" @scroll="handleScroll">
    <h1>懒加载的长列表 Demo</h1>
    <ul>
      <li v-for="item in currentItems" :key="item.id" class="list-item">
        {{ item.name }}
      </li>
    </ul>
    <p v-if="loading" class="loading">加载中...</p>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

// 全部列表项
const items = ref([]);
// 当前显示的列表项
const currentItems = ref([]);
// 每次加载的数量
const batchSize = 20;
// 当前加载到的索引
const currentIndex = ref(0);
// 是否正在加载数据
const loading = ref(false);

// 模拟生成 10000 条数据
const generateItems = (count) => {
  for (let i = 0; i < count; i++) {
    items.value.push({ id: i, name: `Item ${i}` });
  }
};

// 加载数据
const loadMoreItems = () => {
  if (loading.value) return;

  loading.value = true;
  // 模拟加载数据的延迟
  setTimeout(() => {
    const nextItems = items.value.slice(currentIndex.value, currentIndex.value + batchSize);
    currentItems.value.push(...nextItems);
    currentIndex.value += batchSize;
    loading.value = false;
  }, 1000); // 模拟1秒加载时间
};

// 处理滚动事件
const handleScroll = (e) => {
  const container = e.target;
  const bottomReached = container.scrollHeight - container.scrollTop <= container.clientHeight + 100;

  if (bottomReached && !loading.value) {
    loadMoreItems();
  }
};

// 初次加载时生成数据
onMounted(() => {
  generateItems(100000);
  loadMoreItems(); // 初次加载数据
});
</script>

<style scoped>
.scroll-container {
  height: 500px;
  overflow-y: auto;
  border: 1px solid #ddd;
}

.list-item {
  padding: 10px;
  border-bottom: 1px solid #ddd;
}

.loading {
  text-align: center;
  padding: 10px;
  color: #007bff;
}
</style>

长列表渲染 - 分页

PageDemoView.vue

<template>
  <div>
    <h1>分页器的长列表渲染 Demo</h1>

    <!-- 显示当前页的数据 -->
    <ul class="list">
      <li v-for="item in paginatedData" :key="item.id" class="list-item">
        {{ item.name }}
      </li>
    </ul>

    <!-- 分页器 -->
    <div class="pagination">
      <button @click="prevPage" :disabled="currentPage === 1">上一页</button>

      <button
          v-for="page in paginationPages"
          :key="page"
          @click="goToPage(page)"
          :disabled="page === '...' || currentPage === page"
          :class="{ active: currentPage === page }"
      >
        {{ page }}
      </button>

      <button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

// 每页项目数量
const itemsPerPage = 20;
// 总项目数量(模拟)
const totalItems = 1000;

// 当前页码
const currentPage = ref(1);

// 模拟生成数据
const items = ref([]);
for (let i = 0; i < totalItems; i++) {
  items.value.push({ id: i, name: `Item ${i + 1}` });
}

// 计算总页数
const totalPages = computed(() => Math.ceil(totalItems / itemsPerPage));

// 计算当前页的显示数据
const paginatedData = computed(() => {
  const start = (currentPage.value - 1) * itemsPerPage;
  const end = start + itemsPerPage;
  return items.value.slice(start, end);
});

// 动态生成分页页码的逻辑
const paginationPages = computed(() => {
  const pages = [];
  const total = totalPages.value;
  const current = currentPage.value;

  if (total <= 7) {
    // 如果总页数小于等于 7,直接显示所有页码
    for (let i = 1; i <= total; i++) {
      pages.push(i);
    }
  } else {
    // 总页数大于 7 的情况
    const startPage = Math.max(2, current - 1);
    const endPage = Math.min(total - 1, current + 1);

    // 添加第一页
    pages.push(1);

    // 添加省略号或开始页码
    if (startPage > 2) {
      pages.push('...');
    }
    for (let i = startPage; i <= endPage; i++) {
      pages.push(i);
    }
    // 添加省略号或结束页码
    if (endPage < total - 1) {
      pages.push('...');
    }

    // 添加最后一页
    pages.push(total);
  }

  return pages;
});

// 跳转到下一页
const nextPage = () => {
  if (currentPage.value < totalPages.value) {
    currentPage.value++;
  }
};

// 跳转到上一页
const prevPage = () => {
  if (currentPage.value > 1) {
    currentPage.value--;
  }
};

// 跳转到指定页
const goToPage = (page) => {
  if (page !== '...') {
    currentPage.value = page;
  }
};
</script>

<style scoped>
.list {
  padding: 0;
  margin: 0;
  list-style: none;
}

.list-item {
  padding: 10px;
  border-bottom: 1px solid #ddd;
}

.pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 20px;
}

.pagination button {
  padding: 10px 15px;
  margin: 0 5px;
  background-color: #007bff;
  color: white;
  border: none;
  cursor: pointer;
}

.pagination button.active {
  background-color: #0056b3;
}

.pagination button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
</style>

长列表渲染 - 虚拟列表(手写)

VirtualDemoView.vue

<template>
  <h1>虚拟列表 Demo</h1>
  <div class="scroll-container" @scroll="onScroll">
    <!-- 虚拟列表中的填充高度确保滚动条 -->
    <div :style="{ height: totalHeight + 'px' }"></div>
    <!-- 渲染可见的列表项 -->
    <div
        v-for="item in visibleItems"
        :key="item.id"
        class="list-item"
        :style="{ top: item.top + 'px' }"
    >
      {{ item.name }}
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

// 每个列表项的高度
const itemHeight = 50;
// 总项目数量
const totalItems = 100000;
// 可视区域的高度
const visibleHeight = 500;
// 计算可视区域中可见的项目数量
const visibleCount = Math.ceil(visibleHeight / itemHeight) + 1; // 多加1项以避免边界问题
// 保存所有的项目
const items = ref([]);

// 当前可见区域内的起始索引
const startIndex = ref(0);

// 计算整个列表的总高度,用于创建滚动条
const totalHeight = computed(() => totalItems * itemHeight);

// 可见的项目列表
const visibleItems = ref([]);

// 生成虚拟项目数据
const generateItems = (count) => {
  for (let i = 0; i < count; i++) {
    items.value.push({ id: i, name: `Item ${i}` });
  }
};

// 更新可见项目的逻辑
const updateVisibleItems = () => {
  const visible = [];
  for (let i = startIndex.value; i < Math.min(startIndex.value + visibleCount, totalItems); i++) {
    visible.push({
      id: items.value[i].id,
      name: items.value[i].name,
      top: i * itemHeight,
    });
  }
  visibleItems.value = visible;
};

// 处理滚动事件
const onScroll = (e) => {
  const scrollTop = e.target.scrollTop;
  startIndex.value = Math.floor(scrollTop / itemHeight);
  updateVisibleItems();
};

// 初始化逻辑
onMounted(() => {
  generateItems(totalItems);  // 生成项目数据
  updateVisibleItems();       // 初始化可见的项目
});
</script>

<style scoped>
.scroll-container {
  height: 500px;
  overflow-y: auto;
  border: 1px solid #ddd;
  position: relative;
}

.list-item {
  position: absolute;
  width: 100%;
  height: 50px;
  display: flex;
  align-items: center;
  padding: 0 10px;
  box-sizing: border-box;
  border-bottom: 1px solid #ddd;
}
</style>

长列表渲染 - 虚拟列表(库)

VietualPlusDemoView.vue

<template>
  <div style="height: 100vh;">
    <h1>虚拟列表 Plus Demo</h1>
    <RecycleScroller
        :items="items"
        :item-size="50"
        class="virtual-list"
        v-slot="{ item }"
    >
        <div class="list-item">
          {{ item.name }}
        </div>
    </RecycleScroller>
  </div>
</template>

<script setup>
import { ref } from 'vue';

// 模拟生成大量数据
const items = ref([]);
const generateItems = (count) => {
  for (let i = 0; i < count; i++) {
    items.value.push({ id: i, name: `Item ${i}` });
  }
};

generateItems(100000);
</script>

<style scoped>
.virtual-list {
  height: 500px;
  overflow-y: auto;
  border: 1px solid #ddd;
}

.list-item {
  height: 50px;
  display: flex;
  align-items: center;
  padding: 0 10px;
  border-bottom: 1px solid #ddd;
}
</style>

image.png