思路

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

关于图片尺寸

最好后端返回图片的原始宽高信息

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

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

如果有很多图片,就必须要保证所有图片全部加载完毕获取到尺寸信息后才能开始瀑布流布局流程

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

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

代码

WaterFall.vue

<template>
  <div class="WaterFall-container" ref="containerRef">
    <div class="WaterFall-list">
      <div
        class="WaterFall-item"
        v-for="(item, idx) in props.dataSource"
        :key="item.id"
        :style="{
          width: `${state.positions[idx].width}px`,
          height: `${state.positions[idx].height}px`,
          transform: `translate3d(${state.positions[idx].x}px, ${state.positions[idx].y}px, 0)`,
        }"
      >
        <slot name="item" :item="item" :index="idx"></slot>
      </div>
    </div>
  </div>
</template>
 
<script setup lang="ts" generic="T extends ICardItem">
import { computed, reactive, ref, watch } from 'vue'
 
// Props
export interface IWaterFallProps<T> {
  dataSource: T[] // 数据源
  gap: number // 卡片间隔
  column: number // 瀑布流列数
}
 
export interface ICardItem {
  id: string | number
  width: number // 图片原始宽度
  height: number // 图片原始高度
}
 
// 卡片的位置信息
interface ICardPosInfo {
  x: number
  y: number
  width: number
  height: number
}
 
const props = defineProps<IWaterFallProps<T>>()
 
defineSlots<{
  item(props: { item: T; index: number }): any
}>()
 
const containerRef = ref<HTMLElement>() // 容器 DOM,需要容器宽度
const state = reactive({
  columnHeight: new Array(props.column).fill(0) as number[], // 存储每列的高度,以确定下一张卡片该插到哪一列
  positions: [] as ICardPosInfo[], // 存储每张卡片的位置信息
})
// 卡片宽度
const cardWidth = computed(() => {
  const containerWidth = containerRef.value?.clientWidth ?? 0
  return (containerWidth - props.gap * (props.column - 1)) / props.column
})
// 最小列高度和索引
const minColumn = computed(() => {
  let minIdx = -1
  let minHeight = Infinity
  state.columnHeight.forEach((height, idx) => {
    if (height < minHeight) {
      minIdx = idx
      minHeight = height
    }
  })
  return { idx: minIdx, height: minHeight }
})
 
watch(
  () => props.dataSource.length,
  (len, oldLen) => {
    calcPositions(len, oldLen)
  },
)
 
/**
 * 计算 dataSource 的 positions 数组
 * 这里认为 dataSource 会 push 一些数据
 * @param len 新 dataSource 长度
 * @param oldLen 旧 dataSource 长度
 */
const calcPositions = (len: number, oldLen: number) => {
  const deltaLen = len - oldLen
  if (deltaLen < 0) {
    throw new Error('dataSource 只能 push 数据')
  }
  const newPos: ICardPosInfo[] = Array.from({ length: deltaLen }, (_, i) => {
    const item = props.dataSource[oldLen + i]
    const height = (cardWidth.value * item.height) / item.width
    const { idx: minIdx, height: minHeight } = minColumn.value
    state.columnHeight[minIdx] += height + props.gap
    return {
      x: minIdx * (cardWidth.value + props.gap),
      y: minHeight,
      width: cardWidth.value,
      height,
    }
  })
  state.positions = state.positions.concat(newPos)
}
 
// 父组件用来触底加载更多数据
defineExpose({
  containerRef,
  getMinHeight: () => minColumn.value.height,
})
</script>
 
<style lang="scss">
.WaterFall {
  &-container {
    width: 100%;
    height: 100%;
    overflow-y: auto;
    overflow-x: hidden;
  }
 
  &-list {
    width: 100%;
    position: relative;
  }
 
  &-item {
    position: absolute;
    left: 0;
    top: 0;
    // 通过 translate 移动
  }
}
</style>

App.vue

<template>
  <div class="container">
    <WaterFall :data-source="dataSource" :gap="10" :column="4" ref="waterFallRef">
      <template #item="{ item, index }">
        <div class="list-item" :style="{ backgroundColor: item.color }">{{ index + 1 }}</div>
      </template>
    </WaterFall>
  </div>
</template>
 
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import Mock from 'mockjs'
import WaterFall, { type ICardItem } from './components/WaterFall.vue'
 
interface DataSourceItem extends ICardItem {
  color: string
}
 
const waterFallRef = ref<InstanceType<typeof WaterFall>>()
const dataSource = ref<DataSourceItem[]>([])
let isLoading = false
 
const loadMore = () => {
  if (isLoading) return
  isLoading = true
  setTimeout(() => {
    const newData: DataSourceItem[] = Array.from({ length: 20 }, () => ({
      id: Mock.Random.guid(),
      height: Mock.Random.integer(100, 300),
      width: Mock.Random.integer(100, 300),
      color: Mock.Random.color(),
    }))
    dataSource.value = dataSource.value.concat(newData)
    isLoading = false
  }, 40)
}
 
const onScroll = () => {
  const { containerRef, getMinHeight } = waterFallRef.value!
  const { scrollTop, clientHeight } = containerRef
  // 这里应该是当滚动到最小高度时(有白屏)就加载更多
  // 而不是滚动到最底部
  // const bottom = scrollHeight - clientHeight - scrollTop
  const bottom = getMinHeight() - clientHeight - scrollTop
  if (bottom <= 20) {
    loadMore()
  }
}
 
onMounted(() => {
  loadMore()
  waterFallRef.value && waterFallRef.value.containerRef.addEventListener('scroll', onScroll)
})
 
onUnmounted(() => {
  waterFallRef.value && waterFallRef.value.containerRef.removeEventListener('scroll', onScroll)
})
</script>
 
<style scoped lang="scss">
.container {
  width: 600px;
  height: 400px;
  margin: 100px auto;
  border: 1px solid red;
}
 
.list-item {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
}
</style>

参考

女友看了小红书的pc端后,问我这个瀑布流的效果是怎么实现的?