一、前言
在前端需求开发的过程中,我们经常会遇到大数据量内容展示或处理的场景,当页面存在大数据量内容的时候,我们经常会听到用户吐槽"页面太卡了"、页面太慢了"和"页面没反应"等等。如何定位并解决前端大数据量场景下的性能问题是当下非常值得关注的一个问题,同时也是我们前端开发人员无法回避的一个问题。本文围绕大数据量场景下的前端性能问题进行展开,主要介绍了常见的大数据量场景和性能问题原因,同时给出了一些较常见的前端性能解决方案。
二、常见场景
以下列举了在开发中常见的大数据量的场景:
场景名 | 说明 |
---|---|
长列表 | 以列表或网格形式规律展示并且列表项较多的场景,一般通过 v-for 渲染展示 |
表格 | 表格项较多的场景,一般基于 Table 组件实现,同时还有可能结合表单类组件进行编辑 |
选择器 | 选择器或级联选择器存在较多选项的场景,一般基于 Select 等组件实现 |
树形控件 | 树形控件存在较多节点的场景,一般基于 Tree 组件实现 |
图表 | 图表数据量较大的场景,一般基于 Echarts 或 AntV 等库实现 |
三、性能瓶颈
在大数据量内容需要展示的场景下,我们遇到页面卡顿响应慢等问题的时候,首先要做的就是找到产生问题的原因,只有知道原因才能对症下药。在大数据量场景下我们常见的性能原因主要如下:
- JS 线程长时间占用导致渲染不及时,例如进行计算复杂而耗费大量时间等
- 内存占用过高,例如 DOM 节点数量巨大或者在大量 DOM 上绑定了事件等
- 页面存在大量的回流或重绘,例如元素需要实时计算 scrollTop 等维度属性来执行动画等
- ......
四、解决方案
在大数据量场景下,为了缓解页面卡顿和响应慢等的问题,我们基于产生性能问题的原因总结了一些常见的解决方案。
4.1 减少 DOM 的数量
💡 推荐:分页或搜索的方式 > 虚拟列表 > 时间分片或触底加载 (具体情况具体分析)
- 从产品设计层面解决,每次只展示部分内容,例如通过分页或搜索的方式展示内容;
- 初始时不再一次性渲染全部内容,而是通过时间分片或触底加载等方式逐步加载内容;
- 前端每次只渲染可视区域展示的内容,避免大量 DOM 存在,例如虚拟列表等方式。
4.1.1 分页或搜索展示
4.1.1.1 分页展示
在表格场景下,如果后端能够支持分页返回数据的话,优先通过后端分页返回数据前端分页展示数据的方式进行处理,此时我们需要计算和渲染的数据量都大大减少了,避免了直接渲染大量 dom 而导致的页面卡顿。
4.1.1.2 搜索展示
在选择器场景下,如果后端能够支持关键词搜索内容的话,前端可以仅展示部分选项,其余选项可以通过关键词搜索请求后端获取展示,避免了大量的 option 一下子渲染导致页面卡死。
4.1.1.3 前端模拟
当后端接口不支持分页或搜索而是全量返回所有数据的时候,前端可缓存全量数据并模拟分页和搜索,这种方式也在一定程度上避免了大量 DOM 的绘制。但是,在这这场景下我们需要评估直接缓存全量数据进行模拟分页和搜索是否存在 JS 事件执行大量耗时的情况,例如通过响应式缓存数据在编辑时也可能也会出现卡顿,此时我们往往需要结合其他方法进行组合解决。
4.1.2 分片加载
当有大数据量内容需要展示的时候,如果我们直接渲染全量数据,可能会出现较长时间白屏或较长时间用户才能操作。例如我们当前有 100000 条数据需要展示,当我们直接全量渲染的时候,进入页面时能够明显感觉到卡顿,通过 performance 分析 FCP 在 5.5s 以上,也就意味着用户需要等待这么久的时间才能初步看到内容,这无疑是极差的体验。此时,根据分片加载的思想,我们原本需要渲染的大量内容,可以先渲染一小部分,让用户感知到内容,然后再将剩余部分逐步渲染展示。

4.1.2.1 setTimeout 定时器
html
<template>
<div class="list-wrapper">
<template v-for="item in list" :key="item.index">
<div class="list-item">{{ item.index }}-{{ item.value.message }}</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getData } from '../data';
const list = ref<any[]>([]);
const count = ref(0);
const total = 100000;
const once = 50;
const loop = () => {
if (count.value > total) {
return;
}
list.value = list.value.concat(getData(once));
count.value += once;
setTimeout(() => {
loop();
}, 0);
};
onMounted(() => {
loop();
});
</script>
<style lang="less" scoped>
.list-wrapper {
width: 800px;
margin: 0px auto;
.list-item {
margin: 10px 0px;
}
}
</style>
采用 setTimeout 定时器的方式后,通过 performance 分析 FCP 下降到了 1.5s 左右。但是,当我们采用定时器方式的时候,快速滚动页面会发现页面出现闪屏或白屏的现象,这是由于 setTimeout 的执行步调和屏幕的刷新步调不一致,画面出现明显的丢帧现象(详见高性能渲染10万条数据(时间分片)),此时我们可以借助 requestAnimationFrame 去改善这个问题。
4.1.2.2 requestAnimationFrame
html
<template>
<div class="list-wrapper">
<template v-for="item in list" :key="item.index">
<div class="list-item">{{ item.index }}-{{ item.value.message }}</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getData } from '../data';
const list = ref<any[]>([]);
const count = ref(0);
const total = 100000;
const once = 50;
const loop = () => {
if (count.value > total) {
return;
}
list.value = list.value.concat(getData(once));
count.value += once;
requestAnimationFrame(() => {
loop();
});
};
onMounted(() => {
loop();
});
</script>
<style lang="less" scoped>
.list-wrapper {
width: 800px;
margin: 0px auto;
.list-item {
margin: 10px 0px;
}
}
</style>
采用 requestAnimationFrame 的方式后,大大缓解了白屏的情况,同时通过 performance 分析 FCP 下降到了 1.1s 左右。

分片加载的方式存在一个问题就是,随着不断的绘制内容,页面内容依旧会加载到一定量级,此时同样会出现大量的 DOM 元素占用大量内存导致页面卡顿等问题,我们可以借助虚拟列表的方式在一定程度上缓解这个问题。
💡 分片加载的执行不局限于上述提到的 setTimeout 和 requestAnimationFrame 之外,还可以使用 requestIdleCallback 等方式来处理。
4.1.3 触底加载
在一些特殊的大数据量内容展示场景中,例如微博信息流、朋友圈内容等,展示内容都有比较显著的特点,那就是不能分页并且只要用户愿意就能不断随意上下滚动直到达到上下边界。如果一下子直接加载所有内容,数量级过大会导致页面卡死或白屏较长时间,此时我们可以通过"初始加载少部分内容,通过监听滚动事件不断加载新的内容"来解决。但是,与分片加载一样,触底加载会不断地触底不断地加载,依旧会使页面内容加载到一定量级,仍旧存在大量的 DOM 元素而占用大量内存导致页面卡顿等问题,从而带来糟糕的用户体验,此时虚拟列表在一定程度上就缓解了这个问题。
4.1.3.1 自动触底加载
触底自动加载主要通过滚动事件来判断是否到达底部或者是否满足某些条件,如果满足要求就自动会加载或请求下一部分的内容,可参考 ElementUI InfiniteScroll 组件。

4.1.3.2 手动触底加载
触底加载除了自动加载之外,还可以把加载的权利下放给用户,由用户手动来控制加载的实际。例如在内容底部展示一个按钮,每次加载少部分内容,由用户主动点击按钮来加载下一部分内容,从而避免了一下子加载所有内容。
4.1.4 虚拟列表*
4.1.4.1 基础原理

虚拟列表简单来说就是按需渲染,只对用户可见区域进行渲染,对用户不可见区域中的数据不渲染或者部分渲染,从而模拟出一种完整渲染的效果。如上图所示,虚拟列表将原本完整渲染的列表分为三个区域:可视区域 + 预渲染区 + 未渲染区,其中可视区域为我们屏幕看到的实际内容,可视区域+预渲染区为实际渲染的内容。当用户滚动页面时,根据滚动的位置,计算出实际渲染区域第一个元素的索引startIndex 和 最后一个元素的索引 endIndex,根据两个索引渲染相应的内容,同时为了保证实际渲染列表元素一直存在可视区中,设置相应平移的数值。通过虚拟列表技术,列表实际渲染的 DOM 节点数量都会稳定在一个范围内,不会随着列表数据的增多而不断增加。
4.1.4.2 进阶内容
虚拟列表主要可以分为三类:
- 固定虚拟列表:列表项的高度固定,均为固定数值
- 动态虚拟列表:列表项高度不固定但可以通过一个函数获取 (idx: number) => number
- 自动虚拟列表:列表项高度通过实际渲染自动得到
针对不同类型的虚拟列表,可以进行不同的优化:
- 优化点1:使用预渲染(缓解滚动过快产生白屏等)
- 优化点2:使用骨架屏(优化列表项内容复杂导致预渲染效果不佳)
- 优化点3:滚动方向进行预渲染或骨架屏(进一步减少内容渲染)
- 优化点4:缓存列表项位移等样式(样式结果一致重复计算浪费)
- 优化点5:使用 will-change + hover (增强页面渲染性能)
- 优化点6:非固定高度可采用估计高度(减少非必要计算)
4.1.4.3 推荐组件库
- element-plus 系列
- 虚拟滚动库(推荐)
4.2 减少复杂的计算
- 缓存复杂计算的结果
- 通过 web workers 处理计算,可考虑场景:
- 耗时的复杂计算
- 轮询获取数据
- 频繁上报数据
- ......
- 优化算法,降低算法复杂度(一般较难处理)
- 非要求实时计算的内容可以使用防抖只在终点进行计算
- ......
4.3 其他
4.3.1 减少 Vue 响应式数据
对于使用 Vue 库进行开发的应用,在开发过程中,我们通常会将数据通过 ref 或 reactive 直接设置为响应式的对象,所谓响应式就是当我们修改数据后,能够自动触发组件的重新渲染。为了满足响应式,vue 底层会进行一系列的处理(详见《深入响应式系统》),同时 Vue 的响应性系统默认是深度的,每个属性访问都将触发代理的依赖追踪,这会产生非常大的开销,尤其当我们得到数据源不会发生变化或者只会在部分内容上发生变化时,这部分的开销无疑是多余的。针对这种情况,我们需要减少 Vue 响应式数据:
- 数据源全量均不会发生变化:直接赋值
- 数据源单个数据不会发生变化只会发生全量变化:shallowRef 或 shalloReactive(详见《减少大型不可变数据的响应性开销》)
- 数据源仅其中部分数据发生变化:仅将会变化部分的数据设为响应式,例如表格场景中仅将当前页的数据设置为响应式,其他全量数据直接赋值缓存。
场景:当前存在 10 万条数据需要展示,我们分别使用 ref 响应式、shallowRef 浅响应式和直接赋值等三种方式设置数据源,然后通过 chrome performance 观察性能。根据下面三个图可以看出,浅响应式和直接赋值的方式运行均会比响应式稍快一点,但最明显的差别在于内存占用,JS heap size 减少近乎一半,这对由于内存占用过高导致的问题能有较好的作用。
直接赋值 | ref 响应式 | shallowRef 浅响应式 |
---|---|---|
getData(total); | ref(getData(total)); | shallowRef(getData(total)); |
![]() |
![]() |
![]() |
此外,还能减少内存占用的一种方式是在设置为响应式数据的时候减少无用字段。例如后端返回的字段内容包含参数名、参数类型、参数id等等,实际中我们只用到了参数名这一个字段,那我们只需要对参数名这一部分内容进行响应式设置。
五、总结
大数据量场景下页面卡顿是前端较为常见的问题场景,需要针对不同的问题具体分析产生的原因来解决,常用的方法主要有分页、分片/触底加载和虚拟列表等减少 DOM 数量的方法、减少复杂的大数据量计算导致的开销等等。同时,有时候性能问题往往是多种原因综合影响导致的,我们在解决问题时也往往会结合多种方法来处理。