Abstract background

实现 Masonry 布局

/static/masonry-in-css-01-1a87fde8.webp

什么是 Masonry 布局

这篇来自 CSS { In Real Life } 的文章解释了什么是 Masonry 布局,一图胜千言:

Masonry-style grid of rectangles of differing proportions

GPT-4o 如是说:

Masonry Layout 是一种布局方式,经常用于网页设计中,特别是展示图片、卡片或其他内容块。这种布局方式类似于砖石墙的排列(因此得名 "Masonry"),其中不同高度的内容块紧密排列,最大限度地利用了可用空间。Masonry Layout 的特点是内容块(items)之间没有固定的行和列,而是根据内容的高度动态排列,使得页面看起来更加紧凑和美观。

Masonry Layout 的主要特点包括:

  1. 非均匀排列:不同高度的内容块可以在同一行中出现,形成一种砖石墙般的视觉效果。
  2. 动态填充:内容块会自动填充空白区域,最大限度地利用可用空间,减少空白和间隙。
  3. 响应式设计:Masonry Layout 可以适应不同屏幕尺寸和设备,内容块会根据屏幕宽度自动调整排列方式。

常见的实现方式:

  1. JavaScript 库:有很多 JavaScript 库可以帮助实现 Masonry Layout,比如 Masonry.jsIsotope 等。
  2. CSS Grid:使用现代的 CSS Grid 布局,可以通过定义自动行高和自动列宽来实现类似的效果。
  3. CSS Flexbox:虽然 Flexbox 不如 CSS Grid 适合 Masonry Layout,但通过一些技巧和 hack 也可以实现类似的布局。

顺带一提,将 Masonry 布局发扬光大的产品,国外是 Pintrest,国内是小红书。本文的解决方案就借鉴自小红书。

在实际应用场景,Masonry 布局经常和行密铺、列密铺布局联系起来。它们都是排版图片等长宽比不一的元素的好方法。

行密铺和列密铺布局

下面三张图来自 React Photo Album,展示了在相同内容上应用行密铺、列密铺、Masonry 布局的效果。

行密铺:

Rows layout

↑此处可以看到,行密铺布局支持“非常宽”的元素以正常大小显示,而其余两种布局要求元素宽度大致相等,无法做到。

列密铺:

Columns layout

Masonry:

Masonry layout

通过库实现三种布局

这篇来自 Google Photos 的博客文章讲解了他们在谷歌相册应用中,如何使用 Dijkstra 算法解决图片排版问题。具体的算法实现较为复杂,好在我们能站在巨人的肩膀上。React Photo Gallery 是一个例子,但这个仓库上一次提交已经是 2019 年。在 issue 中,维护者推荐使用 React Photo Album 这个库,功能相似,支持行布局、列布局和瀑布流布局。灵感来源于 React Photo Gallery,从头开始重新设计。

此外,也有 LightGalleryMasonry 这两个库可以参考。

手动实现 Masonry 布局

我的博客中,在文章索引页使用了 Masonry 布局。由于 Next.js 的 <Image> 组件需要指定图片宽度和高度参数(可以使用 fill 属性与 sizes 属性设置自动响应式布局,这部分留给读者自行实现吧),因此我们使用 JavaScript 而非 CSS 计算整体的布局。由于遗留原因,这个组件叫做 WaterfallGrid。它在桌面端显示 2 或 3 列 Masonry 布局,在移动端则是 1 列纵向排列的 flexbox 布局。

1. 创建 WaterfallGrid 组件

首先,我们定义了 WaterfallGrid 组件,这是实现 Masonry 布局的核心组件。该组件接收两个 props:items(要显示的内容数组)和 CardComponent(用于渲染单个内容块的组件)。

1import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2import useColumnCount from "@/src/lib/useColumnCount"; 3import { gsap, useGSAP } from "@/src/lib/gsap"; 4 5const cardPadding = 16; 6 7export interface WaterfallGridProps { 8 items: { slug: string; [key: string]: any }[]; 9 CardComponent: React.FC<{ item: { slug: string; [key: string]: any }; imgWidth: number; isMobile: boolean }>; 10} 11 12const WaterfallGrid: React.FC<WaterfallGridProps> = ({ items, CardComponent }) => { 13 const containerRef = useRef<HTMLDivElement>(null); 14 const itemRefs = useMemo(() => items.map(() => React.createRef<HTMLDivElement>()), [items]); 15 const [layout, setLayout] = useState({ 16 columnHeights: [] as number[], 17 positions: [] as { x: number; y: number }[], 18 imgWidth: 0, 19 columnWidth: 0, 20 }); 21 const columnCount = useColumnCount(containerRef); 22 23 // 计算列宽 24 const computeColumnWidth = useCallback(() => { 25 if (!containerRef.current) return; 26 const width = containerRef.current.clientWidth / columnCount; 27 setLayout((prev) => ({ ...prev, columnWidth: width, imgWidth: width - cardPadding * 2 })); 28 }, [columnCount]); 29 30 // 计算布局 31 const computeLayout = useCallback(() => { 32 const newColumnHeights = new Array(columnCount).fill(0); 33 const newPositions: { x: number; y: number }[] = []; 34 itemRefs.forEach((ref, index) => { 35 if (!ref.current) return; 36 const minHeightIndex = newColumnHeights.indexOf(Math.min(...newColumnHeights)); 37 newPositions[index] = { 38 x: minHeightIndex * layout.columnWidth, 39 y: newColumnHeights[minHeightIndex], 40 }; 41 newColumnHeights[minHeightIndex] += ref.current.clientHeight; 42 }); 43 setLayout((prev) => ({ ...prev, columnHeights: newColumnHeights, positions: newPositions })); 44 }, [columnCount, layout.columnWidth, itemRefs]); 45 46 // 监听 items 和 columnCount 的变化 47 useEffect(() => { 48 computeLayout(); 49 }, [items, columnCount, layout.imgWidth, computeLayout]); 50 51 // 使用 ResizeObserver 监听容器大小变化 52 useEffect(() => { 53 const observer = new ResizeObserver(() => { 54 computeColumnWidth(); 55 computeLayout(); 56 }); 57 observer.observe(containerRef.current!); 58 return () => observer.disconnect(); 59 }, [computeColumnWidth, computeLayout]); 60 61 // 使用 GSAP 添加动画效果 62 useGSAP(() => { 63 gsap.from(containerRef.current, { opacity: 0, delay: 0.25 }); 64 }, []); 65 66 // 渲染移动端布局 67 const renderMobile = () => { 68 return ( 69 <div ref={containerRef} className="flex flex-col -mx-8"> 70 {items.map((item) => ( 71 <div key={item.slug} className="px-4 py-2"> 72 <CardComponent item={item} imgWidth={layout.imgWidth} isMobile={true} /> 73 </div> 74 ))} 75 </div> 76 ); 77 }; 78 79 // 渲染列布局 80 const renderColumns = () => { 81 return ( 82 <div 83 ref={containerRef} 84 className="relative px-0 -mx-8" 85 style={{ height: `${Math.max(...layout.columnHeights, 0)}px` }} 86 > 87 {items.map((item, index) => ( 88 <div 89 key={item.slug} 90 ref={itemRefs[index]} 91 className="absolute p-4" 92 style={{ 93 transform: `translate(${layout.positions[index]?.x}px, ${layout.positions[index]?.y}px)`, 94 width: `${100 / columnCount}%`, 95 }} 96 > 97 <CardComponent item={item} imgWidth={layout.imgWidth} isMobile={false} /> 98 </div> 99 ))} 100 </div> 101 ); 102 }; 103 104 return <>{columnCount === 1 ? renderMobile() : renderColumns()}</>; 105}; 106 107export default WaterfallGrid;

2. 解释代码

1. useMemo 和 useRef

1const itemRefs = useMemo(() => items.map(() => React.createRef<HTMLDivElement>()), [items]);

useMemouseRef 用于创建和缓存每个 item 的引用,以便在后续布局计算中使用。

2. useState 初始化布局

1const [layout, setLayout] = useState({ 2 columnHeights: [] as number[], 3 positions: [] as { x: number; y: number }[], 4 imgWidth: 0, 5 columnWidth: 0, 6});

layout 用于存储布局信息,包括列高、位置、图片宽度和列宽。

3. useCallback 计算列宽和布局

1const computeColumnWidth = useCallback(() => { 2 if (!containerRef.current) return; 3 const width = containerRef.current.clientWidth / columnCount; 4 setLayout((prev) => ({ ...prev, columnWidth: width, imgWidth: width - cardPadding * 2 })); 5}, [columnCount]); 6 7const computeLayout = useCallback(() => { 8 const newColumnHeights = new Array(columnCount).fill(0); 9 const newPositions: { x: number; y: number }[] = []; 10 itemRefs.forEach((ref, index) => { 11 if (!ref.current) return; 12 const minHeightIndex = newColumnHeights.indexOf(Math.min(...newColumnHeights)); 13 newPositions[index] = { 14 x: minHeightIndex * layout.columnWidth, 15 y: newColumnHeights[minHeightIndex], 16 }; 17 newColumnHeights[minHeightIndex] += ref.current.clientHeight; 18 }); 19 setLayout((prev) => ({ ...prev, columnHeights: newColumnHeights, positions: newPositions })); 20}, [columnCount, layout.columnWidth, itemRefs]);

computeColumnWidthcomputeLayout 是两个主要的布局计算函数,使用 useCallback 缓存以避免不必要的重新计算。

4. useEffect 监听布局变化

1useEffect(() => { 2 computeLayout(); 3}, [items, columnCount, layout.imgWidth, computeLayout]);

itemscolumnCountlayout.imgWidth 变化时,重新计算布局。

5. ResizeObserver

1useEffect(() => { 2 const observer = new ResizeObserver(() => { 3 computeColumnWidth(); 4 computeLayout(); 5 }); 6 observer.observe(containerRef.current!); 7 return () => observer.disconnect(); 8}, [computeColumnWidth, computeLayout]);

使用 ResizeObserver 监听容器大小变化,并相应地重新计算列宽和布局。

6. 使用 GSAP 添加动画

1useGSAP(() => { 2 gsap.from(containerRef.current, { opacity: 0, delay: 0.25 }); 3}, []);

使用 GSAP 添加加载动画,使容器元素在加载时淡入。

7. 渲染布局

1const renderMobile = () => { 2 return ( 3 <div ref={containerRef} className="flex flex-col -mx-8"> 4 {items.map((item) => ( 5 <div key={item.slug} className="px-4 py-2"> 6 <CardComponent item={item} imgWidth={layout.imgWidth} isMobile={true} /> 7 </div> 8 ))} 9 </div> 10 ); 11}; 12 13const renderColumns = () => { 14 return ( 15 <div 16 ref={containerRef} 17 className="relative px-0 -mx-8" 18 style={{ height: `${Math.max(...layout.columnHeights, 0)}px` }} 19 20 21 > 22 {items.map((item, index) => ( 23 <div 24 key={item.slug} 25 ref={itemRefs[index]} 26 className="absolute p-4" 27 style={{ 28 transform: `translate(${layout.positions[index]?.x}px, ${layout.positions[index]?.y}px)`, 29 width: `${100 / columnCount}%`, 30 }} 31 > 32 <CardComponent item={item} imgWidth={layout.imgWidth} isMobile={false} /> 33 </div> 34 ))} 35 </div> 36 ); 37};

根据 columnCount 的值,决定渲染移动端布局还是列布局。移动端布局使用 flex 排列,而列布局则使用 absolute 定位来实现 Masonry 效果。

3. 总结

通过以上步骤,我们实现了一个基于 React 和 TypeScript 的 Masonry 布局组件 WaterfallGrid。这个组件能够根据屏幕宽度动态调整内容块的排列方式,使得页面布局更加紧凑和美观。

masonry-blog-result

附使用 React Photo Album 和 Next.js Image 实现的列密铺布局:

gallery-rows-result

参考阅读

Tobias Ahlin 的文章:介绍了如何使用 Flexbox、:nth-child()order 属性创建 CSS Masonry 布局。通过将 flex-flow 设置为 column wrap 并使用 :nth-child()order 重新排列元素,可以实现从左到右的顺序渲染。文章详细解释了实现步骤,包括强制换列的伪元素使用。最终效果是无需 JavaScript 即可实现 Masonry 布局,但需要手动设置容器高度和列间距。

FlexMasonry(最后提交:2019 年) 是一个轻量级、零依赖的瀑布流布局库,使用 CSS flexbox 实现。它受 Tobias Ahlin 使用 flex:nth-child()order 创建纯 CSS 瀑布流布局的启发,并加入了一些 JavaScript,使其易于使用。特点包括轻量(6KB JS 和 CSS)、快速(使用 CSS flexbox 布局)、响应式设计(在不同断点显示不同列数)和简单设置(仅需 3 个选项)。

博客文章:使用纯 CSS 实现 Google Photos 照片列表布局,用了很多 trick,最后得到的结果有一些小 bug。胜在不依赖 JavaScript,利好 SSG 静态网页,并且纯 CSS 有 GPU 加速,性能更强。