小程序实现索引列表性能优化
发布于 1 年前 作者 pinghuang 2933 次浏览 来自 分享

简介

本文主要分享个人最近在小程序里面开发索引列表总结经验,索引列表一种一次性展示庞大的数据,并且可以通过点击某个索引跳转到指定索引的内容区域,方便用户快速找到相关的选项

本文直接使用 真实的数据 来深入分析和讨论索引列表 优化手段,功能的实现逻辑不在本文讨论范围,直接去看代码片段即可

本文也为大家带来平时我们经常遇到 假如服务端一次性返回XXX条数据,前端该怎么处理 的真实案例

一. 索引列表功能分析

从上面的图中可以分析出有以下需求:

  1. 每个分组中都带有一个索引,每个索引分组展示相关的索引数据
  2. 每次滚动中,如果当前索引的区域与页面顶部相交,则当前索引区域的标题固定到顶部,同时右边的索引需要变成橙色
  3. 每次点击右边的索引,需要滚动到当前索引区域的顶部,同时当前索引区域标题固定到顶部

从上面的功能中,我遇到最难的一个地方就是 如何处理性能优化,如果一次性请求所有数据并且一次性渲染出来,则一些配置不好的手机需要很长的时间才能把页面渲染出来

二. 索引列表性能优化分析

前端处理与列表相关的功能时,通常会选择以下三种性能优化方案:

  • 分页请求:通过 滚动到底部点击某个分页加载按钮 请求指定的页码,避免一次性请求过多数据和一次性渲染过多数据
  • 虚拟列表:通过 对象池 的设计理念(个人观点),一次性创建指定的节点数量同时不断去判断数据列表的数量来增加当前滚动区域,每次滚动到指定区域时直接替换原有对象的数据,页面始终只有指定数量的节点
  • 分片渲染:如果 屏幕一次性必须要展示太多的数据 时,控制在同一时刻触发页面渲染节点数量,通常是列表的每一条数据存在图片,防止一次性渲染过多图片触发太多的图片请求,设计理念有点类似我们平时实现多文件上传时模拟实现的多线程上传逻辑

但是在索引列表的需求中,这些优化的方案实现起来并不是那么简单,下面我来分享我在开发过程中在思考列表性能优化方案的一些问题

2.1 分页请求的问题

索引列表数据量肯定是非常庞大的,第一时间肯定会想到分页请求,然而分页请求在这个需求中存在以下问题:

  1. 索引列表通常用户希望每次操作能够更快的看到内容,如果频繁出现 Loading 或多次出现请求服务端然后再渲染的卡顿,用户体验会极差
  2. 索引列表的分页时机难以决定,交互的方式有右边的索引点击和滚动,然后视图中索引区域会出现交叉的情况

2.2 虚拟列表的问题

索引列表通常一个索引分组下会渲染当前索引组相关的数据,列表要渲染多条数据肯定会想到虚拟列表,然而虚拟列表在这个需求中存在以下问题:

  1. 每个索引的区域会有一个可能固定定位的标题展示,在出现两个索引交叉展示的情况,虚拟列表的逻辑就会很难处理
  2. 点击右边的索引字母滚动的时候是有动画的,如果使用虚拟列表去实现的话,每次点击索引字母触发快速滚动时,可能会频繁触发虚拟列表滚动的代码逻辑,处理不好容易导致 UI 产生更多的 BUG

2.3 分片渲染的问题

在分页请求和虚拟列表不好使的情况下,最后能想到的只有分片渲染的优化手段,分片渲染也有以下需要考虑的问题:

  1. 要考虑好分片渲染的时机,首先不能直接简单粗暴直接从第一条数据一直分片渲染到最后一条数据,否则用户一旦进入页面就点击最后的字母,然后数据没有渲染出来那就尴尬了
  2. 分片渲染在小程序的应用中就是使用多个 setTimeout() 和多次调用 this.setData() 来在多个时间段触发多次更新,利用多次更新来实现一次 UI 展示来优化渲染,在索引列表中频繁触发滚动是很正常的,所以还需要注意 setTimeout() 数量以及 this.setData() 的调用频率问题

三. 索引列表性能优化基本实现

通过上面对各种性能优化的分析之后,我们还需要结合 索引列表的功能用户的使用习惯 来决定我们的性能优化的最终实现方案:

  1. 索引列表功能通常是供用户快速找到自己需要的选项,比如在汽车相关业务的场景中,希望给用户提供一个更快找到自己的车型信息的渠道,通常索引列表比搜索功能更好用(因为搜索需要用户自己输,每个车型还有各种系列,用户肯定是记不住的)
  2. 索引列表在用户使用的过程中,会频繁的使用右边的关键词点击快速定位到自己想要的区域,然后再通过滚动找到指定的数据,所以 用户能把所有数据看完的几率几乎不可能

结合 索引列表的操作和展示特点用户几乎不可能看完所有数据 两点我总结出一个思路:每当页面的视图滚动到指定索引区域时,开始渲染指定索引区域的数据

3.1 初始化索引数据和索引区域高度

获取每个索引区域的高度把整个滚动的页面撑起来,目前我们索引列表每一项高度是固定且一致的,所以只要通过每个索引分组的数据量计算出来即可

const indexListItemHeight = 120

Page({
  /**
   * 初始化索引列表分组数据
   */
  initIndexListGroupData() {
    const list: IIndexListGroup[] = indexListKeys.map((key, index) => ({
      key,
      index,
      height: `${indexListGroups[key].length * indexListItemHeight}rpx`, // 当前区域高度
      list: [],
    }))

    this.setData({
      indexListGroups: list,
    })

    // 初始化索引分组渲染监听和索引分组的物理信息
    wx.nextTick(() => {
      this.initChangeIndexListGroupsRender()
      this.initIndexListGroupsRect()
    })
  },
})

3.2 实现滚动时触发索引区域的当前索引选中

把所有索引区域高度渲染出来后,同时获取所有索引区域的真实节点物理信息,然后在滚动的过程中判断当前的滚动距离并把当前区域的标题变成固定定位

Page({
  /**
   * 初始化获取索引区域的 top
   */
  initIndexListGroupsRect() {
    const query = this.createSelectorQuery()
    query.selectAll('.index-list-column').boundingClientRect()
    query.exec((res: WechatMiniprogram.BoundingClientRectCallbackResult[][] = []) => {
      if (res[0]?.length) {
        this.data._indexListRects = res[0]
      }
    })
  }, 

  /**
   * 监听滚动
   */
  changeScroll(e: WechatMiniprogram.ScrollViewScroll) {
    const select = this.data.select
    const scrollTop = e.detail.scrollTop
    const _indexListRects = this.data._indexListRects
    const active = _indexListRects.findIndex((item) => (scrollTop - item.top) < item.height)

    if (select === active) {
      return
    }

    this.setData({
      select: active
    })
  }, 
})

3.3 根据屏幕展示当前的索引区域展示对应列表数据

监听当前滚动的位置对应的索引区域,把当前在屏幕展示的区域的数据展示出来,这样就实现了只有屏幕展示相应的索引区域,相关区域的数据才能展示出来

Page({
  /**
   * 初始化监听索引分组渲染
   */
  initChangeIndexListGroupsRender() {
    const observer = this.createIntersectionObserver({ observeAll: true })
    observer.relativeToViewport({ top: 0, bottom: 0 }).observe('.index-list-column', (res) => {
      if (res.intersectionRatio > 0) {
        const index = res.dataset.index as number
        const { key, list } = this.data.indexListGroups[index]

        if (!list.length) {
          this.setData({
            [`indexListGroups[${index}].list`]: indexListGroups[key]
          })
        }
      }
    })
  }
})

四. 索引列表性能优化进阶实现

在上面的实现步骤中,已经实现了最基本的性能优化展示(起码比一次渲染全部数据的体验好多了),但是目前还有一些细节需要深入去思考,上面的基本实现只是单纯把 一次性展示索引列表的全部分组的列表数据 优化成 当屏幕展示当前索引列表的某个分组时展示该分组的列表数据,如果当前索引分组的列表数据过多时(如 A 字母相关的数据有 100 条这样的情况),这样会在一瞬间触发全部图片的 http 请求,导致当前能看到的图片列表渲染大概率加载慢而展示不出来

对于上面的问题我想到了以下解决思路:在渲染指定索引分组的列表时加入 分片渲染 的优化手段,使用前必须要注意一个问题,不能单纯的从该分组从上往下渲染,因为我们是可以往上滚动的,如我在 A 的分组点击了 Z 的索引,我往上面滚动我还需要渲染出 Y 分组的数据,这时候还需要考虑 从最底部开始渲染 的场景

4.1 处理分片渲染初始化逻辑

当滚动到指定的索引分组区域时,给该分组的列表数据一个 isLoading 字段来给列表先展示一个默认的样式,这样就能避免滚动到该分组时会触发该分组的列表所有图片请求

<block wx:for="{{ group.list}}" wx:key="id" >
  <view style="height: {{ _indexListItemHeight }}" class="index-list-column-item">
    <!-- 默认不触发图片加载 -->
    <image
      wx:if="{{ item.isLoading }}" 
      class="index-list-column-item-image"
      src="{{ item.imageUrl }}" 
      mode="aspectFill"
    />
    <text class="index-list-column-item-name">{{ item.isLoading ? item.name : '加载中...' }}</text>
  </view>
</block>
Page({
  /**
   * 处理索引分组分片渲染
   */
  indexListGroupFragmentRender(index: number) {
    const { key } = this.data.indexListGroups[index]
    const currentList = indexListGroups[key]
    
    // 在初始化列表中增加 isLoading 字段
    const initList: ICustomCarModel[] = currentList.map((item) => ({ ...item, isLoading: false }))

    this.setData({
      [`indexListGroups[${index}].list`]: initList
    })
    
    // 触发分组渲染
    wx.nextTick(() => {
      this.handleFragmentRender(index)
    })
  }
})

4.2 处理分片渲染的上下加载逻辑

上面已经分析过了一个问题,就是触发一个索引分组数据的分片渲染时,不能简单的从该分组的上面到下面开始渲染,因为这里有个操作是:先点击右边的索引跳转到某个索引分组,然后向上滚动,如果按照上面的简单处理则往上面滚动时不能及时看到下面的数据触发更新,所以我们要做到 同时上下一起触发分片渲染,具体的实现思路为以下:

  1. 给某个索引分组增加 prevIndexlastIndex 来记录两个指针的渲染进度并指定一个 fragmentCount 字段为当前上下同时渲染的数量
  2. 然后判断当前剩余可渲染的数量来决定 fragmentCount 的数量,先去触发上面的渲染,然后再触发下面的渲染
  3. 如果每次渲染完毕后发现还有数据可渲染,则使用 setTimeout 实现异步递归渲染,这样就能陆陆续续的把所有分组列表渲染完毕
Page({
 /**
   * 初始化索引列表分组数据
   */
  initIndexListGroupData() {
    const list: IIndexListGroup[] = indexListKeys.map((key, index) => ({
      key,
      index,
      height: `${indexListGroups[key].length * INDEX_LIST_ITEM_HEIGHT}rpx`,
      prevIndex: 0, // 分片渲染的顶部索引
      lastIndex: indexListGroups[key].length - 1, // 分片渲染的底部索引
      timer: 0,
      list: [],
    }))

    this.setData({
      indexListGroups: list,
    })

    wx.nextTick(() => {
      this.initChangeIndexListGroupsRender()
      this.initIndexListGroupsRect()
    })
  },

  /**
   * 分片渲染
   */
  handleFragmentRender(index: number) {
    const { prevIndex, lastIndex } = this.data.indexListGroups[index]
    const dataObj: Record<string, any> = {}
    let fragmentCount = 4 // 分片渲染的数量
    let currentPrevIndex = prevIndex
    let currentLastIndex = lastIndex

    // 判断是否有数据分片
    if (lastIndex - prevIndex < 0) {
      return
    }

    // 判断是否剩余的数量不够初始分片的数量
    if ((currentLastIndex - currentPrevIndex) + 1 < fragmentCount) {
      fragmentCount = (currentLastIndex - currentPrevIndex) + 1
    }

    while (currentPrevIndex < prevIndex + fragmentCount) {
      const _key = `indexListGroups[${index}].list[${currentPrevIndex}]`
      dataObj[`${_key}.isLoading`] = true
      currentPrevIndex++
    }

    // 判断是否底部还能渲染
    if (lastIndex - currentPrevIndex >= 0) {

      // 判断是否剩余的数量不够初始分片的数量
      if ((currentLastIndex - currentPrevIndex) + 1 < fragmentCount) {
        fragmentCount = (currentLastIndex - currentPrevIndex) + 1
      }

      while (currentLastIndex > lastIndex - fragmentCount) {
        const _key = `indexListGroups[${index}].list[${currentLastIndex}]`
        dataObj[`${_key}.isLoading`] = true
        currentLastIndex--
      }
      
    }
    
    // 分组指针直接使用同步方式记录
    this.data.indexListGroups[index].prevIndex = currentPrevIndex
    this.data.indexListGroups[index].lastIndex = currentLastIndex
    
    // 触发页面分片渲染
    this.setData(dataObj)
    
    // 判断是否还能继续分片,如果有采用异步递归
    if (currentLastIndex - currentPrevIndex >= 0) {
      setTimeout(() => {
        this.handleFragmentRender(index)
      }, 500)
    }
  }
})

五. 索引列表性能优化最终实现

经过上面的进阶优化手段,现在的索引列表在开始展示当然索引区域的数据之前,会有一个 分片渲染 的效果,先会触发列表数据的 Loading 基本展示,然后再按固定的数量上下分别一起开始加载,这样就能避免当前区域列表中一次性加载 N 次的图片导致用户在当前区域的图片加载速度被其他看不到区域的图片给占了

经过再次分析,虽然按照用户的使用习惯来说,通常是不会预览太多的分组区域,更不会把所有的分组区域的数据都看完,但是作为开发者还是要考虑一下这个问题:当前页面的数据存在的节点数量过多导致性能卡顿

目前通过我的分析之后,总结了以下思路:

  1. 每次滚动时把其他没有在屏幕展示的分组区域的列表数据清空
  2. 每次在数据清空时要注意要清空的区域列表是否在进行分页渲染,如果有则需要把分页渲染停止掉

5.1 给每个索引分组加上一个记录 setTimeout 的属性

timer 的属性存在时,说明该索引分组正在进行分页渲染,分页渲染完毕时需要给该属性设置为 0

Page({
  /**
   * 初始化索引列表分组数据
   */
  initIndexListGroupData() {
    const list: IIndexListGroup[] = indexListKeys.map((key, index) => ({
      key,
      index,
      height: `${indexListGroups[key].length * INDEX_LIST_ITEM_HEIGHT}rpx`,
      prevIndex: 0,
      lastIndex: indexListGroups[key].length - 1,
      timer: 0, // 增加一个 timer 字段记录该分组的延时器
      list: [],
    }))

    this.setData({
      indexListGroups: list,
    })

    wx.nextTick(() => {
      this.initChangeIndexListGroupsRender()
      this.initIndexListGroupsRect()
    })
  },
  
  /**
   * 分片渲染
   */
   handleFragmentRender(index: number) {
     // ...
       
     // 判断是否还能继续分片,如果有采用异步递归
     if (currentLastIndex - currentPrevIndex >= 0) {
       this.data.indexListGroups[index].timer = setTimeout(() => {
         this.handleFragmentRender(index)
       }, 500)
     }
   }
})

5.2 每次滚动时把没有在区域渲染的索引分组数据清空

索引列表是可以上下滚动的,往上面滚动时可能会看到上面分组的最底部的列表数据同时会触发上面分组的标题固定定位;往下面滚动时可以看到下面分组的最顶部的列表数据,所以根据索引列表的特性来分析出来我们要对当前索引分组的上下其他分组制定不同的清空时机:

  1. 上面分组列表:上面分组列表是肯定见不到的,所以直接对当前索引分组的上面所有分组列表直接清空即可
  2. 下面分组列表:从当前所在的索引分组区域开始往下判断是下面分组列表否在滚动区域(这是因为下面可能会展示多个索引分组区域)

这里还有两个细节处理:

  1. 删除列表数据时一定要判断当前索引分组是否在进行分页渲染,否则会出现触发删除后分页渲染还会在继续渲染的 Bug
  2. 删除列表数据是在监听滚动事件时候执行的,所以一定要做一个防抖处理,否则太过于频繁也会造成不必要的回收影响体验
Page({
  /**
   * 监听滚动
   */
  changeScroll(e: WechatMiniprogram.ScrollViewScroll) {
    const select = this.data.select
    const scrollTop = e.detail.scrollTop
    const _indexListRects = this.data._indexListRects
    const active = _indexListRects.findIndex((item) => Math.floor(scrollTop) - item.top < item.height)

    // 触发监听分组列表数据清空
    this.changeIndexListGroupsClear(this, scrollTop, active)

    if (select === active) {
      return
    }

    this.setData({
      select: active
    })
  }, 

  /**
   * 监听滚动时
   */
  changeIndexListGroupsClear: debounce((_this: any, scrollTop: number, index: number) => {
    const windowHeight = wx.getSystemInfoSync().windowHeight
    const _indexListRects: BoundingClientRectCallbackResult[] = _this.data._indexListRects
    const groupList: IIndexListGroup[] = _this.data.indexListGroups
    const dataObj: Record<string, any> = {}

    for (let i = 0; i < groupList.length; i++) {
      const { key, timer } = groupList[i]
      let isView = true

      if (i > index) {
        // 判断当前下面的分组是否在可视区域中
        isView = Math.floor(scrollTop) > (_indexListRects[i].top - windowHeight)
      }

      // 如果是在当前分组上面或在当前分组下面且不在显示视图区域中,则清空列表数据
      if (i < index || !isView) {
        // 如果当前有分片渲染
        if (timer) {
          clearTimeout(timer)
          dataObj[`indexListGroups[${i}].timer`] = 0
        }

        dataObj[`indexListGroups[${i}].list`] = []
        dataObj[`indexListGroups[${i}].prevIndex`] = 0
        dataObj[`indexListGroups[${i}].lastIndex`] = indexListGroups[key].length - 1
      }
    }

    _this.setData(dataObj)
  })
})

六. 总结

经过上面三个大步骤的实现之后,索引列表的性能优化已经完全实现,而优化后的效果在真实的测试中出现很明显的表现出来,接下来总结一下整体流程:

  1. 获取每个索引分组区域的高度,把高度先撑起来实现布局的稳定性
  2. 高度撑起之后开始监听当前滚动到哪个分组区域,然后进行分组列表的 初始化渲染
  3. 初始化渲染 完毕之后,触发当前索引分组的 分片渲染,防止一次性渲染多个图片互抢 http 网络资源,导致用户优先要看的数据图片第一时间展示不出来
  4. 在渲染太多列表数据后,然后找好时机回收不可见区域的列表数据:当前分组上面看不到的所有分组列表数据可直接清空,当前分组下面的需要判断是否展示在视图里面然后再去清空指定的分组列表数据

七. 最后

本文章以 Demo 形式提供本人目前想到的小程序索引列表的优化,这个优化分成三个大步骤,现实的开发场景中可能不需要使用到所有的步骤,这些步骤只是本人能想出来的优化点,帮大家提供小程序索引列表的优化思路

本文章单纯是本人对于小程序索引列表优化开发经验的分享,如果大家有更完善的优化思路务必请在评论区分享出来一起学习分享

该文章的代码形式是以 小程序代码片段 的形式分享,请注意一定要使用自己的手机体验一番,不要用微信开发工具在电脑上体验,因为电脑的性能肯定是比手机好的,如果你同样像我使用 2000 块不到的手机去尝试 优化前优化后 的效果,你肯定很明显就能看出来

回到顶部