还记得那是周五的一个晚上,那天正在寝室用笔记本玩赛博朋克2077 happy 中🥰,突然女朋友给我发了一个小红书的超链接,我像往常一样无视发送的内容直奔链接准备看完赶紧应付几句话继续游戏🤣

打开链接以为跟其他类似产品一样都会让你直接选择下载 App,没想到小红书的 PC 端惊到我了,首页出乎意料的好看

那时候年轻的我还不知道瀑布流是什么,只是觉得这个布局配合卡片的一些点击交互很有感觉,啧啧啧,我一个大男的看一些女性的推荐都被吸引到了,不过吸引我的不是内容,而是它的布局和交互🤩🤩🤩

这段时间全在忙学校实训不写文章手都痒了,今天就来研究一下小红书的瀑布流布局🧐

其实掘金上已经有很多篇文章都讲过小红书瀑布流的实现,但是如果仔细观察小红书使用的是瀑布流虚拟列表,关于这一点我几乎没有见到一篇文章有着重讲解的,都是简单讲讲瀑布流实现了事

但我认为小红书首页布局最大的亮点就在瀑布流和虚拟列表的结合,所以这段时间就来深入讲解一下这块实现的原理

之前已经讲解过虚拟列表的实现👇:

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

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

本文属于瀑布流虚拟列表的前置篇 ,因为瀑布流虚拟列表牵扯的概念比较多所以需要拆分讲解,这次就先科普一下基础瀑布流的实现,用 Vue3 + TS 封装一个瀑布流组件

瀑布流虚拟列表文章已更新!!!👇:

瀑布流优化:我把小红书的瀑布流虚拟列表撕出来了🧐!但女友不理我了😭😭 - 掘金 (juejin.cn)

实现思路

上手写代码之前我们简单介绍一下瀑布流布局的实现思路

瀑布流布局的应用场景主要在于图片或再搭配一点文字形成单独的一张卡片,由于图片尺寸不同会造成卡片大小不一,按照常规的盒子布局思路无外乎只有两种:

  1. 一个盒子独占一行
  2. 盒子与盒子紧挨着排列

独占一行不用讲肯定不符合我们想要实现的效果,而紧挨着排列由于卡片大小问题会出现这样的情况:

当前行高度最大的盒子决定了下一行盒子摆布的起始位置,如果卡片之间高度差距过大就会出现大量的留白

很显然常规布局并不能很好的利用空间,给人带来的视觉效果也较为混乱

而使用瀑布流布局很好的解决了这一点,我们打破常规布局的方案,使用定位或者位移来控制每张卡片的位置,最大化弥补卡片之间的留白情况

所以瀑布流布局的核心实现思想:

  1. 控制容器内每一列卡片的宽度相同(不同图片尺寸等比例缩放)
  2. 第一行卡片紧挨着排列,第二行开始采取贪心思想,每张卡片摆放到当前所有列中高度最小的一列下面

如果按照这样的思想我们改造上面图中卡片摆放的顺序:

①②③ 按照顺序紧挨着排布

④ 准备排布时找到最小高度列是第三列,所以会排布在 ③ 下面

⑤ 准备排布时找到最小高度列是第二列,所以会排布在 ② 下面

⑥ 准备排布时找到最小高度列是第一列,所以会排布在 ① 下面

可以看到这种布局方式解决了第一行和第二行中间留白的情况,布局时卡片再带一点间距视觉效果会更好,同理剩下图片卡片摆放也是按照这样的思路

准备数据

关于图片相关的数据我就不自己准备了,有现成的数据接口那当然拿来用啦🤣

我们直接使用小红书的数据接口把数据粘下来保存到本地即可:

不过稍微动点脑子就知道像这样的网站针对于图片一定会加上防盗链的,所以我也就不费劲绕开处理了,主要是要提取它的尺寸信息,至于图片就先随便给个颜色占位了

后端返回图片尺寸信息问题

在开始写代码之前还有这一个问题需要讨论:一般情况下瀑布流布局后端返回的数据不止有图片的链接还有图片的宽高信息(比如小红书中针对于单个卡片就有 width 和 height 字段)

有了这些信息前端使用时无需再单独获取 img DOM 就能够快速计算卡片缩放后的宽高以及后续的位置信息

但如果后端没有返回这些信息只给了图片链接那就只能全部交给前端来处理,因为图片尺寸信息在瀑布流实现中是必须要获取到的,这里就需要用到图片预加载技术

简单描述一下就先提前访问图片链接进行加载操作但不展示在视图上,后续使用该链接后图片会从缓存中加载而不是向服务器请求,因此被称之为预加载

而在瀑布流当中我们就是提前访问图片链接来获取其尺寸信息,我们封装为一个工具函数:

 1function preLoadImage(link) {
 2  return new Promise((resolve, reject) => {
 3    const img = new Image();
 4    img.src = link;
 5    img.onload = () => {
 6      
 7      resolve({ width: img.width, height: img.height });
 8    };
 9    img.onerror = (err) => {
10      reject(err);
11    };
12  });
13}

所以假设如果有很多图片,那么就必须要保证所有图片全部加载完毕获取到尺寸信息后才能开始瀑布流布局流程,如果一旦有一张图片加载失败就会导致瀑布流布局出现问题😑😑😑

好处就是用户看到的图片是直接从缓存进行加载的速度很快,坏处就是刚开始等待所有图片加载会很慢

而如果后端返回图片尺寸信息我们就无需考虑图片是否加载完成,直接根据其尺寸信息先进行布局,之后利用图片懒加载技术即可,所以真实业务场景肯定还是后端携带信息更好😜

组件结构搭建及样式

前面铺垫了这么多终于要开始写代码了,我们还是按照以前的老规矩,先看看整个 DOM 结构是什么样:

其实和虚拟列表差不多,只需要一个容器 container、列表 list 以及数据项 item

只不过封装组件后 item 后续会使用 v-for 遍历出来,同时可以定义插槽让父组件展示图片,这些后续再说

 1<div class="fs-waterfall-container">
 2  <div class="fs-waterfall-list">
 3    <div class="fs-waterfall-item"></div>
 4  </div>
 5</div>

container 作为整个瀑布流的容器它是需要展示滚动条的, list 作为 item 的容器可以开启相对定位,而 item 开启绝对定位,由于我会通过 translate 来控制每张卡片的位置,所以每张卡片定位统一放到左上角即可:

 1.fs-waterfall {
 2  &-container {
 3    width: 100%;
 4    height: 100%;
 5    overflow-y: scroll; 
 6    overflow-x: hidden;
 7  }
 8
 9  &-list {
10    width: 100%;
11    position: relative;
12  }
13  &-item {
14    position: absolute;
15    left: 0;
16    top: 0;
17    box-sizing: border-box;
18  }
19}

Props 和初始化状态

既然封装成组件必然少不了 props 传递来进行配置,针对于瀑布流其实只需要这几个属性

而对于单个数据项我们只需要图片的信息,其他的信息都不重要

小红书的瀑布流还有 title 以及 author 信息影响整个卡片的高度,这块放到最后实现,我们先只展示图片

 1export interface IWaterFallProps {
 2  gap: number; 
 3  column: number; 
 4  bottom: number; 
 5  pageSize: number;
 6  request: (page: number, pageSize: number) => Promise<ICardItem[]>;
 7}
 8
 9export interface ICardItem {
10  id: string | number;
11  url: string;
12  width: number;
13  height: number;
14  [key: string]: any;
15}
16
17
18export interface ICardPos {
19  width: number;
20  height: number;
21  x: number;
22  y: number;
23}
24

接下来我们定义组件内部状态:

 1const containerRef = ref<HTMLDivElement | null>(null); 
 2const state = reactive({
 3  isFinish: false,  
 4  page: 1,
 5  cardWidth: 0, 
 6  cardList: [] as ICardItem[], 
 7  cardPos: [] as ICardPos[], 
 8  columnHeight: new Array(props.column).fill(0) as number[], 
 9});

初始化操作

初始化操作只有两个工作:计算卡片宽度 、发送请求获取数据

初始化时最重要的就是先计算出该瀑布流布局中卡片的宽度是多少,即 state.cardWidth,每列的宽度都是固定的

其实计算方法很简单,直接来看下图就知道怎么计算了:

 1const containerWidth = containerRef.value.clientWidth;
 2state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;

注意使用 clientWidth 作为容器的宽度(clientWidth 不会计算滚动条的宽度)

之后就需要封装一个发送请求获取数据的函数了,需要注意的就是获取数据后要判断是否为空来决定后续是否还发送请求:

 1const getCardList = async (page: number, pageSize: number) => {
 2  if (state.isFinish) return;
 3  const list = await props.request(page, pageSize);
 4  state.page++;
 5  if (!list.length) {
 6    state.isFinish = true;
 7    return;
 8  }
 9  state.cardList = [...state.cardList, ...list];
10  computedCardPos(list); 
11};

我们整合到 init 方法中,在 onMounted 里进行调用:

 1const init = () => {
 2  if (containerRef.value) {
 3    const containerWidth = containerRef.value.clientWidth;
 4    state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;
 5    getCardList(state.page, props.pageSize);
 6  }
 7};
 8
 9onMounted(() => {
10  init();
11});

计算最小列高度和卡片位置

下面就到瀑布流核心实现环节了,我们在实现思路中谈到每当后续卡片进行布局时都需要计算最小列高度将其摆放至下面,很显然计算最小列高度方法是被频繁使用的,关键在于获取最小列以及最小列高度,这里可以直接使用计算属性实现:

因为还要获取下标,所以没法直接 Math.min 了,直接遍历比较出最小值即可:

 1const minColumn = computed(() => {
 2  let minIndex = -1,
 3    minHeight = Infinity;
 4
 5  state.columnHeight.forEach((item, index) => {
 6    if (item < minHeight) {
 7      minHeight = item;
 8      minIndex = index;
 9    }
10  });
11
12  return {
13    minIndex,
14    minHeight,
15  };
16});
17

在上面一小节的发送请求函数中的末尾有一个 computedCardPos 方法我们没有实现,它就是每当获取到新的数据后计算新数据卡片的位置信息,将其保存至 state.cardPos

我们来看它的实现步骤:

  1. 遍历数据项,计算当前数据项缩放后的卡片高度(根据后端返回的宽高信息以及 state.cardWidth 计算)
  2. 区分第一行和其余行布局
  3. 第一行卡片位置信息紧挨排布,高度更新至对应列的 state.columnHeight
  4. 其余行需要先获得最小高度列信息再计算其卡片位置,最终将高度累加到对应列的 state.columnHeight

下面就直接粘代码了:

 1const computedCardPos = (list: ICardItem[]) => {
 2  list.forEach((item, index) => {
 3    const cardHeight = Math.floor((item.height * state.cardWidth) / item.width);
 4    if (index < props.column) {
 5      state.cardPos.push({
 6        width: state.cardWidth,
 7        height: cardHeight,
 8        x: index ? index * (state.cardWidth + props.gap) : 0,
 9        y: 0,
10      });
11      state.columnHeight[index] = cardHeight + props.gap;
12    } else {
13      const { minIndex, minHeight } = minColumn.value;
14      state.cardPos.push({
15        width: state.cardWidth,
16        height: cardHeight,
17        x: minIndex ? minIndex * (state.cardWidth + props.gap) : 0,
18        y: minHeight,
19      });
20      state.columnHeight[minIndex] += cardHeight + props.gap;
21    }
22  });
23};

这里的计算可能会有一些疑问,简单做下解答吧:

  1. 高度计算,参考小学交叉相乘求值:

  1. 关于 x 偏移量计算,需要注意第一列时它的偏移量为 0,而其他列除了加上前面几列对应的宽度还要加上水平间隔

  2. 列高度累加时不要忘记还要加上垂直间隔

有了 state.cardPos 位置信息就可以修改 template 模板了,我们遍历数据设置位置样式即可:

 1<template>
 2  <div class="fs-waterfall-container" ref="containerRef">
 3    <div class="fs-waterfall-list">
 4      <div
 5        class="fs-waterfall-item"
 6        v-for="(item, index) in state.cardList"
 7        :key="item.id"
 8        :style="{
 9          width: `${state.cardPos[index].width}px`,
10          height: `${state.cardPos[index].height}px`,
11          transform: `translate3d(${state.cardPos[index].x}px, ${state.cardPos[index].y}px, 0)`,
12        }"
13      >
14        <slot name="item" :item="item" :index="index"></slot>
15      </div>
16    </div>
17  </div>
18</template>

对接数据展示效果

到此我们的瀑布流已经可以看到效果了,我们在父组件里使用一下看看

这里就不再解释了,稍微写点结构和样式,导入最早扒来的小红书数据,按照规定属性传入即可,只不过我们不能使用图片链接(防盗链问题),就稍微写一个带颜色的盒子吧:

 1<template>
 2  <div class="app">
 3    <div class="container">
 4      <fs-waterfall :bottom="20" :column="4" :gap="10" :page-size="20" :request="getData">
 5        <template #item="{ item, index }">
 6          <div
 7            class="card-box"
 8            :style="{
 9              background: colorArr[index % (colorArr.length - 1)],
10            }"
11          >
12            
13          </div>
14        </template>
15      </fs-waterfall>
16    </div>
17  </div>
18</template>
19
20<script setup lang="ts">
21import data1 from "./config/data1.json";
22import data2 from "./config/data2.json";
23import FsWaterfall from "./components/FsWaterfall.vue";
24import { ICardItem } from "./components/type";
25
26const colorArr = ["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399"];
27
28const list1: ICardItem[] = data1.data.items.map((i) => ({
29  id: i.id,
30  url: i.note_card.cover.url_pre,
31  width: i.note_card.cover.width,
32  height: i.note_card.cover.height,
33}));
34const list2: ICardItem[] = data2.data.items.map((i) => ({
35  id: i.id,
36  url: i.note_card.cover.url_pre,
37  width: i.note_card.cover.width,
38  height: i.note_card.cover.height,
39}));
40
41const list = [...list1, ...list2];
42
43const getData = (page: number, pageSize: number) => {
44  return new Promise<ICardItem[]>((resolve) => {
45    setTimeout(() => {
46      resolve(list.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize));
47    }, 1000);
48  });
49};
50</script>
51
52<style scoped lang="scss">
53.app {
54  width: 100vw;
55  height: 100vh;
56  display: flex;
57  align-items: center;
58  justify-content: center;
59  .container {
60    width: 700px;
61    height: 600px;
62    border: 1px solid red;
63  }
64  .card-box {
65    position: relative;
66    width: 100%;
67    height: 100%;
68    border-radius: 10px;
69  }
70}
71</style>
72
73

效果还是不错的,但是触底加载更多我们还没有实现,下一步就来实现它😎

触底加载更多

这其实也很好实现,我们只需给 container 添加滚动事件即可,按照以往的判断触底套路,再用上我们前面封装的获取数据函数即可

不过需要注意两个问题:

  1. 触底频繁请求,我们可以在原来状态里加一个 loading 控制
  2. 滚动加载更多数据是添加到原有瀑布流布局的后面,因此需要修改之前计算卡片位置区分第一行和其余行的条件
 1const state = reactive({
 2  
 3  loading: false,
 4});
 5
 6const getCardList = async (page: number, pageSize: number) => {
 7  
 8  state.loading = true;
 9  const list = await props.request(page, pageSize);
10  
11  state.loading = false;
12};
13
14const computedCardPos = (list: ICardItem[]) => {
15  list.forEach((item, index) => {
16    
17    if (index < props.column && state.cardList.length <= props.pageSize) {
18        
19    } else {
20        
21    }
22  });
23};
24
25const handleScroll = rafThrottle(() => {
26  const { scrollTop, clientHeight, scrollHeight } = containerRef.value!;
27  const bottom = scrollHeight - clientHeight - scrollTop;
28  if (bottom <= props.bottom) {
29    !state.loading && getCardList(state.page, props.pageSize);
30  }
31});
32
 1<template>
 2    
 3  <div class="fs-waterfall-container" ref="containerRef" @scroll="handleScroll">
 4     
 5  </div>
 6</template>

嗯,效果不错,至于 loading 蒙层以及图片的懒加载效果我就不做了🧐,留给大伙自行拓展吧

到此一个基础的图片瀑布流组件已经封装完成了,接下来我们来深入研究一下小红书的瀑布流😏

小红书瀑布流分析与实现

分析问题

抛开小红书中虚拟列表的实现先不谈,还有一点就是展示的卡片不仅有图片信息,还有文字信息:

这些文字信息你会发现它还是不定高的,这就比较麻烦了,无法确定单个卡片的高度会导致瀑布流布局计算出现问题

不过如果仔细分析的话,你会发现它只有两种情况: title 文本是单行或者双行,这点直接从 css 就可以看得出来:

后来发现还有一种情况是连 title 都没有😑,但是出现的情况比较少,这点先不考虑了

不仅如此,小红书的瀑布流还是响应式的,如果你去改变视口宽度,可能会出现一种情况:单行文本由于卡片宽度的压缩变成了双行

而关于 author 我发现它的高度是定死的 20px:

所以总结下来最大的两个问题:

  1. 卡片 title 高度不固定
  2. 卡片实现了响应式,相当于你每次改变视口宽度都会全部重新计算布局

小红书卡片样式

废话不多说,我们先封装一个小红书卡片组件出来,当然只实现最基本的样式效果,图片依旧直接纯色占位:

 1<template>
 2  <div class="fs-book-card-container">
 3    <div class="fs-book-card-image">
 4      
 5    </div>
 6    <div class="fs-book-card-footer">
 7      <div class="title">{{ props.detail.title }}</div>
 8      <div class="author">
 9        <div class="author-info">
10          <div class="avatar" />
11          
12          <span class="name">{{ props.detail.author }}</span>
13        </div>
14        <div class="like">100</div>
15      </div>
16    </div>
17  </div>
18</template>
19
20<script setup lang="ts">
21interface ICardDetail {
22  bgColor: string;
23  title: string;
24  author: string;
25  imageHeight: number;
26  [key: string]: any;
27}
28const props = defineProps<{
29  detail: ICardDetail;
30}>();
31</script>
32
33<style scoped lang="scss">
34.fs-book-card {
35  &-container {
36    width: 100%;
37    height: 100%;
38    background-color: #fff;
39  }
40  &-image {
41    width: 100%;
42    height: v-bind("`${props.detail.imageHeight}px`");
43    border: 1px solid #eee;
44    border-radius: 20px;
45    background-color: v-bind("props.detail.bgColor");
46  }
47  &-footer {
48    padding: 12px;
49    font-size: 14px;
50    .title {
51      margin-bottom: 8px;
52      word-break: break-all;
53      display: -webkit-box;
54      -webkit-box-orient: vertical;
55      -webkit-line-clamp: 2;
56      overflow: hidden;
57      color: rgba(51, 51, 51, 0.8);
58    }
59    .author {
60      font-size: 13px;
61      display: flex;
62      align-items: center;
63      justify-content: space-between;
64      gap: 5px;
65      .author-info {
66        flex: 1;
67        display: flex;
68        align-items: center;
69        overflow: hidden;
70        text-overflow: ellipsis;
71        white-space: nowrap;
72        .avatar {
73          margin-right: 6px;
74          width: 20px;
75          height: 20px;
76          border-radius: 20px;
77          border: 1px solid rgba(0, 0, 0, 0.08);
78          background-color: v-bind("props.detail.bgColor");
79        }
80        .name {
81          width: 80%;
82          overflow: hidden;
83          text-overflow: ellipsis;
84          white-space: nowrap;
85          color: rgba(51, 51, 51, 0.8);
86        }
87      }
88    }
89  }
90}
91</style>
92
93</style>
94

emm 🤔,大差不差吧~ 无非是没有图片罢了

改造瀑布流实现

下面就要大改之前实现的瀑布流组件了,思路很简单,现在最大的问题就是卡片的高度不固定了,我们需要自己获取 DOM 来计算

也就是说之前我们实现 computedCardPos 方法要分两步了:

  1. 根据后端返回的信息计算出卡片里图片的高度
  2. 等 DOM 更新后(nextTick 中)获取单个卡片 DOM 拿到高度再计算位置信息

首先我们改造之前存储卡片位置信息的数据结构,现在高度分为:卡片高度、卡片内图片高度

 1export interface IBookCardPos {
 2  width: number;
 3  imageHeight: number; 
 4  cardHeight: number; 
 5  x: number;
 6  y: number;
 7}

在获取到数据信息后我们增加一个计算卡片图片高度的方法,并将其添加到记录卡片位置信息的数组中,而卡片高度和位置信息统一为 0,等下一步 DOM 更新后获取计算:

 1const computedImageHeight = (list: ICardItem[]) => {
 2  list.forEach((item) => {
 3    const imageHeight = Math.floor((item.height * state.cardWidth) / item.width);
 4    state.cardPos.push({
 5      width: state.cardWidth,
 6      imageHeight: imageHeight,
 7      cardHeight: 0,
 8      x: 0,
 9      y: 0,
10    });
11  });
12};

添加完成之后我们需要等待一次 nextTick,保证上面的位置信息 DOM 已经进行了挂载(但是还没有渲染到界面上)

nextTick 之后我们就需要计算真正卡片的高度以及其位置了,我们获取 list DOM 并在内部获取其 children 遍历获取其高度,位置计算和之前一样:

 1
 2const listRef = ref<HTMLDivElement | null>(null);
 3
 4const getCardList = async (page: number, pageSize: number) => {
 5  
 6  computedCardPos(list);
 7  
 8};
 9
10const computedCardPos = async (list: ICardItem[]) => {
11  computedImageHeight(list);
12  await nextTick();
13  computedRealDomPos(list);
14};
15
16const computedRealDomPos = (list: ICardItem[]) => {
17  const children = listRef.value!.children;
18  list.forEach((_, index) => {
19    const nextIndex = state.preLen + index;
20    const cardHeight = children[nextIndex].getBoundingClientRect().height;
21    if (index < props.column && state.cardList.length <= props.pageSize) {
22      state.cardPos[nextIndex] = {
23        ...state.cardPos[nextIndex],
24        cardHeight: cardHeight,
25        x: nextIndex % props.column !== 0 ? nextIndex * (state.cardWidth + props.gap) : 0,
26        y: 0,
27      };
28      state.columnHeight[nextIndex] = cardHeight + props.gap;
29    } else {
30      const { minIndex, minHeight } = minColumn.value;
31      state.cardPos[nextIndex] = {
32        ...state.cardPos[nextIndex],
33        cardHeight: cardHeight,
34        x: minIndex ? minIndex * (state.cardWidth + props.gap) : 0,
35        y: minHeight,
36      };
37      state.columnHeight[minIndex] += cardHeight + props.gap;
38    }
39  });
40  state.preLen = state.cardPos.length;
41};

注意这里的 nextIndex 计算,这是因为要考虑到触底加载更多的情况,我们在状态中增加了 preLen 属性用来保存当前已经计算过的卡片位置数组长度,等触底加载更多数据再重复走计算逻辑时它的索引就应该从 preLen 开始往后计算

同样在 template 模板中,我们使用插槽把卡片里图片高度抛出,正好可以让我们封装的小红书卡片使用:

 1<template>
 2  <div class="fs-book-waterfall-container" ref="containerRef" @scroll="handleScroll">
 3    
 4    <div class="fs-book-waterfall-list" ref="listRef">
 5      <div
 6        class="fs-book-waterfall-item"
 7        v-for="(item, index) in state.cardList"
 8        :key="item.id"
 9        :style="{
10          width: `${state.cardWidth}px`,
11          transform: `translate3d(${state.cardPos[index].x}px, ${state.cardPos[index].y}px, 0)`,
12        }"
13      >
14        
15        <slot name="item" :item="item" :index="index" :imageHeight="state.cardPos[index].imageHeight"></slot>
16      </div>
17    </div>
18  </div>
19</template>

这时候父组件使用瀑布流组件时就可以这样用了:

 1<template>
 2  <div class="app">
 3    <div class="container">
 4      <fs-book-waterfall :bottom="20" :column="4" :gap="10" :page-size="20" :request="getData">
 5        <template #item="{ item, index, imageHeight }">
 6          <fs-book-card
 7            :detail="{
 8              imageHeight,
 9              title: item.title,
10              author: item.author,
11              bgColor: colorArr[index % (colorArr.length - 1)],
12            }"
13          />
14        </template>
15      </fs-book-waterfall>
16    </div>
17  </div>
18</template>
19
20<script setup lang="ts">
21import data1 from "./config/data1.json";
22import data2 from "./config/data2.json";
23import FsBookWaterfall from "./components/FsBookWaterfall.vue";
24import FsBookCard from "./components/FsBookCard.vue";
25import { ICardItem } from "./components/type";
26const colorArr = ["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399"];
27
28const list1: ICardItem[] = data1.data.items.map((i) => ({
29  id: i.id,
30  url: i.note_card.cover.url_pre,
31  width: i.note_card.cover.width,
32  height: i.note_card.cover.height,
33  title: i.note_card.display_title,
34  author: i.note_card.user.nickname,
35}));
36const list2: ICardItem[] = data2.data.items.map((i) => ({
37  id: i.id,
38  url: i.note_card.cover.url_pre,
39  width: i.note_card.cover.width,
40  height: i.note_card.cover.height,
41  title: i.note_card.display_title,
42  author: i.note_card.user.nickname,
43}));
44
45const list = [...list1, ...list2];
46
47const getData = (page: number, pageSize: number) => {
48  return new Promise<ICardItem[]>((resolve) => {
49    setTimeout(() => {
50      resolve(list.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize));
51    }, 1000);
52  });
53};
54</script>
55
56<style scoped lang="scss">
57.app {
58  width: 100vw;
59  height: 100vh;
60  display: flex;
61  align-items: center;
62  justify-content: center;
63  .container {
64    width: 700px;
65    height: 600px;
66    border: 1px solid red;
67  }
68  .box {
69    width: 250px;
70  }
71}
72</style>
73

效果还不错🤩,就是这样做性能就比不上定高的实现了,毕竟现在计算位置信息都需要进行 DOM 操作

响应式实现

小红书的瀑布流响应式一共实现了两点:

  1. 视口不断变化时会重新计算卡片宽度,改变其比例
  2. 断点响应式,视口到达一定数值会改变列数

第一点很好实现,我们可以监听容器 DOM 尺寸改变后重置数据并走一遍所有的计算逻辑即可,只不过这是一个频繁回流的过程,建议上个防抖更好:

这里监听 DOM 尺寸变化推荐使用 ResizeObserver,我就不再封装直接使用了,至于怎么使用看 MDN 文档 👇

ResizeObserver - Web API 接口参考 | MDN (mozilla.org)

 1
 2const resizeObserver = new ResizeObserver(() => {
 3  handleResize();
 4});
 5
 6
 7const handleResize = debounce(() => {
 8  const containerWidth = containerRef.value!.clientWidth;
 9  state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;
10  state.columnHeight = new Array(props.column).fill(0);
11  state.cardPos = [];
12  state.preLen = 0;
13  computedCardPos(state.cardList);
14});
15
16const init = () => {
17  if (containerRef.value) {
18    
19    resizeObserver.observe(containerRef.value);
20  }
21};
22
23
24onMounted(() => {
25  init();
26});
27
28
29
30onUnmounted(() => {
31  containerRef.value && resizeObserver.unobserve(containerRef.value);
32});

可以再给 item 上添加一个过渡,显得更自然一些:

 1.fs-book-waterfall {
 2  &-item {
 3    
 4    transition: all 0.3s;
 5  }
 6}

可以可以,这样就好看多了😍😍😍

接下来我们看断点响应式的实现,它的实现其实有两种:

  1. 瀑布流组件内部进行实现,删除 column props,由组件内部设置对应的断点值修改 column 进行回流重新排布
  2. 让其父组件决定,动态改变 column props,而瀑布流组件内部监听 column 变化进行回流重新排布

为了兼容我们之前的实现就使用第二种方式,我们随便在父组件设几个断点然后监听外部 container 元素的宽度修改 column 即可:

 1
 2const fContainerRef = ref<HTMLDivElement | null>(null);
 3const column = ref(5);
 4const fContainerObserver = new ResizeObserver((entries) => {
 5  changeColumn(entries[0].target.clientWidth);
 6});
 7
 8
 9const changeColumn = (width: number) => {
10  if (width > 960) {
11    column.value = 5;
12  } else if (width >= 690 && width < 960) {
13    column.value = 4;
14  } else if (width >= 500 && width < 690) {
15    column.value = 3;
16  } else {
17    column.value = 2;
18  }
19};
20
21onMounted(() => {
22  fContainerRef.value && fContainerObserver.observe(fContainerRef.value);
23});
24
25onUnmounted(() => {
26  fContainerRef.value && fContainerObserver.unobserve(fContainerRef.value);
27});

而在瀑布流组件中我们使用 watch 监听 column 变化,发生变化就进行回流重新计算布局:

 1watch(
 2  () => props.column,
 3  () => {
 4    handleResize();
 5  }
 6);

完美!这才是完整的瀑布流!😎😎😎

最后源码奉上,有图片版的瀑布流以及小红书版的瀑布流,没有怎么组织,但功能反正实现了👇:

DrssXpro/waterfall-demo: Vue3 + TS:模仿小红书封装瀑布流组件 (github.com)

终于把瀑布流基础篇讲完了😌,下一篇就直接来瀑布流虚拟列表组件了,这次实现一个完完整整的小红书版瀑布流!😁😁😁

个人笔记记录 2021 ~ 2025