Compare commits
3 Commits
5efcaaae9f
...
ca2309dda0
| Author | SHA1 | Date | |
|---|---|---|---|
| ca2309dda0 | |||
| 277b0eea2c | |||
| 9ae9cea563 |
@@ -0,0 +1,51 @@
|
|||||||
|
<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="#f6f4fb"/>
|
||||||
|
<stop offset="1" stop-color="#e9e4f5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="chip" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7c5cff"/>
|
||||||
|
<stop offset="1" stop-color="#5b3fd4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1067" height="600" fill="url(#bg)"/>
|
||||||
|
<text x="533" y="86" text-anchor="middle" font-family="sans-serif" font-size="40" font-weight="700" fill="#3b3550">IP 属地是怎么查出来的?</text>
|
||||||
|
<text x="533" y="128" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#8a84a0">IP 段 → 二分查找 → 省|市|运营商</text>
|
||||||
|
|
||||||
|
<!-- IP 输入 -->
|
||||||
|
<rect x="96" y="190" width="240" height="76" rx="16" fill="url(#chip)"/>
|
||||||
|
<text x="216" y="218" text-anchor="middle" font-family="monospace" font-size="24" font-weight="700" fill="#ffffff">198.51.100.42</text>
|
||||||
|
<text x="216" y="248" text-anchor="middle" font-family="sans-serif" font-size="16" fill="#e6e0ff">IP 地址</text>
|
||||||
|
|
||||||
|
<!-- 箭头1 -->
|
||||||
|
<path d="M356 228 h64" stroke="#7c5cff" stroke-width="4" fill="none" marker-end="none"/>
|
||||||
|
<path d="M412 222 l12 6 l-12 6 z" fill="#7c5cff"/>
|
||||||
|
|
||||||
|
<!-- 二进制 -->
|
||||||
|
<rect x="428" y="196" width="252" height="64" rx="16" fill="#ffffff" stroke="#d8d2ec" stroke-width="3"/>
|
||||||
|
<text x="554" y="224" text-anchor="middle" font-family="monospace" font-size="20" font-weight="700" fill="#5b5568">0xC633642A</text>
|
||||||
|
<text x="554" y="248" text-anchor="middle" font-family="sans-serif" font-size="15" fill="#8a84a0">32 位整数(4 字节拼接)</text>
|
||||||
|
|
||||||
|
<!-- 箭头2 -->
|
||||||
|
<path d="M700 228 h64" stroke="#7c5cff" stroke-width="4" fill="none"/>
|
||||||
|
<path d="M756 222 l12 6 l-12 6 z" fill="#7c5cff"/>
|
||||||
|
|
||||||
|
<!-- 段表 -->
|
||||||
|
<g>
|
||||||
|
<rect x="776" y="176" width="220" height="104" rx="14" fill="#ffffff" stroke="#d8d2ec" stroke-width="3"/>
|
||||||
|
<text x="886" y="204" text-anchor="middle" font-family="monospace" font-size="16" fill="#8a84a0">100.0 ~ 100.255</text>
|
||||||
|
<rect x="790" y="216" width="192" height="26" rx="8" fill="#7c5cff" opacity="0.16"/>
|
||||||
|
<text x="886" y="234" text-anchor="middle" font-family="monospace" font-size="16" font-weight="700" fill="#5b3fd4">100.0 ~ 100.255 ✓</text>
|
||||||
|
<text x="886" y="262" text-anchor="middle" font-family="monospace" font-size="16" fill="#8a84a0">101.0 ~ 101.255</text>
|
||||||
|
<text x="886" y="280" text-anchor="middle" font-family="sans-serif" font-size="13" fill="#8a84a0">二分查找 O(log N)</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 结果 -->
|
||||||
|
<rect x="308" y="360" width="452" height="110" rx="18" fill="#ffffff" stroke="#7c5cff" stroke-width="4"/>
|
||||||
|
<text x="534" y="406" text-anchor="middle" font-family="sans-serif" font-size="26" font-weight="700" fill="#5b3fd4">中国|福建|福州|移动</text>
|
||||||
|
<text x="534" y="440" text-anchor="middle" font-family="sans-serif" font-size="16" fill="#8a84a0">段级归属地(不是精确定位)</text>
|
||||||
|
|
||||||
|
<!-- 底部提示 -->
|
||||||
|
<text x="533" y="540" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#a49cc0">几十万条「IP 段 → 归属地」映射 · 本地毫秒级查询 · 注意 IPv6 盲点</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,238 @@
|
|||||||
|
---
|
||||||
|
title: IP 属地是怎么查出来的?本地查询与在线查询方案对比
|
||||||
|
published: 2026-08-07T12:00:00
|
||||||
|
updated: 2026-08-16
|
||||||
|
description: 科普 IP 属地查询的原理:IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级,对比 ip2region、在线 API 与 MaxMind 三种方案的优劣,并附登录记录场景的 Go 实战与 IPv6 盲点提醒。
|
||||||
|
image: /posts/ip-geolocation-guide/img/cover.svg
|
||||||
|
tags: ['IP', '网络', '科普']
|
||||||
|
draft: false
|
||||||
|
difficulty: 入门
|
||||||
|
---
|
||||||
|
|
||||||
|
## 写在前面
|
||||||
|
|
||||||
|
很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地,比如「福建·福州 · 中国移动」。你可能好奇过:**服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的?**
|
||||||
|
|
||||||
|
这篇科普文章讲清楚三件事:
|
||||||
|
|
||||||
|
1. IP 属地到底查的是什么;
|
||||||
|
2. 纯本地查询为什么能做到毫秒级且不联网;
|
||||||
|
3. 几种主流查询方案的优缺点对比,方便你按场景选型。
|
||||||
|
|
||||||
|
> 说明:文中用到的 IP 均为 RFC 5737 保留的文档示例段(TEST-NET-2,`198.51.100.0/24`),不会对应任何真实地址。
|
||||||
|
|
||||||
|
## 先想清楚:IP 属地查的是什么
|
||||||
|
|
||||||
|
一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 **IP 段与归属地的映射**。
|
||||||
|
|
||||||
|
运营商和机构在申请 IP 时,是按**网段(CIDR)**成块分配的。比如 `198.51.100.0/24` 这一个段,一整块都归属同一地区、同一运营商。所以归属地数据天然可以**按段存储**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IP 起始 IP 结束 归属地
|
||||||
|
────────────────────────────────────────
|
||||||
|
198.51.100.0 198.51.100.255 中国|福建|福州|移动
|
||||||
|
198.51.101.0 198.51.101.255 中国|浙江|杭州|电信
|
||||||
|
```
|
||||||
|
|
||||||
|
全球公网 IP 约有 42 亿个,但**网段只有几十万条**。正因为存的是「段」而不是「每个 IP」,完整的归属地数据文件才只有几 MB(ip2region 的 `.xdb` 约 11MB)。这是「本地离线查询」可行的第一前提。
|
||||||
|
|
||||||
|
所以,**IP 属地从来不是精确到街道的定位**,它返回的是段级的「省|市|运营商」粗粒度信息。
|
||||||
|
|
||||||
|
## 纯本地查询的原理
|
||||||
|
|
||||||
|
以开源的 **ip2region** 为例,它的查询完全在本地完成,不联网、不请求第三方,几十微秒到毫秒级返回。
|
||||||
|
|
||||||
|
### 第一步:把 IP 变成整数
|
||||||
|
|
||||||
|
以 `198.51.100.42` 为例,它会转成一个 32 位整数 `0xC633642A`。IP 的四个数字本质就是 4 个字节,拼起来就是一个整数,这一步没有任何玄学。
|
||||||
|
|
||||||
|
### 第二步:二分查找
|
||||||
|
|
||||||
|
在按起始 IP 排序的段列表里做**二分查找**,找到满足 `段的起始IP ≤ 目标整数 ≤ 段的结束IP` 的那一段。
|
||||||
|
|
||||||
|
二分查找的复杂度是 O(log N),N 是几十万条,所以很快——这就是「不靠网络也能毫秒返回」的核心。
|
||||||
|
|
||||||
|
### 第三步:.xdb 文件的两级结构
|
||||||
|
|
||||||
|
ip2region 的 `.xdb` 不是简单顺序文件,内部分两个区,进一步加速:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 索引区 (Index) │ // 每段一条定长记录:startIP / endIP / 数据区指针 + 数据长度
|
||||||
|
│ …… 二分快速定位段 …… │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ 数据区 (Data) │ // 真正存放各段的地点字符串
|
||||||
|
│ …… │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
先二分索引区定位到「是哪一段」,再按指针去数据区读出地点字符串,一次磁盘读就能拿到结果。
|
||||||
|
|
||||||
|
### 为什么「离线数据」不会很快过期
|
||||||
|
|
||||||
|
IP 归属取决于**注册机构和运营商的分配**,一个网段通常长期稳定属于某地区、某运营商。所以:
|
||||||
|
|
||||||
|
- 下载一次数据文件,可以用很久;
|
||||||
|
- 归属偶尔变化时,**每月更新一次 `.xdb` 文件**即可(替换文件、不用重启服务,见下文实战);
|
||||||
|
- 数据源来自 IANA/APNIC 等机构的分配表与公开归属信息,不是实时探测。
|
||||||
|
|
||||||
|
## 主流查询方案对比
|
||||||
|
|
||||||
|
实际开发中,查询 IP 属地主要有三条路线。
|
||||||
|
|
||||||
|
### 方案一:ip2region(纯本地离线,推荐)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Go 中的用法(github.com/lionsoul2014/ip2region/binding/golang)
|
||||||
|
region, err := searcher.SearchByStr(ip)
|
||||||
|
// 返回类似 "中国|福建省|福州市|移动"
|
||||||
|
```
|
||||||
|
|
||||||
|
| 优点 | 缺点 |
|
||||||
|
|------|------|
|
||||||
|
| 免费、无网络依赖、无 QPS 限制 | 段级粒度,个别段可能显示到相邻地区 |
|
||||||
|
| 毫秒级查询,永不因外部服务挂掉 | 数据按月更新,最新分配的段可能短暂缺失 |
|
||||||
|
| 数据文件可随版本更新,不重启 | 只返回省市区+运营商,无经纬度;**仅支持 IPv4** |
|
||||||
|
|
||||||
|
**适用**:博客/后台的「登录记录显示归属地」这类展示用途。
|
||||||
|
|
||||||
|
### 方案二:在线 API(ip-api / 高德 / 腾讯位置服务)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 以 ip-api.com 为例(免费版约 45 次/分钟,非商用)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", "https://ip-api.com/json/198.51.100.42", nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "未知" // 断网/超时降级:展示「未知」而不是报错
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// 解析 JSON,status != "success" 时同样降级
|
||||||
|
```
|
||||||
|
|
||||||
|
| 优点 | 缺点 |
|
||||||
|
|------|------|
|
||||||
|
| 数据最新、可能更精确 | 有频率限制(免费版限 QPS) |
|
||||||
|
| 部分服务还返回经纬度/时区 | 依赖第三方,断网/被墙/限流时拿不到 |
|
||||||
|
| 无需维护数据文件 | 每次查询都要发一次 HTTP 请求 |
|
||||||
|
|
||||||
|
**适用**:对精度要求高、且能接受外部依赖与限流的场景。**务必加超时和降级**,否则第三方一抖,你自己的页面就卡住。
|
||||||
|
|
||||||
|
### 方案三:MaxMind GeoLite2(离线 .mmdb)
|
||||||
|
|
||||||
|
| 优点 | 缺点 |
|
||||||
|
|------|------|
|
||||||
|
| 全球数据,覆盖广、精度较好 | 免费但需注册账号并遵守 EULA;商用需 GeoIP2 商业授权 |
|
||||||
|
| 标准 `.mmdb` 格式,各语言都有库,支持 IPv6 | 国内 IP 的省市粒度有时不如 ip2region 细 |
|
||||||
|
| 离线查询 | 数据同样要定期更新 |
|
||||||
|
|
||||||
|
**适用**:面向全球用户的场景。
|
||||||
|
|
||||||
|
## IPv6:本地离线方案的一个盲点
|
||||||
|
|
||||||
|
前面聊的都是 IPv4(32 位)。但家宽和移动网络越来越多地**只下发 IPv6**(128 位)地址,而 **ip2region 目前只支持 IPv4**——拿到一个纯 IPv6 地址是查不出归属地的,本地库会返回空结果。
|
||||||
|
|
||||||
|
实际影响与常见应对:
|
||||||
|
|
||||||
|
1. **判断版本**:Go 里 `net.ParseIP(ip).To4() != nil` 就是 IPv4,先判断再查询;
|
||||||
|
2. **降级展示**:IPv6 直接显示「IPv6 地址」,不显示属地,诚实第一;
|
||||||
|
3. **优先取 IPv4**:有反向代理时,从 `X-Forwarded-For` 里挑一个 IPv4 来查;
|
||||||
|
4. **需要 v6 归属**:换支持 IPv6 的数据源(如 MaxMind GeoLite2)。
|
||||||
|
|
||||||
|
这一点在接入时很容易漏掉,上线前记得拿一个纯 IPv6 地址回归一遍。
|
||||||
|
|
||||||
|
## 实战:给登录记录加 IP 属地
|
||||||
|
|
||||||
|
subLog 博客的登录记录就用了 ip2region。完整接入一共四步。
|
||||||
|
|
||||||
|
### 1. 进程启动时加载数据文件
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 用 atomic.Pointer 持有查询器,方便后面热更新
|
||||||
|
var searcher atomic.Pointer[xdb.Searcher]
|
||||||
|
|
||||||
|
func initSearcher() error {
|
||||||
|
content, err := os.ReadFile("./data/ip2region.xdb") // 约 11MB,一次性读入内存
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s, err := xdb.NewWithBuffer(content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
searcher.Store(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 查询一次
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ipRegion(ip string) string {
|
||||||
|
region, err := searcher.Load().SearchByStr(ip)
|
||||||
|
if err != nil {
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
return region // "中国|福建省|福州市|移动"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 加一层内存缓存
|
||||||
|
|
||||||
|
登录记录里同一个 IP 可能反复出现,用 `sync.Map` 按 IP 缓存结果,一次查询终身复用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var regionCache sync.Map
|
||||||
|
|
||||||
|
func ipRegionCached(ip string) string {
|
||||||
|
if v, ok := regionCache.Load(ip); ok {
|
||||||
|
return v.(string)
|
||||||
|
}
|
||||||
|
region := ipRegion(ip)
|
||||||
|
regionCache.Store(ip, region)
|
||||||
|
return region
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 数据热更新(不用重启)
|
||||||
|
|
||||||
|
ip2region 每月发布新数据。下载新文件后重新 `NewWithBuffer` 并 `Store` 覆盖即可,进程完全不用重启:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func reloadSearcher(newContent []byte) error {
|
||||||
|
s, err := xdb.NewWithBuffer(newContent)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
searcher.Store(s) // 原子切换,老查询继续走旧数据,新查询走新数据
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 局限与诚实提醒
|
||||||
|
|
||||||
|
无论用哪种方案,都要清楚 IP 属地的边界:
|
||||||
|
|
||||||
|
- **段级精度**:网段归属是「绝大多数」准确,个别 IP(大型企业自建段、跨省调度段)可能显示到相邻地区或只到省。
|
||||||
|
- **运营商级,不是定位**:拿到的是「省|市|运营商」,不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位,纯 IP 归属做不到。
|
||||||
|
- **动态 IP**:家宽通常是动态 IP,重拨会变,所以「上次登录在福州,这次在广州」不一定是异常,可能是 IP 变了。
|
||||||
|
- **别用于安全判定**:用 IP 段做封禁或风控判定风险自负,段级数据不适合当精确依据。
|
||||||
|
- **隐私与合规**:IP 地址属于个人信息(《个人信息保护法》)。归属地展示建议只出现在**用户本人可见**的页面(登录记录、个人中心);若在评论区等公开场合展示,建议只显示到省市并明确告知用户。
|
||||||
|
|
||||||
|
## 小结
|
||||||
|
|
||||||
|
一句话总结:
|
||||||
|
|
||||||
|
> **IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。** 它查的是网段归属,不是精确位置。
|
||||||
|
|
||||||
|
- 想省心、免费、稳定,用 **ip2region** 本地离线查询(注意 IPv6 盲点);
|
||||||
|
- 想要最新最细、能接受外部依赖,用**在线 API**(记得超时降级);
|
||||||
|
- 面向全球用户、要 IPv6 覆盖,用 **MaxMind GeoLite2**。
|
||||||
|
|
||||||
|
对个人博客「登录记录显示归属地」这种场景,本地离线方案通常是性价比最高的选择。
|
||||||
@@ -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