利用虚拟列表改造索引列表(IndexList)

1,805 阅读6分钟

引言

在一个倡导“快速开发”的团队中,交付日期往往是衡量工作的第一标准。而遇到问题的解决方式也会偏暴力,暴力的方式往往大脑都会厌恶和失声,尤其是在面试官问开发过程中的难点的时候更是无法回答,只能无底气的回一句“感觉开发过程很顺利,并没有碰到什么难以解决的问题。”。

以下便是我想到的非暴力方式来改造原有问题。

问题

需求与问题描述

关键词: 小程序index list卡顿白屏500条1M

在进行小程序项目开发过程中,遇到索引列表的需求,于是采用vantIndexBar作为进行开发,完成并发布线上,但是由于item编写的确实不行以及小程序承载最终将问题暴露了出来。经过测试发现当数据大于500条时部分手机已经开始出现卡顿情况,其中对item操作(删除、增加)时卡顿明显;当数据大小大于1MB后更是出现首屏渲染长时间的白屏。IndexList如下图所示。

IndexBar

问题分析

由于表达能力弱上方描述可能不太清楚,所以将关键词提取出来。

  1. 小程序: 项目环境
  2. index list: 需求
  3. 卡顿/白屏: 问题
  4. 500条/1M: 产生问题的前提

从产生问的题前提很容易的产生一个疑问“数据量这么少还能卡?”。我在测试过程中发现的时候也是觉得诧异,这点数据能干什么?在非小程序开发的情况下我一般会见这一块代码单独开一个项目进行测试,但是小程序众所周知的卡,所以我采用了一个非常简单的方式百度“小程序 列表 卡顿”,在搜索的时候我甚至没写“长列表”,但是我还是得到了结果,还是在搜索结果的第一条。搜索结果如下图所示。

3366F8B8-C33C-4CD5-A0CC-7ED61789B7D3.png

2018的提出问题,2019年官方给出了解决方案recycle-view微信小程序长列表卡顿,但是这个只能解决部分问题,对于嵌套数据可能并不能适配。而且内部实现也是按虚拟列表渲染的思路去操作的。

方案和实现

在后续方案实现细节和环境将换成浏览器环境并采用Vue进行编码。

ps: vite + vue 在写demo方面实在是太丝滑了。

前提

采用小程序开发工具进行编码对个人来说较为难受,考虑到方案和实现以及迁移都成本相对低,所以后续实现采用浏览器实现后移植小程序。

开发环境: vscode + vite + vue

mock数据

domo环境,采用mock数据为后续开发提供数据支持。

ps: 暂时不考虑keys顺序问题

mock结构


{
    "A":[ ... ],
    ...
    "Z":[ ... ]
}

mock生成代码如下。

import { Random } from 'mockjs'

export const indexListData = Array(26).fill('A'.codePointAt()).reduce((pv, indexCode, index) => {
  const currentCharAt = indexCode + index
  const currentChar = String.fromCharCode(currentCharAt)
  pv[currentChar] = Array(Math.random() * 460 | 0).fill(0).map((_, itemIndex) => {
    const id = currentCharAt + '-' + itemIndex
    return {
      id,
      index: currentChar,
      pic: "https://image.notbucai.com/logo.png",
      title: Random.ctitle(5, 20),
      group: id,
      content: Random.ctitle(100, 150),
      user: {
        id: 123,
        name: '不才',
        avatar: 'https://image.notbucai.com/logo.png',
        age: 12,
        sex: 1,
      },
      createAt: Date.now(),
      updateAt: Date.now(),
    }
  })
  return pv;
}, {})

业务代码

渲染图

渲染图

没有改造之前的代码。只做部分实现,未完全实现

<template>
  <div class="list-page-box">
    <div class="list-box">
      <div class="group-box" v-for="(value, key) of list" :key="key">
        <div class="gropu-index">{{ key }}</div>
        <div class="group-content">
          <div class="group-item" v-for="item in value" :key="item.id">
            <img
              class="group-item-pic"
              :src="item.pic"
              alt="123"
              loading="lazy"
            />
            <div class="group-item-content">
              <h1>{{ item.title }}</h1>
              <p>{{ item.content }}</p>
            </div>
            <div class="group-item-aciton">
              <button>删除</button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="index-field-box">
      <div class="index-key-name" v-for="(_, key) of list" :key="key">
        {{ key }}
      </div>
    </div>
  </div>
</template>

<script>
import { reactive } from "vue"
import { indexListData } from "./mock"

export default {
  setup () {
    const list = reactive(indexListData)

    return {
      list
    }
  },
}
</script>

<style lang="scss" >
* {
  padding: 0;
  margin: 0;
}
.list-page-box {
  position: relative;
}
.list-box {
  .group-box {
    margin-bottom: 24px;
    .gropu-index {
      background-color: #f4f5f6;
      padding: 10px;
      font-weight: bold;
      position: sticky;
      top: 0;
    }
    .group-content {
      .group-item {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 6px 10px;

        .group-item-pic {
          width: 68px;
          min-width: 68px;
          height: 68px;
          margin-right: 12px;
        }
        .group-item-content {
          display: flex;
          flex-direction: column;
          height: 100%;
          h1 {
            font-size: 16px;
            font-weight: bold;
            color: #333333;
          }
          p {
            color: #666666;
            font-size: 14px;
          }
        }

        .group-item-aciton {
          min-width: 60px;
          display: flex;
          align-items: center;
          justify-content: end;
        }
      }
    }
  }
}
.index-field-box {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 10;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
</style>

方案

采用虚拟列表,参考云中桥-「前端进阶」高性能渲染十万条数据(虚拟列表)的方案。

94A6DB13-758E-4E69-ADC8-E22A748245D0.png

根据上面对虚拟列表的描述,编写了一个简单的虚拟列表,代码如下。

<template>
  <div class="list-page-box" ref="scrollRef">
    <!--暂时固定高度-->
    <div style="height: 10000px"></div>
     <!--列表-->
    <div class="list-box" :style="{ transform: listTransform }">
      <div class="item" v-for="item in list" :key="item">{{ item }}</div>
    </div>
  </div>
</template>

<script>
import { computed, onMounted, reactive, ref } from "vue"

export default {
  setup () {
    const scrollRef = ref(null)
    const listTransform = ref("translate3d(0px, 0px, 0px)")
    // 生成数据
    const originList = reactive(Array(10000).fill(0).map((_, index) => index))
    const startIndex = ref(0)
    
    const list = computed(() => {
      return originList.slice(startIndex.value, startIndex.value + 10)
    })

    onMounted(() => {
      scrollRef.value.addEventListener('scroll', () => {
        const scrollTop = scrollRef.value.scrollTop
        // 计算list开始位置
        const start = scrollTop / 83 | 0
        startIndex.value = start;
        // 计算偏移
        listTransform.value = `translate3d(0px, ${((start * 83)).toFixed(2)}px, 0px)`
      })
    })

    return {
      list,
      scrollRef,
      listTransform
    }
  },
}
</script>

<style lang="scss" >
.list-page-box {
  position: relative;
  height: 100vh;
  width: 100vw;
  overflow: hidden;
  overflow-y: auto;
}
.list-box {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.item {
  padding: 30px;
  border-bottom: 1px solid #000;
}
</style>

改造难点

在这个改造中主要问题就是当前是一个嵌套的数据列表

  1. 需要将原来单层结构改造成双层结构
  2. 偏移方案,transform 对 sticky 有冲突
  3. index key的高度问题
  4. 可视区域多个 index list item
  5. 点击右侧Index Key跳转到指定位置

实现

通过上方虚拟列表代码进行后续的改造和实现,这里先放实现代码,后面将分别解决上述问题。

<template>
  <div class="list-page-box" ref="scrollRef">
    <div :style="{ height: scrollHeight + 'px' }"></div>
    <!-- fix: 问题 3 的解决方案 更换成 top 临时解决一下 -->
    <div class="list-box" :style="{ top: offsetTop + 'px' }">
      <div class="group-box" v-for="(value, key) of list" :key="key">
        <div class="gropu-index">{{ key }}</div>
        <div class="group-content">
          <div class="group-item" v-for="item in value" :key="item.id">
            <div class="group-item-pic" style="background: #f5f6f7"></div>
            <div class="group-item-content">
              <h1>{{ item.title }}</h1>
              <p>{{ item.content }}</p>
            </div>
            <div class="group-item-aciton">
              <button>删除</button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="index-field-box">
      <div
        class="index-key-name"
        v-for="key in keys"
        :key="key"
        @click.stop.prevent="handleToList(key)"
      >
        {{ key }}
      </div>
    </div>
  </div>
</template>

<script>
import { computed, onMounted, reactive, ref, watch, watchEffect } from "vue"
import { indexListData } from "../mock"

// mock index list data
console.log('indexListData', JSON.stringify(indexListData));
// todo 封装 问题 (暂时不考虑数据更新后的其他问题)
// 先去看一些优秀的封装
// 感觉都是一个想法 
// 传入 数据 -> slot item 这样的话我就懒得封了 md 懒鬼
// 1. 输入
//   数据 index 高度 list item 高度
// 2. 输出
//   初始化的函数
//   渲染的数据
export default {
  setup () {
    // 原数据
    const originList = indexListData
    
    const scrollRef = ref(null)
    const scrollTop = ref(0) // todo 需要额外计算偏移
    // 存储数据最终渲染的高度
    const scrollHeight = ref(0)
    const offsetTop = ref(0)
    // 当前下标
    const showListIndexs = reactive({
      key: 'A',
      index: 0,
      sonIndex: 0
    });

    // 临时存储
    const originListHeight = ref([])
    const keys = ref([])

    // 需要渲染的数据
    const list = computed(() => {
      // 获取key 
      const { key, index, sonIndex } = showListIndexs;
      // 获取数据 
      // todo 这里的10个元素 后期需要进行计算 目前无所谓
      const showList = originList[key].slice(sonIndex, sonIndex + 10)
      // todo 实际上目前的key: value的机构还是有些问题的(无序),这个暂时按下不表
      const showData = {
        [key]: showList
      }
      // 计算 数据长度不够时的处理
      // todo 需要再细致化
      if (showList.length < 10) {
        // 处理 数据不够时的问题
        const nextIndex = index + 1

        if (nextIndex >= originListHeight.value.length) return showData
        const nextHeightData = originListHeight.value[nextIndex];
        if (!nextHeightData) return showData;
        const nextKey = nextHeightData.key;
        const nextShowList = originList[nextKey].slice(0, 10 - showList.length)
        showData[nextKey] = nextShowList
      }

      return showData
    })

    // 监听数据
    onMounted(() => {
      scrollRef.value.addEventListener('scroll', () => {
        const _scrollTop = scrollRef.value.scrollTop
        // todo 高度计算
        // 高度偏移需要配合上数据更新才能完成滚动的交互
        scrollTop.value = _scrollTop
      })
    })

    // 用一个生命周期 后期可换成 异步触发
    onMounted(() => {
      let total = 0;
      for (let key in originList) {
        const value = originList[key]
        // todo 临时借用
        keys.value.push(key)

        originListHeight.value.push({
          index: 42,
          list: value.length * 80,
          total: value.length * 80 + 42,
          key
        })
        total += value.length * 80 + 42
      }
      scrollHeight.value = total
    })

    // 只关注 scrollTop 的变化
    watchEffect(() => {
      // 分离一下 计算过程 减少列表更新 无意义渲染
      // 这里主要计算 index
      if (originListHeight.value.length == 0) {
        // 分别赋值 减少无意义的list渲染
        showListIndexs.key = 'A'
        showListIndexs.index = 0
        showListIndexs.sonIndex = 0
        return
      }
      // todo 
      // scrollTop 通过scrollTop 
      // 计算之前需要计算原数据(originList)的高度
      // 目前不考虑 px -> rem 造成的问题
      // 通过设置的css可知一个item height: 80px;
      // 但是还需要知道indxKey也就是 class="gropu-index"的高度 height: 42px;
      // todo 前期单位固定 先完成核心 再考虑动态高度的问题
      // 1. 找到大方向 也就是 首层数据
      // 2. 根据大方向 减去 scrollTop 后 计算子数据Index 
      // 3. 数据不够需要 拿到下层数据
      let total = 0;
      let index = originListHeight.value.findIndex(item => {
        // 找到高度和比当前滚动高度 大的第一个
        let t = total + item.total
        if (t > scrollTop.value) {
          return true;
        }
        total = t;
        return false;
      });
      // 处理 首次 top 为0的情况
      // todo 这里还有点小问题 晚点说明
      if (index === -1) return {
        key: 'A',
        sonIndex: 0
      };
      const key = originListHeight.value[index].key;
      // total 为最近的
      const sonListTop = scrollTop.value - total
      // 得到子列表开始下标
      const sonIndex = sonListTop / 80 | 0
      // console.log('sonIndex',sonIndex);
      // 计算偏移 ok
      offsetTop.value = total + sonIndex * 80;

      showListIndexs.key = key
      showListIndexs.index = index
      showListIndexs.sonIndex = sonIndex
    }, [scrollTop])

    return {
      list,
      scrollRef,
      scrollTop,
      scrollHeight,
      offsetTop,
      keys,
      handleToList (key) {
        // 由于数据加载后已经对预渲染的高度进行了一个计算
        // 所以这里只要改变滚动的高度即可完成其他所有操作
        if (!scrollRef.value) return;
        // 计算高度
        let height = 0;

        const heightData = originListHeight.value.find(item => {
          if (item.key === key) return true;
          height += item.total;
          return false;
        })
        if (!heightData) return;
        scrollRef.value.scrollTo(0, height)
      }
    }
  },
}
</script>

<style lang="scss" >
* {
  padding: 0;
  margin: 0;
}
.list-page-box {
  position: relative;
  height: 100vh;
  width: 100vw;
  overflow: hidden;
  overflow-y: auto;
}

.list-box {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  .group-box {
    /* padding-top: 24px; */
    box-sizing: border-box;
    .gropu-index {
      background-color: #f4f5f6;
      padding: 10px;
      font-weight: bold;
      // todo bug
      position: sticky;
      top: 0;
      height: 42px;
      box-sizing: border-box;
    }
    .group-content {
      .group-item {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 6px 10px;
        // 固定的高度
        height: 80px;
        box-sizing: border-box;
        /* 不做其他处理 保证高度一致 */
        overflow: hidden;

        .group-item-pic {
          width: 68px;
          min-width: 68px;
          height: 68px;
          margin-right: 12px;
        }
        .group-item-content {
          display: flex;
          flex-direction: column;
          height: 100%;
          h1 {
            font-size: 16px;
            font-weight: bold;
            color: #333333;
          }
          p {
            color: #666666;
            font-size: 14px;
          }
        }

        .group-item-aciton {
          min-width: 60px;
          display: flex;
          align-items: center;
          justify-content: end;
        }
      }
    }
  }
}
.index-field-box {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 10;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 10px;
}
</style>

难点解决

渲染位置和偏移位置

由于是双层数据中单个IndexList包含Index和List的高度,所以在拿到数据后先对数据高度进行预测,这里预测方式为固定的item和key高度。

前提: item 高度为80,index 高度为42; 这里后续可以先进行预渲染然后拿到渲染的高度。

高度计算

// 总高度 用于固定 scroll height
let total = 0;
// 循环计算所有高度
for (let key in originList) {
    const value = originList[key]
    // 记录所欲key 用于右侧列表的渲染
    keys.value.push(key)
    // 缓存
    originListHeight.value.push({
      index: 42,
      list: value.length * 80,
      total: value.length * 80 + 42,
      key
    })
    total += value.length * 80 + 42
}
scrollHeight.value = total

对于渲染数据的计算是根据滚动位置和数据高度。对于渲染数据来说,双层数据只需要分别计算出第一层和第二层的数据下标即可。

对于第一层只需要计算滚动高和数据高度的大小即可得到。

第二层位置拿到与第一层数据高度和滚动高度的差额再除去单个元素的高度。

// 只关注 scrollTop 的变化
watchEffect(() => {
  // 分离一下 计算过程 减少列表更新 无意义渲染
  // 这里主要计算 index
  if (originListHeight.value.length == 0) {
    // 分别赋值 减少无意义的list渲染
    showListIndexs.key = 'A'
    showListIndexs.index = 0
    showListIndexs.sonIndex = 0
    return
  }

  // 找到第一层数据位置
  let total = 0;
  let index = originListHeight.value.findIndex(item => {
    // 找到高度和比当前滚动高度 大的第一个
    let t = total + item.total
    if (t > scrollTop.value) {
      return true;
    }
    total = t;
    return false;
  });
  // 处理 首次 top 为0的情况
  // todo 这里还有点小问题 晚点说明
  if (index === -1) return {
    key: 'A',
    sonIndex: 0
  };
  const key = originListHeight.value[index].key;
  // total 为最近的
  const sonListTop = scrollTop.value - total
  // 得到子列表开始下标
  const sonIndex = sonListTop / 80 | 0
  // console.log('sonIndex',sonIndex);
  // 计算偏移 ok
  offsetTop.value = total + sonIndex * 80;

  showListIndexs.key = key
  showListIndexs.index = index
  showListIndexs.sonIndex = sonIndex
}, [scrollTop])

渲染数据的计算

采用计算属性根据 showListIndexs 的变化来进行更新,通过scrollTop计算位置后,拿到一二层下标进行数据截取,不过滚动位置的变化导致第二层数据可能无法满足渲染整个可视区域。所以需要额外的数据补充的计算,这里补充计算暂时只做两层。

// 需要渲染的数据
const list = computed(() => {
  // 获取key 
  const { key, index, sonIndex } = showListIndexs;
  // 获取数据 
  // todo 这里的10个元素 后期需要进行计算 目前无所谓
  const showList = originList[key].slice(sonIndex, sonIndex + 10)
  // todo 实际上目前的key: value的机构还是有些问题的(无序),这个暂时按下不表
  const showData = {
    [key]: showList
  }
  // 计算 数据长度不够时的处理
  // todo 需要再细致化 需要一个循环
  if (showList.length < 10) {
    // 处理 数据不够时的问题
    const nextIndex = index + 1

    if (nextIndex >= originListHeight.value.length) return showData
    const nextHeightData = originListHeight.value[nextIndex];
    if (!nextHeightData) return showData;
    const nextKey = nextHeightData.key;
    const nextShowList = originList[nextKey].slice(0, 10 - showList.length)
    showData[nextKey] = nextShowList
  }

  return showData
})

右侧点击跳转

由于提前对预渲染高度进行了计算,所以这个问题约等于不存在。

// 由于数据加载后已经对预渲染的高度进行了一个计算
// 所以这里只要改变滚动的高度即可完成其他所有操作
if (!scrollRef.value) return;
// 计算高度
let height = 0;

const heightData = originListHeight.value.find(item => {
  if (item.key === key) return true;
  height += item.total;
  return false;
})
if (!heightData) return;
scrollRef.value.scrollTo(0, height)

移植问题

只需要替换监听和滚动位置,即可完成大体功能的移植。所以这里不做细节的描述。

参考

前端进阶」高性能渲染十万条数据(虚拟列表)