记一次小程序左右滑动列表性能优化

西梁 2021年07月19日 91次浏览

需求

开发一个可滑动切换的tab瀑布流列表

难点

  1. 长列表卡顿问题
  2. 滑动切换页面位置,再次滑回来不重载数据、不回到顶部
  3. 瀑布流加载性能优化

错误案例分析

搜狐网小程序

  1. 单页数据加载>10页后,出现明显卡顿
  2. 左右切换多个tab后,出现明显卡顿
  3. 再次切换回加载过的tab,会自动回到顶部


解决思路

  1. 核心思路是减少位置计算,减少DOM渲染
  2. 缓存指定数量的tab页,比如只缓存该tab左右页的数据,其他页面是数据先释放掉
  3. 每页缓存指定数量的数据,比如每页只缓存50个DOM,新获取的数据替换掉旧的DOM,不创建新的DOM
  4. 不建议加下拉刷新功能,该功能平白加大了页面的计算量,我参考了几个大厂的小程序,他们类似的页面也是没有下拉刷新功能的
  5. 瀑布流的优化先不在本篇blog展开(有空再分享)

核心代码

<template>
    <scroll-view class="tab-bar" enable-flex :scroll="false" :scroll-x="true" :show-scrollbar="false" :scroll-into-view="scrollInto">
      <view class="tab-item" v-for="(tab, index) in labelList" :key="tab.id" :id="tab.id" :ref="'tabitem' + index" :data-id="index" :data-current="index" @click="ontabtap">
        <text class="tab-item-title" :class="tabIndex == index ? 'tab-item-title-active' : ''">{{ tab.label_name }}</text>
      </view>
    </scroll-view>
    <swiper class="tab-box" :current="tabIndex" :duration="300" @transition="onswiperscroll" @animationfinish="animationfinish" @onAnimationEnd="animationfinish">
      <swiper-item class="swiper-action" v-for="(page, index) in labelList" :key="index">
        <itemList :nid="page.label_id" ref="page"></itemList>
      </swiper-item>
    </swiper>
</template>
// 缓存tab页数量
const MAX_CACHE_PAGE = 3
const TAB_PRELOAD_OFFSET = 1
// 每页最多缓存数据量
const MAX_CACHE_DATA = 50

data() {
  return {
    tabIndex: 0,
    cacheTab: [],
    scrollInto: '',
    navigateFlag: false,
    isTap: false,
    lastTabIndex: 0,
    swiperWidth: 0,
    tabbarWidth: 0,
    tabListSize: {},
    touchTabIndex: 0
  }
}

methods: {
  ontabtap(e) {
    // 点击切换
    let index = e.target.dataset.current || e.currentTarget.dataset.current
    this.isTap = true
    this.touchTabIndex = index
    this.switchTab(index)
  },
  onswiperscroll(e) {
    if (this.isTap) { return }
    let offsetX = e.detail.dx
    let preloadIndex = this.lastTabIndex
    if (offsetX > TAB_PRELOAD_OFFSET) {
      preloadIndex++
    } else if (offsetX < -TAB_PRELOAD_OFFSET) {
      preloadIndex--
    }
    if (
      preloadIndex === this.lastTabIndex ||
      preloadIndex < 0 ||
      preloadIndex > this.pageList.length - 1
    ) {
      return
    }
    if (this.pageList[preloadIndex].dataList.length === 0) {
      this.loadTabData(preloadIndex)
    }
  },
  animationfinish(e) {
    // 监听切换完成
    let index = e.detail.current
    if (this.touchTabIndex === index) {
      this.isTap = false
    }
    this.lastTabIndex = index
    this.switchTab(index)
  },
  selectorQuery() {
    // 获取当前tab
    uni
      .createSelectorQuery()
      .in(this)
      .select('.tab-box')
      .fields(
        {
          dataset: true,
          size: true
        },
        (res) => {
          this.swiperWidth = res.width
        }
      )
      .exec()
    uni
      .createSelectorQuery()
      .in(this)
      .selectAll('.tab-item')
      .boundingClientRect((rects) => {
        rects.forEach((rect) => {
          this.tabListSize[rect.dataset.id] = rect
        })
      })
      .exec()
  },
  switchTab(index) {
    // 切换后缓存数据
    if (this.pageList[index].dataList.length === 0) {
      this.loadTabData(index)
    }
    if (this.tabIndex === index) {
      return
    }
    // 缓存 tabId
    if (this.pageList[this.tabIndex].dataList.length > MAX_CACHE_DATA) {
      let isExist = this.cacheTab.indexOf(this.tabIndex)
      if (isExist < 0) {
        this.cacheTab.push(this.tabIndex)
      }
    }
    this.tabIndex = index
    this.scrollInto = this.labelList[index].id
    // 释放 tabId
    if (this.cacheTab.length > MAX_CACHE_PAGE) {
      let cacheIndex = this.cacheTab[0]
      this.clearTabData(cacheIndex)
      this.cacheTab.splice(0, 1)
    }
  },
  scrollTabTo(index) {
    // 页面滚动
    const el = this.$refs['tabitem' + index][0]
    let offset = 0
    if (index > 0) {
      offset = this.tabbarWidth / 2 - this.tabListSize[index].width / 2
      if (this.tabListSize[index].right < this.tabbarWidth / 2) {
        offset = this.tabListSize[0].width
      }
    }
    dom.scrollToElement(el, {
      offset: -offset
    })
  },
  loadTabData(index) {
    // 加载数据
    this.pageList[index].loadData()
  },
  clearTabData(index) {
    // 释放数据
    this.pageList[index].clear()
  }
}

效果展示



发现问题

可以看到经过优化后长列表滑动的时候十分流畅,但是仍然存在左右滑动后有稍微卡顿现象,我猜测很可能是小程序的swiper组件太重了,每次滑动都要去重新计算swiper的位置

继续优化

优化思路当然是废弃swiper组件,难道滑动不做了吗?

做是肯定要做的,我用了一种很取巧的方式,利用手势功能去做滑动

优化思路

  1. 使用 @touchstart @touchmove 去监听用户在当前DOM下是否存在滑动操作,计算滑动距离和方向
  2. 在监听的DOM外再包一层DOM,做滑动时候的动画效果

核心代码

<template>
  <view class="water-full-box">
    <!-- 滑动组件 -->
    <scroll-nav ref="scrollNav" :scrollData="scrollData" :activeKey.sync="activeKey" @change="changeTab" :animate="animate"></scroll-nav>
    <view class="scroll-view">
      <!-- 监听滑动 -->
      <view :class="['tab-wrapper',{animate}]" :style="[{left:tabview.left+'px'}]">
        <view class="tab" v-for="(item,key) in scrollData" :key="key">
          <view v-show="activeKey === key" class="tab-content" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd($event,key)">
            <!-- 这里放瀑布流组件 -->
            <waterList />
          </view>
        </view>
      </view>
    </view>
  </view>
</template>
  methods: {
    // 获取DOM信息,用于计算位置
    $document(self, select, cfg) {
      return new Promise((res, rej) => {
        if (!self || !select) rej('$document:params error')
        uni.createSelectorQuery().in(self).select(select).fields(Object.assign({
          id: true,
          dataset: true,
          rect: true,
          size: true,
          scrollOffset: true,
          context: true
        }, cfg), data => res(data)).exec()
      })
    },
    // 导航栏发生改变
    async changeTab(e) {
      await this.setPosition(e)
    },
    // 用户开始滑动
    touchStart(e) {
      if (this.locked) return
      let client = e.touches[0]
      Object.assign(this, {
        client,
        position: [client]
      })
    },
    // 用户正在滑动
    touchMove(e) {
      if (!this.position) return
      let client = e.touches[0]
      if (Math.abs(client.clientY - this.client.clientY) < 25) {
        this.position.push(client)
        const
          positions = this.position.map(item => Object.assign({}, item)),
          sorted = positions.sort((a, b) => a.clientX - b.clientX)
        if (sorted[0].clientX !== client.clientX && sorted.slice(-1)[0].clientX !== client.clientX) {
          this.clearPosition()
        }
      } else {
        this.clearPosition()
      }
    },
    // 用户结束滑动
    async touchEnd(e, key) {
      let
        positions = this.position,
        act
      if (positions) {
        let move = positions.slice(-1)[0].clientX - positions[0].clientX
        if (Math.abs(move) < this.scrollThreshold) return
        act = move > 0 ? 1 : -1
        let changeKey = key - act
        if (changeKey < 0 || changeKey > this.navData.length - 1) return
        await this.setPosition(changeKey)
        this.clearPosition()
      }

    },
    // 设置样式位置
    async setPosition(key, act) {
      this.locked = true
      switch (act) {
        case 'set':
          break
        default:
          this.$emit('update:value', key)
      }
      this.activeKey = key
      try {
        !this.itemWidth && (this.itemWidth = (await this.$document(this, '.tab')).width)
        this.tabview.left = -key * this.itemWidth
      } catch (e) { }
      await new Promise(r => setTimeout(r, 300))
      this.locked && (this.locked = false)
    },
    // 设置位置信息
    clearPosition() {
      this.position = null
    },
    // 触发导航栏高亮样式动画
    resizeStyle() {
      this.$refs.scrollNav.changeStyle(this.activeKey)
    }
  }
.animate {
  transition: all 0.3s linear;
}

完美



感想

有难度才有成就感