有关小红书“瀑布流”及其优化的学习笔记

157 阅读4分钟

核心实现思想

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

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

  • 第一种情况:后端返回数据中包含图片链接、图片宽高信息等。
  • 第二种情况:后端返回数据中只包含图片链接,需要获取图片宽高信息,所以需要使用到图片预加载技术。

图片预加载:提前访问链接加载,但不显示在视图上,后续使用链接时从缓存中加载,而不是请求服务器。

图片预加载封装函数如下:

function preLoadImage(link) { //与加载函数,传入链接
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = link;
    img.onload = () => {
      // load 事件代表图片已经加载完毕,通过该回调才访问到图片真正的尺寸信息
      resolve({ width: img.width, height: img.height });
    };
    img.onerror = (err) => {
      reject(err);
    };
  });
}

上述代码中image对象的描述 Javascript中的图像数据对象:Image、ImageData 和 ImageBitmap blog.csdn.net/jimojianghu…

正文

子组件:除js外的组件结构如下

  • container作为容器,需要滚动条
  • list作为item容器可开启相对定位
  • item开启绝对定位,通过translate控制每张卡片位置,定位的统一到左上角

html

<div class="fs-waterfall-container">
  <div class="fs-waterfall-list">
    <div class="fs-waterfall-item"></div>
  </div>
</div>

scss

.fs-waterfall {
  &-container {
    width: 100%;
    height: 100%;
    overflow-y: scroll; // 注意需要提前设置展示滚动条,如果等数据展示再出现滚动造成计算偏差
    overflow-x: hidden;
  }

  &-list {
    width: 100%;
    position: relative;
  }
  &-item {
    position: absolute;
    left: 0;
    top: 0;
    box-sizing: border-box;
  }
}

ts定义类型

export interface IWaterFallProps {
  gap: number; // 卡片间隔
  column: number; // 瀑布流列数
  bottom: number; // 距底距离(触底加载更多)
  pageSize: number;
  request: (page: number, pageSize: number) => Promise<ICardItem[]>;
}

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

// 单个卡片计算的位置信息,设置样式
export interface ICardPos {
  width: number;
  height: number;
  x: number;
  y: number;
}

js

定义

const containerRef = ref<HTMLDivElement | null>(null); // 绑定 template 上的 container,需要容器宽度
const state = reactive({
  isFinish: false,  // 判断是否已经没有数据,后续不再发送请求
  page: 1,
  cardWidth: 0, // // 容器内卡片宽度
  cardList: [] as ICardItem[], // 卡片数据源
  cardPos: [] as ICardPos[], // 卡片摆放位置信息
  columnHeight: new Array(props.column).fill(0) as number[], // 存储每列的高度,进行初始化操作
});

初始化操作

计算瀑布流布局中卡片宽度

//计算瀑布流布局中卡片宽度
const containerWidth = containerRef.value.clientWidth; //clientWidth不计算滚动条宽度
state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;

封装请求函数

const getCardList = async (page: number, pageSize: number) => {
  if (state.isFinish) return;
  const list = await props.request(page, pageSize);
  state.page++;
  if (!list.length) {
    state.isFinish = true;
    return;
  }
  state.cardList = [...state.cardList, ...list];
  computedCardPos(list); // key:根据请求的数据计算卡片位置
  
  const computedCardPos = (list: ICardItem[]) => { //计算卡片位置函数
  list.forEach((item, index) => { //遍历每一项
    const cardHeight = Math.floor((item.height * state.cardWidth) / item.width); //计算当前项缩放后的卡片高度
    if (index < props.column) { //如果是第一行
      state.cardPos.push({
        width: state.cardWidth,
        height: cardHeight,
        x: index ? index * (state.cardWidth + props.gap) : 0,
        y: 0,
      });
      state.columnHeight[index] = cardHeight + props.gap; //高度更新至对应列的state.columnHeight
    } else { //如果不是第一行
      const { minIndex, minHeight } = minColumn.value; //获取最小高度列信息
      state.cardPos.push({
        width: state.cardWidth,
        height: cardHeight,
        x: minIndex ? minIndex * (state.cardWidth + props.gap) : 0,
        y: minHeight,
      });
      state.columnHeight[minIndex] += cardHeight + props.gap;
    }
  });
};

};

使用

const init = () => {
  if (containerRef.value) {
    const containerWidth = containerRef.value.clientWidth;
    state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;
    getCardList(state.page, props.pageSize);
  }
};

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

计算最小列高度

const minColumn = computed(() => {
  let minIndex = -1,
    minHeight = Infinity;

  state.columnHeight.forEach((item, index) => {
    if (item < minHeight) {
      minHeight = item;
      minIndex = index;
    }
  });

  return {
    minIndex,
    minHeight,
  };
});

父组件如下

<template>
  <div class="app">
    <div class="fs-waterfall-container" ref="containerRef" @scroll="handleScroll">
      <fs-waterfall :bottom="20" :column="4" :gap="10" :page-size="20" :request="getData">
        <template #item="{ item, index }">
          <div
            class="card-box"
            :style="{
              background: colorArr[index % (colorArr.length - 1)],
            }"
          >
            <!-- <img :src="item.url" /> -->
          </div>
        </template>
      </fs-waterfall>
    </div>
  </div>
</template>

<script setup lang="ts">
import data1 from "./config/data1.json";
import data2 from "./config/data2.json";
import FsWaterfall from "./components/FsWaterfall.vue";
import { ICardItem } from "./components/type";

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

const list1: ICardItem[] = data1.data.items.map((i) => ({
  id: i.id,
  url: i.note_card.cover.url_pre,
  width: i.note_card.cover.width,
  height: i.note_card.cover.height,
}));
const list2: ICardItem[] = data2.data.items.map((i) => ({
  id: i.id,
  url: i.note_card.cover.url_pre,
  width: i.note_card.cover.width,
  height: i.note_card.cover.height,
}));

const list = [...list1, ...list2];

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


//触底加载更多

const state = reactive({
  // ...
  loading: false,
});

const getCardList = async (page: number, pageSize: number) => {
  // ...
  state.loading = true;
  const list = await props.request(page, pageSize);
  // ...
  state.loading = false;
};

const computedCardPos = (list: ICardItem[]) => {
  list.forEach((item, index) => {
    // 增加另外条件,cardList <= pageSize 说明是第一次获取数据,第一行紧挨排布
    if (index < props.column && state.cardList.length <= props.pageSize) {
        // ...
    } else {
        // ...
    }
  });
};

const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight, scrollHeight } = containerRef.value!;
  const bottom = scrollHeight - clientHeight - scrollTop;
  if (bottom <= props.bottom) {
    !state.loading && getCardList(state.page, props.pageSize);
  }
});


</script>

<style scoped lang="scss">
.app {
  width: 100vw;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  .container {
    width: 700px;
    height: 600px;
    border: 1px solid red;
  }
  .card-box {
    position: relative;
    width: 100%;
    height: 100%;
    border-radius: 10px;
  }
}
</style>