Vue处理页面渲染卡顿问题(虚拟滚动 + requestIdleCallback + IntersectionObserver)+ JS卡顿问题解决

👉 个人博客主页 👈
📝 一个努力学习的程序猿


专栏:
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>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端Jerry_Zheng

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值