画廊功能构建报告
摘要
本文介绍了为 Flare Stack Blog 新增画廊功能的实现。技术栈采用 React、TanStack Start 及 Cloudflare Workers。针对布局跳动,采用图片真实尺寸动态修正容器比例;针对 PhotoSwipe 报错,统一生命周期管理;针对加载慢,利用 CDN 端点优化图片格式与尺寸,性能提升 20 倍。最终实现了瀑布流布局、灯箱及管理后台,并解决了旧设备崩溃问题。
一、项目背景
为 Flare Stack Blog(基于 React + TanStack Start + Cloudflare Workers 的 Fuwari 主题博客)新增画廊功能,要求:
· 瀑布流布局(2/3 列自适应)
· 标签筛选与洗牌功能
· PhotoSwipe 灯箱,支持标题/描述、原图加载、缩放
· 管理后台 CRUD(复用标签系统)
· 图片加载速度优化(CDN 缓存、响应式尺寸、现代格式)
二、技术架构
层级 | 技术选型 | 说明 |
|---|---|---|
数据库 | D1 (SQLite) + Drizzle ORM gallery_items 表 + gallery_item_tags 关联表 | 图片存储 Cloudflare R2 原始图片存储,支持 URL 参数缩放 |
图片加速 | Cloudflare cdn-cgi/image 端点 | 实时缩放、格式转换(WebP/AVIF)、质量压缩 |
前端框架 | React 19 + TanStack Query | 状态管理、数据获取 |
灯箱 | PhotoSwipe 5(静态导入) | 稳定可靠,避免 SIMD 指令错误 |
瀑布流 | 纯 CSS Flexbox + JS 列高计算 | 最短列优先算法 |
后台管理 | 内联编辑 + TagSelector 组件 | 复用标签系统 |
三、核心难点与试错过程
3.1 图片加载闪烁(布局跳动)
现象:图片加载前后,容器比例突变,导致图片显示位置偏移,视觉上“闪一下”。
根因分析:
· 初始方案:用数据库中的 imgWidth/imgHeight 设置容器 aspect-ratio
· 问题:数据库存储的尺寸与实际图片尺寸可能不一致(如 CDN 缩放后),导致容器形状错误
· object-fit: cover 在错误比例的容器中裁剪位置偏移,图片看起来“跳了一下”
试错经历:
尝试次数 | 方案 | 结果 |
|---|---|---|
1 | 添加 opacity 过渡动画 | 只延迟了错误显示,容器形状未变 |
2 | 使用骨架屏占位 | 骨架屏依赖错误数据,同样形状错误 |
3 | 移除 srcset,直接加载单一图片 | 图片比例可能正确,但容器仍按错误数据裁剪 |
4 | 添加 object-fit: contain | 显示完整图片,但布局不整齐 |
最终方案:
关键洞察:问题的根源不在视图层(CSS),而在数据层——静态尺寸与真实尺寸脱节。用 naturalWidth/naturalHeight 动态修正容器,从根源切断错误数据的影响。
3.2 PhotoSwipe refresh 报错 is not a function
现象:洗牌、切换标签、加载更多后,灯箱有时无法打开,控制台报 TypeError: is not a function。
根因分析:
· 原代码结构:初始化与刷新分散在两个 useEffect 中
· React 18 Strict Mode 下,useEffect 可能多次执行,且执行顺序不可控
· 刷新 useEffect 先于初始化执行 → lbRef.current 为 null(可选链静默失败)
· 更隐蔽:lbRef.current 引用了已被 destroy() 的实例,原型方法被移除,但引用仍存在(非 null),可选链 ?. 无法防御
试错经历:
尝试次数 | 方案 | 结果 |
|---|---|---|
1 | 使用可选链 ?.refresh() | 只能防 null,无法防“已销毁”对象 |
2 | 调整依赖数组顺序 | React effect 执行顺序不可控 |
3 | 添加 if (lbRef.current) 判断 | 实例已销毁但引用仍在,条件为 true |
4 | 延迟执行 setTimeout(refresh, 0) | 竞态条件未解决 |
最终方案:
· 将所有操作(初始化、刷新、清理)统一在一个 useEffect 中管理
· 使用 needsRefresh 状态作为统一触发信号(而非多个依赖)
· 清理函数统一销毁,并将 lbRef.current 设为 null
· 刷新前先尝试 requestAnimationFrame 确保 DOM 更新完成
关键洞察:第三方库的生命周期必须与 React 严格绑定。分散管理必然导致竞态条件,统一管理是唯一解。
3.3 图片加载速度优化(电脑端 1 分多钟 → 3 秒)
现象:电脑端加载 9 张图耗时 60-90 秒,手机端 10-30 秒。
根因分析:
· 瀑布流请求了过大尺寸的图片(如原图 4000px 宽)
· 未使用现代图片格式(WebP/AVIF)
· 未利用 CDN 缓存
试错经历:
尝试次数 | 方案 | 结果 |
|---|---|---|
1 | 限制列宽为 320px | 桌面端列宽减小,但请求尺寸仍由 srcSet 决定 |
2 | 使用 cdn-cgi/image 端点 | 参数格式错误(双斜杠),优化失效 |
3 | 响应式 srcSet + sizes | 浏览器自动选择合适尺寸,但手机端仍加载大图 |
4 | 调整 getOptimizedImageUrl 默认宽度 | 平衡画质与体积,桌面端显著加速 |
最终方案:
· cdn-cgi/image/width={w},quality=80,format=auto/原始路径
· 响应式 srcSet(200/400/600/800w)+ sizes 属性
· 灯箱使用 1200px 占位图(data-pswp-msrc),原图后台替换
· Cloudflare CDN 全站缓存
性能提升(以9张图为例):
场景 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
电脑端 | 60-90s | 3-5s | ~20x |
手机端 | 10-30s | 1-3s | ~10x |
灯箱原图 | 15-30s | 2-5s | ~6x |
缓存后 | N/A | <100ms | ∞ |
3.4 STATUS_ILLEGAL_INSTRUCTION 错误
现象:部署后,部分设备点击灯箱时页面崩溃,显示 STATUS_ILLEGAL_INSTRUCTION。
根因分析:
· PhotoSwipeLightbox 使用动态导入 pswpModule: () => import("photoswipe")
· 某些旧 CPU 不支持 WebAssembly SIMD 指令,而 Photoswipe 可能包含相关优化
· 打包工具在 SSR 环境可能预解析动态导入,触发不兼容代码
解决方案:改为静态导入
效果:错误彻底消失,所有设备正常。
四、技术实现亮点
4.1 瀑布流算法
· 时间复杂度 O(n × cols),对于画廊规模完全足够
· 无需外部库,纯 JS 实现
4.2 图片比例动态修正
· 完全消除了布局跳动
· 修正过程的过渡动画让体验更流畅
4.3 加载淡入动画
· 300ms 淡入,配合 #f0f0f0 浅灰背景占位
· 无外部动画库,纯 CSS
4.4 灯箱标题自定义
· 完全自定义样式,与主题风格统一
· 自动处理无标题/无描述的情况(隐藏容器)
五、最终成果
瀑布流布局(2/3 列自适应)
标签筛选与"全部"切换
洗牌功能
加载更多按钮
PhotoSwipe 灯箱(无崩溃)
灯箱从缩略图放大动画
灯箱标题/描述显示
灯箱原图加载
图片加载淡入动画
无布局跳动
响应式 srcSet + sizes
Cloudflare CDN 缓存
管理后台 CRUD
数据库索引优化
六、经验总结
1. 数据真实性优先:永远不要完全信任数据库中的静态尺寸,naturalWidth/naturalHeight 是解决图片布局问题的终极方案。
2. 第三方库生命周期必须与 React 严格绑定:统一管理、统一销毁,避免遗留无效实例。
3. CDN 优化是性能瓶颈的核心解药:正确使用 Cloudflare cdn-cgi/image 端点,可实现专业级的图片优化。
4. 细节决定体验:淡入动画、比例修正过渡、灯箱标题,这些微小的优化是“能用”和“好用”的分水岭。
5. 试错是技术成长的必经之路:每一次看似重复的修改,都是在逐步逼近问题本质,最终达到“知其然,更知其所以然”的境界。
注:画廊展示页源码基于开源博客leehenry-blog改造,以下是github仓库链接。

