Compare commits

...

3 Commits

4 changed files with 498 additions and 0 deletions
@@ -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

+238
View File
@@ -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 函数是双参——就能在十分钟内把图片墙升级成真正的瀑布流。**