项目:PEXELS 壁纸应用 | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本项目的质量保障体系可以概括为"一条自动门禁 + 一个人工习惯 + 一份注释档案":自动门禁只有 vue-tsc 类型检查(pnpm type-check),人工习惯只有 Prettier 格式化,注释档案则是散布在源码里的几十条历史 bug 记录。没有单元测试、没有组件测试、没有 E2E 测试、没有 ESLint、没有 CI、没有 git 钩子——以下每一项都有可复核的证据:
| 检查项 | 结论 | 证据 |
|---|---|---|
| 单元/组件/E2E 测试 | 无 | package.json 的 scripts 仅 dev/build/preview/type-check 四条,无 test;dependencies 与 devDependencies 共 18 个包,无任何测试框架;pnpm-lock.yaml 中 grep vitest/jest/cypress/playwright 计数为 0;仓库无测试目录、无 vitest.config 等 |
| ESLint / oxlint | 无 | 仓库根目录无 eslint.config.* 或 .eslintrc.*;lock 文件中 eslint 计数为 0;.prettierrc 只管格式不管语义 |
| git 提交钩子 | 无 | 无 husky/lint-staged/lefthook 依赖与 .husky 目录;.git/hooks 为样例 ⚠️ 待确认(本地 hooks 目录不入库,以实际仓库为准) |
| CI | 无 | 无 .github 目录、无其它 CI 配置文件;type-check 完全依赖开发者手动执行 |
| vue-tsc | 有且通过 | pnpm type-check 实测退出码 0、零输出(全量通过) |
| Prettier | 有 | .prettierrc:semi:false、singleQuote:true、trailingComma:none、printWidth:100;插件 prettier-plugin-tailwindcss 排序类名;无 lint-staged 集成,格式化靠手动 |
换句话说,类型系统是唯一会在"提交前"自动拦截错误的机制。这样的配置对一个单人维护的工具型项目是合理的成本取舍,但它把全部回归压力转移到了两处:vue-tsc 的类型网能兜住多少,与源码注释能不能提醒改动者别踩旧坑。本篇第 3 节先量清类型网的覆盖面,第 4 节把注释档案整理成可检索的回归清单,第 5 节给出按风险排序的补测方案。
门禁入口是 package.json 的 "type-check": "vue-tsc --noEmit",实际效力由 tsconfig.json 决定,逐项拆解:
| 编译选项 | 值 | 对本项目质量的具体含义 |
|---|---|---|
strict | true | 全家族严格检查(strictNullChecks、strictFunctionTypes 等)全部开启;PexelsApiError 携带 status、门面层 catch 里 (err as { status?: number })?.status 这类判别写法都在其约束下 |
noUncheckedIndexedAccess | true | 数组与 Record 裸取下标返回 `T |
noUnusedLocals / noUnusedParameters | true | 未使用变量/参数报错,死代码不易存活 |
noFallthroughCasesInSwitch | true | changeTheme 的 switch 主题分支受其保护 |
allowJs: true + checkJs: false | — | JS 文件参与编译但不做类型检查——这是第 2.2 节覆盖面缺口的直接原因之一 |
isolatedModules / moduleResolution: Bundler | — | 与 Vite 的转译模型对齐,保证类型检查与构建行为一致 |
paths 别名 | @/* → src/* | 与 vite.config.ts 的 resolve.alias 保持一致,避免"构建能过、类型检查报找不到模块" |
| include | `src/**/*.ts | .d.ts |
vue-tsc --noEmit 的额外价值在于模板检查:对 lang="ts" 的 SFC,它会检查模板里的 props 名、emit 事件名、插槽作用域变量。src/env.d.ts 文件头注释解释了为什么这里特别重要——早期存在过的 declare module '*.vue' shim 会把所有 SFC 抹平成无类型的通用组件,让模板检查形同虚设,因此该项目刻意不写这个 shim(这个决策与类型门禁的效力直接绑定)。
《06-部署运行》第 5 节原有的"全量检查"表述对应着 docs/dependency-upgrade-plan.md 中"type-check 实际只检查 .ts 文件"的旧结论(前者高估、后者低估当前覆盖面),该结论已过时,06 篇对应表述已在 2026-10-06 一致性回刷中按本节实测口径修订:实测 src/ 下共 29 个 SFC,其中 28 个已升级为 <script setup lang="ts">,仅 src/views/videos/components/list/index.vue 仍是纯 JS 的 <script setup>——该文件的内部类型与模板 props 校验不在 vue-tsc 的检查范围内(checkJs: false 使其按 JS 处理)。这既是当前门禁唯一的结构性盲区,也说明"补齐 lang=ts"只剩一个文件的工作量(依赖升级档案写的是 39 个 SFC 全无 lang=ts,属历史快照,其修正过程本身就是第 4 节"文档漂移"的一个例证)。
门禁还有两个天然管不到的层面需要靠第 4、5 节的机制补位:运行时行为(类型全对但逻辑反了——比如页码自增时机、序号比对方向,类型系统不判断语义)与跨模块契约(WallpaperItem 的形状契约靠类型对齐,但"切分类必清搜索词"这类语义约束只存在于 store action 的实现里)。另外 tsconfig.node.json 单独管 vite.config.*(composite 引用),配置文件的类型错误在 pnpm type-check 的引用链中一并被查。
Prettier 的价值不在"好看"而在"免争议":printWidth 100 + 无分号 + 单引号的组合与源码现状一致,prettier-plugin-tailwindcss 保证类名顺序统一——瀑布流这类模板里动辄十几个工具类的组件,类名顺序不稳定会让 diff 充满噪声。但需注意它没有与 git 钩子集成,格式一致性依赖自觉。
真正承担"质量保障"第二职能的是注释纪律。抽样统计四个核心文件的注释行密度:src/libs/waterfall/index.vue 544 行中有 75 行注释(约 14%)、src/api/pexels.ts 356 行中 54 行(约 15%)、src/utils/pexels.ts 674 行中 34 行、src/directives/modules/lazy.ts 96 行中 27 行(约 28%)。这些注释绝大多数不是"这段代码做了什么"的复述,而是"为什么这么写、不这么写会出什么 bug"的决策记录——它们是本项目事实上的回归测试用例库,下一节全部收编。
以下清单从全部源文件注释中逐条收编,每条都是"改动对应模块前必读"的旧坑,也是第 5 节补测方案里测试用例的直接来源。按模块归组:
协议与门面层:① 认证头无 Bearer 前缀(src/utils/pexels.ts 文件头)——照搬主流 axios 模板加 Bearer 会 401;② 代理模式允许空 Key、直连模式构造即抛错——两条守卫缺一不可;③ 401/403/429 必须上抛不可静默降级,否则熔断与配额提示成死分支(src/api/pexels.ts 的 isAuthOrLimitError 注释);④ 详情/媒体接口失败必须抛出:图片详情静默换随机图是"张冠李戴"、合集媒体静默换占位会污染缓存;⑤ getMyCollections 任何失败返回空数组比占位更准确;⑥ total 截断 8000 防无限滚动不收敛。
页面列表层:⑦ requestSequence 序号防旧响应覆盖新响应,组件卸载时再 +1 终结在途写回;⑧ 页码只在成功后自增,否则失败重试跳页;⑨ isLoading 必须在 finally 复位,否则 infinite-list 永不再触发;⑩ 完成判定用 >= 而非 ===,防返回条数轻微超出被截断 total 导致无限翻页;⑪ 分类/搜索合并单 watch + appliedQuery 快照去重,拆两个 watch 会"切分类列表不变"或"一次操作连发两次";⑫ keep-alive 失活期间只记快照不发请求,防视频页搜索时首页白烧配额;⑬ resetQuery 必须显式发请求,不能依赖"清空后 loading 元素重新进视口"的副作用,否则首屏铺满时列表不刷新。
瀑布流(src/libs/waterfall/):⑭ 空列高对象时 Math.min() 返回 Infinity,utils.ts 统一兜底 0;⑮ 未定位 item 不输出 left/top,防 left: undefinedpx 非法 CSS;⑯ 容器不可见时 offsetWidth 为 0 会算出负列宽,负数对 v-if 仍是 truthy,Math.max(width, 0) 兜底;⑰ item 高度为 0 会让定位错乱,开发环境一次性 console.warn(warnZeroHeight);⑱ 定位结果必须放独立响应式数组而非挂在 props.data 元素上——污染调用方数据 + deep watch 组合引发额外重排;⑲ 用 WeakMap 存定位不行——模板读不到,不触发渲染;⑳ watch 数组引用 + 长度区分整体替换与原地追加,deep:true 造成多余重排;㉑ 列数 watch 延迟到 onMounted 后注册,防初始化期重复重排。
懒加载与弹层积木:㉒ img src 置空串会被浏览器解析为当前页面 URL 再发一次请求并触发 error——用 1×1 透明 GIF 占位(src/directives/modules/lazy.ts);㉓ updated 钩子必须以 binding.value 为准,读 el.src 会把透明占位图误当新地址;㉔ 交叉回调里要从 stateMap 取最新地址而非闭包旧值;㉕ loadedCache 只在 load 成功后登记,失败图片滚回可重试;㉖ message 必须每条独立容器,否则 document.body._vnode 互相覆盖:先弹的销毁时卸掉后弹的、自身 DOM 残留泄漏(src/libs/message/index.ts);㉗ message 过渡只用纵向位移,translate3d(-50%) 会与 mx-auto 工具类叠加导致横向偏移翻倍回弹;㉘ popover 判断插槽内容不能按 children 是否数组,编译后 vnode children 未必是数组——正确写法见 src/libs/popover/index.vue;㉙ popup 必须在 onUnmounted 与 onDeactivated 双钩子解锁 body 滚动,keep-alive 页面离开只触发后者,漏掉整站永久不可滚。
主题与状态:㉚ 切主题只增删自己的 class,html.className = x 整写会清掉第三方注入类(src/utils/theme.ts);㉛ 系统主题回调先判 themeType === 'system' 才应用,防 OS 覆盖用户显式选择;㉜ 分类词表 id 必须唯一,早期后 4 项重复 id 导致重复 key 警告 + findIndex 定位永远命中第一项、滑块错位(src/constants/index.ts);㉝ 切分类必须同时清搜索词,语义内聚在 changeCurrentCategory;㉞ 卡片占位色用 computed 而非模板内联 randomTint(),否则每次重排全站卡片换色闪烁(src/views/main/components/list/item.vue);㉟ 占位高度计算先判零宽高防 Infinity/NaN 非法样式。
合集与构建:㊱ 封面请求不能挂 onMounted——瀑布流初始布局会挂出全部 item,等于回到整页 N+1(src/views/collections/components/collection-item.vue);㊲ 封面 img 用 v-show 不用 v-if,v-if + 动态 src 场景浏览器可能不把图片识别为"应进入视口"而永不加载;㊳ DOM 按下标复用,watch data.id 在已相交时立即重取封面;㊴ @vue 在 manualChunks 必须单独列出、且不能写成 '@vue/'(模板带尾斜杠会拼出双斜杠匹配不到),否则 Vue 运行时落进 vendor-misc 被入口预加载(vite.config.ts);㊵ chunkSizeWarningLimit 300 是分包退化的唯一报警信号,不许调大;⑤ px-to-viewport 必须配在 vite.config.ts,Vite 8 下 postcss.config.js 对 .vue 样式块加载不可靠造成 dev/prod 不一致。
这份清单本身也暴露了质量体系的脆弱性:约 40 条历史 bug 知识全部以自然语言注释存在,没有一条被自动化验证。任何一次大规模重构(尤其是批量改注释风格的"清理")都可能让档案失真——这正是下一节补测优先级排序的出发点。
选型前置结论:项目是 Vite 8 + Vue 3.5,测试框架首选 Vitest(与 Vite 共享配置与转换管线、对 import.meta.env 原生支持),组件测试用 @vue/test-utils,必要时补 jsdom/happy-dom 环境。以下按"出错概率 × 出错代价 ÷ 编写成本"排序,前两档是强烈建议的最小集。
第一优先:瀑布流布局与虚拟裁剪(纯逻辑占比高,收益最大)。src/libs/waterfall/utils.ts 的三个函数是纯函数,零成本即测:空对象兜底(对应旧坑⑭)、多列取最矮/最高。index.vue 的核心算法(列高累加、最矮列定位、visibleIndexes 区间裁剪、sameIndexes 比对)虽耦合 DOM,但可以用 happy-dom 挂载组件、给 item 固定高度后断言:全量重排后 itemStyles 的 left/top 序列、追加重排保留旧定位(旧坑⑱⑲⑳的回归)、容器不可见时列宽为 0(⑯)。虚拟滚动用例:滚动位置变化后 visibleIndexes 只含视口 ±400px 的下标、未定位 item 始终保留、集合不变时不更新。这里同时是全项目复杂度最高、注释最密的区域——测试一旦写出来,第 4 节的 7 条旧坑全部自动化。
第二优先:降级决策矩阵与错误分级(行为契约,改错即事故)。src/api/pexels.ts 的 8 个函数对"无 client / 网络抖动 / 401 / 403 / 429"共 5 种输入的行为矩阵(见《03-接口设计》3.2 节表格)是纯决策逻辑,mock PexelsClient 即可全量断言,包括:列表降级 total=200、详情抛出(旧坑④)、getMyCollections 恒空、total 截断 8000。配套测协议层 PexelsApiError 的状态码分档与 isAuthOrLimitError。这组测试直接锁定旧坑①③⑤⑥——它们全是"改一行就坏、坏了不报错"的类型。
第三优先:封面调度器(时序敏感,用 fake timers)。src/utils/pexels-collection-cache.ts 的 queue/drain/熔断状态机适合用 vi.useFakeTimers 测:3 并发批处理、250ms 批间隔、429 后 5 分钟内新请求直接空回调、冷却到期自动复位且提示计数重置、失败不写缓存、取消函数生效后不再通知。这个模块当前无任何测试且是配额保护的核心(旧坑相关行为见《07-模块详解》第 5 节)。
第四优先:纯工具函数与归一化。src/utils/viewer.ts 的 toViewerItem 三来源判别与 pickVideoFile(HLS 过滤、hd 优先、宽度兜底)、src/utils/pexels.ts 的 normalizePhoto 兜底链与降级生成器 id 规则(翻页不重复、取模 1084)、src/utils/helpers.ts 的 formatDuration 边界(0/负数/undefined)。全部纯函数,半天工作量,收益是归一化契约被永久锁定(《04-数据模型》第 3 节的规则即测试断言)。
第五优先:组件交互抽查(成本高,选点即可)。media-viewer 的地址同步状态机(pushState/replaceState/popstate 三分支与 hasPushed 快照)需要 mock history,值得为它写 4 个用例;列表页的序号竞态(旧坑⑦⑧⑨⑩)可通过对 getPexelsList 注入可控 Promise 时序验证。E2E 层面当前 ROI 最低,建议只保留《06-部署运行》第 5 节的 7 条手工验收路径,暂不引入 Playwright(仓库中的 .playwright-mcp 空目录是历史痕迹,不构成 E2E 基础设施)。
工程化收尾三件事:把 pnpm type-check 接入 CI(哪怕只是 GitHub Actions 单步,无 CI 现状即改变);给剩余的 src/views/videos/components/list/index.vue 补 lang="ts" 消除唯一盲区;引入 ESLint 时从" eslint:recommended + vue/essential + 与 Prettier 关闭格式冲突规则"的最小集起步,重点开 no-v-html(锁定注入纪律,呼应《08-安全与权限设计》第 5 节)与 unused-imports 类规则。完成后,本项目的质量保障就从"一条门禁"升级为"类型 + lint + 单测"三件套,而第 4 节那份 40 条的注释档案,也就从"人读的备忘"变成"机器验证的契约"。