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

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

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

问题分析
由于表达能力弱上方描述可能不太清楚,所以将关键词提取出来。
  1. 小程序: 项目环境
  2. index list: 需求
  3. 卡顿/白屏: 问题
  4. 500条/1M: 产生问题的前提
从产生问的题前提很容易的产生一个疑问“数据量这么少还能卡?”。我在测试过程中发现的时候也是觉得诧异,这点数据能干什么?在非小程序开发的情况下我一般会见这一块代码单独开一个项目进行测试,但是小程序众所周知的卡,所以我采用了一个非常简单的方式百度“小程序 列表 卡顿”,在搜索的时候我甚至没写“长列表”,但是我还是得到了结果,还是在搜索结果的第一条。搜索结果如下图所示。
利用虚拟列表改造索引列表(IndexList)
文章图片

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 = https://www.it610.com/article/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; }, {})

业务代码 渲染图
利用虚拟列表改造索引列表(IndexList)
文章图片

没有改造之前的代码。只做部分实现,未完全实现
* { 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; }

方案
采用虚拟列表,参考云中桥-「前端进阶」高性能渲染十万条数据(虚拟列表)的方案。
利用虚拟列表改造索引列表(IndexList)
文章图片

根据上面对虚拟列表的描述,编写了一个简单的虚拟列表,代码如下。
.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; }

改造难点
在这个改造中主要问题就是当前是一个嵌套的数据列表
  1. 需要将原来单层结构改造成双层结构
  2. 偏移方案,transform 对 sticky 有冲突
  3. index key的高度问题
  4. 可视区域多个 index list item
  5. 点击右侧Index Key跳转到指定位置
实现
通过上方虚拟列表代码进行后续的改造和实现,这里先放实现代码,后面将分别解决上述问题。
* { 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; }

难点解决
渲染位置和偏移位置 由于是双层数据中单个IndexList包含Index和List的高度,所以在拿到数据后先对数据高度进行预测,这里预测方式为固定的item和key高度。
前提: item 高度为80,index 高度为42; 这里后续可以先进行预渲染然后拿到渲染的高度。
高度计算
// 总高度 用于固定 scroll height let total = 0; // 循环计算所有高度 for (let key in originList) { const value = https://www.it610.com/article/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 = https://www.it610.com/article/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 = https://www.it610.com/article/{ [key]: showList } // 计算 数据长度不够时的处理 // todo 需要再细致化 需要一个循环 if (showList.length < 10) { // 处理 数据不够时的问题 const nextIndex = index + 1if (nextIndex>= originListHeight.value.length) return showData const nextHeightData = https://www.it610.com/article/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 = https://www.it610.com/article/originListHeight.value.find(item => { if (item.key === key) return true; height += item.total; return false; }) if (!heightData) return; scrollRef.value.scrollTo(0, height)

移植问题 只需要替换监听和滚动位置,即可完成大体功能的移植。所以这里不做细节的描述。
参考 前端进阶」高性能渲染十万条数据(虚拟列表)

    推荐阅读