早有搭建一个自己的网络相册的想法,只是之前我一直没有找到合适的方案。最先开始想的是一些正经的方案:比如用公共的图床,但是国内公共的图床其实 90% 都不稳定,号称永久免费没多久就倒闭了,而且管理起来也不是很方便;再比如对象存储吧,各种乱七八糟的计费规则:存储空间要收费、读取写入操作要收费、请求也要收费,于是还是放弃了。
正经的路不通,我也考虑过一些邪路:最开始是想用国内大厂泄漏的 API 当图床用,这也是我博客的封面图一直使用的方案,虽然速度挺快,不过这玩意实在有些不稳定,但是万一被人家发现了轻则修复漏洞,重则之前存的图片都会被删掉;还有就是最近发现的用 Webdav 协议把阿里云盘挂载当图床,不稳定的同时,用国外的 VPS 访问速度更慢,还有被阿里封号的风险……
于是这个想法就一直搁置了,就在我以为这个想法就要「胎死腹中」的时候,偶然登录 Cloudflare 发现左侧菜单多了好多新功能(Images,Stream,R2),点进这些新功能看了一下就让我发现了一个可行的方案——R2 对象存储。R2 相比于友商的对象存储定价是真的良心:存储空间每月免费 10GB,此外的每 GB 需要 0.015 美元,改变状态(新增、修改、删除)有 100 万次额度,读取现有状态有 1000 万次额度——最重要的是,它是 0 带宽费的。而通过配置 Cloudflare 的缓存策略,能直接把上一次访问的对象缓存下来,后续直接从边缘节点响应,而不是每次需要都从 R2 读取状态!这样访问操作基本可以算是无限次额度的(白嫖使我快乐。
需要指出的是,Images 本身的功能(自由裁切、调整大小等)其实更适合当图床,但是 Images 的价格会更高一些:读取现有图片是要收费的,而且它的转化功能针对 AVIF 格式的图像限制非常大。因此我还是使用了 R2,无非就是我自己来处理一下格式转换以及裁切的工作。
功能设想
我把网络相册分成了两个较为独立的模块:
- 网络浏览:此模块直接在互联网上供用户访问,有浏览图片详情、以及按相册过滤的功能;
- 后台管理:此模块包含的功能较为复杂,需密码认证用户方可访问:
- 处理图片:在浏览器端裁切原图,并添加水印后转换成 AVIF 格式和缩略图(纯浏览端完成
- 上传图片:处理后的图片上传到 R2、图片包含的 EXIF 信息保存在数据库里
- 查看图片:能查看历史上传的所有图片
- 编辑图片:对图片的信息进行更新:给图片添加拍摄地、归类到指定相册等
- 删除图片
技术选型
自从一年多前我在博客的项目中使用 SolidStart 之后,后续我的前端项目就都是用 SolidStart 框架了:Solidjs 函数式的写法和 react 很接近,性能又仅比纯 JavaScript 慢一些而已。
此外,因为 Solidstart 是一个全栈框架,在这次项目中,我也破天荒的没有使用 REST API 来交互数据,而是使用了 Server Functions。简单来说,就是当浏览器端需要展示数据时,直接向服务端发送一个 RPC 调用,包含了调用函数的名称以及参数——当然,是以 HTTP 请求的形式。这么做的好处是不再需要去专门定义 REST API 路由了,极大简化了交互的流程,坏处就是和服务器端本身绑定地太死,跨平台是个问题,但是好在我的网络相册并不用考虑其他的平台。
处理图片
因为想着能尽可能存储多的照片在 R2 上,我决定所有的照片都选用 AVIF 格式存储,同时处理图片的操作也需在浏览器端完成(和 Squoosh类似)。本想直接用 libSquoosh,不过这个库已经被废弃了,于是我只好选择了 jSquash。
好在使用起来并不复杂,唯二需要注意的就是在 Vite 的 optimizeDeps.exclude 里把它加上;再就是启用多线程时处理时,需要设置指定的 HTTP Headers。
我把处理图片的逻辑放在了 Web Worker 里,然后由 Worker 再去创建多线程处理。以免阻塞主线程的运作造成浏览器标签页崩溃的现象。
状态管理
我实在没想到,状态管理会让我花费超过两天的时间。主要耗费了太多时间在「我以为」的问题上了。
const limit = 12;
let firstLoad = true;
const album = createMemo(() =>
new URLSearchParams(location.search).get("album"),
);
const [offset, setOffset] = createSignal(0);
const [allImages, setAllImages] = createSignal([]);
const [currentImages] = createResource(
() => ({ album: album(), offset: offset() }),
async ({ album, offset }) => {
firstLoad = false
setState("first", false);
const newImages = await getImages(album, offset);
setAllImages((allImages) => [...allImages, ...newImages]);
if (newImages.length < limit) {
setHasMore(false);
}
return newImages;
},
);
我以为是 offset 和 album 状态更新的顺序不一致导致的 createResource 重复请求进而导致页面渲染出现了混乱,但是实际上并不是,只是单纯因为 offset 在短时间更新两次造成的问题。因为在切换 album 并加载请求的时候,控制瀑布流加载的元素又一次在视窗内部被观测到了,所以再次改变了 offset 的状态又触发了 createResource 的请求。后面在控制瀑布流加载的 Intersection Observer API 内部加一个判断 loading 的状态就行了。
另外,因为 SSR 的原因,页面首次进入的 createResource 是在服务器端请求并渲染的,所以需要额外创建一个函数来确保服务端和浏览器端的 allImages 状态的一致性:
createEffect(() => {
const images = currentImages();
if (!images) return;
// 只在页面首次渲染时更新 allImages,避免 SSR 渲染不一致
if (firstLoad) {
setallImages(images);
if (images.length < limit) {
setHasMore(false);
}
}
})
浏览页面的优化
针对浏览页面做了一些优化以提升用户体验:
缩略图
在电脑端的左侧区域展示的是图片的缩略图,由服务器端直接通过 Server Functions 直接返回 BASE64 格式的结果,点击缩略图后,右侧首先会展示缩略图以及加载的图标,同时会用 createResource 对大图发起请求,请求完成后,缩略图会被直接替换成大图:
<Suspense>
<div class="relative h-full">
<div
class="absolute inset-0 bg-contain bg-center bg-no-repeat h-full w-full transition-opacity duration-200"
style={{
"background-image": "url(thumbnail_url)""
}}
/>
<Show when={data.loading}>
<i class="w-8 h-8 text-[#F5F3F2] i-svg-spinners-90-ring absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-20" />
</Show>
<Show when={!data.loading}>
<img
src={imgURI}
class="absolute inset-0 h-full w-full object-contain transition-opacity duration-300"
style={{
opacity: 1,
}}
/>
</Show>
</div>
</Suspense>
控制图片切换
目前手机端和电脑端各有 3 种方式来切换图片:
- 直接点击缩略图;
- 电脑端的键盘左右键以及手机端的左滑右滑;
- 在大图的边缘位置点击下一张或者上一张。
一点想法
我没有把我的网络相册做成博客的一个子页面,除了我想保持博客内容的纯粹外,我也希望它能成为我的另一个具有我个人风格的作品集。
所以,我将网络相册挂在了 moments 子域下,之所以选择的是 moments 这个单词而不是 photos 或者 gallery,也是想传达我对于摄影的看法,正如我在网站的描述写下的「Some moments to remember」,我觉得摄影就是为了留着值得铭记的瞬间,让这些瞬间不会随着记忆而褪色。
网络相册里的照片均拍摄自我和我的女朋友~
结语
有段时间没写代码了,看了看 Wakatime 发送的提醒邮件,有差不多有接近 2 个月了。之前在写日本游记的时候都有些感觉思维没有之前那么敏捷了,有一种像放了很久的毛笔想在纸上书写却只能留下四处飞散的墨屑的感觉,因此上一篇日本游记我写的并不满意。这种感觉并不好,连带着让我觉得修个 bug都有点费劲了。
好在本文写的还算流畅。