响应式瀑布流布局

瀑布流是一种流行的网页布局。这种效果的名称取自瀑布,悬挂在高处并自顶向下流淌,就像这种布局的视觉效果一样。瀑布流布局将元素按照垂直方向排列,以达到优化页面空间的效果,通常很受图片 - heavy 网站的欢迎。

原理:对每个 item 都使用绝对定位,left 和 top 都是 0, 最后根据容器大小、item 的高度通过计算来得到 item 的 transform 值

初始化结构

我们先搭建一个简单的结构,然后为 container 设置一个 ref,在 useEffect 中获取所有 item 元素,并将其保存到 items 数组中。

import { useRef } from'react';
function App() {
   const list = [
    '/photo-1.avif',
    '/photo-2.avif',
    '/photo-3.avif',
    '/photo-4.avif',
    '/photo-5.avif',
    '/photo-6.avif',
    '/photo-7.avif',
    '/photo-8.avif',
    '/photo-9.avif',
    '/photo-10.avif',
    '/photo-11.avif',
    '/photo-12.avif'
  ];

  const container = useRef<HTMLDivElement | null>((null);
  const [items, setItems] = useState<HTMLDivElement[]>([]);

  // 保存所有 items 元素
  useEffect(() => {
    if (container.current) {
      setItems(Array.from(container.current.querySelectorAll('.item')));
    }
  }, [])

  return (
    <div
      ref={container}
      className="container"
    >
      {list.map((item) => (
        <div
          className="item"
          key={item}
        >
          <img src={item} />
        </div>
      ))}
    </div>
  )
}
html,
body {
  width: 100%;
  height: 100%;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

#root {
  width: 100%;
  height: 100%;
  --item-width: 0px;
}

.container {
  position: relative;
  width: 100%;
}

.item {
  position: absolute;
  width: var(--item-width);
  top: 0;
  left: 0;
  padding: 10px;
  border: 1px solid #eee;
  border-radius: 5px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
  background-color: #fff;
}

.item img {
  width: 100%;
  object-fit: cover;
}

监听窗口大小变化

由于我们需要监听窗口大小变化,所以我们可以使用 ResizeObserver 监听容器的大小变化。
主要逻辑在 handleResize 函数中。

useEffect(() => {
  const observer = new ResizeObserver(() => handleResize());

  if (container.current) {
    observer.observe(container.current);
  }

  return () => {
    observer.disconnect();
  };
}, []);

计算 item 的 transform 值 和 item 的宽度

  1. 首先我们可以获取容器的宽度,然后根据宽度来计算每一行放置多少 item,并计算每一个 item 的宽度。
  2. 创建一个 columnsHeights 数组用来保存每一列的高度,初始化为 0。
  3. 遍历所有 item,根据 item 的宽度来计算 item 的 transform 值,同时更新 columnHeights 的值。
function getColumns(containerWidth: number) {
  if (containerWidth > 1200) {
    return 6;
  } else if (containerWidth > 768 && containerWidth <= 1200) {
    return 4;
  } else {
    return 3;
  }
}

const handleResize = () => {
  if (container.current) {
    const containerWidth = container.current.offsetWidth;
    const columns = getColumns(containerWidth);
    const columnWidth = containerWidth / columns;
    container.current.style.setProperty('--item-width', columnWidth + 'px');
    const columnHeights = new Array(columns).fill(0); // 初始化高度为 0

    // 计算每个 item 的 transform 值
    items.forEach((item) => {
      const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights));
      item.style.transform = `translate(${columnWidth * shortestColumn}px, ${
        columnHeights[shortestColumn]
      }px)`;
      // 更新 columnHeights 的值
      columnHeights[shortestColumn] += item.offsetHeight;
    });
  }
};

完整代码

import { useCallback, useEffect, useRef, useState } from 'react';
import './App.css';

function getColumns(containerWidth: number) {
  if (containerWidth > 1200) {
    return 6;
  } else if (containerWidth > 768 && containerWidth <= 1200) {
    return 4;
  } else {
    return 3;
  }
}

function App() {
  const list = [
    '/photo-1.avif',
    '/photo-2.avif',
    '/photo-3.avif',
    '/photo-4.avif',
    '/photo-5.avif',
    '/photo-6.avif',
    '/photo-7.avif',
    '/photo-8.avif',
    '/photo-9.avif',
    '/photo-10.avif',
    '/photo-11.avif',
    '/photo-12.avif'
  ];

  const container = useRef<HTMLDivElement | null>(null);
  const [items, setItems] = useState<HTMLDivElement[]>([]);

  useEffect(() => {
    if (container.current) {
      const items = Array.from(
        document.querySelectorAll('.item')
      ) as HTMLDivElement[];
      setItems(items);
    }
  }, []);

  const handleResize = useCallback(() => {
    if (container.current) {
      const containerWidth = container.current.offsetWidth;
      const columns = getColumns(containerWidth);
      const columnWidth = containerWidth / columns;
      container.current.style.setProperty('--item-width', columnWidth + 'px');
      const columnHeights = new Array(columns).fill(0);

      items.forEach((item) => {
        const shortestColumn = columnHeights.indexOf(
          Math.min(...columnHeights)
        );
        item.style.transform = `translate(${columnWidth * shortestColumn}px, ${
          columnHeights[shortestColumn]
        }px)`;

        columnHeights[shortestColumn] += item.offsetHeight;
      });
    }
  }, [items]);

  useEffect(() => {
    const observer = new ResizeObserver(() => handleResize());
    if (container.current) {
      observer.observe(container.current);
    }

    return () => {
      if (observer) {
        observer.disconnect();
      }
    };
  }, [handleResize]);

  return (
    <div
      ref={container}
      className="container"
    >
      {list.map((item) => (
        <div
          className="item"
          key={item}
        >
          <img src={item} />
        </div>
      ))}
    </div>
  );
}

export default App;

总结

瀑布流布局主要就是需要计算每行最小高度,然后根据最小高度来计算每个 item 的位置。
使用 transform 控制每个 item 的位置可以避免不必要的重排。