个人网站轮换景观背景的实现与体验优化
记录个人网站轮换景观壁纸从技术选型、壁纸池与客户端轮换,到首帧恢复、图层合成和持久外壳接管的完整实现过程。
- 首次发布
- 最后更新
本文目录
我希望用持续更新的景观照片替代网站原有的网格背景,让每位访客看到的背景有所不同,并在浏览过程中定期轮换。围绕照片的获取与更新、客户端状态、页面切换与刷新,以及原有界面对动态画面的适配,我逐步建立了从内容获取到页面呈现的完整链路。
整个过程先后完成了从 Unsplash 到浏览器的内容链路、用户控制与状态持久化、动态壁纸池、首帧恢复和全站视觉适配。主体功能完成后的实测又暴露出更细的边界:首帧恢复与运行时图层之间仍可能发生短暂替换,关闭景观背景时图片与遮罩可能不同步,筛选结果变化会影响相邻布局,慢速页面导航也缺少明确反馈。本文记录其中值得保留的技术取舍和问题治理过程,而不是逐项复述代码改动。
需求不只是更换背景图
这套功能最终需要同时满足以下要求:
- 景观照片能够持续更新,而不是使用一组长期不变的本地文件;
- 用户访问网站时从候选照片中随机选择,并在浏览期间自动轮换;
- 用户可以关闭景观壁纸、关闭自动轮换、立即刷新或下载当前照片;
- API 密钥不能发送到浏览器,普通页面访问也不应直接消耗搜索接口额度;
- 新图片按需加载,不能为了轮换一次性下载整个壁纸池;
- 客户端页面切换、浏览器刷新、背景模式和亮暗主题切换都不能产生突兀闪烁;
- 图片或接口不可用时,网站仍能使用原有背景正常工作;
- 景观照片不能破坏正文、导航、标签和交互状态的可读性。
这些要求分别属于内容获取、服务端缓存、客户端状态、浏览器生命周期和视觉设计。只有把它们作为同一个系统处理,最终效果才不会停留在一个容易失效的装饰功能上。
整体架构与技术选型
Unsplash 只提供照片,不能承担网站状态
Unsplash API 提供照片搜索、横向方向筛选、动态尺寸参数、摄影者信息和 BlurHash 等数据,适合作为持续更新的图片来源。接口返回的 raw 地址可以继续附加宽度、质量和格式参数,因此同一张照片能够分别生成适合不同视口的资源。
但浏览器不能直接携带 Access Key 请求 Unsplash。一方面密钥会暴露,另一方面每位访客都会重复执行相同的搜索。Unsplash 的技术规范也要求 Access Key 保持机密,并要求应用直接使用 API 返回的图片地址,而不是把原图转存到自己的图床。基于这一边界,Cloudflare 只缓存照片元信息,不缓存 Unsplash 的图片文件;浏览器显示图片时仍然直接使用 Unsplash CDN 地址。
Worker、KV 与 Cron 的职责
网站原本已经由 Cloudflare Worker 部署静态资源,因此没有必要再引入一套单独的后端。最终由同一个 Worker 处理两个壁纸接口,其他请求继续交给 Static Assets:
Cloudflare Cron │ ▼Worker ──────▶ Unsplash Search API │ ├──────────▶ KV:壁纸清单 │ ├─ /api/wallpapers ─────────▶ 浏览器 ├─ /api/wallpapers/download ▶ Unsplash 下载记录接口 │ └─ 其他路径 ────────────────▶ 网站静态资源Workers KV 保存可供浏览器使用的壁纸清单。Cron Trigger 定时调用 Worker 的 scheduled() 处理器,由 Worker 搜索新照片并更新 KV。普通访客只访问本站的清单接口,不会触发一次新的 Unsplash 搜索。
这种结构把两个生命周期分开了:服务端负责维护“有哪些照片可用”,客户端只负责决定“现在显示哪一张”。即使 Unsplash 暂时不可用,只要 KV 中仍有有效清单,已经发布的网站就能继续轮换;如果清单也不可用,页面则保留原有默认背景。
没有引入轮播框架
最初也考虑过 Vegas、Swiper 一类现成方案,但这个需求没有幻灯片导航、分页、触摸滑动或复杂编排,真正需要的只有预加载、切换和淡入淡出。为此引入完整轮播框架会增加依赖和状态层级,却不会减少核心逻辑。
最终使用两个原生 <img> 图层:下一张照片先在不可见图层中加载并解码,成功后再与当前图层交叉过渡。图片通过 srcset 提供多档响应式资源,由浏览器根据视口选择;旧图层在过渡结束后清空,后续再次遇到相同地址时可以使用 HTTP 缓存。用户启用 prefers-reduced-motion 时则不运行定时轮换。
从照片快照到动态壁纸池
第一阶段有意使用一份规模受限、整体替换的照片清单,以验证图片来源、服务端缓存、响应式加载和客户端轮换能否构成完整链路。这种设计适合验证架构,但不作为长期的数据维护方式:整体替换会割裂新旧照片之间的连续性,较小的候选范围也更容易产生重复。
最终实现改为增量维护壁纸池:
- 按固定条件搜索一批横向候选照片;
- 过滤分辨率不足、宽高比不合适、地址异常或缺少必要元信息的结果;
- 将新结果与 KV 中的现有照片按 ID 合并去重;
- 使用 Unsplash 记录中的
created_at维护时间顺序; - 为壁纸池设置容量边界,并按时间顺序淘汰较旧记录;
- 合并结果没有变化时,不重复写入 KV。
这里的 created_at 表示照片在 Unsplash 中的创建时间,而不是实际拍摄时间。搜索接口虽然提供 order_by=latest,但返回结果不应被当作严格递增的事件流;保留自己的有限壁纸池,才能在多次搜索之间稳定去重和淘汰旧记录。
清单结构后来加入了 createdAt 和 blurHash。升级时没有遗留一份永远不会再读取的旧 KV 数据,而是继续使用原来的存储键:读取函数先验证完整数据结构,旧结构无法通过新版验证,下一次成功刷新便会在同一位置写入新清单。这种做法适合仍处于开发阶段、无需保留旧格式回滚能力的场景。
三种时间尺度不能混在一起
最终系统中存在三种不同的时间尺度:
| 时间尺度 | 控制对象 | 决策依据 |
|---|---|---|
| 服务端内容更新 | 从上游发现并合并新的候选照片 | 上游更新节奏与接口额度 |
| 客户端清单同步 | 感知服务端壁纸池是否变化 | 数据新鲜度与无效请求之间平衡 |
| 客户端视觉轮换 | 改变当前显示的照片 | 阅读稳定性与背景变化节奏 |
服务端更新频率受上游内容变化和 API 额度影响,客户端同步频率只消耗本站接口请求,视觉轮换频率则决定用户感知。把三个周期独立设置后,可以增加新照片的发现机会,而不必让访客频繁请求 Unsplash,也不必让背景以同样的频率切换。
随机轮换不等于每次随机抽取
如果每次都从整个壁纸池中独立随机选择,虽然实现简单,却可能连续或频繁选中刚刚显示过的照片。最终使用的是随机队列:获得清单后先用 Fisher–Yates 算法打乱候选顺序,每次轮换从队首取出一张;队列耗尽后,再排除当前照片并生成下一轮随机顺序。
轮换间隔也不是单一固定值,而是每次在一个预设区间内重新取一个随机时长。整体变化频率仍落在设计范围内,但不会形成可预期的机械节奏。
客户端会把待轮换照片的 ID 保存在 sessionStorage 中。同一标签页切换到其他页面再返回时,轮换序列仍然延续,而不是每个页面重新随机一遍。
壁纸清单在浏览期间可能发生更新。此时不能另写一套“清单更新专用”的选图算法,否则两套随机逻辑迟早会产生不同的重复规则。清单更新仍然只修改同一条待轮换队列:
- 保留当前壁纸,不立即触发切换;
- 从队列中移除新清单已经不存在的照片;
- 将新增照片加入尚未展示的队列;
- 重新打乱剩余队列;
- 新照片只有轮到显示时才开始加载。
这样既不会因为后台数据更新打断当前画面,也没有为增量同步创造第二套轮换模型。
控制功能需要明确语义
最早的交互设计把“自动轮换”“固定当前”和“使用默认背景”看作三个并列模式。实际使用后,这种结构存在语义冲突:当默认背景正在显示时点击“固定当前”,系统究竟应该固定默认背景,还是先切换到一张景观照片再固定?后者虽然可以实现,却违背了按钮字面含义。
最终控制面板收敛为两层状态和两个动作:
- 使用随机景观壁纸:总开关,关闭后显示原有默认背景;
- 自动轮换:独立开关,只控制是否按时切换;
- 刷新:立即从同一条随机队列中取出下一张;
- 下载:下载当前照片。
景观壁纸关闭时,自动轮换开关同步禁用。自动轮换与手动刷新共享同一套选图和加载逻辑,区别只在触发来源。
下载是唯一需要区分意图的动作。普通展示和刷新都属于浏览行为,不应计入下载;只有用户明确点击下载按钮时,本站才调用 Unsplash 的下载记录接口并保存当前图片。这既是接口规范的要求,也避免把轮换次数误报成下载量。
摄影署名则是另一个容易被忽略的连续性问题。署名结构由服务端固定输出,页面内的同步脚本在解析阶段就从壁纸启动快照填入摄影者与照片链接,水合和后续轮换只原位更新已有节点的文本和 href。这样刷新时不会先出现一块缺失署名的区域,再由客户端突然插入整段内容。署名始终随当前照片更新,但在视觉上保持为全站最弱的一档信息,不与正文和导航争夺注意力。
用户偏好和标签页状态使用不同的存储周期:
| 存储 | 保存内容 | 原因 |
|---|---|---|
localStorage | 是否启用景观壁纸、是否自动轮换、固定照片 ID | 跨标签页和浏览器重启保留用户选择 |
sessionStorage | 当前照片、待轮换队列、当前背景记录 | 只维持当前标签页中的浏览连续性 |
这种区分避免把一次随机结果永久固化,也避免用户每次打开新页面都重新设置偏好。
页面切换与刷新为什么会闪烁
背景系统最棘手的问题并不是淡入淡出的参数,而是浏览器中存在两种完全不同的页面生命周期。
客户端页面切换
网站使用 Swup 处理客户端页面切换,但路由只替换正文和页脚所在的内容容器。导航栏、背景图层及其控制器属于长期存活的站点外壳,不参与每次 DOM 交换;页面过渡也只作用于被替换的内容区域,不再创建覆盖整个文档的过渡快照。
对壁纸来说,这一条边界就已经足够:同一张照片不会因为进入另一篇文章而重新淡入,轮换计时器、设置浮层和署名节点也不会被销毁后重建。剩下唯一需要额外约定的是主题切换——它改变整站配色,页面导航只改变内容,两者必须使用各自独立的动画对象,否则切换主题时会连带把内容区域一起卷进过渡。
持久外壳、脚本作用域和全局控制器的完整设计见从页面过渡到持久外壳:个人网站的客户端生命周期治理。本文只保留它与壁纸连续性直接相关的边界。
浏览器硬刷新
硬刷新不同。旧文档会被完整销毁,任何 DOM 持久化都无法跨越这条边界。第一版只能等客户端脚本获取清单、找到上一次照片并重新加载,于是页面会先显示默认背景,再突然出现景观照片。
只保存照片 ID 不能解决首帧问题,因为 HTML 解析阶段并不知道该照片的显示地址。BlurHash 可以表达低频占位画面,却不适合恢复一张已经清晰显示过的照片;刷新后重新经历“模糊到清晰”仍然是明显的功能退化。浏览器 HTTP 缓存也只能减少网络传输,并不能替客户端决定首帧应该使用哪一层、何时交出显示所有权。
这个问题最终通过重新划分图层职责解决:
- 根状态在首帧绘制前恢复主题、背景模式、照片 ID 和实际显示地址;
- SSR 启动层读取根状态,直接显示上次已经稳定呈现的照片及遮罩;
- 运行时媒体层包含两个图片层和自己的遮罩,只负责真正切换到下一张照片;
- 固定署名层从同一份当前照片状态恢复内容,随后由运行时原位更新。
照片成功显示后,客户端记录当前图片地址,并在条件允许时把大小受限的图片数据保存为 poster。下一次硬刷新时,<head> 中的内联脚本会在绘制前把主题、背景模式、照片信息和 --wallpaper-boot-image 写到 <html>;服务端已经输出的固定启动层随后直接使用这些属性和变量。可见背景仍然是 SSR DOM 图层,并不是根元素自己绘制了一张背景图。
运行时控制器启动后不会立即隐藏启动层,也不会为了“接管”而把同一张照片重新装入活动 <img>。如果当前照片和启动背景有效,启动层继续承担显示;控制器恢复照片对象、轮换队列和计时器。只有真正选中下一张照片、完成加载与解码并激活运行时图片层后,根元素上的合成器状态(data-wallpaper-compositor)才让启动层退出。整个过程中不存在一帧没有背景,也不会把刚刚清晰显示的旧照片再加载一次。
如果 poster 因体积、存储额度或网络失败而无法生成,启动快照会退回上次记录的 Unsplash 远程地址;如果照片状态本身也无效,则安全使用默认背景。
BlurHash 因此不再承担首帧画面,但它并没有从清单里删除。绘制前脚本必须判断一份壁纸快照是否完整可信,而 blurHash 正是这份结构校验的必填字段之一:缺失或为空即视为快照无效,直接回落到默认背景。它从“渲染数据”变成了“完整性标记”——保留一个字段的理由,可以和最初引入它的理由完全不同。
背景模式切换不是照片轮换
关闭景观背景时曾经出现过另一种闪烁:照片先消失,遮罩随后才退出,页面会短暂只剩一层浅色或深色遮罩。即使两个变化使用相同的时长,只要它们属于不同图层,浏览器仍可能绘制出这种中间状态。把 opacity 改成 visibility 可以绕过渐变,却会把问题变成突兀跳变。
最终没有把启动层和运行时图片层强行塞进同一个容器,而是让它们各自成为完整合成层:启动层包含启动图片与遮罩,运行时媒体层包含活动图片与遮罩。景观背景与默认背景之间切换时,始终改变当前完整合成层的可见程度;自动轮换或手动刷新照片时,才使用运行时媒体层内部的两个图片层交叉过渡。这样不会再出现“图片已经消失,遮罩还留在页面上”的半成品画面。
这一调整也使开启景观背景的过程保持对称:运行时图片层已经存在时显示完整媒体层;运行时图片层尚不存在时显示完整启动层。关闭时则让当前完整合成层一起退出,同时保留照片和队列状态,重新开启后无需随机选择一张新的背景。控制状态、图层所有权和动画对象因此保持一致。
页面连续性还包括等待反馈
背景不再闪烁之后,页面导航仍有另一类连续性问题:网络响应较慢时,用户点击链接后旧页面会暂时保持不变,很容易误以为点击没有生效并重复操作。预取能缩短一部分等待,却覆盖不了移动端首次点击、缓存未命中和弱网场景。
这里与背景直接相关的只有一条约束:加载反馈必须复用同一套导航生命周期,而不是另建一套链接拦截机制。等待指示是一条覆盖在导航栏边缘的不确定进度条,不占据额外高度,也就不会在等待期间推动布局、连带影响背景图层的合成。进度条的显示时机、重复点击合并和无障碍状态属于导航自身的设计,同样归入前面链接的那篇。
浏览器硬刷新没有复用这条进度条。完整文档刷新在新 HTML 到达前无法运行站内脚本,强行模拟只能覆盖刷新周期的后半段,还可能额外制造一次闪烁;这部分继续由浏览器自身提供反馈。
一张照片迫使全站视觉系统重构
原有网格背景的颜色和对比度是可预测的,标题、正文和弱信息可以使用差距较大的文字亮度。景观照片则同时包含天空、树枝、岩石、水面和阴影;同一种灰色文字在某些区域清晰,在另一些区域几乎消失。
最初的修复方式是逐个增加遮罩、卡片底色和局部透明度,但很快出现了两个问题:页面越来越阴暗,组件之间也形成了不一致的补丁。凡是出现文字就增加一块接近不透明的底板,并没有真正建立新的视觉规则。
最终将显示状态明确分成四种组合:
| 默认背景 | 景观背景 | |
|---|---|---|
| 亮色模式 | 原有亮色文字体系 | 高对比黑色文字与浅色全局遮罩 |
| 暗色模式 | 原有暗色文字体系 | 亮度差被压缩的白色文字与深色全局遮罩 |
主题与背景类型分别写入根元素属性,CSS 变量再根据组合选择文字、表面、边界和遮罩。默认背景因此可以继续使用原有配色;景观背景则使用专门为复杂图像设计的对比关系。
暗色景观模式下,正文不再使用与标题差距很大的灰白色,而是只比标题稍暗;亮色景观模式同理,标题、正文和辅助文字都保持接近黑色。层级仍然存在,但不能依赖大幅降低文字亮度来表达。背景图片本身则通过统一遮罩和亮度处理让位于内容,而不是由每个组件分别修补。
导航栏、项目卡片和壁纸设置弹窗使用一致的半透明表面及边界,避免一侧明、一侧暗的渐变。交互状态也被简化:悬停与键盘聚焦使用下划线,当前项使用左侧标记线;目录选中时不改变字重,因为字重变化会改变换行和布局。摄影来源被保留在页面边缘,但使用全站最弱的一档文字效果。
博客筛选进一步暴露出“状态正确”与“界面稳定”是两件事。筛选结果变少时,右侧内容高度会明显变化;如果粘性筛选面板直接作为同一网格项参与布局,它的位置也可能随结果区域改变。最终由外层侧栏提供稳定的纵向布局边界,内层面板只负责粘性定位,使结果数量变化不再带动筛选区移动。
筛选项的选中状态也不能只依靠文字颜色。全局文字系统调整后,原有强调效果一度被削弱,标签计数还可能使用自己的弱文字颜色覆盖父级状态。最终为选中项恢复强调色左边线、克制的半透明背景和文字强调,并让计数继承当前状态后再降低不透明度。它与正文目录采用不同策略:筛选项宽度受控,可以使用字重帮助识别;目录文字可能换行,因此保持字重不变以避免布局跳动。
这次调整说明,动态背景不是孤立的装饰层。只要背景进入正文的视觉环境,网站的文字层级、表面系统、导航结构和交互反馈都需要重新检查。
失败处理与测试边界
壁纸属于增强功能,任何失败都不应阻止网站主体显示。服务端和客户端分别设置了边界:
- KV 没有有效清单时,Worker 尝试初始化;初始化失败则返回
503,而不是伪造空清单; - Unsplash 搜索失败,或合并后可用照片数量低于下限时,不覆盖已有壁纸池;
- 清单响应提供 ETag,浏览器可以重新验证而不重复传输未变化的数据;
- 图片加载和解码失败时继续保留当前背景,并尝试队列中的下一张;
- 快速连续操作通过一个自增的激活序号丢弃过期结果,控制器释放时再由
AbortController终止整个生命周期; - 页面隐藏时暂停轮换与清单刷新,重新可见后再安排计时;
- 用户关闭景观背景后,不再继续执行轮换和清单同步。
测试也不只验证“能看到图片”。Worker 单元测试覆盖搜索结果过滤、壁纸池合并去重、容量淘汰、无变化更新、清单初始化和下载端点。浏览器测试覆盖控制状态、自动轮换、主动刷新、页面切换、亮暗主题、移动端菜单和异常回退。
硬刷新问题还需要更具体的断言:刷新后的第一个绘制帧应直接包含已保存的清晰背景,同一照片不能再次进入活动图片层,也不能因为控制器初始化而重新发起一次图片请求。模式切换测试则分别检查媒体层的整体可见程度和活动图片是否保留,避免再次出现“照片消失但遮罩仍在”的中间状态。
页面其他部分也加入了对应的不变量:筛选结果高度变化时,筛选面板顶端位置保持不变;慢速客户端导航只产生一次目标请求,主内容暴露忙碌状态,导航栏高度在进度条出现前后完全一致。只有把用户看到的连续性和稳定性转化为可观察状态,后续改动才不容易以另一种形式引入回归。
总结
轮换景观壁纸最终形成了三个相互独立又彼此配合的层次:
- 服务端维护持续更新、容量有限的可用照片池;
- 客户端维护当前标签页中的随机队列和显示状态;
- 视觉系统处理亮暗主题、默认背景与景观背景的四种组合。
这次实现中最重要的经验不是某个 API 或动画参数,而是区分看似相近的问题:API 更新不等于客户端轮换,浏览器缓存不等于视觉状态恢复,页面切换不等于硬刷新,远程地址不等于首帧显示所有权,背景模式切换也不等于照片轮换。只有把这些生命周期分别建模,背景才不会在每次修复后以另一种方式闪烁。
同样,一个全局视觉元素不能靠不断给局部组件增加补丁来适配。明确背景图层的职责、把显示模式落实为全局状态、用统一变量重建文字和表面层级之后,还要继续检查等待反馈、筛选布局和交互状态这些相邻体验。代码中的状态边界与用户看到的视觉边界一致,整体体验才会真正稳定下来。