banner lightbanner dark
约 2,100 字
7 分钟

画廊功能构建报告

摘要

本文介绍了为 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

显示完整图片,但布局不整齐

最终方案:

TSX
// GalleryImage 组件:onLoad 动态修正比例
const handleLoad = (e) => {
  const img = e.currentTarget;
  setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight });
  setLoaded(true);
};
// 容器 aspect-ratio 优先用真实尺寸
const aspectRatio = naturalSize
  ? ${naturalSize.w} / ${naturalSize.h}
  : ${item.imgWidth || 1200} / ${item.imgHeight || 800};
// transition 让修正过程丝滑
style={{ aspectRatio, transition: "aspect-ratio 0.3s ease" }}

关键洞察:问题的根源不在视图层(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 更新完成

TSX
// 单例模式 + 统一生命周期管理
useEffect(() => {
  if (lbRef.current) {
    requestAnimationFrame(() => { lbRef.current?.refresh(); });
    return;
  }
  // 创建新实例...
  return () => {
    lb?.destroy();
    lbRef.current = null;
  };
}, [needsRefresh]);

关键洞察:第三方库的生命周期必须与 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 环境可能预解析动态导入,触发不兼容代码

解决方案:改为静态导入

TSX
import PhotoSwipeLightbox from "photoswipe/lightbox";// 初始化时直接传入静态模块
lb = new PhotoSwipeLightbox({
  pswpModule: PhotoSwipe,  // 不再动态导入
});

效果:错误彻底消失,所有设备正常。

四、技术实现亮点

4.1 瀑布流算法

TSX
// 最短列优先:将每张图片放入当前累积高度最小的列
const minIdx = heights.indexOf(Math.min(...heights));

· 时间复杂度 O(n × cols),对于画廊规模完全足够

· 无需外部库,纯 JS 实现

4.2 图片比例动态修正

TSX
// 初始占位用数据库值,加载完成后用真实值修正
const aspectRatio = naturalSize
  ? ${naturalSize.w} / ${naturalSize.h}
  : ${item.imgWidth || 1200} / ${item.imgHeight || 800};
// transition 让修正过程肉眼不可感知
style={{ aspectRatio, transition: "aspect-ratio 0.3s ease" }}

· 完全消除了布局跳动

· 修正过程的过渡动画让体验更流畅

4.3 加载淡入动画

TSX
className={cn(
  "absolute inset-0 w-full h-full object-cover transition-opacity duration-300",
  loaded ? "opacity-100" : "opacity-0"
)}

· 300ms 淡入,配合 #f0f0f0 浅灰背景占位

· 无外部动画库,纯 CSS

4.4 灯箱标题自定义

TSX
// 手动创建 DOM 元素,显示标题(粗体衬线)和描述(小号衬线)
const titleEl = document.createElement("span");
titleEl.textContent = title;
titleEl.style.fontWeight = "bold";
titleEl.style.fontFamily = 'Georgia, "Times New Roman", serif';

· 完全自定义样式,与主题风格统一

· 自动处理无标题/无描述的情况(隐藏容器)

五、最终成果

  • 瀑布流布局(2/3 列自适应)

  • 标签筛选与"全部"切换

  • 洗牌功能

  • 加载更多按钮

  • PhotoSwipe 灯箱(无崩溃)

  • 灯箱从缩略图放大动画

  • 灯箱标题/描述显示

  • 灯箱原图加载

  • 图片加载淡入动画

  • 无布局跳动

  • 响应式 srcSet + sizes

  • Cloudflare CDN 缓存

  • 管理后台 CRUD

  • 数据库索引优化

六、经验总结

1. 数据真实性优先:永远不要完全信任数据库中的静态尺寸,naturalWidth/naturalHeight 是解决图片布局问题的终极方案。

2. 第三方库生命周期必须与 React 严格绑定:统一管理、统一销毁,避免遗留无效实例。

3. CDN 优化是性能瓶颈的核心解药:正确使用 Cloudflare cdn-cgi/image 端点,可实现专业级的图片优化。

4. 细节决定体验:淡入动画、比例修正过渡、灯箱标题,这些微小的优化是“能用”和“好用”的分水岭。

5. 试错是技术成长的必经之路:每一次看似重复的修改,都是在逐步逼近问题本质,最终达到“知其然,更知其所以然”的境界。


:画廊展示页源码基于开源博客leehenry-blog改造,以下是github仓库链接。

LeeHero0803
/leehenry-blog
正在加载...


END