引言

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

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

问题

需要与问题形容

关键词: 小程序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 dataconsole.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 heightlet 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)

移植问题

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

参考

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