feat: add react-photo-album masonry gallery article
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1067" height="600" viewBox="0 0 1067 600">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#f4f2fb"/>
|
||||||
|
<stop offset="1" stop-color="#e9e5f6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="p" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7c5cff"/>
|
||||||
|
<stop offset="1" stop-color="#fe8bbb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1067" height="600" fill="url(#bg)"/>
|
||||||
|
<!-- 瀑布流示意:5 列不同高度的块 -->
|
||||||
|
<g font-family="sans-serif" font-size="26" font-weight="700" fill="#5b5568">
|
||||||
|
<text x="533" y="86" text-anchor="middle" font-size="40" fill="#3b3550">react-photo-album · Masonry</text>
|
||||||
|
<text x="533" y="130" text-anchor="middle" font-size="24" fill="#8a84a0">生图图库瀑布流排版实战</text>
|
||||||
|
</g>
|
||||||
|
<g stroke="#ffffff" stroke-width="6">
|
||||||
|
<rect x="140" y="180" width="150" height="130" rx="14" fill="url(#p)"/>
|
||||||
|
<rect x="302" y="180" width="150" height="200" rx="14" fill="#7c5cff" opacity="0.75"/>
|
||||||
|
<rect x="464" y="180" width="150" height="95" rx="14" fill="#fe8bbb" opacity="0.85"/>
|
||||||
|
<rect x="626" y="180" width="150" height="170" rx="14" fill="#7c5cff" opacity="0.55"/>
|
||||||
|
<rect x="788" y="180" width="150" height="120" rx="14" fill="#b39cff"/>
|
||||||
|
<rect x="140" y="326" width="150" height="200" rx="14" fill="#fe8bbb" opacity="0.6"/>
|
||||||
|
<rect x="302" y="396" width="150" height="130" rx="14" fill="#7c5cff" opacity="0.85"/>
|
||||||
|
<rect x="464" y="291" width="150" height="235" rx="14" fill="url(#p)"/>
|
||||||
|
<rect x="626" y="366" width="150" height="160" rx="14" fill="#b39cff"/>
|
||||||
|
<rect x="788" y="316" width="150" height="210" rx="14" fill="#7c5cff" opacity="0.7"/>
|
||||||
|
<rect x="140" y="542" width="150" height="90" rx="14" fill="#7c5cff" opacity="0.5"/>
|
||||||
|
<rect x="302" y="542" width="150" height="150" rx="14" fill="#fe8bbb" opacity="0.8"/>
|
||||||
|
<rect x="464" y="542" width="150" height="110" rx="14" fill="#7c5cff" opacity="0.9"/>
|
||||||
|
<rect x="626" y="542" width="150" height="180" rx="14" fill="#b39cff" opacity="0.9"/>
|
||||||
|
<rect x="788" y="542" width="150" height="100" rx="14" fill="#7c5cff"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
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 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。**
|
||||||
Reference in New Issue
Block a user