项目:PEXELS 壁纸应用 | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇是对项目 12 条关键架构决策的"考古记录":每条决策按背景(当时面对的问题)、备选方案(为什么别的路没走)、决策内容(最终怎么做的)、源码证据(决策落在哪一行代码或哪条注释)、后果与遗留(换来什么、付出什么)五段展开。它与《01-系统架构》第 4 节"关键设计决策"的分工是:那一节用一段话给出结论供快速检索,本篇则负责把"决策是怎么长出来的"讲完整——包括那些后来被修正、被推翻、被"以注释形式钉在原地"的曲折过程。
证据来源有四类,可靠性依次递减。第一类是源码注释中的决策原文:本项目的注释纪律非常特殊,绝大多数"为什么"都写在触发点旁边(例如 src/api/pexels.ts 中 isAuthOrLimitError 上方那段"认证 / 配额类错误必须向上抛出"的整段论证),这类证据可以视为决策者当时的原话。第二类是仓库内的历史档案:docs/dependency-upgrade-plan.md(原"依赖升级计划",账号体系移除后重写为现状说明)记录了 Vite 2→8、Vuex→Pinia、Tailwind 3→4 的完整路径与六条踩坑;.codebuddy/plans/pexels-api-integration_a662121c.md 是一次改造的完整计划存档,保存了"改造前→改造中→改造后"的中间形态,是本篇最重要的考古材料之一——它证明了若干决策在落地时发生过关键修正。第三类是 git 提交信息:如提交 c5f41bd 的自述"将环境变量文件移出版本库并清除历史中的 API Key",本身就是一条决策记录。第四类是结构反推:从"存在的代码"与"不存在的代码"之间的差异反推决策(例如 .codebuddy 计划里规划了 filter/index.vue 筛选组件与 getHint/getThemes 函数,而当前源码中三者皆不存在,可反推出"协议先行、界面未跟上"是主动收窄而非遗漏,与《13-技术债务与演进建议》D-12 的定性一致)。
需要预先声明两个方法论约束。其一,本项目的 git 历史经历过一次重写(c5f41bd 自述清除了历史中的 API Key),早期提交序列已不可完整回溯 ⚠️ 待确认,因此凡涉及"具体是哪一天做的决策"的时间线均标注待确认,本篇只对"决策顺序"做相对推断。其二,部分备选方案是"从未写进代码、也没有文字记录"的,只能按当时的工程上下文合理重建,这类内容会明确标注"推断"。12 条决策按依赖关系排列:前四条是数据链路的地基,中间四条是交互与路由的骨架,后四条是横切的质量与工程约定。
背景。项目的第一形态并非如此:它曾是一套"自有后端 + 账号体系"的完整应用,前端经 axios 封装的 request 层访问后端代理,后端再转发 Pexels;配套有接口签名(VITE_APP_SECRET 盐值 + md5)、401 统一登出、QQ/微信登录、微博分享、收藏与 OSS 上传等能力(docs/dependency-upgrade-plan.md"安全现状"第 2、3 条与 .env.example 文件头的历史注释均可佐证)。问题在于:这套重装备对一个"浏览 + 下载"工具而言,维护成本与产品价值严重倒挂——后端进程、签名协议、账号安全每一项都是持续支出,而核心体验(找图、看图、下载图)一个都不依赖它们。
备选方案。其一,保留后端但砍掉账号:后端仍可作为 Key 的托管方,但项目失去"纯静态部署"的运维便利,且后端本身仍需维护、监控、付费。其二,前端经 axios 直连:保留熟悉的 HTTP 库,但 axios 封装里沉积的签名与 401 登出逻辑对 Pexels 毫无意义,反而制造"请求失败被登出逻辑劫持"的隐患。其三(推断,未见于文字记录):换一个提供匿名配额的图源 API——放弃 Pexels 的内容质量,不可取。
决策与源码证据。改造计划档案 .codebuddy/plans/pexels-api-integration_a662121c.md 的开篇即写明目标:"把项目从『后端代理获取 Pexels 数据』改造为『前端直连 Pexels API』……整体保留现有组件与 api/pexels.ts 函数签名,最小侵入"。落地结果就是当前源码的形态:src/utils/pexels.ts 文件头注释给出了最浓缩的决策陈述——"直连 https://api.pexels.com/v1,不经过后端代理与 axios 封装,避免触发 request 里的接口签名与 401 登出逻辑";认证退化为一个裸请求头 Authorization: <API_KEY>(无 Bearer 前缀,同文件注释强调);docs/dependency-upgrade-plan.md 记录了随账号体系一并移除的九个依赖:axios、md5、dayjs、vee-validate、ali-oss、cropperjs、driver.js、tailwind-scrollbar、vite-plugin-svg-icons。值得注意的是决策的"留后门"处理:src/api/pexels.ts 仍保留 VITE_PEXELS_PROXY_BASE 通道与 dev 内置代理(vite.config.ts 的 server.proxy),即"直连"是默认态而非唯一态——这为后来《08-安全与权限设计》的代理方案留出了零成本切换位。
后果与遗留。收益面:依赖清单骤减九项、部署退化为纯静态产物、{ list, total } 返回形状刻意保持与改造前一致(getPexelsList 注释:"与改造前的后端代理一致,调用方无需改动"),页面层对这次架构手术零感知。代价面:Key 进入前端产物成为结构性暴露(08 篇 P0 问题 S-01);/collections(我的合集)依赖 Pexels 用户登录态而无获取路径,沦为功能性留白(13 篇 D-10);接口签名链路的类型声明与环境变量残留至今(D-03、S-04)。历史包袱的实物还有 src/types/vendor.d.ts 中只为已移除的 md5 服务的模块声明。这条决策是其余所有决策的语境:正因为没有后端,才有了 ADR-003 的"配额一等公民"与 ADR-009 的"隐私边界"。
背景。无后端之后,"数据从哪来"出现了两个不稳定源:使用者可能不配置 Key(首次运行、开源访客),配置了也可能遭遇网络抖动或限流。若每个页面各自处理这些情形,占位逻辑会散落成十几个 if 分支,且"真数据还是假数据"的判断无法保证一致。更隐蔽的问题是:改造计划档案中最初的设计是"任何失败回退 picsum 兜底"(.codebuddy 计划原文:"错误分级:401 →『API Key 无效』;429 → 配额提示;其他 → 通用失败提示;任何失败回退 picsum 兜底")——这个设计在落地时被推翻了一半。
备选方案。其一(计划初稿):一刀切兜底,任何失败都换占位图。它的致命缺陷在落码时被识别:Key 失效或配额耗尽时若仍返回兜底图,用户与页面层都无法区分"真实数据"与"假数据",未来要做的一切配额保护(429 熔断、配额提示)都会变成永远进不去的死分支。其二:错误也收敛进协议层处理——但协议层的职责是"懂 Pexels 协议",让它同时懂"调用方该看什么"会破坏分层。其三:页面层各自决定降级——十几个页面十几种口径,不可审计。
决策与源码证据。最终决策拆成两半。第一半是收敛:全部降级决策集中在 src/api/pexels.ts(门面层)一个文件,页面层只面对 { list, total } 这类规整形状,"数据是真是假"的判定被物理隔离(《01-系统架构》第 1 节称之为"降级决策边界")。第二半是分级:src/api/pexels.ts 第 39~49 行的注释块是决策修正的原文存档——"认证 / 配额类错误必须向上抛出,不能静默降级为兜底数据。降级只应用于『网络抖动』这类瞬时故障:Key 失效或配额耗尽时若仍然返回兜底图,用户与页面层都无法区分『真实数据』与『假数据』,页面里的 429 熔断与配额提示也会变成永远进不去的死分支。"配套的还有按数据可信度要求分档的精细矩阵:getPexelsFromId 失败必须抛出(注释:"避免静默返回一张与点击目标无关的随机图,造成『张冠李戴』")、getCollectionMedia 一律抛出(注释:"媒体与封面会被写进缓存并当作真实数据展示……绝不静默换成 picsum 占位")、getMyCollections 三种失败形态都返回空数组(注释:"返回空数组比兜底占位更准确,避免误导用户")。完整矩阵见《03-接口设计》3.2 与《15-接口全量参考》第 3 节。
后果与遗留。收益:页面层与组件层至今零 if (降级) 分支,占位数据与真实数据形状完全同构,"演示模式"得以无成本存在;错误分级由 PexelsApiError 携带的 status 字段统一承载,门面层(isAuthOrLimitError)与调度层(429 熔断判定,src/utils/pexels-collection-cache.ts 第 71 行)各取所需。代价:矩阵本身成为认知负担——"为什么这个接口有占位、那个没有"需要读注释才能理解,且矩阵没有任何测试锁定(09 篇第 5 节把它列为第二优先补测对象)。这条决策后来派生了 ADR-004:正因为 429 必须上抛,封面调度器才需要自己实现熔断来"接住"这个错误。
背景。直连方案下,Pexels 免费 Key 约 200 次/小时的限流从"后端可管控的资源"变成了"前端必须自觉珍惜的稀缺品"。这个数字有多大压力?按《15-接口全量参考》第 6 节的测算,一个完整浏览动线约消耗 20~35 次请求,即一个免费 Key 理论上每小时只支撑不到 10 个完整会话。任何"顺手就发一次请求"的设计在这个约束下都是奢侈品。
备选方案。分类数据原本走 /category 接口(store 注释可证)——每次会话固定消耗 1 次且是首屏阻塞点;total 若原样透传真实值(如 nature 分类的 18000+),配合"已加载 ≥ total 才停"的完成判定,无限滚动会在 Pexels 分页上限(page×per_page ≤ 8000)之外永远发注定失败的请求;合集列表页每页 18 条是"信息密度直觉"的默认值。
决策与源码证据。三个独立但同源的省法构成这条决策。分类静态化:src/store/index.ts 第 43 行注释直言"分类已本地静态化,不再请求后端",13 个分类的 id 就是 Pexels 搜索词,本地常量(src/constants/index.ts 的 CATEGORY_NOMAR_DATA)替代了一次网络请求;改造计划档案亦记录"分类数据本地静态化,不请求后端"。total 截断:src/api/pexels.ts 第 30~34 行注释——"Pexels 分页上限:page * per_page ≤ 8000,超过后 next_page 为 null。total 截断到该值,避免无限滚动永远等不到 list.length === total 而无法收敛",对应常量 PEXELS_MAX_TOTAL = 8000;兜底模式的 FALLBACK_MAX_TOTAL = 200 则是同一思路在降级侧的镜像(注释:"picsum 有效图片 id 有限,限制滚动页数防止大量 404")。每页 12 条:合集列表页的 DEFAULT_PAGE_SIZE = 12(src/views/collections/index.vue),注释明说动机是"在 N+1 封面策略下把单页配额消耗从 1+18 降到 1+12"——这是三个省法里最能体现"配额是设计输入"的一条:分页大小不是体验参数,是成本参数。
后果与遗留。收益是配额账本可控(逐场景消耗表见 15 篇第 6 节),且三条省法互相独立、可分别撤销。代价与遗留:total 截断后 totalPages 未同步截断,成为"存在但没人读"的参考值(15 篇第 1 节有专门解释);>= 而非 === 的完成判定成为必须记住的隐性契约(返回条数可能轻微超出被截断的 total,=== 会永不收敛,见《04-数据模型》第 4 节);"配额三问"(能不能晚点发、能不能不发、能不能只发一次)被《11-扩展开发指南》第 0 节固化为扩展开发的前置检查。本条与 ADR-004 共同构成 10 篇第 4 节"配额战线"的全部内容。
背景。合集列表是全站唯一无法避免 N+1 的页面:/collections/featured 响应不返回封面,每张卡片要单独发一次 collectionMedia(perPage=1) 取首图。无节制时一页 18 条 = 19 次请求,翻 10 页就是 190 次——一次会话几乎打穿小时配额。同时封面是"不加载也不影响功能"的软需求,这给了它区别于列表请求的处理自由度。
备选方案。其一,服务端批量接口:直连架构下不存在(ADR-001 的直接后果)。其二,一次性并发拉整页封面:等于把 N+1 集中引爆,还容易触发 Pexels 的突发速率限制。其三,把封面 URL 缓存落地 localStorage:跨会话省配额,但引入"缓存何时失效"的一致性问题,与 ADR-009 的极简存储立场冲突。其四(推断):用 CSS 渐变色块代替封面——体验损失过大,未采用。
决策与源码证据。决策是写一个 145 行的模块级单例调度器 src/utils/pexels-collection-cache.ts,其文件头注释把设计浓缩为四件事:"1. 封面内存缓存:同 ID 只请求一次,跨页面、跨滚动重建都直接命中。2. 按需排队:只加载瀑布流真正渲染出来的卡片……不再对整页数据一次性补封面。3. 并发与节流:单批 3 个并发、批间延迟,避开突发速率限制。4. 429 熔断 + 冷却自动恢复:命中限流后暂停排队,冷却期过后允许重新尝试,避免一次限流导致整个会话的封面永久丢失。"对应源码常量:CONCURRENCY = 3、BATCH_DELAY_MS = 250、RATE_LIMIT_COOLDOWN_MS = 5 * 60 * 1000。两个实现级的决策尤值一提:熔断复位不用定时器——isRateLimited() 在每次读取时惰性判断 Date.now() >= rateLimitUntil 并自动复位(第 4554 行),意味着"下一次有人问是否熔断中时答案永远是新鲜的";requestCover 返回取消函数——卡片被虚拟滚动回收时从 136 行),防止回调打到已卸载组件。消费端 waiters 订阅表反注册(第 134src/views/collections/components/collection-item.vue 的注释则记录了配套决策:"不能挂在 onMounted 上:瀑布流初始布局会把所有 item 都挂载出来,那样等于又回到『整页一次性补封面』的 N+1",因此改用 useIntersectionObserver(rootMargin 200px)只在卡片真正接近视口时入队。
后果与遗留。收益:一个会话的封面请求量从"页数 × 18"压缩为"实际看过的卡片数(去重)",最坏情况被熔断封顶;429 提示在同一冷却期只弹一次(rateLimitNotified 标志),冷却到期自动放行,无需用户刷新。代价:这是全项目唯一"静默失败"的路径(普通失败只丢封面不提示),排查需要独立方法论(12 篇 FAQ-12 专为此设条目);调度器 import 了 @/libs 的 message,形成分层纪律的唯一"业务用 UI"例外(07 篇第 5.3 节)。该模块无任何测试,是 09 篇第三优先补测对象。
背景。无限滚动 + 瀑布流是这个应用的主形态:用户可能一口气翻到第 400 页(8000 条上限),若 DOM 随数据线性增长,移动端很快卡死。同时页面的卡片高度由图片原始宽高比推算、可滚动祖先不固定(页面自带 h-full overflow-auto 容器而非 body 滚动),这些结构特征决定了选型约束。
备选方案。其一,CSS columns 多列:实现最简单,但内容按列纵向填充,"第 N 项在第几列"无法预知,与虚拟裁剪(需要按 top 值判断可见性)天然冲突,也不支持"最矮列"语义——被排除的主因。其二,第三方瀑布流库(vue-waterfall-plus、vue3-masonry-css 等,具体候选已不可考 ⚠️ 待确认):通用库通常假设"容器高度已知、卡片高度加载后测量",而本项目需要"高度由宽高比预计算 + 向上查找滚动祖先 + 虚拟裁剪"三件事同时成立(00 篇第 3 节的选型论证)。其三,不用虚拟滚动、只做分页丢弃:会破坏"回滚不重排"的体验。
决策与源码证据。自研 src/libs/waterfall/index.vue(544 行)+ src/libs/waterfall/utils.ts,同时承担绝对定位瀑布流、虚拟滚动、响应式列数三件事。核心常量与注释就是决策证据:第 37 行 const VISIBLE_BUFFER = 400,注释"可视区域外额外渲染的缓冲高度(px):避免滚动到边界时出现白块/闪动";findScrollParent(第 197208 行)向上查找最近的 89 行注释:"既污染了调用方传入的数据(props 应视为只读),又会在『deep 监听 + 回调内改写数据』的组合下额外触发一轮完整重排");存储结构否决了 WeakMap(同段注释:"模板需要读取定位结果,而 WeakMap 不是响应式的");列宽计算加 overflow-y: auto/scroll/overlay 祖先、找不到回退 window——这是"页面自带滚动容器"结构的直接适配。更有价值的是注释里保存的实现路径上的三次自我修正:定位结果从"挂在 props.data 元素上"迁移到独立响应式数组 itemStyles(第 82Math.max(width, 0) 兜底(第 146~149 行注释:容器不可见时 offsetWidth 为 0 会算出负列宽,"负数对模板 v-if 仍是 truthy")。utils.ts 则记录了第四条:getMinHeight/getMaxHeight 对空对象兜底 0,因为 Math.min() 空参返回 Infinity(历史 bug,09 篇档案⑭)。
后果与遗留。收益:DOM 数量只与视口面积相关(视口 ±800px 缓冲内约十几到几十个卡片)、与 8000 条数据总量彻底解耦;追加重排路径保证翻页成本只与新卡片相关(10 篇第 2 节)。代价:与"滚动容器约定"强耦合(02 篇第 6 节记录的三方依赖),是全项目复杂度最高的组件、注释密度也最高(544 行中 75 行注释);其核心算法耦合 DOM,测试成本偏高,因此 09 篇把它列为第一优先补测对象。它的存在同时派生了两个搭档决策:v-lazy 的 loadedCache 防止"DOM 销毁重建"下的重复请求(07 篇第 7 节),以及封面调度的按需入队。
背景。详情查看是弹层形态(从卡片中心放大、不跳页),但纯组件弹层有两个硬伤:URL 不变化导致详情不可分享、不可刷新、后退会退出整站而非关闭弹层。壁纸应用"把某张图发给朋友"是高频动作,这两个硬伤不可接受。
备选方案。其一,真实路由跳转到详情页:URL 语义正确,但弹层的"从卡片原地放大"动画会在路由切换中断裂,且返回列表时丢失滚动上下文与数据状态。其二,弹层 + 查询参数(/?photo=id):刷新后仍在列表页,但列表数据需要按参数重建,复杂度更高。其三,只做 pushState 不做直达页:后退语义正确,但刷新后 /pins/:id 无路由承接,404 收场。
决策与源码证据。决策是"双形态弹层 + 手写 History 操作"的组合。src/components/media-viewer/index.vue 的 syncUrl(第 223228 行)用原生 250 行):打开时 push(history.pushState/replaceState 而非 router.push,且刻意保留 history.state——注释:"vue-router 会在其中存放滚动位置等信息,传 null 会清空"。三个分支的行为定义在 watch([visible, modelValue])(第 230hasPushed = true)、弹层内切换时 replace(注释:"用 replace,避免浏览器后退变成『逐张回退』")、embedded=false 的直达模式不动地址。popstate 监听让"浏览器后退 = 关闭弹层"(第 253258 行)。关闭逻辑 265 行的注释揭示了最容易踩的坑:"先取快照:下面 emit 之后,watcher 会把 hasPushed 复位"——先 onClose 第 263const shouldGoBack = hasPushed 再 emit,顺序不能颠倒。另一半决策是给 /pins/:id、/videos/:id 配独立页面(src/views/pins/index.vue、src/views/video/index.vue),以 embedded=false 复用同一弹层组件,实现"刷新直达详情"。合集详情页的 onItemClick 注释则记录了这条决策的边界适用:"照片与视频统一在弹层内查看(不再把照片踢到 /pins/:id 路由),这样合集内的左右切换才是连续的"——路由化不等于"每处点击都改地址",混排场景刻意保留了弹层的纯组件形态。
后果与遗留。收益:URL 可分享、可刷新、可后退,三个目标全部达成且不牺牲动画与上下文;弹层内切换零网络请求(数据与列表同序)。代价:对 history.state?.back 的读取属于对 vue-router 内部行为的弱契约,升级 vue-router 大版本需回归验证(13 篇第 5 节"隐性契约"其三);嵌入式模式不支持"弹层开着时前进到另一条详情地址"(12 篇 FAQ-6 的已知边界);hasPushed 状态机没有任何机制保护,改动 onClose 的取值顺序即回归(12 篇 FAQ-6 异常形态第 2 条)。
背景。一级导航(图片/视频/集合)之间切换频繁,每次切换都重新请求接口的话,配额与体验双重受损;但组件缓存又带来"看不见的页面也在消费请求"的新问题——用户在视频页搜索时,首页的 store watch 同样会触发。
备选方案。其一,不缓存:每次切回都白屏重载,配额浪费。其二,全页面缓存:详情页(pins/video/collection)依赖路由参数,缓存反而造成旧参数残留;且缓存页越多,滚动位置、失活同步等衍生问题越多。其三,缓存但不管失活请求:切分类、搜索等操作在后台页面白烧配额,正是 200 次/小时下最不可接受的浪费形态。
决策与源码证据。决策是"有限名单 + 失活挂起"的组合。src/views/layout/index.vue 第 25~29 行定义 CACHE_NAMES = ['home', 'videos', 'collections'],注释给出名单依据:"一级导航切换频繁,不缓存则每次切回来都要重新请求接口"。列表组件侧,失活挂起由 isActive 开关 + onActivated/onDeactivated 实现(《07-模块详解》第 11 节):失活期间 store 变化只更新 appliedQuery 快照、不发起请求,激活时统一比对补拉。挂起的必要性有明确的反面论证——若不挂起,"在视频页搜索时首页白烧一次配额"(《01-系统架构》第 3 节)。这条决策不是孤立生效的,它强制派生了三个配套,缺一即出缺陷(《11-扩展开发指南》第 0 节"约定三"):组件 name 必须与路由 name 一致(src/views/videos/index.vue 注释特别指出默认推断名 index 会导致 keep-alive 的 include 无法命中);滚动位置恢复(src/utils/helpers.ts 的 useScrollRestore,注释:"组件被缓存时 DOM 会从文档中移除,多数浏览器会把 scrollTop 归零");以及 m-popup 的 onUnmounted 与 onDeactivated 双钩子解锁 body 滚动(09 篇档案㉙:keep-alive 页面离开只触发后者,"漏掉整站永久不可滚")。
后果与遗留。收益:任何时刻只有当前可见页面在消费配额(10 篇第 4 节的量化论证);一级导航切换即点即达。代价:缓存页的开发成本从"一个组件"上升到"一个组件 + 三个配套";include 按字符串匹配的机制脆弱(改名即静默失效,无编译期检查);useScrollRestore 返回的 containerTarget 必须绑定到页面滚动容器,属于靠约定维持的隐性契约(13 篇第 5 节其一)。
背景。产品同时面向移动端与 PC 浏览器交付同一套产物。分歧点的粒度很细:移动端有底部 tabbar、导航滑块、触屏手势;PC 有分段导航、hover 浮层、键盘操作——但页面集合、路由结构、数据流完全一致。
备选方案。其一,两套路由表或两个构建入口(如 m 站与 PC 站分离):维护成本翻倍,且本项目两端差异远没到需要两套代码的程度。其二,纯 CSS 响应式:能处理布局差异,处理不了"移动端才渲染 tabbar""hover 交互替换为点击"这类结构性分支。其三,按视口宽度单一条件判定:会把"桌面端窄窗口"误判为移动端、把 iPadOS 横屏误判为桌面端。
决策与源码证据。决策是"单路由表 + 组件内布尔分支"。src/router/index.ts 文件头注释即决策陈述:"双端共用同一份:一级结构(layout + 子页面)完全一致,差异全部由组件内部的 isMobileTerminal 分支与布局承担。"判定逻辑在 src/utils/helpers.ts 第 6~16 行,三条件叠加:移动端 UA 正则、触摸能力(maxTouchPoints > 0 || 'ontouchstart' in window)、视口宽 < 1024。注释点名了它要规避的盲区:"纯 UA 判断的盲区:iPadOS 13+ 的 UA 伪装为 Macintosh、桌面浏览器开启移动模拟等。这里叠加『触摸能力 + 视口宽度』兜底:有触摸能力且视口较窄时也按移动端处理。"这是一个计算属性而非函数,保证同一渲染帧内全站判定一致。应用点上,分支收敛在组件层:src/views/layout/index.vue 的 tabbar-vue 用 v-if="isMobileTerminal" 整体裁剪;src/components/media-hover-actions/index.vue 同理(移动端无 hover,弹层内已有同功能入口);navigation 组件是唯一"两端两套模板"的特例(滑块 + 汉堡弹层 vs 墨条按钮)。
后果与遗留。收益:一份路由、一份构建、一套数据流,双端功能永不分叉(导航配置 MAIN_TABS 两端共用,02 篇第 2 节)。代价与遗留:判定存在真实的边界误差——触屏笔记本(宽屏 + 有触摸)会走 PC 分支、iPad 横屏(宽 > 1024 + 有触摸)也走 PC 分支,手势与 tabbar 消失;移动端模拟器需要同时开启触摸模拟才能命中移动分支(12 篇 FAQ-10);hover 类交互在移动端的弱化被定性为已知留白(13 篇 D-09)而非缺陷。作为横切开关,它的三条件权重(UA 优先、触摸与视口为兜底)没有任何单测锁定。
背景。账号体系退役后,"用户数据"这个类别在本产品里几乎消失:没有收藏、没有浏览历史、没有搜索历史、没有上传。剩下的只有外观偏好。此时存在一个反向诱惑:既然有了 pinia-plugin-persistedstate,顺手把封面缓存、浏览历史也持久化,似乎能省配额、提体验。
备选方案。其一,持久化封面缓存与合集元信息(coverCache/metaCache 落 localStorage):跨会话省配额,但引入缓存失效、存储配额、多标签页一致性三个新问题,收益却不匹配——封面 URL 本身不耗 API 配额(媒体 CDN 不占配额,15 篇第 6 节)。其二,持久化搜索历史:需要新增 store 与清理入口,产品价值低。其三(推断):用 IndexedDB 存媒体元数据:对一个纯浏览工具属于过度设计。
决策与源码证据。决策是把持久化面压缩到唯一的、无争议的一项:主题。src/store/index.ts 第 62 行,仅 theme store 带 persist: true;src/main.ts 启动链里 useTheme() 在 mount 之前执行,src/utils/theme.ts 的 watch(themeType, changeTheme, { immediate: true }) 确保恢复值在首帧就生效。业务代码 0 处直接调用 localStorage(08 篇第 5 节实测),其余一切状态——列表、分页游标、请求序号、两个内存缓存——都是会话级的,刷新即复位。这个立场的完整表述见《15-接口全量参考》第 5 节:"本项目对浏览器存储的态度是极度保守的……直接收益是没有『缓存失效一致性』问题(刷新即全部复位,永远从干净状态开始),代价是每次会话都要重新消耗配额重建封面与元信息缓存——在 200 次/小时的现实下,这是一个『存储换配额』的明确取舍。"安全侧的对应结论在《08-安全与权限设计》第 5 节:localStorage 里没有任何动态数据读取回显路径,注入面因此趋近于零。
后果与遗留。收益:无缓存一致性问题、无用户数据合规负担(无 Cookie 声明的数据侧依据)、排查时"刷新复现"永远成立。代价:每会话重建封面/元信息缓存的配额成本;若未来反转该决策(持久化 coverCache),src/utils/pexels-collection-cache.ts 的模块级 Map 需要整体加序列化与淘汰机制,15 篇第 5 节明确把该表列为"迁移清单"。这也是一条"与 ADR-003 配对"的决策:配额压力越大,持久化的诱惑越大,两个决策的边界要一起看。
背景。列表页的筛选条件(分类、搜索词、Tab、路由参数)在快速操作下会并发发出多个请求,响应顺序不保证与发出顺序一致——旧响应覆盖新响应是列表类应用的经典竞态。此外组件卸载(keep-alive 页面被替换、路由切换)时在途请求仍在飞行,其响应若写回已销毁组件的状态同样错乱。
备选方案。其一,AbortController 中断在途请求:语义最干净,但每个请求点都要持有并管理 AbortSignal,改造面大;且中断后错误处理分支增多。其二,比较"响应携带的页码与当前页码":只能防翻页竞态,防不了筛选词竞态(两次请求可能同页码)。其三,请求锁(isLoading 期间忽略新触发):会让"快速连点"丢操作。
决策与源码证据。决策是沿用项目早期的 requestSequence 模式(改造计划档案原话:"请求序列号:沿用现有 requestSequence 模式,丢弃过期响应,防止旧筛选结果覆盖新结果"——注意这条在账号时代就已存在,属于穿越架构迁移活下来的机制)。当前实现在全部四个列表页:src/views/collection/index.vue 第 163 行 const currentSequence = ++requestSequence 发起前取号,第 173 行 if (currentSequence !== requestSequence) return 响应到达时验号,不匹配整包丢弃(包括错误也静默丢弃,第 186 行 if (currentSequence === requestSequence) 才置 hasError 并弹提示);onUnmounted 里 requestSequence += 1 让全部在途响应过期。配套的另外三条不变量(《04-数据模型》第 4 节):页码只在成功后自增(第 179 行注释:"若把自增放在请求前,失败后点击重试会跳过失败的那一页");isLoading 无论成败必须在 finally 复位;完成判定用 >= 而非 ===(防返回条数轻微超出被截断 total 导致无限翻页)。
后果与遗留。收益:无锁、无中断、零依赖,四个页面共用同一套心智模型;过期请求"连错误提示都不弹"的设计避免了用户被早已过时的失败打扰。代价:这套状态机在四个页面里各复制了一份(约 40 行/份),是全项目最大的代码重复;四条不变量全靠自觉与注释维持,任何一次重构 try/catch/finally 结构都可能静默破坏(12 篇 FAQ-4 的排查指引即针对此);它同样是 09 篇第五优先补测中"序号竞态"用例的目标区。
背景。直连架构让产物里出现了两类此前不存在的负担:第三方 API 客户端与它带来的"Key 内联"问题(构建期静态注入),以及"重物后置"的包体压力(gsap、file-saver 等只有点开详情才需要的依赖,若进首屏会让移动端首屏显著变重)。同时 Vite 2→8 的升级跨越了 webpack 化到 rolldown 内核的换代,一批配置假设失效。
备选方案。其一,不做手动分包、信任默认策略:Vue 运行时会与未点名依赖混入同一个大 chunk 被入口预加载。其二,调大 chunkSizeWarningLimit 消除告警:告警消失的同时也失去了"分包退化"的唯一报警信号。其三,postcss 配置留在 postcss.config.js(Vite 传统位置):在 Vite 8(rolldown)下 dev 对 .vue 样式块的加载不可靠,dev/prod 表现不一致。其四,开发期也直连 Pexels:localhost 来源会被 Cloudflare 拦截(src/api/pexels.ts 第 19~21 行注释)。
决策与源码证据。vite.config.ts 用注释钉下了四条决策。分包常量区(第 1013 行):"『@vue』必须单独列出:Vue 3 运行时拆在 @vue/runtime-core 等子包里,只匹配 '/node_modules/vue/' 会让整个运行时落进 vendor-misc 并被入口预加载。注意不要写成 '@vue/'——下面的模板已带尾斜杠,多写会拼成双斜杠而匹配不到"——一段注释记录了两个方向相反的坑。告警阈值区(第 8688 行):"不要为了消除告警而调大它:这是发现分包退化(如运行时被错分进 misc)的唯一信号"。postcss 位置(第 4344 行):"显式配置在 vite.config.ts 而非 postcss.config.js:Vite 8(rolldown)dev 下对 postcss.config.js 的 .vue 样式块加载不可靠,会造成 dev/prod 不一致"。dev 代理(第 7172 行):"让浏览器只请求同源的 /api/pexels,由 Vite 转发到 Pexels。规避 localhost 来源跨域直连被 Cloudflare 拦截"。配套的组件级决策是三个列表页用 defineAsyncComponent 加载 media-viewer(注释动机:弹层依赖 gsap,异步化后 gsap 不进首屏),实测 gsap 的 69.6KB 成功推迟到首次点开详情(10 篇第 5 节)。
后果与遗留。收益:首屏 JS 约 139KB(未压缩),包结构"入口极薄、重物后置";dev/prod 样式一致性问题有据可查。代价与遗留:@vue 双坑、postcss 位置、px-to-viewport 的 include 规则共同构成一套"改错任意一处都静默劣化"的配置暗礁(14 篇第 8 节称之为"配置漂移"),只能靠行为验证拦截;包体策略有一个已确认的漏点——src/views/collection/index.vue 静态 import 弹层连带 gsap 提前加载(13 篇 D-13,一行可修);vendor-misc 的 78KB 是下一个优化对象(10 篇第 6 节第 2 条)。
背景。详情弹层要同时服务三种数据形状:首页的 WallpaperItem(字段名 photo/photoDownLink,为兼容旧后端而保留)、合集内照片 NormalizedWallpaper(src.original/url)、视频 NormalizedVideo(videoFiles、duration)。若弹层模板直接面对联合类型,每个字段都要写来源分支。
备选方案。其一,弹层内联判别:模板里大量 v-if 与可选链,任何新来源都要改弹层。其二,把三种来源在各自页面预先转换成统一形状再传入:转换逻辑分散,漏字段无编译期提示。其三,让后端/数据源直接返回统一形状:直连架构下无法要求 Pexels 改协议。
决策与源码证据。决策是建立独立的归一化层 src/utils/viewer.ts(126 行),定义弹层专用模型 ViewerItem 与三入一出函数 toViewerItem。文件头注释即设计宣言:"三类来源字段各不相同,弹层内部只读这一个模型:1. WallpaperItem:首页瀑布流(photo / photoWidth / photoDownLink);2. NormalizedWallpaper:合集内照片(url / width / src);3. NormalizedVideo:视频(image / videoFiles)。"类型判别用鸭子测试而非 instanceof 或显式 type 字段——第 51~53 行注释:"视频项为唯一带 videoFiles 的类型,以此区分图片与视频",isVideoSource 只认 Array.isArray(item?.videoFiles) && item.videoFiles.length > 0,这使混排项(带小写 type 标签)与纯视频对象走同一条分支。兜底哲学在同函数注释:"缺失字段一律回退为空串 / 0,保证弹层模板不需要到处判空。"清晰度选择 pickVideoFile 的注释则记录了对源数据怪癖的防御:"只保留 video/mp4 这类可直接播放的文件(Pexels 会返回 hls 的 m3u8,原生 <video> 在非 Safari 下无法播放);随后优先 hd,其次取宽度最大者。"上游的配套决策在 src/utils/pexels.ts 的 toWallpaperItem:avatar 置空串而非删除字段,注释"Pexels 照片接口不返回摄影师头像,avatar 置空串,由组件 v-if 兜底隐藏"——保留字段形状以兼容历史模板。
后果与遗留。收益:弹层模板(src/components/media-viewer/index.vue,398 行)零判空表达式;新来源接入只需在 ViewerSource 联合里加类型并在 toViewerItem 补一个分支,SOP-E(《11-扩展开发指南》第 5 节)的新内容类型扩展直接复用这条通路。代价:鸭子测试对"恰好带 videoFiles 字段的图片"会误判(当前数据源无此形态,属理论风险);三处来源字段名不统一(title vs description)的根源是历史兼容,被归一化层掩盖而非消除,WallpaperItem 的影子字段(avatar 恒空、liked 无消费)被 13 篇 D-04 定性为"有意保留的预留",删除它会破坏注释记录的兼容意图。
十二条决策不是并列的清单,而是一棵以 ADR-001 为根的决策树:直连(001)取消了后端,才迫使降级门面(002)承担"真假数据边界"、配额意识(003/004)承担"成本内化"、隐私边界(009)顺水推舟;瀑布流自研(005)与弹层路由化(006)是交互侧两大支点,前者派生了懒加载缓存与封面按需入队两个搭档决策,后者派生了直达页与弱契约;双端分支(008)与构建架构(011)是贯穿性横切,约束了其余所有决策的落点。理解这个结构的意义在于改代码时能识别"决策优先级":当两条决策冲突时(例如"加一个首屏预热封面请求"同时触碰 003 的配额纪律与 009 的存储边界),应回溯到根决策 001 的立场——"前端自觉承担全部成本"——再在低层寻找不破坏高层的方案。
做一次反事实推演来检验决策质量:假如当初选了"保留自建后端",002/003/004/009 四条决策将整体消失或退化(降级由服务端兜、配额由服务端管、缓存由服务端持),代价是多一个进程、多一套签名与账号安全;假如当初选了"第三方瀑布流库",005 的 500 余行自研代码消失,但"页面自带滚动容器"的结构约定需要反过来迁就库的假设,且 09 篇档案里七条瀑布流旧坑将以另一种形式重现。两条推演都指向同一个结论:这套决策组合在"单人维护的纯前端工具"这个约束下是自洽且成本最优的,其最大的非技术风险已被本档案集反复点名——决策知识几乎全部存在于注释与文档,需要 09 篇 D-14 的测试集把它固化成机器契约。
阅读本篇的推荐姿势:先读 001、003、011 三条建立"为什么没有后端、为什么这么省、为什么这么分"的骨架认知,再按改动方向选读——改数据链路读 002/012,改交互读 005/006/008,改列表页读 007/010,动构建配置读 011。每条决策的完整机制细节以《07-模块详解》对应小节为准,行为矩阵以《15-接口全量参考》为准,遗留债务的处置以《13-技术债务与演进建议》为准。