# 前端如何处理10万条数据

# 业务场景介绍

当后端一次性返回10万条数据不做分页,需要前端直接做渲染展示。

# 解决方法

如果直接迭代所有数据进行展示,则会因数据量庞大导致页面性能降低,造成页面卡顿。本章主要使用三种方式来处理数据量庞大的渲染: 虚拟列表懒加载时间分片。(本文以vue3环境作示例。)

# 虚拟列表

# 定义

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

# 原理

根据容器可视区域的列表容积数量,监听用户滑动或滚动事件,动态截取长列表数据中的部分数据渲染到页面上,动态使用空白站位填充容器上下滚动区域内容,模拟实现原生滚动效果

# 代码演示

<script setup >
import { onMounted, ref, computed } from "vue";
// index.js

// 获取列表函数
const getList = (num) => {
  let dataList = [];
  for (let i = 0; i < num; i++) {
    dataList.push({ id: i, name: generateRandomName(3) });
  }
  return dataList;
};

// 随机生成指定长度的名字
const generateRandomName = (namelen) => {
  let words = "周杰伦王力宏林俊杰陶喆袁娅维古天乐谭维维王靖雯";
  let res = "";
  for (let j = 0; j < namelen; j++) {
    res += words[Math.floor(Math.random() * words.length)];
  }
  return res;
};

const container = ref(); // container节点
const blank = ref(); // blank节点
const header = ref(); // header节点
const list = ref([]); // 列表
const page = ref(1); // 当前页数
const limit = 200; // 一页展示
// 最大页数 (向上取整)
const maxPage = computed(() => Math.ceil(list.value.length / limit));
// 真实展示的列表
const showList = computed(() =>
  list.value.slice((page.value - 1) * limit, page.value * limit)
);
const handleScroll = () => {
  // 当前页数与最大页数的比较
  if (page.value > maxPage.value) return;

  // 获取容器高度
  const clientHeight = container.value?.clientHeight;

  // 获取底部空标签当前顶部位置
  const blankTop = ~~blank.value?.getBoundingClientRect().top;

  // 获取顶部空标签当前顶部位置
  const headerTop = ~~header.value?.getBoundingClientRect().top;


  // 若滚动条已经拉到顶
  if (headerTop == 0) {
    // header出现在视图,则当前页数减1
    page.value--;
  }

  // 若滚动条已经拉到底
  if (clientHeight === blankTop) {
    // blank出现在视图,则当前页数加1
    page.value++;
  }
};

// 防抖函数
const debounce = (fn, delay) => {
  let timeout = null;
  return function () {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      fn.apply(this, [...arguments]);
    }, delay);
  };
};

// 简单防抖
const debounceHandleScroll = debounce(handleScroll, 200);

onMounted(() => {
  const res = getList(100000);
  list.value = res;
});
</script>

<template>
  <div id="container" @scroll="debounceHandleScroll" ref="container">
    <div ref="header"></div>
    <div class="sunshine" v-for="item in showList" :key="item.id">
      <span>{{ item.id }}__{{ item.name }}</span>
    </div>
    <div ref="blank"></div>
  </div>
</template>

<style scoped>
#container {
  height: 100vh;
  overflow-y: auto;
}
</style>

# 懒加载

# 定义

懒加载是指数据量一开始只加载一部分,当需要加载更多数据时,触发回调后才会加载下一部分。

# 原理

在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态更新列表后续数据。

# 代码演示

<script setup >
import { onMounted, ref, computed } from "vue";
// index.js

// 获取列表函数
const getList = (num) => {
  let dataList = [];
  for (let i = 0; i < num; i++) {
    dataList.push({ id: i, name: generateRandomName(3) });
  }
  return dataList;
};

// 随机生成指定长度的名字
const generateRandomName = (namelen) => {
  let words = "周杰伦王力宏林俊杰陶喆袁娅维古天乐谭维维王靖雯";
  let res = "";
  for (let j = 0; j < namelen; j++) {
    res += words[Math.floor(Math.random() * words.length)];
  }
  return res;
};

const container = ref(); // container节点
const blank = ref(); // blank节点
const list = ref([]); // 列表
const page = ref(1); // 当前页数
const limit = 200; // 一页展示
// 最大页数 (向上取整)
const maxPage = computed(() => Math.ceil(list.value.length / limit));

const showList = computed(()=> list.value.slice(0,page.value*limit))

const handleScroll = () => {
  // 当前页数与最大页数的比较
  if (page.value > maxPage.value) return;

  // 获取容器高度
  const clientHeight = container.value?.clientHeight;

  // 获取底部空标签当前顶部位置
  const blankTop = ~~blank.value?.getBoundingClientRect().top;

  // 若滚动条已经拉到底
  if (clientHeight === blankTop) {
    // blank出现在视图,则当前页数加1
    page.value++;
  }
};

// 防抖函数
const debounce = (fn, delay) => {
  let timeout = null;
  return function () {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      fn.apply(this, [...arguments]);
    }, delay);
  };
};

// 简单防抖
const debounceHandleScroll = debounce(handleScroll, 200);

onMounted(() => {
  const res = getList(100000);
  list.value = res;
});
</script>

<template>
  <div id="container" @scroll="debounceHandleScroll" ref="container">
    <div ref="header"></div>
    <div class="sunshine" v-for="item in showList" :key="item.id">
      <span>{{ item.id }}__{{ item.name }}</span>
    </div>
    <div ref="blank"></div>
  </div>
</template>

<style scoped>
#container {
  height: 100vh;
  overflow-y: auto;
}
</style>
</script>

<template>
  <div id="container" @scroll="handleScroll" ref="container">
    <div class="sunshine" v-for="item in showList" :key="item.id">
      <span>{{item.id}}__{{ item.name }}</span>
    </div>
    <div ref="blank"></div>
  </div>
</template>

<style scoped>
#container{
    height: 100vh;
    overflow-y:auto;
}
</style>

# 时间分片

# 定义

如字面意思,随时间将数据分成多个片段加载渲染。那么使用setTimeout异步循环加载定量数据,直到定量数据加载至全部,便完成数据渲染。

# api选用

由于屏幕自身的刷新率实际异步执行的间隔时间的不相同,导致页面会闪屏。我将使用requestAnimationFrame来代替setTimeout,配合DocumentFragments去实现10万条数据渲染。

# requestAnimationFrame定义

requestAnimationFrame是浏览器用于定时循环操作的一个接口,类似于setTimeout,主要用途是按对网页进行重绘。

# DocumentFragment定义

DocumentFragment,文档片段接口,表示一个没有父级文件的最小文档对象。它被作为一个轻量版的Document使用,用于存储已排好版的或尚未打理好格式的XML片段。最大的区别是因为DocumentFragment不是真实DOM树的一部分,它的变化不会触发DOM树的(重新渲染) ,且不会导致性能等问题。

# 代码实现

<script>
import { reactive } from "vue";
export default {
  setup() {
    const dataList = reactive({ personalInfo: [] });
    // 获取数据表容器dom,注意这里是类collection数组,需要实例化后取第一个元素。
    const dataContainerDom = document.getElementsByClassName("data-container");

    const once = 20; // 一次性添加 20 条

    let total = 100000; // 添加10万条数据

    let index = 0; // 每条记录的索引

    // 递归循环,添加数据
    function loop(curTotal, curIndex) {
      // 总数为0时,结束循环
      if (curTotal <= 0) return;
      // 每页多少条(主要处理末次循环)
      let pageCount = Math.min(curTotal, once);

      // 类似于setTimeout,按帧重绘
      window.requestAnimationFrame(function () {
        // 生成片段,数据添加时将不会触发页面回流。
        let fragment = document.createDocumentFragment();
        for (let i = 0; i < pageCount; i++) {
          let row = document.createElement("div");
          row.classList.add("data-row", "fl", "jc-center", "ai-center");
          row.innerText = `${dataList.personalInfo[curIndex+i].id}__${dataList.personalInfo[curIndex+i].name}`;
          fragment.appendChild(row);
        }

        // 给容器添加数据条
        dataContainerDom[0].appendChild(fragment);

        // 数据量减少当前渲染条数,下标移动至当前渲染条数之后。
        loop(curTotal - pageCount, curIndex + pageCount);
      });
    }

    // 随机生成指定长度的名字
    function generateRandomName(namelen) {
      let words = "周杰伦王力宏林俊杰陶喆袁娅维古天乐谭维维王靖雯";
      let res = "";
      for (let j = 0; j < namelen; j++) {
        res += words[Math.floor(Math.random() * words.length)];
      }
      return res;
    }

    return {
        dataList,
        dataContainerDom,
        once,
        total,
        index,
        loop,
        generateRandomName,
    }
  },

  created() {
      // 在组件渲染完成之前,添加10万条数据
      for (let i = 0; i <= this.total; i++) {
        this.dataList.personalInfo.push({
          id: i.toString(),
          name: this.generateRandomName(3),
        });
      }
   
  },
  mounted() {
    // 页面开始时,开始循环加载渲染。
    this.loop(this.total, this.index);
  },
};
</script>

<template>
  <div class="million_page fl jc-center ai-center">
    <div class="data-container">
    </div>
  </div>
</template>


<style lang="less">
.million_page {
  height: 100vh;

  .data-container {
    overflow-y: auto;
    width: 75vw;
    height: 80vh;
    border: 1px solid black;

    .data-row {
      padding: 0 50px;
      height: 80px;
      border: 1px solid rgb(153, 153, 255);
    }
  }
}
</style>

# 步骤概括

1、虚拟列表的步骤:

  • 通过添加在容器顶部、底部添加空标签。
  • getBoundingClientRect 获取空标签高度。
  • 在空标签顶部位置与容器高度一致时,加载后续一段列表数据,移除前一段列表数据。

2、懒加载的步骤与虚拟列表基本相同,区别在于懒加载是不断添加数据,前面的数据都有所保留。

3、数据列表时间分片的步骤:

  • 获取容器的DOM
  • 声明loop函数,用到requestAnimationFrame代替setTimeout,传入添加数据回调
  • 创建createDocumentFragment文本对象fragment,该文本对象不会引起回流。数据appendChild到这fragment后,再向容器DOM上appendChild这个fragment上。
  • 通过loop递归循环,不断地在容器DOM中添加定量数据,直到加载完毕。

# 参考文章

后端一次给你10万条数据,如何优雅展示,到底考察我什么? - 掘金 (juejin.cn) (opens new window)

时间分片处理10万条数据渲染 - 掘金 (juejin.cn) (opens new window)