Files
SuBlog/content/posts/react-photo-album-masonry/index.md
T

175 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: 用 react-photo-album 给生图图库做瀑布流排版
published: 2026-08-16T00:00:00
updated: 2026-08-16
description: 记录在 Magic Img 生图图库中引入 react-photo-album 实现 Masonry 瀑布流的过程:技术选型背景、需求梳理、安装使用,以及「忘记引入样式表导致一张一列」等踩坑与排查方法。
image: /posts/react-photo-album-masonry/img/cover.svg
tags: ['React', 'react-photo-album', '瀑布流', '前端', 'Tailwind']
series: 前端工程实践
seriesOrder: 1
difficulty: 入门
---
## 技术背景
Magic Img 是我做的一个 AI 生图提示词管理工具(自托管、本地优先),其中「生图图库」页面要把所有生成图片以图片墙的形式展示出来。图片墙有两个天然矛盾:
1. **等宽网格**(CSS Grid):整齐,但每行高度由最高的那张决定——不同比例的图片混排时,矮图下面会留下大洞,看起来"很乱";
2. **原生比例展示**:图库必须不裁剪(这是产品约束),所以不能用 `object-cover` 强行统一比例。
业内的标准解法是 **Masonry 瀑布流**:按列紧密堆放,每张图保持原始比例,列间高度错落但整体紧凑。实现瀑布流的常见方案有三类:
| 方案 | 依赖 | 顺序 | 说明 |
|------|------|------|------|
| CSS `columns` 多列 | 无 | 纵向阅读序(先下后右) | 最新图片不再出现在左上角,时间语义丢失 |
| grid row-span 自研 | 无 | 保持 | 需固定列数 + 固定行高,逻辑要自己写 |
| **react-photo-album** | ~4KB,无依赖 | 基本保持(逐列最短列填充) | 专为相册墙设计,TS 友好,维护活跃 |
最终选了 **react-photo-album v3**:体积小、类型齐全,而且它的 Masonry 模式恰好需要「每张图的 `width/height`」——我们的数据库里本来就存了,零额外成本。
## 需求梳理
1. 展示全部生成图(21 张起,会持续增长),**原比例、不裁剪**;
2. 不同尺寸(2304×1728、4608×3456 等)混排时**紧凑、不留洞**;
3. 顺序基本保持「新图靠前」;
4. 点击任意一张打开大图预览(YARL 灯箱),悬停显示所属提示词;
5. 平板性能:墙上只用 300px 缩略图,原图只进灯箱按需加载。
## 安装与使用
### 1. 安装
```bash
npm install react-photo-album
```
### 2. 准备数据(关键输入是宽高)
库要求的 `Photo` 对象只有三个必需字段:`src`、`width`、`height`。我们的接口本来就返回每张图的原始宽高:
```tsx
import PhotoAlbum, { type Photo } from "react-photo-album"
// 必须引入样式表!容器/列的布局规则都在这里(坑 1 详述)
import "react-photo-album/styles.css"
interface GalleryPhoto extends Photo {
prompt_text?: string
}
const albumPhotos: GalleryPhoto[] = images.map((img) => ({
key: String(img.id),
src: assetUrl(img.thumb_path) || assetUrl(img.file_path),
width: img.width,
height: img.height,
alt: img.prompt_text || `生成图 ${img.id}`,
prompt_text: img.prompt_text,
}))
```
### 3. 使用 Masonry 布局
```tsx
<PhotoAlbum
layout="masonry"
columns={(cw) => (cw < 640 ? 2 : cw < 960 ? 3 : cw < 1280 ? 4 : 5)}
spacing={16}
padding={0}
photos={albumPhotos}
onClick={({ index }) => openPreview(index)}
render={{
photo: (props, context) => {
const { onClick } = props
const { photo, index, width, height } = context
return (
<button type="button" onClick={onClick} className="group relative block w-full ...">
<img
src={photo.src}
alt={photo.alt || ""}
decoding="async"
style={{ width, height }}
/>
{/* hover 提示词浮层 */}
<div className="... opacity-0 group-hover:opacity-100">
{photo.prompt_text}
</div>
</button>
)
},
}}
/>
```
几个要点:
- `columns` 可以是数字或 `(containerWidth) => number` 函数,做响应式列数很方便;
- 点击回调 `onClick` 里拿到的是**原始数组下标** `index`,直接传给灯箱即可;
- 自定义 `render.photo` 后,图片尺寸要自己用 context 里的 `width/height`(渲染尺寸)设置——这两个值库已经按列宽算好了。
## 遇到的坑
### 坑 1:忘记引入样式表 → 「一张一列」
这是最坑的一个。装完包、写好 `<PhotoAlbum layout="masonry">`,页面渲染出来却是:**每张图占一整行,右侧大片置灰空白**。
排查过程:先怀疑是 `columns` 函数没生效,改成固定数字无效;再检查 DOM——容器和 5 个列(track)都在,列的宽度也算对了,但图片全都堆在布局里。
最后翻包的 `package.json` 发现它导出了独立的 CSS 文件(`./styles.css`、`./masonry.css` 等),而**官方 README 示例都带了一行 `import "react-photo-album/styles.css"`**。容器是 flex、列是 flex-column、列宽按 `--react-photo-album--columns` 计算——这些规则全在这个样式表里,不引入就退化成普通块级堆叠。
**解决**:顶部加一行:
```tsx
import "react-photo-album/styles.css"
```
经验:**用这类"无运行时依赖"的布局库,先看它的 exports 里有没有独立 CSS,有就一定要引。**
### 坑 2:`render.photo` 是 `(props, context)` 双参签名
自定义渲染时我凭直觉写了:
```tsx
photo: ({ photo, index, width, height, onClick }) => ... // ❌ 报错:Property 'photo' does not exist
```
TS 报错提示 `photo` 等属性不存在于 `RenderPhotoProps`。看类型定义才发现 v3 的渲染函数是**两个参数**:
```tsx
type RenderFunction<Props, Context> = (props: Props, context: Context) => ReactNode
// photo 的渲染上下文(photo/index/width/height)在第二个参数里
photo: (props, context) => {
const { onClick } = props
const { photo, index, width, height } = context
...
}
```
**解决**:按 `(props, context)` 拆解即可。装新库先扫一眼 `.d.ts` 里的 Render 类型签名,能少走很多弯路。
### 坑 3:测试断言把「DOM 顺序」当成了「视觉顺序」
布局修好后,我在浏览器自测脚本里加了一条断言:取前两个 `<img>`,比较它们的左坐标,认为应该并排(不同列)。
结果断言失败——但页面明明是 5 列并排。排查发现:**瀑布流的 DOM 是按"列"组织的**(第 1 列的全部图片在前,第 2 列的在后),所以 `img[0]` 和 `img[1]` 属于**同一列**,左坐标当然相同。
**解决**:断言改为比较「第 1 列的首张图 vs 第 2 列的首张图」:
```js
const a = tracks[0].querySelector("img")
const b = tracks[1].querySelector("img")
return Math.abs(a.getBoundingClientRect().left - b.getBoundingClientRect().left) > 10
```
经验:**验证布局类库时,先搞清楚它生成的 DOM 结构,再写断言**;另外把「真实浏览器探针」当排查手段(直接打印各 track 的 x 坐标和子元素数量),比盯着代码猜快得多。
### 坑 4:等宽网格的"洞"(引入前就存在的产品问题)
顺带记录一下为什么换掉原来的布局:CSS Grid `repeat(auto-fill, minmax(220px, 1fr))` 等宽铺满,图片 `w-full h-auto`。当一行里混有不同比例时,行高取最高的那张,矮图底部留空,几行错落下来就显得乱;另外窗口宽度"差一点放不下下一张"时,右侧会空出近一整列。
换成 Masonry 后这两个问题都消失了——列内紧密堆叠、列数自适应。
## 效果与小结
最终效果:21 张不同比例的生成图在 5 列瀑布流里紧密排列,原比例不裁剪,悬停出提示词浮层,点击进 YARL 大图预览;缩略图总大小仅 360KB,进页面即全量加载 + `immutable` 长缓存,平板上快速滚动也跟手。
一句话总结:**react-photo-album 是个小但专业的相册布局库,只要记住三件事——引样式表、喂宽高、render 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。**