Skip to content
瀑布流 + 虚拟列表实现不定高博客列表
如何优雅地把一堆高度不一的卡片摆得整整齐齐,还不让浏览器累瘫?试试瀑布流 + 虚拟滚动!
2025-06-18 09:18
AI总结: 加载中...

开始 🚀

我这里使用 vue3 实现瀑布流+虚拟列表,实现效果可以看这里。

文章列表

什么是瀑布流?🌊

什么是瀑布流,瀑布流是一种常用于展示不等高内容块的网页布局方式,其特点是:

从上往下填充,优先放到当前最短的那一列。

像下面小红书这样

小红书首页

css 实现 🎨

首先纯 css 就可以实现类似上面的效果,如下面的代码

html
<div class="p-10 columns-3 lg:columns-5">
  <div class="h-[100px] bg-red-100 break-inside-avoid mb-4">1</div>
  <div class="h-[120px] bg-blue-100 break-inside-avoid mb-4">2</div>
  <div class="h-[50px] bg-slate-200 break-inside-avoid mb-4">3</div>
  <div class="h-[90px] bg-orange-200 break-inside-avoid mb-4">4</div>
  <div class="h-[40px] bg-yellow-100 break-inside-avoid mb-4">5</div>
  <div class="h-[180px] bg-pink-100 break-inside-avoid mb-4">6</div>
  <div class="h-[180px] bg-gray-100 break-inside-avoid mb-4">7</div>
</div>

css瀑布流

这种方式虽然简单直观,但它是“列优先”填充,而不是我们预期中的“高度最短列优先”。所以当我们需要更精准控制(比如配合虚拟列表)时,CSS 方式就不太够用了。🙅‍♂️

js 实现 🧩

我们通过 JavaScript 来实现更灵活、可控的瀑布流布局。核心逻辑:

  • 获取容器宽度,计算每个卡片宽度;
  • 通过响应式断点适配不同列数;
  • 根据图片宽高比 & 文本内容动态计算卡片高度;
  • 每张卡片插入当前“最短”的列中;
  • transform 进行绝对定位,性能更优;

布局

布局我们分为容器,列表,列表项三个部分

html
<div
  class="waterfall-container w-full h-full overflow-x-hidden overflow-y-auto"
>
  <div class="waterfall-list relative">
    <div
      class="waterfall-item absolute top-0 left-0 transition-transform duration-300 overflow-hidden will-change-transform"
    >
      <slot :item="item"></slot>
    </div>
  </div>
</div>

TIP

container 部分要设置超出滚动,子项通过绝对定位统一在左上角,后面通过 translate3d 来实现偏移,再设置插槽来显示内容。

参数定义

  • 因为我们是封装的组件,所以要配置一些参数让用户传入。
ts
interface IItem {
  id: number;
  url: string;
  date: Record<string, any>;
  frontmatter: Record<string, any>;
}
interface IBreakpoint {
  [key: string]: {
    columns: number;
    gap: number;
  };
}
const props = defineProps<{
  list: IItem[];
  gap: number;
  columns: number;
  breakPoint?: IBreakpoint;
}>();

TIP

list 是列表数据,这里的 id 要设置列表的索引值,后面取数据时要用到。gap 是列表项之间的间距,columns 是列数,breakPoint 是响应式的配置,当屏幕宽度小于某个值时,列数会改变。

  • 然后是定义一些变量,用来存储卡片的数据和每一列的高度。
ts
interface ICardPosition {
  imageHeight: number;
  width: number;
  height: number;
  x: number;
  y: number;
}
const state = reactive({
  cardWidth: 0,
  cardPosition: [] as ICardPosition[],
  columnHeight: [] as number[],
});

TIP

cardWidth 是卡片的宽度,cardPosition 存储这卡片的图片高度,卡片宽高和 x,y 的偏移量,columnHeight 是每一列的高度。

获取列数和间距

因为我们是响应式的,所以要根据屏幕宽度来获取用户传入的正确的列数和间距。

ts
const breakPoint = {
  560: {
    columns: 3,
    gap: 10,
  },
  1024: {
    columns: 4,
    gap: 20,
  },
  1280: {
    columns: 5,
    gap: 20,
  },
};

通过循环判断获取正确的列数和间距。

ts
const realColumnsAndGap = reactive({
  columns: props.columns,
  gap: props.gap,
});
const calculateColumnsAndGap = () => {
  const breakpoints = props.breakPoint;
  const windowWidth = window.innerWidth;
  let matched = {
    columns: props.columns,
    gap: props.gap,
  };
  let maxMatchedWidth = -1;
  if (breakpoints) {
    Object.keys(breakpoints).forEach((bp) => {
      const bpNum = Number(bp);
      if (windowWidth >= bpNum && bpNum > maxMatchedWidth) {
        maxMatchedWidth = bpNum;
        matched = breakpoints[bpNum];
      }
    });
  }
  if (matched) {
    realColumnsAndGap.columns = matched.columns;
    realColumnsAndGap.gap = matched.gap;
  } else {
    realColumnsAndGap.columns = props.columns;
    realColumnsAndGap.gap = props.gap;
  }
};

如果没传响应式配置则用默认的 columns 和 gap。

计算卡片的宽度

获取到列数和间距后,就可以通过容器的宽度来计算卡片的宽度了。

这里我们定义个 ref 绑定 container

ts
const containerRef = ref<HTMLDivElement | null>(null);
const calculateCardWidth = () => {
  if (!containerRef.value) return;
  const containerWidth = containerRef.value.clientWidth;
  state.cardWidth =
    (containerWidth - realColumnsAndGap.gap * (realColumnsAndGap.columns - 1)) /
    realColumnsAndGap.columns;
};

TIP

gap 的数量是 columns - 1

计算图片高度

这里的图片宽高信息要通过后端返回,不然只能通过前端预加载来获取,但是对于图片多的来说,这样非常耗时间。

ts
const calculateCardImageHeight = () => {
  list.value.forEach((item, index) => {
    if (!item.frontmatter.pic) return;
    const [width, height] = item.frontmatter.picSize.split("x");
    const imageHeight = Math.floor((state.cardWidth * height) / width);
    state.cardPosition[index] = {
      ...state.cardPosition[index],
      imageHeight,
    };
  });
};

计算卡片高度

首先我们分析下小红书的布局,分为图片、标题、和作者信息。

这里图片的高度我们知道了,作者信息的高度通过元素选择我们可以看到是固定的 20px,所以我们只需要计算标题的高度就可以了。

小红书这里标题分为一行跟两行,我们通过 canvas 的 measureText 可以获取文本的宽度,我们可以拿到这个宽度跟卡片的宽度比较,如果超过卡片的宽度就表示文本会换行显示,直接取两行的高度即可。

我的文章列表也类似小红书的布局,分为图片、标题、简介和发布时间。

文章列表

内容区域的边距,标题高度,发布时间高度是固定的,一行简介跟两行简介的高度也是固定的,所以我们可以先定义好变量。

ts
const padding = 24;
const titleHeight = 20;
const oneRowContent = 22;
const twoRowContent = 38;
const timeHeight = 28;

首先定义一个隐藏的 canvas,用来测量文本的宽度

html
<canvas id="canvas" ref="canvasRef" class="hidden"></canvas>

接着就可以计算文本宽度

ts
const canvasRef = ref<HTMLCanvasElement | null>(null);
const getTextWidth = (text: string) => {
  if (!canvasRef.value || !containerRef.value) return 0;
  const ctx = canvasRef.value.getContext("2d");
  if (!ctx) return 0;
  const style = getComputedStyle(containerRef.value);
  ctx.font = `12px ${style.fontFamily}`;
  const canvasText = ctx.measureText(text);
  return canvasText.width;
};

TIP

这里需要注意设置 canvas 的字体样式,我的标题是用的 12px 的字体大小,字体用的默认字体。

获取卡片高度

ts
const getCardHeight = () => {
  list.value.forEach((item, index) => {
    const padding = 24;
    const titleHeight = 20;
    const oneRowContent = 22;
    const twoRowContent = 38;
    const timeHeight = 28;
    const contentWidth = getTextWidth(item.frontmatter.desc);
    const contentHeight =
      contentWidth > state.cardWidth - 16 ? twoRowContent : oneRowContent;
    const cardHeight =
      padding +
      titleHeight +
      contentHeight +
      timeHeight +
      state.cardPosition[index].imageHeight;
  });
};

获取到卡片的高度信息后,我们需要将信息添加到上面定义的 state.cardPosition 中,同时将信息加入 state.columnHeight 中,方便后面计算卡片的位置。 我们知道瀑布流的布局第一排都是在最顶上,从第二排开始,每个卡片在插入时,要插入最小高度的那一列中,所以我们分两种情况。

  • 第一排时
ts
if (index < realColumnsAndGap.columns) {
  state.cardPosition[index] = {
    ...state.cardPosition[index],
    width: state.cardWidth,
    height: cardHeight,
    x:
      index % realColumnsAndGap.columns === 0
        ? 0
        : index * (state.cardWidth + realColumnsAndGap.gap),
    y: 0,
  };
  state.columnHeight[index] = cardHeight + realColumnsAndGap.gap;
}

TIP

当是最左边一个的时候,x 的值为 0,否则为当前列数乘以卡片宽度加上间距。

  • 不是第一排 不是第一排的时候我们要获取最小高度的列和对应的索引值。因为后面需要将高度最大的那一列的值绑定到列表的高度上,所以这里我们定义一个用来获取最大和最小高度列的函数。
ts
const columnStats = computed(() => {
  let minIndex = -1;
  let minHeight = 0;
  let maxHeight = 0;

  state.columnHeight.forEach((height, index) => {
    // 处理最小值
    if (minIndex === -1 || height < minHeight) {
      minIndex = index;
      minHeight = height;
    }

    // 处理最大值
    if (height > maxHeight) {
      maxHeight = height;
    }
  });

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

用获取到的最小高度列的索引值来替换 index,和 y。

ts
const { minIndex, minHeight } = columnStats.value;
state.cardPosition[index] = {
  ...state.cardPosition[index],
  width: state.cardWidth,
  height: cardHeight,
  x: minIndex * (state.cardWidth + realColumnsAndGap.gap),
  y: minHeight,
};
state.columnHeight[minIndex] += cardHeight + realColumnsAndGap.gap;

完整的获取卡片高度的代码如下

ts
const getCardHeight = () => {
  list.value.forEach((item, index) => {
    const padding = 24;
    const titleHeight = 20;
    const oneRowContent = 22;
    const twoRowContent = 38;
    const timeHeight = 28;
    const contentWidth = getTextWidth(item.frontmatter.desc);
    const contentHeight =
      contentWidth > state.cardWidth - 16 ? twoRowContent : oneRowContent;
    const cardHeight =
      padding +
      titleHeight +
      contentHeight +
      timeHeight +
      state.cardPosition[index].imageHeight;

    if (index < realColumnsAndGap.columns) {
      state.cardPosition[index] = {
        ...state.cardPosition[index],
        width: state.cardWidth,
        height: cardHeight,
        x:
          index % realColumnsAndGap.columns === 0
            ? 0
            : index * (state.cardWidth + realColumnsAndGap.gap),
        y: 0,
      };
      state.columnHeight[index] = cardHeight + realColumnsAndGap.gap;
    } else {
      const { minIndex, minHeight } = columnStats.value;
      state.cardPosition[index] = {
        ...state.cardPosition[index],
        width: state.cardWidth,
        height: cardHeight,
        x: minIndex * (state.cardWidth + realColumnsAndGap.gap),
        y: minHeight,
      };
      state.columnHeight[minIndex] += cardHeight + realColumnsAndGap.gap;
    }
  });
};

最后我们将上面的方法整合进 init 中统一执行,再加监听下 container 的宽度变化,来实现响应式。

ts
const init = () => {
  // 1.初始化
  state.cardWidth = 0;
  state.cardPosition = [];
  state.columnHeight = [];
  // 2.计算列数和间距
  calculateColumnsAndGap();
  // 3.根据容器宽度、列数和间距,计算卡片宽度
  calculateCardWidth();
  // 4.计算图片高度
  calculateCardImageHeight();
  // 5.计算卡片高度
  getCardHeight();
};

我们用 ResizeObserver 来监听容器宽度的变化。

ts
// 因为宽度变化是很频繁的,我们封装一个防抖来优化下
const debounce = (fn, delay, immediate = false) => {
  let timer;
  return function (...args) {
    const context = this;
    if (timer) clearTimeout(timer);

    if (immediate && !timer) {
      fn.apply(context, args);
    }

    timer = setTimeout(() => {
      if (!immediate) fn.apply(context, args);
      timer = null;
    }, delay);
  };
};
const debounceInit = debounce(init, 500);
const resizeObserver = new ResizeObserver(() => {
  debounceInit();
});

在 onMounted 中执行。

ts
onMounted(async () => {
  if (!containerRef.value) return;
  init();
  resizeObserver.observe(containerRef.value);
});
onUnmounted(() => {
  containerRef.value && resizeObserver.unobserve(containerRef.value);
});

最后通过 v-for 渲染,绑定下样式。

html
<canvas id="canvas" ref="canvasRef" class="hidden"></canvas>
<div
  class="waterfall-container w-full h-full overflow-x-hidden overflow-y-auto"
  ref="containerRef"
>
  <div
    class="waterfall-list relative"
    ref="listRef"
    :style="{ height: columnStats.maxHeight + 'px' }"
  >
    <div
      class="waterfall-item absolute top-0 left-0 transition-transform duration-300 overflow-hidden will-change-transform"
      v-for="item in list"
      :key="item.id"
      :style="{
          width: `${state.cardWidth}px`,
          height: `${state.cardPosition[item.id]?.height}px`,
          transform: `translate3d(${state.cardPosition[item.id]?.x || 0}px, ${
            state.cardPosition[item.id]?.y || 0
          }px, 0)`,
        }"
    >
      <slot :item="item"></slot>
    </div>
  </div>
</div>

到这瀑布流的效果就做好了,可以来看看效果。

瀑布流

虚拟列表 👀

虚拟列表是一种性能优化技术,用于高效渲染大量数据项的滚动列表,常用于前端开发中。它的核心思想是:

只渲染可视区域内的元素,避免一次性将所有列表项都插入 DOM。

虚拟列表

在前面实现瀑布流的时候获取了卡片的位置信息,所以我们结合容器的 scrollTop 和位置信息从 list 中筛选出可见的卡片数组进行渲染。

虚拟列表

通过上面的图可以得出:

  • 当卡片的高度 h + 卡片的 y 大于 容器的滚动高度时,该卡片在容器顶部的下方。
  • 当卡片的 y 小于容器的滚动高度 + 容器高度时,该卡片在容器底部上方。

同时满足以上条件的卡片,即为可见的卡片。现在我们就来将满足这两个条件的卡片过滤出来。

同时加入一段缓冲区域,让滚动更丝滑(比如 buffer 高度是容器高度的 50%)。

ts
const visibleCard = ref<IItem[]>([]);
const calculateVisibleCard = () => {
  const bufferHeight = containerRef.value
    ? containerRef.value.clientHeight * 0.5
    : 0;
  const arr = list.value.filter((_, index) => {
    if (state.cardPosition[index] && containerRef.value) {
      const h = state.cardPosition[index].height;
      const y = state.cardPosition[index].y;
      const scrollTop = containerRef.value.scrollTop;
      const viewportHeight = containerRef.value.clientHeight;
      return (
        y + h > scrollTop - bufferHeight &&
        y < scrollTop + viewportHeight + bufferHeight
      );
    }
  });
  visibleCard.value = arr;
};

再将这个方法绑定到滚动事件上,这里放到 requestAnimationFrame 中,来优化性能。

ts
const throttleRAF = (fn) => {
  let running = false;
  return function () {
    if (running) return;
    running = true;
    requestAnimationFrame(() => {
      fn();
      running = false;
    });
  };
};
const throttledCalculateVisibleCard = throttleRAF(calculateVisibleCard);

最后 html 中添加滚动事件监听

html
<canvas id="canvas" ref="canvasRef" class="hidden"></canvas>
<div
  class="waterfall-container w-full h-full overflow-x-hidden overflow-y-auto"
  ref="containerRef"
  @scroll="throttledCalculateVisibleCard"
>
  <div
    class="waterfall-list relative"
    ref="listRef"
    :style="{ height: columnStats.maxHeight + 'px' }"
  >
    <div
      class="waterfall-item absolute top-0 left-0 transition-transform duration-300 overflow-hidden will-change-transform"
      v-for="item in visibleCard"
      :key="item.id"
      :style="{
          width: `${state.cardWidth}px`,
          height: `${state.cardPosition[item.id]?.height}px`,
          transform: `translate3d(${state.cardPosition[item.id]?.x || 0}px, ${
            state.cardPosition[item.id]?.y || 0
          }px, 0)`,
        }"
    >
      <div class="animate-box">
        <slot :item="item"></slot>
      </div>
    </div>
  </div>
</div>

这里嵌套了个 animate-box 的 div 用来添加渐入的动画效果。

css
.animate-box {
  animation: MoveAnimate 0.5s;
}
@keyframes MoveAnimate {
  from {
    opacity: 0;
    transform: translateY(200px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

最后实现的效果,注意右侧 dom 的变化。

虚拟列表

Released under the MIT License