乐趣区

关于javascript:利用虚拟列表改造索引列表IndexList

引言

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

以下便是我想到的非暴力形式来革新原有问题。

问题

需要与问题形容

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

在进行 小程序 我的项目开发过程中,遇到索引列表的需要,于是采纳 vantIndexBar作为进行开发,实现并公布线上,然而因为 item 编写的的确不行以及小程序承载最终将问题裸露了进去。通过测试发现当数据 大于 500 条 时局部手机曾经开始呈现 卡顿 状况,其中对 item 操作(删除、减少)时卡顿显著;当数据大小大于 1MB 后更是呈现首屏渲染长时间的 白屏。IndexList 如下图所示。

问题剖析

因为表达能力弱上方形容可能不太分明,所以将关键词提取进去。

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

从产生问的题前提很容易的产生一个疑难“数据量这么少还能卡?”。我在测试过程中发现的时候也是感觉惊讶,这点数据能干什么?在非小程序开发的状况下我个别会见这一块代码独自开一个我的项目进行测试,然而小程序家喻户晓的卡,所以我采纳了一个非常简单的形式百度“小程序 列表 卡顿”,在搜寻的时候我甚至没写“长列表”,然而我还是失去了后果,还是在搜寻后果的 第一条。搜寻后果如下图所示。

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>

计划

采纳虚构列表,参考云中桥 -「前端进阶」高性能渲染十万条数据 (虚构列表) 的计划。

依据上面对虚构列表的形容,编写了一个简略的虚构列表,代码如下。

<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)

移植问题

只须要替换监听和滚动地位,即可实现大体性能的移植。所以这里不做细节的形容。

参考

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

退出移动版