suka

suka

记一次vue性能优化实战:从51秒优化到3秒

17
2024-03-13

image

记一次vue性能优化实战:从51秒优化到3秒

这周前端组同事休假,所以我去顶替,万万没想到,开幕雷击,刚进组就有业务反馈一个页面的弹框卡顿,需要优化

计算机科学领域的任何问题都可以通过增加一个中间层来解决。 —— Butler Lampson

发现问题

点进去这个页面,页面本身支持500条的表格数据展示,然后可以选中整个表格,点击操作按钮,就会在弹框中重新渲染一个相同条数的表格,字段有略微的不一样,多了一些表格内的输入框和批量操作按钮。

此时,卡顿分为两个阶段:

  1. 第一个是点击按钮到打开弹框的阶段,选中500条数据的情况下,该页面会卡死51秒才有反应

    image

  2. 第二个是弹框打开后渲染表格后的阶段,渲染的阶段倒是挺快的,但是渲染出来的表格基本不能用,上下滚动都很卡

寻找原因

先说上面的第二个阶段,就是渲染出来的表格卡顿的问题:

这个很好定位原因,原因就是外层的表格和弹框的表格都是500行数据,用的是iview的table组件,这个table组件没有虚拟滚动的功能,所以基本上就是1000行的表格全部加载出来了,而且表格内还有额外的dom结构和操作事件等等,当然卡了。

然后是第一个阶段,由于是弹框显示前的卡顿,所以要找找在点击按钮到弹框显示的这一段旅程,到底发生了什么:

选中500条数据,点击上面的修改价格​按钮,执行了如下代码

    batchStockandPrice(title) {
      if (this.calInfoSelected.length <= 0) {
        this.$Message.warning('请选择要修改的产品')
      } else {
        this.showPriceModal = true
        this.title = title
      }
    },

附上部分的template:

<Button type="primary" @click="batchStockandPrice('修改库存')" :loading="loading">修改库存</Button>
<Button type="primary" @click="batchStockandPrice('修改价格')" :loading="loading">修改价格</Button>
<Button type="primary" @click="batchStockandPrice('修改重量')" :loading="loading">修改重量</Button>
<Button type="primary" @click="batchStockandPrice('修改包装尺寸')" :loading="loading">修改包装尺寸</Button>
<batchPrice v-model="showPriceModal" :selectTable="calInfoSelected" :title="title" :allCountryList="allCountryList"></batchPrice>

可以见逻辑是,点击按钮,将showPriceModal​设为ture​,传入的数据是选择的表格数据,然后显示批量修改的弹框,这里发现这个弹框竟然承载了4个功能。

然后这里没啥好优化的,所以进入这个batchPrice​组件看看显示前都在做什么:然后我就发现了这个可怕的循环嵌套函数:

image

无需关心代码具体在执行什么逻辑,光是看这几个红色的嵌套循环,我就感觉有点震惊了。于是我在循环前记了一下数,然后循环完毕打印出来,在200条量级的情况下,循环了2w次左右:

而在500条数据的量级下,循环膨胀到了12w次:

image

但是按理来说,cup搞定这点循环,不应该耗时51秒那么长时间。

想到vue相关的问题,最多的就是响应式变量的问题,所以再分析如上代码,看有没有其它的优化点,结果这个时候发现,这段代码不仅仅在循环,而且在循环中不断的给响应式的变量push值进去,然后又循环更改响应式变量的值,如下蓝色的标注:

image

不断循环更新响应式变量,vue就会不断的追踪更改,最终导致的结果就是成千上万的更新响应式变量,带来的是指数级上涨的依赖更改追踪,带来的直观的后果就是页面卡死了51秒才计算完毕。

解决问题

第一个阶段的解决方案

先不优化代码本身的逻辑,避免遗漏逻辑或者写出bug,而是使用一个非响应式的temp变量,来代替上面的this.tableDataStock​,然后在所有的循环完毕后,将这个temp赋值给this.tableDataStock​即可:

image

第二个阶段的解决方案:

  1. 先观察Table组件和vxe-grid组件的所需配置的差异

    1. Table组件和vxe-grid都是用columns接收表格配置,用data接收表格数据,这点很好,不用特别设置了

    2. columns的配置有一些差异,稍微总结了一下如下:

      Table vxe-grid 备注
      key field
      slot: xxx slots: {
      default: xxx
      }
      render slots: {
      default: xxx
      }
      vxe-grid在v2版本接收的default​字段如果是VNode,好像必须是数组
      该字段由于此次适配没有用到,所以没有测试过,但是理论是可行的。
  2. 然后写一个中间件组件EsGrid,它接收Table一模一样的传参和插槽,内部将这些传参和插槽处理成vxe-grid能识别的产物,然后传递给vxe-grid即可;

    关于插槽的处理:
    <template>
      <div>
        <vxe-grid :columns="computedColumns" v-bind="$attrs" v-on="$listeners">
          <template v-for="slotName in Object.keys(slots)" #[slotName]="{ row, index }">
            <slot :name="slotName" :row="row" :index="index"></slot>
          </template>
        </vxe-grid>
      </div>
    </template>
    

    这里两点需要注意的是:

    1. $slots不知道为啥没生效,所以使用了vue2.7的hook useSlots()​来获得的slots
    2. vue2.7版本,需要同时使用v-bind="$attrs" v-on="$listeners"​来继承父组件传来的属性和事件,而vue3不用,直接v-bind="$attrs"​就可以同时继承父组件传来的属性和事件

    关于columns的适配:

    const computedColumns = computed(() => {
      if (isIViewTable()) {  // isIViewTable判断是否是iview的表格columns
        return props.columns.map(item => {
          const newSlots = {}
    
          if (item.slot) {
            newSlots.default = item.slot
          }
    
          return {
            ...item,  // 先继承所有的属性
            field: item?.key,  // 转换key为field
            slots: item.slot ? newSlots : undefined // 转换slot为vxe能识别的slot
          }
        })
      } else {
        return props.columns
      }
    })
    
  3. 最后直接使用EsGrid替换Table组件,无需改动任何其它的东西。

优化结果

经过上述的步骤,在选中500条数据的情况下,该弹框打开的速度从原本的51s骤降为3s左右,效率提升了1700%。

如果还需要对这3s进行优化的话,就得改写上面的嵌套循环的逻辑了,但是emmm,下次一定!

总结

关于性能方面的总结:

  1. 尽量使用性能表现优异的组件,比如使用自带虚拟滚动的vxe-table代替iview自带的table组件
  2. 避免在循环中重复的给响应式对象赋值! 正确的做法是先给临时的非响应式变量赋值,然后在循环完毕后将这个临时变量一次性赋值给响应式对象

关于组件封装方面的总结:

跟我之前保存的一篇文章所说的一样:添加一个中间层是一种有效且通用的解决问题的思路,根据需要解决的问题,思考中间层的位置,以及一个能注入代码的时机,并让代码尽早执行

所以这里我就采用了封装一个中间层的组件EsGrid,对外接收插槽和props,对内处理这些插槽和props:将这俩货转为vxe支持的格式,并提供给vxe使用即可。

关于vue2.7的总结:

  1. 在template里面直接使用$slots​不能正确的拿到插槽,所以要在生命周期里面,使用useSlots()​hook来获取到slot,然后在template里面v-for遍历即可

    1. 同样的 getCurrentInstance​​​之类的hook也需要在vue的生命周期里使用,比如setup函数或者created,或者onMounted等等,不然会返回null

附录

https://hughfenghen.github.io/posts/2023/12/23/web-spy/