👉 个人博客主页 👈
📝 一个努力学习的程序猿
专栏:
HTML和CSS
JavaScript
jQuery
Vue
Vue3
React
TypeScript
uni-app
Linux
个人经历+面经+学习路线【内含免费下载初级前端面试题】
更多前端面试分享
前端学习+方案分享(VitePress、html2canvas+jspdf、vuedraggable、videojs)
前端踩坑日记(ElementUI)
最近遇到一些case,浅浅记录下。
问题现象:
1、数据量较大 / 使用的组件较多时,每次初始化访问页面都需要等待许久。
2、由于数据量较大,保存时又会对这些数据做JS处理,最后导致在点击保存时,网页出现卡顿/崩溃现象。
综上,本文主要记录解决两大类问题:如何提升页面在数据量较大/组件较多时的渲染效率;如何避免JS在数据量较大时处理不崩溃;
一、页面渲染卡顿问题
在页面渲染卡顿问题中,解决的关键是如何避免让页面一次性渲染太多数据或复杂组件效果(结构)。经过调试,本文方法主要分为以下几种:数据分批次加载、虚拟滚动、浏览器空闲加载、仅渲染浏览器视口内容。
前言-第三方组件
说明下:如果遇到的是第三方组件卡顿问题,有可能不适用本文方法。比如第三方组件只允许使用者传数据进去,渲染逻辑完全由组件自行控制,那么这种情况除了给第三方组件提优化诉求外,只能在网上搜索针对该组件特定的解决方案或寻求可替代组件。
解决方法一:原生实现虚拟滚动(不建议)
先介绍一下虚拟滚动:虚拟滚动是一种优化长列表渲染性能的技术,主要用于处理大量数据的信息。其核心原理是:只渲染可视区域内的元素,随后通过计算滚动位置来动态更新显示的内容,从而实现类似完整列表的滚动效果。如下图所示:
Vue 示例:(写的场景比较简单)
<template>
<div class="virtual-select">
<div ref="viewport" class="viewport">
<div class="list" :style="{ height: totalHeight + 'px' }">
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ transform: `translateY(${item.offsetTop}px)` }"
>
{{ item.label }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'TestIndex',
data() {
return {
items: [], // 所有选项数据
visibleItems: [], // 可见区域的选项
itemHeight: 30, // 每个选项的高度
viewportHeight: 300, // 可视区域高度
buffer: 5 // 增加缓冲区,这里假设缓冲区大小为5
}
},
computed: {
totalHeight() {
return this.items.length * this.itemHeight
}
},
mounted() {
this.items = Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
label: `选项 ${index + 1}`,
offsetTop: 0,
}))
this.updateVisibleItems(0)
// 添加滚动事件监听
this.$refs.viewport.addEventListener('scroll', this.onScroll)
},
beforeDestroy() {
// 组件销毁前移除事件监听
this.$refs.viewport.removeEventListener('scroll', this.onScroll)
},
methods: {
onScroll(e) {
// 使用requestAnimationFrame优化滚动性能
window.requestAnimationFrame(() => {
const scrollTop = e.target.scrollTop
this.updateVisibleItems(scrollTop)
})
},
updateVisibleItems(scrollTop) {
const buffer = this.buffer
const startIndex = Math.max(0, Math.floor(scrollTop / this.itemHeight) - buffer)
const endIndex = Math.min(
this.items.length - 1,
Math.floor((scrollTop + this.viewportHeight) / this.itemHeight) + buffer
)
this.visibleItems = this.items
.slice(startIndex, endIndex + 1)
.map((item, index) => ({
...item,
offsetTop: (startIndex + index) * this.itemHeight
}))
}
}
}
</script>
<style>
.viewport {
height: 300px;
overflow-y: auto;
}
</style>
该方法的优缺点:
1、针对大部分场景能写出解决方案(但如同前言说的一样,如果第三方组件无法自定义渲染内容,那大概率是不行的),而且几乎没有浏览器兼容性问题。但是它相对的缺点就是:写起来复杂且麻烦,逻辑需要自己完成,维护成本偏高(相比于下方所有方法),毕竟代码还是从简为好。
2、通过代码可以看出来,该方法想要实现 必须能确定虚拟滚动区域的总高度,等价于能确定或推测每个区域的高度。所以如果某个区域的数据为动态获取,从而无法第一时间确定总高度,那么就无法确定滑动到什么位置时,展示什么数据,虚拟滚动就会无法使用(当然这也是下面要说的第三方虚拟滚动组件的痛点)。所以如果是简单场景,该方法或许是一种可选择方案。
当然上述给出的示例已经做了部分优化,比如考虑用 transform 定位选项,避免重排;使用 requestAnimationFrame 优化渲染性能;预渲染额外选项(buffer),提升滚动体验。因为本文不推荐使用该方案,所以就不再做过多扩展。
解决方法二:虚拟滚动 vue-virtual-scroller
相比于使用上方原生虚拟滚动,使用网上成熟的第三方组件方案来解决就会方便简单很多。这里推荐一个第三方组件,是我自己使用很长时间的:vue-virtual-scroller。
https://github.com/Akryum/vue-virtual-scroller/tree/v1/packages/vue-virtual-scroller
其最常见的两个组件为:RecycleScroller(普通虚拟滚动) 和 DynamicScroller(动态虚拟滚动)。因为 github 上有描述和示例,各位大佬可以直接参考进行尝试使用(下文不会详细介绍官方的示例和提供的参数)。只不过这里有些注意项,需要额外说明。
RecycleScroller 有个限制,它要用于能给定高度的模块(为什么需要高度,可以参考解决方法一)。换句话说,这里每一项高度都必须是确定的(虽然文档里还给了很多建议,不过我确实没试出来,所以目前只当它必须给定高度)。
官方示例:
<template>
<RecycleScroller
class="scroller"
:items="list"
:item-size="32"
key-field="id"
v-slot="{ item }"
>
<div class="user">
{{ item.name }}
</div>
</RecycleScroller>
</template>
<script>
export default {
props: {
list: Array,
},
}
</script>
<style scoped>
.scroller {
height: 100%;
}
.user {
height: 32%;
padding: 0 12px;
display: flex;
align-items: center;
}
</style>
但实际上我的 case 中不能确定具体高度,因为我的数据是接口下发的,第一时间并不能确定高度是多少。而官方也确实给了 DynamicScroller,给的解释是:当事先不知道项目的尺寸时,它会在滚动过程中自动发现项目尺寸。
官方示例:
<template>
<DynamicScroller
:items="items"
:min-item-size="54"
class="scroller"
>
<template v-slot="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[
item.message,
]"
:data-index="index"
>
<div class="avatar">
<img
:src="item.avatar"
:key="item.avatar"
alt="avatar"
class="image"
>
</div>
<div class="text">{{ item.message }}</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>
<script>
export default {
props: {
items: Array,
},
}
</script>
<style scoped>
.scroller {
height: 100%;
}
</style>
不过我在使用之后发现,DynamicScroller 还是有限制的。换句话说,这两个组件并不能100%涵盖所有情况。简述下我遇到的问题:
1、我的数据结构是对象数组内含子对象数组,它可能会有无穷深的层级:
[
{
id: 1,
name: '1',
children: [
{
id: 11,
name: '1-1',
children: [
...
],
}
],
},
]
而文档中提到要给一个 min-item-size,随后这个模块的判断高度需要根据一个特定字段来判断。我在上面的结构里,尝试绑定了字段,发现并没有什么效果,导致模块的高度只能展示 min-item-size 大小。
但为什么没效果,我并没寻找其根因,但我猜测和我层级有关。size-dependencies 我并没找到能传递子对象数组中字段的方法(或许的确是我没找到用法),而我的高度要根据数组内最后一个子对象数据来判断。但普通的一层对象数组是可行的,大家可以多尝试一下。
2、于我而言放弃的根因是,工程内是 tsx 写法,而 DynamicScroller 需要使用插槽,但是 tsx 目前不能实现这样的操作:
<template v-slot="{ item, index, active }">
而把代码改成 Vue 文件工作量很大,因此没考虑该方案。
综上,方法二并没有完全解决掉我的问题。不过解决了我的 elementUI el-select 下拉数据过大导致卡顿的问题,RecycleScroller 成功示例:
代码说明:因为很多组件要用到 el-select,所以自己简单做了层自己的封装。所以如果各位要参考,可以不用完全 copy 过去,代码加了很多注释,大家可以按需参考。如果想看虚拟滚动前后的对比,只需要将 test.vue 中的 el-select 注释打开即可,效果很明显。
外侧使用组件 test.vue:
<template>
<div>
测试虚拟滚动:
<div>
<!--
如果不虚拟滚动的后果!将这里注释打开
<el-select
v-model="selectVal2"
multiple
placeholder="请选择"
>
<el-option
v-for="item in list"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
-->
<VirtualScroller
:select-model.sync="selectVal"
:show-list="list"
:select-options="{
placeholder: '请选择',
filterable: true,
multiple: true,
clearable: true,
loading: loading,
}"
:scroll-options="{
id: 'configFilter',
}"
:computed-width="true"
style="margin-left: 20px;"
@change="changeSelect"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted } from '@vue/composition-api'
import VirtualScroller from './components/virtual-scroller.vue'
export default defineComponent({
name: 'TestVirtual',
components: { VirtualScroller },
setup() {
const selectVal = ref('')
const selectVal2 = ref('')
const list = ref([]) as any
const loading = ref(false)
onMounted(() => {
loading.value = true
for (let i = 0; i < 100000; i++) {
list.value.push({
value: i,
label: i,
})
}
loading.value = false
})
const changeSelect = (val) => {
console.log('切换选中值', val)
}
return {
list,
selectVal,
changeSelect,
loading,
selectVal2,
}
},
})
</script>
封装el-select的通用组件 virtual-scroller.vue:
<template>
<el-select
:value="internalModel"
v-bind="virtualSelectOptions"
@change="handleChange"
@focus="optionsFocus"
@blur="optionsBlur"
>
<!--
开启虚拟滚动后,如果展示数据数量为0,则点击后不会展开下拉选,即也不会出现暂无数据的提示框。
因此需要单独处理数据数量为0的情况
-->
<el-option
v-if="virtualShowList.length === 0"
disabled
label="暂无数据"
value="-1"
>
<span class="option-disabled">{{ selectOptions.noDataText || '暂无数据' }}</span>
</el-option>
<!-- 虚拟滚动组件 -->
<RecycleScroller
v-else
v-bind="recycleScrollerBind"
:items="virtualShowList"
:style="{
height: scrollerHeight + 'px',
minWidth: virtualMaxWidth + defaultWidth + 'px',
}"
>
<template #default="{ item }">
<el-option
:key="scrollOptions.keyField ? item[scrollOptions.keyField] : item.value"
class="recycle-option"
:value="selectOptions.optionValue ? selectOptions.optionValue(item) : item.value"
:label="selectOptions.optionLabel ? selectOptions.optionLabel(item) : item.label"
/>
</template>
</RecycleScroller>
</el-select>
</template>
<script lang="ts">
import {
defineComponent,
computed,
ref,
watch,
nextTick,
toRefs,
} from '@vue/composition-api'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { cloneDeep } from 'lodash'
import {
commonFilterMethod,
DEFAULT_SCROLL_OPTIONS,
DEFAULT_WIDTH,
DEFAULT_SELECT_OPTIONS,
DELETE_PARAMS,
} from '../lib/constant'
export default defineComponent({
name: 'VirtualScroller',
components: { RecycleScroller },
props: {
// 必传,调整选取数据
selectModel: {
type: [String, Number, Array],
required: true,
},
/* 非必传,调整下拉选配置,传参说明:
* 支持 el-select 所有参数,如需绑定相关事件需要手动扩展。特殊说明如下:
* (1) 如果下拉选列表数据不存在"label、value"字段,而必须使用其他字段,
可通过以下方式来调整组件默认检测的"label、value"字段:
(但不建议这么做,会有性能损耗,最好能提前处理好数据)
:select-options="{
optionLabel: item => {
return `${item.name}(${item.activeCode})`
},
optionValue: item => {
return item.activeId
},
}"
因为options必然需要绑定key值,且通常value肯定是唯一值,所以组件默认绑定value
当上述列表数据不存在value字段,则需要通过以下方式,修改默认唯一key值绑定
:scroll-options="{
keyField: 'activeId',
}"
* (2) 如需开启下拉选模糊搜索,且只需要根据"label、value"字段做筛选,
只需要使用以下方式,仅开启 filterable 即可。目前会默认配置remote和remoteMethod:
:select-options="{
filterable: true,
}"
* (3) 当模糊搜索想自定义筛选的字段依据,只需要使用以下方式,传递 remoteMethodKey:
:select-options="{
filterable: true,
remoteMethodKey: ['label', 'value', 'pinyin'],
}"
* (4) 不建议传递并使用 filterMethod,且目前已禁用。
该方法会导致虚拟滚动组件卡顿,且筛选会异常(不满足要求的数据行会直接显示空白,并不会筛掉)
如有自定义筛选的需要,可传递 remoteMethod 覆盖组件默认方法,
并在自定义 remoteMethod 方法中,将筛选结果同步到 showList 字段上,让组件刷新
* */
selectOptions: {
type: Object,
default: () => ({}),
},
// 必传,展示下拉选可选数据
showList: {
type: Array,
default: () => [],
},
/*
* 控制虚拟滚动相关数据展示
* @param {number} itemSize - 每个选项数据的展示高度(必传,默认35)
* @param {number} maxVisibleItems - 打开下拉选最大可见选项数(必传,默认7,非官方配置)
* @param {string} id - 如果一个页面内有多个虚拟滚动组件,需要给每个组件设置id,避免渲染异常(建议传,非官方配置)
* @param {string} keyField(动态绑定下拉选value时必传,非官方配置)
* 更多配置:https://github.com/Akryum/vue-virtual-scroller/blob/master/packages/vue-virtual-scroller/README.md#props
* */
scrollOptions: {
type: Object,
default: () => ({}),
},
// 是否需要额外计算下拉选宽度(用于解决开启虚拟滚动后,下拉选宽度不会自动撑开)
computedWidth: {
type: Boolean,
default: false,
},
// 如果开启额外计算下拉选宽度,此时宽度仍不满足需求,可通过调整改值增大宽度
defaultWidth: {
type: Number,
default: DEFAULT_WIDTH,
},
},
setup(props, { emit }) {
// 统一定义相关数据, 避免直接修改props传值
const {
selectModel: internalModel,
} = toRefs(props)
// 用于同步组件内展示数据 => 主要用于 remoteMethod
const virtualShowList = ref(cloneDeep(props.showList))
/* ============
* 在动态获取或第一次拿到时,对虚拟滚动组件传参的处理,便于扩展
* */
const recycleScrollerBind = computed(() => {
const options = ref(props.scrollOptions)
// 确保虚拟滚动组件不被外侧props传递相关字段,避免影响组件展示
DELETE_PARAMS.SCROLL.forEach(key => {
delete options.value[key]
})
// 以下为虚拟滚动必需参数,如果不存在,则默认配置
if (options.value.id === undefined) {
options.value.id = DEFAULT_SCROLL_OPTIONS.ID
}
if (options.value.itemSize === undefined) {
options.value.itemSize = DEFAULT_SCROLL_OPTIONS.ITEM_SIZE
}
if (options.value.maxVisibleItems === undefined) {
options.value.maxVisibleItems = DEFAULT_SCROLL_OPTIONS.MAX_VISIBLE_ITEMS
}
if (options.value.keyField === undefined) {
options.value.keyField = DEFAULT_SCROLL_OPTIONS.KEY_FIELD
}
if (options.value.buffer === undefined) {
options.value.buffer = DEFAULT_SCROLL_OPTIONS.BUFFER
}
if (options.value.prerender === undefined) {
options.value.prerender = DEFAULT_SCROLL_OPTIONS.PRERENDER
}
return options.value
})
/* ============
* 便于el-select使用,在动态获取或第一次拿到时,针对下拉选自带模糊搜索的处理:
* 当开启虚拟滚动后,下拉选的高度为根据showList动态计算。
* 此时如果下拉选仅开启filterable,当搜索后结果的数据数量小于设定的itemSize,
* 此时下拉选不会根据结果数量动态设定高度。
* 因此此处对这种情况做处理。
* */
const virtualSelectOptions = computed(() => {
const options = ref(props.selectOptions)
// 当传递数据存在想要筛选数据的意图时,判断是否传递了remote和remoteMethod
if (options.value.filterable === true || options.value.remote === true) {
// 如果没有remote,就注入,保证筛选无异常
if (options.value.remote === undefined) {
options.value.remote = true
}
if (options.value.remoteMethod === undefined) {
options.value.remoteMethod = remoteMethod
}
}
// 确保el-select组件不被外侧props传递相关字段,避免影响组件展示
DELETE_PARAMS.SELECT.forEach(key => {
delete options.value[key]
})
return options.value
})
// 默认的remoteMethod方法
function remoteMethod(query) {
query = query && query.trim()
// 拿到全量下拉选数据
const list = cloneDeep(props.showList)
// 拿到需要进行筛选的指定key
const remoteMethodKey =
props.selectOptions.remoteMethodKey || DEFAULT_SELECT_OPTIONS.REMOTE_METHOD_KEY
// 进行筛选
if (query) {
virtualShowList.value = list.filter(item => {
const filterValues = [] as any
for (let i = 0; i < remoteMethodKey.length; i++) {
if (item[remoteMethodKey[i]]) {
filterValues.push(item[remoteMethodKey[i]])
}
}
return commonFilterMethod(query, filterValues)
})
} else {
virtualShowList.value = list
}
}
// ============ 动态计算下拉选高度
const DEFAULT_LIST_LENGTH = 0
const scrollerHeight = computed(() => {
// 每个选项的高度
const itemHeight = props.scrollOptions.itemSize || DEFAULT_SCROLL_OPTIONS.ITEM_SIZE
// 最大可见选项数
const maxVisibleItems =
props.scrollOptions.maxVisibleItems || DEFAULT_SCROLL_OPTIONS.MAX_VISIBLE_ITEMS
const visibleItems = Math.min(
virtualShowList.value.length || DEFAULT_LIST_LENGTH,
maxVisibleItems,
)
return visibleItems * itemHeight
})
// ============ 避免直接修改props传值
function handleChange(value) {
emit('update:selectModel', value)
emit('change', value)
}
/* ============
* 开启虚拟滚动后,下拉选展示数据的宽度将仅能和下拉选本身的宽度保持一致,
* 不再会根据内容长度自动撑开宽度。当传递computedWidth时,将进行额外宽度处理。
* */
// 记录组件存在期间内,最大的宽度,避免渲染区域变化,导致width变小
const DEFAULT_MAX_WIDTH = 0
const virtualMaxWidth = ref(DEFAULT_MAX_WIDTH)
// 记录当前组件是否处于聚焦状态
const hasSelectFocus = ref(false)
// 聚焦事件
const EMPTY_LENGTH = 0
function optionsFocus() {
hasSelectFocus.value = true
// 当页面渲染完成后再计算宽度,否则拿不到标签
nextTick(() => {
const scroller = document.getElementById(recycleScrollerBind.value.id as string)
if (props.computedWidth && virtualShowList.value.length > EMPTY_LENGTH && scroller) {
const spanList = scroller.getElementsByTagName('span') || []
virtualMaxWidth.value = Array.from(spanList).reduce((maxWidth, span) => {
const width = span.getBoundingClientRect().width
return width > maxWidth ? width : maxWidth
}, virtualMaxWidth.value)
}
})
}
// 失焦事件
function optionsBlur() {
hasSelectFocus.value = false
}
/* ============
* 在虚拟滚动中,由于一次数据展示有限,当要做修改数据时,
* 下拉选的数据回显可能会因为选中的数据没有渲染出来,导致展示不出相关信息。
* 而通常下拉选数据showList是接口返回,所以只能通过watch方法来监听第一次获取下拉选数据。
* 在第一次拿到数据后,后续不触发相关操作。
* */
const firstGetShowList = ref(true)
watch(
() => props.showList,
newValue => {
// 同步数据
virtualShowList.value = newValue
// 用于解决:当数据未加载出来时,抢先打开下拉选,此时不会再根据初始数据进行自动撑开宽度
// 当showList变化时,如果当前处于聚焦状态,则进行一次宽度计算
if (hasSelectFocus.value) {
optionsFocus()
}
// 如果首次进入组件,此时存在默认值,则进行数据回显处理
if (firstGetShowList.value && props.selectModel) {
getAndSubmitSameItem(props.selectModel, newValue)
}
firstGetShowList.value = false
},
)
// 处理数据回显逻辑:把已选中的数据移动到数组第一位,保证回显正常
function getAndSubmitSameItem(selectModel, list) {
// 分情况处理:多选框 / 常规数据(number、string)
const queries = Array.isArray(selectModel) ? selectModel.map(String) : [String(selectModel)]
const selectedItems = list.filter(item => {
return queries.some(query => {
return (
String(item.label)
.toLowerCase()
.indexOf(query.toLowerCase()) > -1 ||
String(item.value)
.toLowerCase()
.indexOf(query.toLowerCase()) > -1
)
})
})
const otherItems = list.filter(item => {
return queries.every(query => {
return (
String(item.label)
.toLowerCase()
.indexOf(query.toLowerCase()) === -1 &&
String(item.value)
.toLowerCase()
.indexOf(query.toLowerCase()) === -1
)
})
})
emit('update:showList', selectedItems.concat(otherItems))
}
return {
scrollerHeight,
internalModel,
virtualSelectOptions,
virtualShowList,
recycleScrollerBind,
virtualMaxWidth,
handleChange,
optionsFocus,
optionsBlur,
}
},
})
</script>
<style lang="scss">
.recycle-option {
white-space: nowrap;
}
.option-disabled {
color: #000000;
}
</style>
constant.ts:
// 控制虚拟滚动相关默认参数
export const DEFAULT_SCROLL_OPTIONS = {
ID: 'scroller',
ITEM_SIZE: 35, // 每个选项数据的展示高度
MAX_VISIBLE_ITEMS: 7, // 打开下拉选最大可见选项数
BUFFER: 200, // 添加到滚动可见区域边缘以开始渲染更远的项目的像素量
PRERENDER: 100, // 为服务器端渲染 (SSR) 渲染固定数量的项目
KEY_FIELD: 'value',
}
// 如果开启额外计算下拉选宽度,此时宽度仍不满足需求,可通过调整改值增大宽度
// 目前宽度计算方式默认为:文字宽度 + padding32 + 滚滑轮宽度16 @px
export const DEFAULT_WIDTH = 48
// 控制mtd-select相关默认参数
export const DEFAULT_SELECT_OPTIONS = {
REMOTE_METHOD_KEY: ['label', 'value'],
}
// 确保相关组件不被外侧props传递以下key字段,避免影响组件展示
export const DELETE_PARAMS = {
SCROLL: ['items', 'style'],
SELECT: ['value', 'label', 'optionLabel', 'optionValue', 'remoteMethodKey', 'filterMethod'],
}
/**
* 通用select组件过滤方法
*
* @export
* @param {string} query 查询条件
* @param {array[any]} toBeFilteredValues 查询值
*/
export function commonFilterMethod(query, toBeFilteredValues) {
let result = false
for (let i = 0; i < toBeFilteredValues.length; i++) {
if (
String(toBeFilteredValues[i])
.toLowerCase()
.indexOf(String(query).toLowerCase()) > -1
) {
result = true
break
}
}
return result
}
vue-virtual-scroller.d.ts:
/*
* 因虚拟滚动组件 vue-virtual-scroller 没有提供 TypeScript 类型声明文件,
* 所以在没有此文件时,引入 vue-virtual-scroller 会存在ts提示错误(但能正常使用):
* Cannot find module 'vue-virtual-scroller'
* */
declare module 'vue-virtual-scroller' {
import { Component } from 'vue'
export const RecycleScroller: Component
// 如有需要可补充类型声明
}
解决方法三:分批次加载数据 setTimeout(仅适用Vue)
除了上述虚拟滚动外,这里介绍一种简单粗暴的解法:分批次加载数据。
思路是:使用 setTimeout,将一次性渲染全部数据改成每隔一段时间推入一组数据,直到把所有数据推入完成(期间一直加载即可)。
不过该方法只能在 vue 中使用,纯 JS 不行。因为其借用 vue 响应式和虚拟DOM,只更新每次新增的部分,详细可以参考下我之前的文章:https://blog.youkuaiyun.com/qq_45613931/article/details/109470718。纯 JS 这样做的话,依然会重新渲染所有数据,相当于负提升;
除此以外,借用了 setTimeout 的特性。因为它是宏任务,所以在消息队列中会依次执行回调,所以这才是能实现分批次加载的根因。关于JavaScript的运行机制(微任务、宏任务、事件循环)可参考我之前的文章:https://blog.youkuaiyun.com/qq_45613931/article/details/109028679。
代码说明:test.vue中使用了一个优化项 getDevicePerformance,动态调整 setTimeout 的等待时长,如有需要各位可按需调整。如果想看设置前后的对比,只需要将 test-each.vue 中的 isSetTimeout 改为 false 即可,效果很明显。loading是随便写的效果,可按需调整。
要使用功能的父组件 test.vue
<template>
<div>
<TestEach
:all-list="allList"
:timeout-delay="timeoutDelay"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from '@vue/composition-api'
import TestEach from './test-each.vue'
export default defineComponent({
name: 'TestDetail',
components: { TestEach },
setup() {
// 优化项:根据电脑性能设置setTimeout delay时长,也可以给一个固定值
const timeoutDelay = ref(500)
// 动态获取设备的性能指标
function getDevicePerformance() {
// 使用 Performance API 获取设备的性能数据:页面加载时间
const navigationEntries = performance.getEntriesByType('navigation') as any
const loadTime =
navigationEntries && navigationEntries.length > 0
? navigationEntries[0].loadEventEnd - navigationEntries[0].startTime
: 0
// 根据加载时间来判断设备的性能
if (loadTime < 1000) {
return 'high'
} else if (loadTime < 3000) {
return 'medium'
} else {
return 'low'
}
}
// 根据设备的性能来设置延迟时间
function setDynamicDelay() {
const devicePerformance = getDevicePerformance()
let delay
switch (devicePerformance) {
case 'high':
delay = 10 // 高性能设备,延迟时间较短
break
case 'medium':
delay = 50 // 中等性能设备,延迟时间适中
break
case 'low':
delay = 100 // 低性能设备,延迟时间较长
break
default:
delay = 50 // 默认延迟时间
}
timeoutDelay.value = delay
}
const allList = ref([]) as any // 全量数据
onMounted(() => {
setDynamicDelay()
// 模拟复杂结构的大量数据:
const list = [] as any
for (let i = 0; i < 1000; i++) {
const children1 = [] as any
for (let j = 0; j < 10; j++) {
children1.push({
label: `${i}${j}`,
value: `${i}${j}`,
children: [],
})
}
list.push({
label: i,
value: i,
children: children1,
})
}
allList.value = list
})
return {
allList,
timeoutDelay,
}
},
})
</script>
遍历展示复杂数据 test-each.vue
<template>
<div class="component-container">
<div v-if="dataLoading" class="loading-overlay">
加载中...
</div>
<div v-for="(item, index) in showTableList" :key="index">
<span style="margin-right: 10px">value:{{ item.value }}</span>
<span>label:{{ item.label }}</span>
<TestEach
:all-list="item.children"
:timeout-delay="timeoutDelay"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, watch } from '@vue/composition-api'
import TestEach from './test-each.vue'
export default defineComponent({
name: 'TestEach',
components: { TestEach },
props: {
allList: {
type: Array,
default: () => ([]),
},
timeoutDelay: {
type: Number,
default: 500,
},
},
setup(props) {
const showTableList = ref([]) as any // 用于展示的数据
const dataLoading = ref(false) // 加载
const ADD_STEP = 10 // 每次加载条数 => 可自行调整
const nowStepNum = ref(0) // 当前加载到第几个
const dataListLength = ref(0) // 全量数据数组的长度
const setTime = ref(null) as any // 递归加载定时器
// 数据推送
const pushData = () => {
// 如果数据已全部推送完成,停止加载
if (nowStepNum.value >= dataListLength.value) {
dataLoading.value = false
return
}
setTime.value = setTimeout(() => {
const addNum = nowStepNum.value + ADD_STEP
// 更新数据数组
showTableList.value = showTableList.value.concat(
props.allList.slice(nowStepNum.value, addNum),
)
nowStepNum.value = addNum
pushData() // 递归调用,继续推送数据
}, props.timeoutDelay)
}
// 如果不设置setTimeout的后果!将这里改为false
const isSetTimeout = ref(true)
watch(
() => props.allList,
value => {
if (isSetTimeout.value) {
dataListLength.value = value.length
if (value.length > 0) {
handlePushData()
}
} else {
showTableList.value = props.allList
}
},
)
onMounted(() => {
if (isSetTimeout.value) {
dataListLength.value = props.allList.length
if (props.allList.length > 0) {
handlePushData()
}
} else {
showTableList.value = props.allList
}
})
const handlePushData = () => {
if (setTime.value) {
clearTimeout(setTime.value)
}
nowStepNum.value = 0
showTableList.value = []
dataLoading.value = true
pushData()
}
return {
showTableList,
dataLoading,
}
},
})
</script>
<style>
.component-container {
position: relative;
min-height: 20px; /* 设置一个最小高度,确保小组件也能显示加载效果 */
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(255, 255, 255, 0.7);
z-index: 1000;
}
</style>
该方法的优劣很明显:
优点是:代码思路相对简单,几乎适用所有数组 / 对象数组的数据(不过必须是Vue)。即使是层级很深,很复杂的对象数组,你依然只需要递归遍历,每次推入特定数量的子数据即可。
缺点是:
1、针对对象数组层级很深的情况,从代码可以很明显看出来,你首先需要能把它们拆成单独组件,这样才能在 onMounted / watch 中监听数据变化,从而正确进行每一层级的 setTimeout(除非你自己在一个组件内,选择复杂的递归遍历)。
不过也正因为如此,如果你有新增、删除某一行数据的诉求,那么每次监听到数据变化,就从头加载肯定不合理。但代码里写的情况很简单,为了解决这个问题,你可以考虑在第一次加载成功后,就不再调用 setTimeout。当然这个缺点带来的最大副作用,就是代码可能会比较复杂。
2、回显所有数据的加载时间比常规方法长。因为页面卡顿,数据量必然会在万级以上。现在假设数据有 10000 条,此时 setTimeout 即使设置 1ms,也要等待加载 10s。
当然你也可以不设置 loading,但是这就强依赖于数据有较低的交互性,或者用户必然会从上到下浏览,这样一来用户就不会感知到这个过程。否则,假如要新增数据,点击了新增,但 setTimeout 还没结束,这大概率会打乱加载的顺序以及数据所要展示的位置。
所以如果针对缺点提出的问题,在你的场景中不会受到其影响,考虑该方法或许会更加简单高效。
解决方法四:浏览器空闲时段加载 requestIdleCallback
requestIdleCallback 的目的是在浏览器的空闲时段内调用其回调函数。所以,我们要做的就是在空闲时段将要展示的数据展示出来即可,改善效果还是比较明显的。
它的优点是:使用方法极其简单(这会在下面代码部分体现);
不过它的缺点也比较致命:
1、解决方法三的加载时间会比较长,而该方法在极端情况下可能会永远无法加载对应模块数据。因为它的调度优先级较低。换句话说,如果系统资源紧张,或者电脑配置较低,可能会导致回调函数的执行被延迟,影响到功能的响应速度。
当然,为了解决这个缺陷,在下文的代码中想到了一个解决方法,就是定义一个 setTimeout,我们给定一个最长能被接受的延迟时间。假设在时间内,requestIdleCallback 真的没有触发,那么 setTimeout 的回调函数自动触发,就能避免永远无法加载。
但这样一来,又出现了解决方法三的加载时间较长的问题。不过该方法会比解决方法三有更多优势:一是代码层面更简单;二是只要页面展示了相关数据,你就可以直接操作真实数据,不用等到 setTimeout 结束。
2、该方法对浏览器的兼容性较差:https://caniuse.com/?search=requestIdleCallback
所以无论你是否注意到缺点1,为了兼容浏览器并尽量减少电脑配置对功能影响,你依然还是要选用 setTimeout 或其他方法作为兜底方案,从而保证在 requestIdleCallback 未能执行的情况下,功能会正确运行。这样也就变成了解决方法三的优化版本。同时,为了再进一步提高响应速度,依然可以使用 getDevicePerformance,动态调整 setTimeout 的等待时长,以此来平衡执行效率和用户体验。
具体代码:
父组件 test.vue:
<template>
<div>
<TestEach
:all-list="allList"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from '@vue/composition-api'
import TestEach from './test-each.vue'
export default defineComponent({
name: 'TestDetail',
components: { TestEach },
setup() {
const allList = ref([]) as any // 全量数据
onMounted(() => {
// 模拟复杂结构的大量数据:
const list = [] as any
for (let i = 0; i < 1000; i++) {
const children1 = [] as any
for (let j = 0; j < 10; j++) {
children1.push({
label: `${i}${j}`,
value: `${i}${j}`,
children: [],
})
}
list.push({
label: i,
value: i,
children: children1,
})
}
allList.value = list
})
return {
allList,
}
},
})
</script>
遍历数据 test-each.vue
<template>
<div>
<div v-for="(item, index) in allList" :key="index">
<TestItem :item="item" />
<TestEach
:all-list="item.children"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api'
import TestEach from './test-each.vue'
import TestItem from './test-item.vue'
export default defineComponent({
name: 'TestEach',
components: { TestEach, TestItem },
props: {
allList: {
type: Array,
default: () => ([]),
},
},
})
</script>
要渲染的效果 test-item.vue
<template>
<div :id="item.value">
<template v-if="!isOpenRequestCallback || (isOpenRequestCallback && isVisible)">
<span style="margin-right: 10px">value:{{ item.value }}</span>
<span>label:{{ item.label }}</span>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from '@vue/composition-api'
export default defineComponent({
name: 'TestItem',
props: {
item: {
type: Object,
default: () => ({}),
}
},
setup(props) {
// 动态获取设备的性能指标
function getDevicePerformance() {
// 使用 Performance API 获取设备的性能数据:页面加载时间
const navigationEntries = performance.getEntriesByType('navigation') as any
const loadTime =
navigationEntries && navigationEntries.length > 0
? navigationEntries[0].loadEventEnd - navigationEntries[0].startTime
: 0
// 根据加载时间来判断设备的性能
if (loadTime < 1000) {
return 'high'
} else if (loadTime < 3000) {
return 'medium'
} else {
return 'low'
}
}
// 根据设备的性能来设置延迟时间
function setDynamicDelay(callback: any) {
const devicePerformance = getDevicePerformance()
let delay
switch (devicePerformance) {
case 'high':
delay = 10 // 高性能设备,延迟时间较短
break
case 'medium':
delay = 50 // 中等性能设备,延迟时间适中
break
case 'low':
delay = 100 // 低性能设备,延迟时间较长
break
default:
delay = 50 // 默认延迟时间
}
setTimeout(() => {
callback()
}, delay)
}
// 如果不设置requestIdleCallback的后果!将这里改为false
const isOpenRequestCallback = ref(true)
// 内容可见性
const isVisible = ref(false)
const isSetVisibleDone = ref(false)
onMounted(() => {
if (isOpenRequestCallback.value) {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
isVisible.value = true
isSetVisibleDone.value = true
})
// 如果系统资源紧张,requestIdleCallback 2s内没有执行,则用setTimeout兜底,直接展示
setTimeout(() => {
if (!isSetVisibleDone.value) {
isVisible.value = true
isSetVisibleDone.value = true
}
}, 2000)
} else {
// 使用setTimeout作为回退方案,同时动态调整延迟时间
setDynamicDelay(() => {
isVisible.value = true
})
}
}
})
return {
isVisible,
isOpenRequestCallback,
}
},
})
</script>
解决方法五:进入可视区域后加载数据 IntersectionObserver
关于 IntersectionObserver 的使用,可以参考该文章:https://blog.youkuaiyun.com/fmk1023/article/details/122475012
其原理就是自动监听元素是否进入了设备的可视区域之内,当进入可视区域,让对应区域显示即可。目前效果明显,打开页面无卡顿现象。
该方法相比前面的所有方法,已经可以说是相对较优的解决方案了,因为它至少规避了前面很多方法的缺陷:
1、代码相对简单;
2、在未进入可视区域的时候,你依然可以操作真实数据;
3、不用依赖环境信息,不用等待系统调度;
4、几乎不用在首屏等待加载(但依然可能需要处理加载的情况,详见下方缺点);
5、可以针对所有组件;
不过它还是有其他缺点:
1、该方法无法处理第三方组件内部数据加载问题。我们无法修改第三方组件的源码,所以就需要借助其他解决方案;
2、因为你只有进入可视区域的时候,对应模块才会加载,所以这虽然让首屏加载更快,且用户感知不到加载,但当你在移动到下一模块时,如果该模块数据量较大,还是可能会出现页面的长时间空白(当然,这时你可以用加载提示用户)。换句话说,可能会出现,滑动到区域后,刚开始没任何内容,然后突然出现大量内容的效果。
3、一定要注意!在 Vue 中我们知道,像 getElementById、$refs.xx 这种操作DOM的操作,一定要等待页面渲染完成(这也是为什么会用到 nextTick)。所以,如果你的代码中用到了这样的操作,因为不在可视区域的模块是没有渲染的,如果滚动条滑动特别快,则部分模块会渲染不出来,此时可能会操作失败。但我们没办法强行控制使用者的滑动速度,除非你确保你的页面不会出现这个问题,否则该方案似乎很难使用在这样的场景下。
4、对部分浏览器依然有兼容问题:https://caniuse.com/?search=IntersectionObserver
父组件 test.vue
<template>
<div>
<TestEach
:all-list="allList"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from '@vue/composition-api'
import TestEach from './test-each.vue'
export default defineComponent({
name: 'TestDetail',
components: { TestEach },
setup() {
const allList = ref([]) as any // 全量数据
onMounted(() => {
// 模拟复杂结构的大量数据:
const list = [] as any
for (let i = 0; i < 1000; i++) {
const children1 = [] as any
for (let j = 0; j < 10; j++) {
children1.push({
label: `${i}${j}`,
value: `${i}${j}`,
children: [],
})
}
list.push({
label: i,
value: i,
children: children1,
})
}
allList.value = list
})
return {
allList,
}
},
})
</script>
遍历数据 test-each.vue
<template>
<div>
<div v-for="(item, index) in allList" :key="index">
<TestItem :item="item" />
<TestEach
:all-list="item.children"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api'
import TestEach from './test-each.vue'
import TestItem from './test-item.vue'
export default defineComponent({
name: 'TestEach',
components: { TestEach, TestItem },
props: {
allList: {
type: Array,
default: () => ([]),
},
},
})
</script>
要渲染的效果 test-item
<template>
<div :id="item.value">
<template v-if="!isOpenObserver || (isOpenObserver && isVisible)">
<span style="margin-right: 10px">value:{{ item.value }}</span>
<span>label:{{ item.label }}</span>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, onUnmounted, nextTick } from '@vue/composition-api'
export default defineComponent({
name: 'TestItem',
props: {
item: {
type: Object,
default: () => ({}),
}
},
setup(props) {
// 如果不设置IntersectionObserver的后果!将这里改为false
const isOpenObserver = ref(true)
const isVisible = ref(false)
const observedElement = ref<HTMLElement | null>(null)
// 定义 observer 变量在 setup 函数的顶层,以便在 onUnmounted 钩子中访问
let observer: any = null
const target = ref(null) as any
onMounted(() => {
observer = new IntersectionObserver(
([entry]) => {
if (!isVisible.value) {
isVisible.value = entry.isIntersecting
}
},
{
root: null,
rootMargin: '0px 0px 200px 0px',
threshold: 0.1,
},
)
nextTick(() => {
// 获取元素
target.value = document.getElementById(props.item.value)
if (target.value) {
// 开始观察
observer.observe(target.value)
} else {
isVisible.value = true
}
})
})
onUnmounted(() => {
if (observedElement.value) {
// 停止观察
observer.unobserve(target.value)
// 手动清理
observer.disconnect(target.value)
observer = null
}
})
return {
isVisible,
isOpenObserver,
}
},
})
</script>
二、JS代码优化
JS优化没有特别需要说明的,所有方法都写在下方示例中,大家可以直接参考:
1、Web Worker: 允许在后台线程中运行代码,避免阻塞主线程。你可以将大数据量的处理逻辑放在Worker中执行,然后将结果传回主线程(不过我试了下没什么改善效果)。
2、分批处理:和页面优化时的分批次处理概念相同,将大数组分成小块,逐一处理,这样可以避免一次性处理大量数据导致的卡顿。不过加载时间可能会比较长,你可以给一些提示信息。
3、requestAnimationFrame:对于需要连续处理数据并更新UI的情况,可以使用requestAnimationFrame来平滑处理。
4、最后的最后:检查自己的代码,寻找是否存在优化空间。比如,避免在循环中使用高复杂度的操作,减少不必要的计算和内存使用。
各位可以比较这些方法的性能和效果,选择最适合自己应用场景的方法。
public/worker.js
self.onmessage = function(e) {
const list = []
for (let i = 0; i < e.data; i++) {
list.push({
label: i,
value: i,
})
}
self.postMessage(list)
}
主代码:
<template>
<div>
<div v-if="loading">
加载中...
</div>
<div class="test-btn" @click="clickOne">
原始卡顿问题
</div>
<div class="test-btn" @click="clickTwo">
优化1 (Worker)
</div>
<div class="test-btn" @click="clickThree">
优化2 (分批)
</div>
<div class="test-btn" @click="clickFour">
优化3 (RAF)
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api'
export default defineComponent({
name: 'TestDetail',
setup() {
const allList = ref([]) as any
const listLength = 10000000
const loading = ref(false)
// 原始卡顿问题
const clickOne = () => {
if (loading.value) {
return
}
loading.value = true
const list = [] as any
for (let i = 0; i < 10000000; i++) {
list.push({
label: i,
value: i,
})
}
allList.value = list
console.log(allList.value)
loading.value = false
}
// 解决方法一 worker
const clickTwo = () => {
if (loading.value) {
return
}
loading.value = true
const worker = new Worker('/worker.js')
worker.postMessage(listLength)
worker.onmessage = function(e) {
console.log(e.data)
worker.terminate()
loading.value = false
}
}
// 解决方法二 分批处理
const clickThree = () => {
if (loading.value) {
return
}
loading.value = true
const totalItems = listLength
const batchSize = 1000
let processedItems = 0
const list = [] as any
function processBatch() {
const end = Math.min(processedItems + batchSize, totalItems)
for (let i = processedItems; i < end; i++) {
list.push({
label: i,
value: i,
})
}
processedItems = end
if (processedItems < totalItems) {
setTimeout(processBatch, 0)
} else {
console.log(list)
loading.value = false
}
}
processBatch()
}
// 解决方法三 requestAnimationFrame
const clickFour = () => {
if (loading.value) {
return
}
loading.value = true
let index = 0
const list = [] as any
function step() {
const end = Math.min(index + 1000, listLength)
for (let i = index; i < end; i++) {
list.push({
label: i,
value: i,
})
}
index = end
if (index < listLength) {
requestAnimationFrame(step)
} else {
console.log(list)
loading.value = false
}
}
requestAnimationFrame(step)
}
return {
allList,
loading,
clickOne,
clickTwo,
clickThree,
clickFour,
}
},
})
</script>
<style lang="scss" scoped>
.test-btn {
background: #409eff;
padding: 5px 8px;
cursor: pointer;
margin-bottom: 20px;
color: white;
border-radius: 10px;
width: 80px;
text-align: center;
}
</style>