瀑布流优化:我把小红书的瀑布流虚拟列表撕出来了🧐!但女友不理我了😭😭

扯皮

接上篇瀑布流内容,我仔细研究发现小红书的首页布局并不是简单的瀑布流,所以停下手边的游戏开始打开 F12 慢慢查看🤔

不知不觉忘记了女朋友跟我发的小红书链接具体内容,一头扎在了小红书的首页,快半小时过去才想起忘来给女朋友回消息了😇

当我反应过来赶紧发消息后,发现两极反转,这次换成我被冷落了😶😶😶

啧啧啧,如今女朋友已经成为了前女友,我们也已经分手有将近两年时间了,记得分手半年之后身边的朋友推荐我读一本书:《非暴力沟通》,说能够加强自己的沟通能力,虽然以后会成为码农但人与人之间的沟通还是需要的,所以提前学习一下沟通技巧

读完之后发现自己纯粹是一个🤡,书中举的反例都让哥们踩完了,而且完美的应用到了女友身上🤡🤡🤡

如今写最近两篇文章时脑子不断浮现以前的日子,回想起来自己真是铁直男啊,不过还好前女友和我都是初恋,双方彼此还是比较包容的,都是没谈过恋爱的傻瓜罢了

不过纸包不住火,初恋虽然美好,但是没有任何恋爱经验,女方矫情一些,男方再直男一些,直接就寄了😑

感觉大学的恋爱就是一句话:一个敢说,一个敢信...

只能说一场恋爱下来最大的成长就是让我踩了不少关于女生的坑,也感受到了年轻时代爱情的美好,同时掌握了一些沟通的技巧吧😁

不知道以后如果还有机会的话同一个坑自己会不会长记性呢😔😔😔

正文

这次扯的无关内容有点多哈,不过都是有感而发😜

终于来到了关于虚拟列表的最终篇:使用 Vue3 + TS 实现瀑布流虚拟列表,其实之前的几篇文章可以说都是为了这一篇做铺垫,没想到会有这么多人看😂,其实我当时的想法是希望这篇文章阅读量高一些来着...😶

如果你还不了解虚拟列表相关实现的话,请出门左转👇:

定高的虚拟列表会了,那不定高的...... 哈,我也会!看我详解一波!🤪🤪🤪 - 掘金 (juejin.cn)

面试官:能否用原生JS手写一个虚拟列表...啊?你还真能写啊? - 掘金 (juejin.cn)

如果你还不了解瀑布流实现的话,请出门右转👇:

女友看了小红书的pc端后,问我这个瀑布流的效果是怎么实现的?🤔 - 掘金 (juejin.cn)

本文不再去额外讲解两者的具体实现原理,所以如果你都了解了,那请进来坐,我们来一点一点分析瀑布流虚拟列表的实现过程🤗

开始之前还是想声明一下:我不可能实现的和小红书的瀑布流虚拟列表一模一样,因为我也没有扒过它的源码不知道它的具体实现方案是怎样的,但是做出瀑布流虚拟列表的效果还是可以的🤗,争取最终实现的效果和小红书保持一致吧

以及本文实现只是一个 demo 级别,仅提供一个实现思路,切勿随便在真实业务场景里使用 ❗ ❗ ❗

浅谈瀑布流虚拟列表

在写代码之前还是简单讲讲瀑布流虚拟列表的优势,它属于瀑布流的优化方案

我们知道瀑布流每张卡片都需要计算布局,随着你滚动加载更多在视图上展示的 DOM 数量也会越来越多,那么带来的问题就是出现滚动卡顿掉帧的情况

且假如你的视图上已经有 1000 个卡片了,如果现在更改视口触发回流后会直接来 1000 次 DOM 操作计算位置并布局,这无疑带来巨大的性能损耗

而瀑布流虚拟列表结合了虚拟列表的特性,保证在视图上真实渲染的 DOM 只控制在容器范围内,而其他数据只存储在内存当中,当进行滚动时根据每张卡片计算的位置来控制显隐实现 "有限的 DOM 来加载无限的数据" 的效果

当然也并不是用到瀑布流就无脑想着虚拟列表进行优化,任何技术始终会有它自己的缺陷,具体是否使用还要根据业务场景来定,如果普通的瀑布流没有达到性能瓶颈那完全没必要使用该技术

组件结构搭建及样式

瀑布流虚拟列表的 DOM 结构实际上与瀑布流的结构以及样式大体上相同,依旧是只需要 container、list、item 这三个内容即可,设置 container 滚动条,list 开启相对定位,item 开启绝对定位,item 通过 transform 来设置卡片具体位置,统一定位到左上角:

html 复制代码
<div class="fs-virtual-waterfall-container">
  <div class="fs-virtual-waterfall-list">
    <div class="fs-virtual-waterfall-item">
        <slot name="item"></slot>
    </div>
  </div>
</div>
scss 复制代码
.fs-virtual-waterfall {
  &-container {
    width: 100%;
    height: 100%;
    overflow-y: scroll;
    overflow-x: hidden;
  }
  &-list {
    position: relative;
    width: 100%;
  }
  &-item {
    position: absolute;
    top: 0;
    left: 0;
    box-sizing: border-box;
  }
}

组件 props 和 初始化状态

首先来设置组件的 props,我们还是先来讲固定宽高的卡片,且数据源已经携带了图片的尺寸信息:

ts 复制代码
export interface IVirtualWaterFallProps {
  gap: number; // 卡片间隔
  column: number; // 瀑布流列数
  pageSize: number; // 单次请求数据数量
  request?: (page: number, pageSize: number) => Promise<ICardItem[]>; // 数据请求方法
}

export interface ICardItem {
  id: number | string;
  width: number;
  height: number;
  [key: string]: any;
}

然后就是组件内部的状态了,因为这次的组件具备了虚拟列表和瀑布流两个特性,所以内部的状态定义还是比较多的,我们慢慢解释

首先就是数据源的状态,都是老熟人了:

ts 复制代码
const dataState = reactive({
  loading: false, // 发送请求 loading 状态
  isFinish: false, // 请求数据是否已经结束(返回空数据)
  currentPage: 1, // 当前页数
  list: [] as ICardItem[], // 数据源
});

紧接着是虚拟列表和瀑布流相关的状态,我们需要容器的宽度计算出卡片宽度 ,需要用到容器高度和 start 来计算出 end

这里的容器高度其实可以类比虚拟列表的最大容纳量,start 就是 startIndex,end 就是 endIndex,只不过现在是二维布局,不再是简单的一个列表索引了,至于为什么这样计算我们后面再揭晓:

ts 复制代码
const containerRef = ref<HTMLDivElement | null>(null);

const scrollState = reactive({
  viewWidth: 0,
  viewHeight: 0,
  start: 0
});

const end = computed(() => scrollState.viewHeight + scrollState.start);

以及瀑布流单独的数据状态,存储卡片的二维数组 ,针对于瀑布流的每一列包含了当前列高和当前列的所有卡片 list,注意这里存储的卡片数据与普通的数据源不同,它多了卡片自身的位置信息,以及后续我们要进行动态绑定的样式(偏移量)

ts 复制代码
interface IColumnQueue {
  list: IRenderItem[];
  height: number;
}

// 渲染视图项
 interface IRenderItem {
  item: ICardItem; // 数据源
  y: number; // 卡片距离列表顶部的距离
  h: number; // 卡片自身高度
  style: CSSProperties; // 用于渲染视图上的样式(宽、高、偏移量)
}

const queueState = reactive({
  queue: Array(props.column)
    .fill(0)
    .map<IColumnQueue>(() => ({ list: [], height: 0 })), // 存储所有卡片的二维数组
  len: 0, // 存储当前视图上展示的所有卡片数量
});

有了它之后就需要回忆我们瀑布流实现的原理了,其中有一个关键步骤就是计算当前的最小高度列以及其索引

这次由于结合虚拟列表还需要计算一下最大高度列,因为这个值代表着 DOM 结构中 list 的 height 样式

ts 复制代码
const computedHeight = computed(() => {
  let minIndex = 0,
    minHeight = Infinity,
    maxHeight = -Infinity;
  queueState.queue.forEach(({ height }, index) => {
    if (height < minHeight) {
      minHeight = height;
      minIndex = index;
    }
    if (height > maxHeight) {
      maxHeight = height;
    }
  });

  return {
    minIndex,
    minHeight,
    maxHeight,
  };
});

我们拿到了最高列的高度,就可以设置样式了,之后再在 template 上进行绑定即可:

ts 复制代码
const listStyle = computed(() => ({ height: `${computedHeight.value.maxHeight}px` } as CSSProperties));

这个思路其实和虚拟列表是一样的,因为我们滚动条的高度是与当前数据源的长度对应的,普通虚拟列表的实现还要动态绑定 transform 来实现虚拟效果,这里由于结合瀑布流,我们不再这样做了

确认视图范围、计算 renderList

在初始化状态中我们有了 start 和 end,按照虚拟列表的步骤应该就可以直接通过 slice 截取数据源设置最终展示到视图上的 renderList 了🤔 ,不过这里毕竟不止实现了虚拟列表,别忘了还有瀑布流😇

在上面定义 queueState 状态时我们有一个 IRenderItem , 它才是最终要渲染在视图上的 item 项, renderList 里的内容是 IRenderItem 而不是数据源 ICardItem

queueState 只是一个瀑布流的二维抽象数据结构,最终还要转换成一个一维的 renderList 列表渲染到视图上,至于二维的效果已经在 IRenderItem 中存储了,就是它的 style 样式属性

明白了这一点其实就知道了我们这回并不是要截取数据源,而是要截取 queueState 里的 list 二维数组

现在问题是 queueState 里是一个二维结构,我们需要先把它转换为一维的,二维数组转一维怎么转呢🤔?

用 reduce 直接秒了🤪:

ts 复制代码
const cardList = computed(() => queueState.queue.reduce<IRenderItem[]>((pre, { list }) => pre.concat(list), []));

之后就到了截取操作,而到此就进入了瀑布流虚拟列表的第一个关键实现🧐

这回可没有像虚拟列表的 startIndex、endIndex 索引了,有的是 start、end 这两个值,我们来看这两个值具体的含义,直接上图:

在没有滚动的初始状态,这两个值是这样的:

而当进行滚动时 container 的 scrollTop 值发生变化,实际上当我们触发滚动事件时需要始终让 start 为 scrollTop 值 ,至于 end 的本质就是 scrollTop + container Height

为什么要这么做呢🤔?这牵扯到滚动,在解答这个问题之前我们先来看一下渲染在视图上卡片的 IRenderItem 中信息字段的含义,主要关注 y、h 两个字段:

总结一下:y 是该卡片与 list 顶部的距离,h 是该卡片的高度

根据这种布局方式你会发现 ⑥ 的 y 值 = ① 的 y 值 + ① 的 h 值,这个关系式是我们后面需要计算位置的关键,这点我们后面再说

我们知道虚拟列表只会保留出现在 container 视图中的 DOM,之前虚拟列表是通过 startIndex、endIndex 来确定这个视图范围,现在结合了瀑布流就不一样了,我们单独看一张卡片,观察它什么时候滚出视图:

上面两张图画出了①、⑥ 卡片滚出视图的情景,我们重点关注 y、h 与 start(scrollTop) 三者的关系,不难发现有这样的结论:

针对于一个 item,当其 y + h <= start 时表示它已超出 container 视图范围

别忘了这只是向上滚动,如果卡片本身就在 container 外面即在它的下面,我们再来看看这种情况:

我们也能得出一个结论:

针对于一个 item,当其 y >= end 时表示它已超出 container 视图范围

有了这样的关系,不就直接能确认视图范围了嘛🤪:

针对于一个 item,当其 y + h > start && y < end 时表示它在 container 视图中

利用我们扁平化的 cardList,直接上代码计算 renderList:

ts 复制代码
const renderList = computed(() => cardList.value.filter((i) => i.h + i.y > scrollState.start && i.y < end.value));

现在 template 上就可以利用 renderList 遍历渲染 item 并绑定其样式了,我们顺便把插槽补充上:

html 复制代码
<template>
  <div class="fs-virtual-waterfall-container" ref="containerRef">
    <div class="fs-virtual-waterfall-list" :style="listStyle">
        <div class="fs-virtual-waterfall-item" v-for="{ item, style } in renderList" :style="style" :key="item.id">
          <slot name="item" :item="item"></slot>
        </div>
    </div>
  </div>
</template>

数据源 item 转换为渲染卡片

不知道大伙看完上一节之后是否有这样的疑问:卡片 item 的 h、y 值以及样式在哪计算呢?数据源 dataState 怎么转换成 queueState?🤔

这一节就来解决这个问题,也是整个瀑布流虚拟列表的第二个关键实现

我们实现一个 addInQueue 函数,它用来将原数据源 item 转换为 cardItem 并添加到 dataState 队列里,添加的规则很简单,就是瀑布流布局操作:

ts 复制代码
const addInQueue = (size: number) => {
  for (let i = 0; i < size; i++) {
    const minIndex = computedHeight.value.minIndex;
    const currentColumn = queueState.queue[minIndex];
    const before = currentColumn.list[currentColumn.list.length - 1] || null;
    const dataItem = dataState.list[queueState.len];
    const item = generatorItem(dataItem, before, minIndex);
    currentColumn.list.push(item);
    currentColumn.height += item.h;
    queueState.len++;
  }
};

这里乍一看信息比较多,其实过程就是瀑布流中找到最小高度列,然后计算出一个 cardItem,将其添加到对应的队列当中,不过还是需要做一下讲解的,我们一点一点来看 🤓

首先就是函数的参数 size,它代表着此次添加到瀑布流队列中卡片的数量,与单次请求到的数据量保持一致

之后就到了我们熟悉的瀑布流计算环节了,我们针对于单个卡片添加来看:

  1. 通过 computedHeight 找到最小高度列的索引
  2. 通过索引以及 queueState 找到当前最小高度列
  3. 计算新添加的卡片 item 信息(h、y、style)
  4. 将新添加的卡片添加到当前最小高度列里面
  5. 更新当前最小列的高度(累加新的卡片高度),累计到当前瀑布流队列里 (len++)

这里面最大的问题在于如何计算卡片的信息,我们以添加 ⑥ 为例,现在它已经找到最小高度列是第一列:

首先我们需要获取到当前列的最后一项 ① ,因为之前我们已经分析出这样的关系式:⑥ 的 y 值 = ① 的 y 值 + ① 的 h 值,这就是代码中 before 代表的含义,但别忘了它可能为空,因为当第一行数据添加时队列里还没有数据呢

之后我们需要获取到 ⑥ 的数据源信息 ,我们给 queueState 预留了 len 属性,它就是用来保存当前的瀑布流队列里所有卡片的数量,我们直接利用该属性值作为索引去访问 dataSource 即可

现在还差 ⑥ 的 h 值以及它的 style 样式,这些我们将其封装到了 generatorItem 函数中实现:

ts 复制代码
const generatorItem = (item: ICardItem, before: IRenderItem | null, index: number): IRenderItem => {
  const rect = itemSizeInfo.value.get(item.id);
  const width = rect!.width;
  const height = rect!.height;
  let y = 0;
  if (before) y = before.y + before.h + props.gap;

  return {
    item,
    y,
    h: height,
    style: {
      width: `${width}px`,
      height: `${height}px`,
      transform: `translate3d(${index === 0 ? 0 : (width + props.gap) * index}px, ${y}px, 0)`,
    },
  };
};

这里又冒出来了一个 itemSizeInfo,它是用来辅助计算卡片的宽度高度的

关于卡片高度的计算就是我们瀑布流一个交叉相乘的算法,只不过还需要用到它的数据源图片的尺寸

我们直接将数据源的数据映射到 itemSizeInfo 中提前计算出每个卡片的宽度高度,之后可以通过 id 直接访问到该数据对应的卡片信息:

ts 复制代码
interface IItemRect {
  width: number;
  height: number;
}

const itemSizeInfo = computed(() =>
  dataState.list.reduce<Map<ICardItem["id"], IItemRect>>((pre, current) => {
    const itemWidth = Math.floor((scrollState.viewWidth - (props.column - 1) * props.gap) / props.column);
    pre.set(current.id, {
      width: itemWidth,
      height: Math.floor((itemWidth * current.height) / current.width),
    });
    return pre;
  }, new Map())
);

到此我们已经实现了数据源与卡片信息的转化,当请求获取到数据之后调用 addInQueue 函数即可,响应式数据会自动再计算 renderList 更新视图的

现在一个简单的瀑布流虚拟列表核心原理都已经被我们实现了🧐剩下的其实就是代码组装呈现效果

组织代码呈现效果

初始化操作

初始化操作的主要任务如下:

  1. 计算容器宽度和高度以及初始化 start 值
  2. 请求获取数据
  3. 获取到数据后将其转化保存至 queueState 中
  4. 添加滚动事件
ts 复制代码
const initScrollState = () => {
  scrollState.viewWidth = containerRef.value!.clientWidth;
  scrollState.viewHeight = containerRef.value!.clientHeight;
  scrollState.start = containerRef.value!.scrollTop;
};

const init = async () => {
  initScrollState();
  const len = await loadDataList();
  len && addInQueue(len);
};

onMounted(() => {
  init();
});

获取数据没什么好说的,就是注意要把获取到数据的长度返回,给后面添加到 queueState 队列使用:

ts 复制代码
const loadDataList = async () => {
  if (dataState.isFinish) return;
  dataState.loading = true;
  const list = await props.request(dataState.currentPage++, props.pageSize);
  if (!list.length) {
    dataState.isFinish = true;
    return;
  }
  dataState.list.push(...list);
  dataState.loading = false;
  return list.length;
};

滚动事件主要是将 start 设置为 scrollTop 值,其次考虑触底加载处理

需要注意的是触底的条件,是滚动到当前最小列的高度,而不是最大列高度

ts 复制代码
const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight } = containerRef.value!;
  scrollState.start = scrollTop;
  if (scrollTop + clientHeight > computedHeight.value.minHeight) {
    !dataState.loading &&
      loadDataList().then((len) => {
        len && addInQueue(len);
      });
  }
});

别忘了最后在 template 模板上给 container 添加对应的 scroll 事件~

html 复制代码
<template>
  <div class="fs-virtual-waterfall-container" ref="containerRef" @scroll="handleScroll">
    <div class="fs-virtual-waterfall-list" :style="listStyle">
      <div class="fs-virtual-waterfall-item" v-for="{ item, style } in renderList" :key="item.id" :style="style">
        <slot name="item" :item="item"></slot>
      </div>
    </div>
  </div>
</template>

准备数据

关于数据还是把上次小红书的扒过来,直接组织一下使用就行:

ts 复制代码
// config/index.ts
import { ICardItem } from "../components/type";
import data1 from "./data1.json";
import data2 from "./data2.json";

const colorArr = ["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399"];

const list1: ICardItem[] = data1.data.items.map((i) => ({
  id: i.id,
  width: i.note_card.cover.width,
  height: i.note_card.cover.height,
  title: i.note_card.display_title,
  author: i.note_card.user.nickname,
}));

const list2: ICardItem[] = data2.data.items.map((i) => ({
  id: i.id,
  width: i.note_card.cover.width,
  height: i.note_card.cover.height,
  title: i.note_card.display_title,
  author: i.note_card.user.nickname,
}));

const list = [...list1, ...list2].map((item, index) => ({ bgColor: colorArr[index % (colorArr.length - 1)], ...item }));

export default list;

查看组件效果

哈,因为这次我们在初始化之前已经把关键计算全部实现过了,所以初始化结束之后就能够看到效果啦😎

现在在父组件中就能够愉快的使用我们的瀑布流虚拟列表组件了😃:

xml 复制代码
<template>
  <div class="app">
    <div class="container">
      <FsVirtualWaterfall :request="getData" :gap="15" :page-size="15" :column="3">
        <template #item="{ item }">
          <div
            class="test-item"
            :style="{
              background: item.bgColor,
            }"
          ></div>
        </template>
      </FsVirtualWaterfall>
    </div>
  </div>
</template>

<script setup lang="ts">
import FsVirtualWaterfall from "./components/FsVirtualWaterfall.vue";
import { ICardItem } from "./components/type";
import list from "./config/index";

const getData = (page: number, pageSize: number) => {
  return new Promise<ICardItem[]>((resolve) => {
    setTimeout(() => {
      resolve(list.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize));
    }, 1000);
  });
};
</script>

<style scoped lang="scss">
.app {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh;
  .container {
    width: 70vw;
    height: 90vh;
    border: 1px solid red;
  }

  .test-item {
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    border-radius: 10px;
  }
}
</style>

来看看效果咋样,注意看右边控制台的变化:

感觉还不错,至少瀑布流的样子有了,观察 DOM 数量也符合虚拟列表的特性😁😁😁

响应式实现和卡片动画效果

响应式实现

虽然现在瀑布流虚拟列表的效果有了,但是还没有实现瀑布流的响应式,大体的实现过程是一样的,当视口改变时都需要重新计算位置,我们一起来简单看一下:

卡片的所有位置信息都在 queueState 里,所以当视口改变时我们需要全部重新计算 queueState 中的卡片数据,我们实现一个 reComputedQueue 来重新计算:

ts 复制代码
const reComputedQueue = () => {
  // 清空 queueState 的所有卡片数据
  queueState.queue = new Array(props.column).fill(0).map<IColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  // 根据当前数据源长度全部重新计算添加
  addInQueue(dataState.list.length);
};

对于视口改变我们执行 resize 事件并添加防抖:

ts 复制代码
const handleResize = debounce(() => {
  initScrollState();
  reComputedQueue();
}, 300);

响应式的实现过程就直接复制粘贴瀑布流的实现咯,如果还不了解的可以看下之前瀑布流实现中的响应式实现过程,已经很清楚了:

女友看了小红书的pc端后,问我这个瀑布流的效果是怎么实现的?🤔 - 掘金 (juejin.cn)

就是这里的响应式如果给 item 加上过渡效果感觉怪怪的,应该是卡片全部重新计算的原因动画移动莫名其妙🤔,不像普通瀑布流依旧是在原来的 item DOM 上调整位置进行过渡,所以索性不加了:

卡片动画效果

虽然响应式的过渡效果很烂,但是其实可以单独给卡片添加一些动画🧐

因为这与普通的瀑布流不同,虚拟列表的不断滚动都会引起单个卡片 DOM 的创建和移除,如果给这些卡片添加一些动画的话效果要比普通的瀑布流一批卡片创建好太多:

比如更改一下透明度添加一个渐入的动画效果:

由于视频转 gif 的原因动画可能会有一些掉帧,实际动画效果是很丝滑的

scss 复制代码
// 父组件卡片添加样式
.test-item {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border-radius: 10px;
  animation: OpacityAnimate 0.25s;
}
@keyframes OpacityAnimate {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

这样滚动的时候给人的视觉效果可比普通的瀑布流好太多了,这都得益于虚拟列表的特性😎:

当然你也可以让动画复杂一些,滚动时让卡片有一个上移的动画,这效果更赞了👍,显得更加自然:

scss 复制代码
.test-item {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border-radius: 10px;
  animation: MoveAnimate 0.25s; 
 }

@keyframes MoveAnimate {
  from {
    opacity: 0;
    transform: translateY(200px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

最最重要的是这些动画都是父组件给卡片 DOM 添加的,与我们实现的瀑布流虚拟列表组件无关,想要什么动画随便你🤪

小红书效果实现

终于来到我们的主角了,小红书的一些问题在瀑布流的文章当中已经分析过了

响应式效果我们上面已经实现了,重点就在它的文本不定高的问题:

这里可以再扯一小段,其实按照之前的分析我们知道小红书的卡片 title 文本只有零行、一行、两行这三种情况,它的 author 部分是写死的高度

我一开始的想法是前端完全可以根据这个文本内容长度来预估出它应该要展示几行,然后针对于这三种情况我们只需要固定三种 title 高度就行,这样做就完美避免了获取每个卡片高度的 DOM 操作,大大提高瀑布流虚拟列表的性能

后来发现就如瀑布流那篇文章里面所说由于响应式的影响,可能会造成卡片的宽度进行压缩,这时候原来一行的文本又变成了两行,这是真一点脾气都没有了😑😑😑... 老老实实 DOM 操作获取高度吧

动态卡片高度的计算思路

虽然知道要进行 DOM 操作来获取卡片高度,但是现在又有问题了,这跟普通的瀑布流不一样啊😅

回想一下我们瀑布流是怎么计算的:

  1. 根据返回的图片尺寸确定卡片的宽度,让当前的所有卡片根据该宽度先进行挂载
  2. 通过 nextTick 获取所有卡片 DOM 来确定高度
  3. 计算所有卡片位置进行瀑布流布局

但我们目前实现的瀑布流虚拟列表偏向于定高情景,比如 generatorItem 生成卡片这些步骤都与图片高度信息的高度强相关,如果现在要强行插入一个真实 DOM 高度就需要大改之前的实现了,所有的代码结构都因为要获取这个高度而改变

我尝试了很多方法,思来想去还是不愿意在原来实现上进行大改,毕竟架子已经搭起来了,更希望的是在架子以外的地方补充额外的功能,最后再将它添加到架子上🤔

最终我选择了一个不太寻常但还是能够解决问题的方案:用一个盒子临时存放添加的卡片,等它挂载获取到真实高度后再把它卸载

我们只需要最终卡片高度这个信息,后面按照我们之前实现的流程走一遍就 OK 了

改造之前的 type 类型

和之前的思路一样,由于动态高度的原因,我们区分出图片高度以及卡片高度,新增加一个高度字段:

ts 复制代码
export interface IBookColumnQueue {
  list: IBookRenderItem[];
  height: number;
}

export interface IBookRenderItem {
  item: ICardItem;
  y: number;
  h: number;
  imageHeight: number;
  style: CSSProperties;
}

export interface IBookItemRect {
  width: number;
  height: number;
  imageHeight: number;
}

创建临时 DOM 获取真实卡片高度

首先我们给 template 模板上增加一个 temporary-list 的盒子,同时增加一个 temporaryList 来存储将要添加的卡片数据,对其进行遍历以渲染出卡片。既然是临时的,我们增加一个 isShow 变量来控制,当获取到真实卡片高度信息后这个盒子就可以卸载了,挂载我们真正的卡片:

html 复制代码
<template>
  <div class="fs-virtual-waterfall-container" ref="containerRef" @scroll="handleScroll">
    <div class="fs-virtual-waterfall-list" :style="listStyle">
      <div
        v-if="isShow"
        class="fs-virtual-waterfall-item"
        v-for="{ item, style, imageHeight } in renderList"
        :key="item.id"
        :style="style"
      >
        <slot name="item" :item="item" :imageHeight="imageHeight"></slot>
      </div>
      <!-- 临时存储要添加的卡片,用来获取动态卡片的高度 -->
      <div id="temporary-list" v-else>
        <div v-for="{ item, style, imageHeight } in temporaryList" :style="style">
          <slot name="item" :item="item" :imageHeight="imageHeight"></slot>
        </div>
      </div>
    </div>
  </div>
</template>
ts 复制代码
const temporaryList = ref<IBookRenderItem[]>([]);
const isShow = ref(false);

所以现在的思路就是我们获取到数据之后不再立即执行之前实现的 addInQueue 函数,而是先将这些数据存入到 fakeList 中,之后在 fake-list 盒子中挂载卡片

不过在实现之前还要更改之前的一个计算属性:itemSizeInfo,还记得它吗?它是用来映射数据源存储每个卡片的宽度和高度的(根据容器宽度以及数据图片尺寸信息计算)

但现在不能把它设置为计算属性了,因为后续的操作需要我们手动修改 itemSizeInfo,而且现在有图片高度以及卡片高度两个字段。虽然 computed 也可以让我们自定义 setter,但是我尝试了一下还是不太方便...😑

我们先单独实现一个 setSizeInfo 方法,刚开始卡片的高度初始化为 0,宽度和图片的高度都可以计算:

ts 复制代码
const itemSizeInfo = ref(new Map<ICardItem["id"], IBookItemRect>());

const setItemSize = () => {
  itemSizeInfo.value = dataState.list.reduce<Map<ICardItem["id"], IBookItemRect>>((pre, current) => {
    const itemWidth = Math.floor((scrollState.viewWidth - (props.column - 1) * props.gap) / props.column);
    pre.set(current.id, {
      width: itemWidth,
      height: 0,
      imageHeight: Math.floor((itemWidth * current.height) / current.width),
    });
    return pre;
  }, new Map());
};

之后就来实现 mountTemporaryList,在这里挂载临时的卡片,并获取其真实高度:

ts 复制代码
const mountTemporaryList = (list: ICardItem[]) => {
  isShow.value = false;
  list.forEach((item) => {
    const rect = itemSizeInfo.value.get(item.id)!;
    temporaryList.value.push({
      item,
      y: 0,
      h: 0,
      imageHeight: rect.imageHeight,
      style: {
        width: `${rect.width}px`,
      },
    });
  });
  nextTick(() => {
    const list = document.querySelector("#temporary-list")!;
    [...list.children].forEach((item, index) => {
      const rect = item.getBoundingClientRect();
      temporaryList.value[index].h = rect.height;
      updateItemSize();
      isShow.value = true;
    });
  });
};

思路很简单,就是根据请求的数据 list 遍历,配合 itemSizeInfo 的初始数据来添加到 temporaryList 中,后面会执行 diff 算法里的挂载逻辑,我们通过 nextTick 获取挂载的卡片 DOM 来拿到卡片的最终高度,将它更新到 temporaryList 以及 itemSizeInfo 中,存储之后我们的 temporaryList 任务就完成了,通过 isShow 来控制将它卸载完事

这里的 updateItemSize 方法还没实现,就是从更新过的 temporaryList 里获取卡片最终高度来更新 itemSizeInfo:

ts 复制代码
const updateItemSize = () => {
  temporaryList.value.forEach(({ item, h }) => {
    const rect = itemSizeInfo.value.get(item.id)!;
    itemSizeInfo.value.set(item.id, { ...rect, height: h });
  });
};

对接之前实现流程

OK,我们绕了一大圈最终把卡片的真实高度给存到 itemSizeInfo 里了,其实剩下的步骤就跟之前实现定高的瀑布流虚拟列表一样了,因为我们就是只需要 itemSizeInfo 这里的高度信息来计算位置,只不过还要修改一下之前的实现来对接当前的实现流程,比如之前类型新增了 imageHeight 这个字段,需要调整一下之前的代码,这块我就省略了...😜

重点讲解一下改动影响较大的,首先是请求数据方法 loadDataList 改动,之前为了配合 addInQueue 我们把新获取的数据长度返回了,这次我们配合 mountTemporaryList 直接将数据返回:

ts 复制代码
const loadDataList = async () => {
  // ...
  const list = await props.request(dataState.currentPage++, props.pageSize);
  // ...
  
  // before: return list.length
  return list;
};

同样初始化操作我们不再获取数据后执行 addInQueue,而是改成 mountTemporaryList 的执行:

ts 复制代码
const init = async () => {
  // ...
  // before: const len = await loadDataList();
  const list = await loadDataList();
  // 初始化 itemSizeInfo 信息
  setItemSize();
  // 获取到数据后
  list && mountTemporaryList(list.length);
  // before: len && addInQueue(len);
};

而真正要执行 addInQueue 的地方是我们更新完真实卡片高度后的 mountTemporaryList 里:

ts 复制代码
const mountTemporaryList = (list: ICardItem[]) => {
  isShow.value = false;
  // ...
  nextTick(() => {
    // ...
    isShow.value = true;
    updateItemSize();
    // 获取到真实高度,开始执行瀑布流计算逻辑
    addInQueue(temporaryList.value.length);
    // 注意每次结束之后都需要清空,方便滚动加载更多数据添加
    temporaryList.value = [];
  });
};

之后就是滚动加载更多以及响应式尺寸变动的重新计算处理,都需要替换成 mountTemporaryList:

ts 复制代码
const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight } = containerRef.value!;
  scrollState.start = scrollTop;
  if (scrollTop + clientHeight > computedHeight.value.minHeight) {
    !dataState.loading &&
      loadDataList().then((list) => {
        list && setItemSize();
        list && mountTemporaryList(list);
      });
    // before
    // !dataState.loading &&
    //   loadDataList().then((len) => {
    //     len && addInQueue(len);
    //   });
  }
});
ts 复制代码
const reComputedQueue = () => {
  
  queueState.queue = new Array(props.column).fill(0).map<IBookColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  // before: addInQueue(dataState.list.length);
  setItemSize();
  mountTemporaryList(dataState.list);
};

注意我们之前实现的 setItemSize 是属于初始化,所以这里滚动以及重新计算都会将已经计算的真实高度重置为 0,我们需要区分出初始化和更新的情况:

ts 复制代码
const setItemSize = () => {
  itemSizeInfo.value = dataState.list.reduce<Map<ICardItem["id"], IBookItemRect>>((pre, current) => {
    const itemWidth = Math.floor((scrollState.viewWidth - (props.column - 1) * props.gap) / props.column);
    // 如果 itemSizeInfo 里已经存在值,使用存在的高度,而不是重置为 0 
    const rect = itemSizeInfo.value.get(current.id);
    pre.set(current.id, {
      width: itemWidth,
      height: rect ? rect.height : 0,
      imageHeight: Math.floor((itemWidth * current.height) / current.width),
    });
    return pre;
  }, new Map());
};

呈现效果

以上几步都完成后我们基本上就能够看到一个不定高的瀑布流虚拟列表了,我们改一下父组件的代码,用上我们之前瀑布流中封装的小红书卡片:

之前瀑布流文章中忘了解释为什么还需要把 imageHeight 传出来给子组件

主要是因为录制 gif 的缘故,如果使用图片的话会导致 gif 文件过大无法上传,因此卡片组件的图片直接用色块 div 代替了,但是 div 肯定要给个固定的高度才能撑起来

所以如果使用是真实图片的话直接设置宽度 100% 让其自由伸展就行,无需再单独用一个 imageHeight 字段设置图片高度

xml 复制代码
<template>
  <div class="app">
    <div class="container" ref="fContainerRef">
      <FsBookVirtualWaterfall :request="getData" :gap="15" :page-size="20" :column="column">
        <template #item="{ item, imageHeight }">
          <fs-book-card
            :detail="{
              imageHeight,
              title: item.title,
              author: item.author,
              bgColor: item.bgColor,
            }"
          />
        </template>
      </FsBookVirtualWaterfall>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import FsBookCard from "./components/FsBookCard.vue";
import FsBookVirtualWaterfall from "./components/FsBookVirtualWaterfall.vue";
import { ICardItem } from "./components/type";
import list from "./config/index";
const fContainerRef = ref<HTMLDivElement | null>(null);
const column = ref(6);
const fContainerObserver = new ResizeObserver((entries) => {
  changeColumn(entries[0].target.clientWidth);
});

const changeColumn = (width: number) => {
  if (width > 960) {
    column.value = 5;
  } else if (width >= 690 && width < 960) {
    column.value = 4;
  } else if (width >= 500 && width < 690) {
    column.value = 3;
  } else {
    column.value = 2;
  }
};

onMounted(() => {
  fContainerRef.value && fContainerObserver.observe(fContainerRef.value);
});

onUnmounted(() => {
  fContainerRef.value && fContainerObserver.unobserve(fContainerRef.value);
});

const getData = (page: number, pageSize: number) => {
  return new Promise<ICardItem[]>((resolve) => {
    setTimeout(() => {
      resolve(list.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize));
    }, 1000);
  });
};
</script>

<style scoped lang="scss">
.app {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh;
  .container {
    width: 1400px;
    height: 600px;
    border: 1px solid red;
  }

}

</style>

见证奇迹的时刻家人们!😎😎😎(GIF 12MB)

无非是没有图片展示,但效果基本上和小红书的瀑布流虚拟列表差不太多了😎

其实这个方案一出来不定高的瀑布流虚拟列表就可以实现了,比如可以完全脱离图片,假设你只展示为文本不定高的卡片也可以使用这个思路

但是如果一旦使用带有图片的卡片就一定要保证后端有返回图片的尺寸信息,不然神仙都救不了,等图片全部加载完黄花菜都凉了🤣

浅谈优化思路

到此我们的瀑布流虚拟列表组件就结束了,但是性能还是比较差的,如果只是展示图片的话定高实现还算可以,但是不定高的目前还是有比较大的缺陷

像常规的虚拟列表优化方案我这里就不再过多讲了,已经有很多文章提供思路🧐

包括小红书它优化了虚拟列表最头疼的一个点就是滚动白屏问题,为了防止快速滚动或者跳断滚动小红书直接把滚动条给禁了,没想到吧,直接从用户根源解决问题😅😅😅 所以真想省事还真可以这样做

这里主要讲一个使用我这种实现方案可以优化的点

注意这里优化思路需要明白上面讲的所有实现流程,不然真不一定能 get 到

首先我们思考一个这样的问题:假设我们使用不定高的实现不断向下滚动加载更多数据,这时候从后端接收到的数据已经达到了 10000 条(存储在内存当中),现在我们突然更改了一下视口尺寸触发 resize 逻辑,还记得之前怎么处理的吗?我们把代码拉过来看看:

ts 复制代码
const reComputedQueue = () => {
  setItemSize();
  queueState.queue = new Array(props.column).fill(0).map<IBookColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  mountTemporaryList(dataState.list);
};

现在这里的 dataState.list 就有 10000 条数据,我们执行了mountTemporaryList 逻辑,这时候 10000 条数据会变为卡片被挂载到 temporary-list 中,我们还要获取高度,相当于你来了 10000 次的 DOM 操作,啧啧啧,估计狠狠地掉帧,甚至卡死都有可能

包括你的 pageSize 设置的大一些,那触底请求加载过来的数据也是一样需要都先进行 DOM 操作获取高度,这个 pageSize 越大就越会造成你的视图卡顿

所以考虑到我们经常吹嘘的虚拟列表十万条数据的场景,这个问题肯定是需要解决的

解决问题的关键就是内存里的这 10000 条数据我们真的要全部重新计算吗?其实没有必要,我为什么不只先重新计算前 50 条,然后慢慢再往后计算?或者再精确一点,我们只先重新计算比视图上能够展示的卡片数量多几个,当我们滚动加载时再接着计算个几十条数据添加到视图上这样累加,而不是一口气全部计算完成

计算完成之后你的卡片位置已经固定了,所以你想怎么滚动就怎么滚动,那就是虚拟列表的事了,这个性能其实还算 OK 的

所以不难发现我们上面实现的方案性能瓶颈其实就在不断滚动添加新数据时的 DOM 计算 ,以及视口尺寸改变的重新 DOM 计算

我们假设视图容器只能展示 4 个卡片,而我们每次获取到的数据是 10 条,那么我们之前实现的方案是这样的:

这种方案的缺点就是后端返回的数据量与前端处理计算的数量强绑定,实际上完全没必要与后端返回的数量一致

所以我们优化的方案是这样的(5MB):

后端返回数据数量一定,但是我们可以不一次性全部处理完

初始化与之前相同,这时候 list 和 queueList 中有 10 条数据

第一次滚动触底时我们去后端获取额外 10 条,这时候 list 有 20 条数据,但我们后续选择只计算两条数据,此时 queueList 为 12 条

第二次滚动触底时我们无需再请求后端数据 ,而是在原来 list 里 20 条数据中再取两条数据添加到 queueList 中,变为 14 条

直到触底时 queueList 与 list 相等时说明已经全部处理完,我们需要请求后端数据,然后继续重复第一次触底操作

我们这样对比:

之前方案:

第一次触底:发送请求获取十条数据 十次 DOM 操作 十次计算

第二次触底:发送请求获取十条数据 十次 DOM 操作 十次计算

优化方案:

第一次触底:发送请求获取十条数据 两次 DOM 操作 两次计算

第二次触底:两次 DOM 操作 两次计算

...

第 n 次触底:判断 list.length === queueList.length 发送请求获取十条数据 两次 DOM 操作 两次计算

相当于我们把之前的 DOM 计算进行了分片,增大了触底触发的频率,但减少了每次的 DOM 操作

至于这里每次 DOM 操作具体设置的值需要根据使用场景来定,默认可以设置成 column 值,或者比它大一些也可以

这种优化其实在我们之前代码实现很容易做到,因为我们的 addInQueue 的参数就是相当于设置这个值的,我们只不过统一都是 pageSize 大小,现在我们进行修改即可,并且修改我们原来的 scroll 实现

上代码!首先我们添加一个计算属性 hasMoreData,它就是 queueList.length 与 list.length 的比较,我们将用它来区分滚动是请求数据还是在原有数据上添加到队列当中:

ts 复制代码
const hasMoreData = computed(() => queueState.len < dataState.list.length);

新增一个传值 props:enterSize,这里为了兼容之前的写法就先设置为可选,实际上是必填的,该值代表着滚动时进队的个数,决定我们每次滚动操作 DOM 的数量,默认可以给一个 column * 2 的大小:

ts 复制代码
export interface IVirtualWaterFallProps {
  gap: number;
  column: number;
  pageSize: number;
  enterSize?: number;
  request: (page: number, pageSize: number) => Promise<ICardItem[]>;
}

修改之前 mountTemporaryList 里的逻辑,参数传值不再是 list而是一个长度且不再是 pageSize 大小,使用 enterSize 代表要进队的个数

ts 复制代码
const mountTemporaryList = (size = props.enterSize) => {
// 判断当前队列是否已满,不再额外添加
  if (!hasMoreData.value) return;
  isShow.value = false;
  for (let i = 0; i < size!; i++) {
    const item = dataState.list[queueState.len + i];
    // 可能存在 item 为空的情况(enterSize 过大)添加过程中已经超出了数据源 list 的范围, 直接退出循环
    if (!item) break;
    const rect = itemSizeInfo.value.get(item.id)!;
    temporaryList.value.push({
      item,
      y: 0,
      h: 0,
      imageHeight: rect.imageHeight,
      style: {
        width: `${rect.width}px`,
      },
    });
  }
 // ...
};

修改滚动事件处理函数,改为我们的优化逻辑,数据源长度大于当前队列长度时不再发送请求而是选择入队,当队列已满时再选择发送请求获取数据入队:

ts 复制代码
const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight } = containerRef.value!;
  scrollState.start = scrollTop;
  if (!dataState.loading && !hasMoreData.value) {
    loadDataList().then((len) => {
      len && setItemSize();
      len && mountTemporaryList();
    });
    return;
  }
  if (scrollTop + clientHeight > computedHeight.value.minHeight) {
    mountTemporaryList();
  }
});

当然初始化时入队的 size 还是与 pageSize 保持一致,loadDataList 又改为返回数据的长度了,反复横跳了属于是😅:

ts 复制代码
const init = async () => {
  initScrollState();
  resizeObserver.observe(containerRef.value!);
  const len = await loadDataList();
  setItemSize();
  len && mountTemporaryList(len);
};

最后在响应式这块重新计算时先传入 pageSize 即可:

ts 复制代码
const reComputedQueue = () => {
  setItemSize();
  queueState.queue = new Array(props.column).fill(0).map<IBookColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  mountTemporaryList(props.pageSize); // key
};

现在我们在滚动时给发送请求和滚动入队打上日志:

优化前日志:

因为我设置了请求定时器 1s 延迟,而优化前的触底又没有给提前距离,所以滚动到底部会看到短暂的白屏请求数据的情况,而优化后选择了多次触发滚动入队逻辑可以尽可能保证整个滚动的流畅度

最重要的还是我之前提到 DOM 操作次数分片,具体哪种性能更高这个还待验证,并不是说分片就一定好,但是这个思路确实是正确的

而且这个优化其实也适配我们之前定高的实现逻辑,把获取卡片高度的步骤全部移除就行了

当然这个方案有一个巨大的缺陷目前我还没有解决:响应式重新计算问题 ,当我们滚动到比较靠低的地方时改变了尺寸,这时候会清空队列重新入队,入队的长度 pageSize 假设为 20 ,但是我们滚动到底部时当前队列已经达到 40 条了,所以当前处于底部的位置是靠近 40 条的数据。这时候重新计算过后你会发现视图展示的数据跟滚动的位置就会不匹配

避免这个问题也行,但是牺牲了用户体验,就是每次重新计算时设置 scrollTop 把让它回到顶部:

ts 复制代码
const reComputedQueue = () => {
  setItemSize();
  queueState.queue = new Array(props.column).fill(0).map<IBookColumnQueue>(() => ({ list: [], height: 0 }));
  queueState.len = 0;
  containerRef.value!.scrollTop = 0;
  mountTemporaryList(props.pageSize);
};

但肯定不是最好的方案,我们当前这种优化方案在响应式上是没有滚动记忆的,目前还没找到更好的解决办法🤔下去我再研究研究

不过后来我又发现小红书好像也是这样做的,当列数改变的时候它也是直接回滚到顶部🧐

End

到此我们的瀑布流虚拟列表的实现终于结束了🥳🥳🥳

源码奉上:DrssXpro/virtualwaterfall-demo: Vue3+TS:实现小红书瀑布流虚拟列表组件 (github.com)

这篇文章说实话是我写的耗时最长的一篇文章,因为在决定要写瀑布流虚拟列表的文章时我自己其实还不怎么会😅,包括不定高的实现都是自己写到这部分时才开始思考实践最终输出内容的

所以如果你能一直坚持看到这里,相信你是真的比较好奇这种瀑布流虚拟列表的具体实现过程,也欢迎评论交流提供不同的实现思路或者方案🥰

最后感觉文章里实现的方案不错的话不妨点个免费的赞以及收藏一下😁😁😁

相关推荐
有梦想的刺儿18 分钟前
webWorker基本用法
前端·javascript·vue.js
cy玩具39 分钟前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
清灵xmf1 小时前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据1 小时前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_390161771 小时前
防抖函数--应用场景及示例
前端·javascript
334554322 小时前
element动态表头合并表格
开发语言·javascript·ecmascript
John.liu_Test2 小时前
js下载excel示例demo
前端·javascript·excel
Yaml42 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事2 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro