思路

❶首先定义一个瀑布流容器,它的高度暂定(后面会更新)。把需要布局的组件(这里叫做waterfall-item)放在瀑布流容器里面渲染出来。使用绝对定位(position: absolute),把它移到屏幕外面,不要占用页面高度,并且设置不可见(visibility:hidden)。

<div class="waterfall-container" ref="waterfallWrapper" :style="{ height: wrapperHeight + 'px' }">
    <div
      v-for="(post, index) in posts"
      :key="post.title"
      class="waterfall-item"
    >
      <div class="waterfall-card">
        <PostCard
          :title="post.title"
          :content="post.excerpt"
          :time="new Date(post.date).toLocaleDateString()"
          :tag="post.tags ? post.tags.join(', ') : '未分类'"
          :img="post.img"
          :path="`/post/${post.file}`"
          :id="post.title"
          @imageLoaded="onImageLoaded"
        />
      </div>
    </div>
  </div>

.waterfall-item {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate3d(0, 3000px, 0);
  visibility: hidden;
  /* 让容器不被空白占位的 trick */
}

❷渲染出来之后才能计算高度。获取.waterfall-item的dom元素,遍历这些元素,使用getBoundingClientRect()获取高度。

❸开始布局。以2列为例,新建两个变量分别表示2列的高度。遍历第2步获取的dom元素的高度,把dom元素的高度加到最小的高度上。这个过程还可以考虑在组件之间加上留白(gutter)。

❹使用transform样式将waterfall-item移动到对应高度所在的位置。transform将组件移动到指定坐标,横坐标跟列有关,纵坐标跟高度有关。

❺更新瀑布流容器的高度为两列高度中最大者。

// 瀑布流容器引用
const waterfallWrapper = ref<HTMLElement | null>(null);
// 瀑布流容器高度
const wrapperHeight = ref(0);

// 一些瀑布流配置属性
const gutter = 20;       // 卡片之间的间距,单位px
const cols = ref(2);     // 列数(可根据响应式需求调整)
const colWidth = ref(0); // 每列宽度,或你可以动态计算
const hasAroundGutter = ref(true);
const animationCancel = ref(false);   // 是否取消动画
const posDuration = ref(300);         // 位置动画时长 (ms)

/** 2、3、4、5步的布局函数 */
const layoutHandle = async (): Promise<boolean> => {
  return new Promise((resolve) => {
    // 初始化 posY
    initY();

    // 获取 .waterfall-item DOM 列表
    const items: HTMLElement[] = [];
    if (waterfallWrapper.value) {
      waterfallWrapper.value.childNodes.forEach((el: any) => {
        if (el.className === 'waterfall-item') items.push(el);
      });
    }
    if (!items.length) return false;

    // 遍历每个卡片
    for (let i = 0; i < items.length; i++) {
      const curItem = items[i] as HTMLElement;
      const style = curItem.style;

      // 最小列
      const minY = Math.min(...posY.value);
      const minYIndex = posY.value.indexOf(minY);

      // 计算 X
      const curX = getX(minYIndex);

      // 设置 transform
      style.transform = `translate3d(${Math.floor(curX)}px, ${Math.floor(minY)}px, 0)`;
      style.width = `${colWidth.value}px`;
      style.visibility = 'visible';

      // 测量高度
      const { height } = curItem.getBoundingClientRect();
      // 输出卡片标题和高度
      // console.log(`Card "${i}" height: ${height}`);
      // 更新列高
      posY.value[minYIndex] += height;

      // 入场动画(可选)
      if (!animationCancel.value) {
        addAnimation(curItem, () => {
          const time = posDuration.value / 1000;
          style.transition = `transform ${time}s`;
        });
      }
    }
    
    // 容器高度 = 最长列
    wrapperHeight.value = Math.max(...posY.value);

    // 等待动画结束
    setTimeout(() => {
      resolve(true);
    }, posDuration.value);
  });
};
    
/** 初始化 posY */
const initY = () => {
  posY.value = new Array(cols.value).fill(hasAroundGutter.value ? gutter : 0);
};

/** 计算给定列的 X 坐标 */
const getX = (index: number): number => {
  const count = hasAroundGutter.value ? index + 1 : index;
  return gutter * count + colWidth.value * index;
};

/** 简单的动画函数,给卡片添加class或行内属性 */
function addAnimation(item: HTMLElement, callback?: () => void) {
  // 也可以从 item.firstChild 取到实际卡片 DOM
  // 并添加动画class
  // 这里为简单演示
  const content = item.firstChild as HTMLElement;
  if (content) {
    // 添加一系列动画class/属性
    content.classList.add('animate__animated', 'animate__fadeIn'); 
    // etc.

    // 回调
    if (callback) {
      setTimeout(() => {
        callback();
      }, 300); // 300ms or 你自己的计算
    }
  }
}

细节

🔴实际场景中图片一般使用懒加载,这会导致在组件计算高度时因为图片还没有被加载出来,所以得到错误的高度。这时候就需要在组件里添加一个onImageLoaded函数,当图片加载完成后调用,重新布局。

// 图片加载完成后再次布局(可加防抖)
let timer: number | null = null;
function onImageLoaded() {
  if (timer) clearTimeout(timer);
  timer = setTimeout(() => {
    layoutHandle(); // 重新布局
  }, 100); // 防抖 100ms
}

🔴屏幕尺寸变化时重新布局。设置两个监听器监听屏幕尺寸变化,当屏幕尺寸改变了之后,一个触发宽度重新设置,第二个触发重新布局。

// 定义函数: colWidth = containerWidth - 3 * gutter
function updateColWidth() {
  const container = waterfallWrapper.value;
  if (!container) return;

  // 父容器的实际宽度
  const containerWidth = container.clientWidth;
  // 计算后赋值
  colWidth.value = (containerWidth - 3 * gutter)/2;
  console.log('containerWidth:', containerWidth);
  console.log('colWidth:', colWidth.value);
}
onMounted(() => {
  updateColWidth();
  window.addEventListener('resize', updateColWidth);
  window.addEventListener('resize', layoutHandle);
});

🔴防抖,意思就是防止频繁触发布局造成性能爆炸。这个问题可能会在图片非常多,或者屏幕宽度频繁变化时出现。解决思路很简单,就是在所有布局操作前加个定时器,使得在规定时间内只能触发有限次数。代码在细节1有体现。

代码参考:https://github.com/heikaimu/vue3-waterfall-plugin

感谢作者开源。