项目:PEXELS 壁纸应用 | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇选取三个文件做"逐段带码走读":src/api/pexels.ts(门面层,356 行——降级与错误分级的决策中枢)、src/libs/waterfall/index.vue(瀑布流,544 行——全站最复杂的 UI 组件)、src/utils/pexels-collection-cache.ts(调度层,145 行——配额保护的浓缩体)。三者分别代表"决策密度最高的业务层""复杂度最高的组件层"与"时序密度最高的工具层",合起来覆盖了这个项目最值得被精读的代码。走读粒度是"段落":每段引用当前源码的原始片段(四反引号围栏,行号为文件内真实行序),先逐行解释代码做了什么,再还原它背后的设计意图与历史成因,段落以「这段代码教会我们什么」收束——它是把本段经验抽成可迁移结论的小结,也是 09 篇"注释即回归测试档案"方法论的一次示范性执行。
选段的标准不是"这段代码写得漂亮",而是"这段代码的每一行都有理由"。本项目注释密度约 14%~28%(09 篇第 3 节抽样),大量行内注释记录着"不这么写会出什么 bug",走读时对注释原文会原样保留并展开。引用时的约定:代码片段保持原样(含空行与中文注释),不做格式化改写;省略号 ... 表示中间有与本段主题无关的代码被折叠;所有行为矩阵、字段消费关系等横向知识一律引用既有篇目(《03》《15》)而不在走读中重复。
const API_KEY = import.meta.env.VITE_PEXELS_API_KEY || ''
/**
* 后端代理地址(如 /pexels 反向代理)。
* 配置后请求改走代理,Key 由服务端注入,不再暴露在前端产物。
*
* dev 下若未显式配置,则自动走 Vite 的 /api/pexels 代理(见 vite.config.ts),
* 避免 localhost 来源跨域直连 Pexels 被 Cloudflare 拦截;
* prod 未配置时保持直连 api.pexels.com(线上真实域名来源,已验证可用)。
*/
const PROXY_BASE =
import.meta.env.VITE_PEXELS_PROXY_BASE ||
(import.meta.env.DEV ? '/api/pexels' : '')
/** 单例客户端:无 Key 且无代理时不实例化(走 picsum 兜底) */
const client = API_KEY || PROXY_BASE ? new PexelsClient(API_KEY, PROXY_BASE || undefined) : null开篇五行决定了整个应用的数据通道,值得逐个拆。API_KEY 是标准的 Vite 环境变量注入——VITE_ 前缀意味着它会构建期静态内联进产物(08 篇第 3 节详述的暴露机制),|| '' 保证 undefined 归一为空串以便后续判空。PROXY_BASE 的三级优先是这段的核心:显式配置的代理地址在任何环境生效;否则开发环境落到 /api/pexels——这不是一个任意的约定,而是为了规避"localhost 来源直连 Pexels 被 Cloudflare 拦截"这一真实故障(vite.config.ts 第 71~72 行注释同一动机);生产环境落空串,即直连官方域名。最后一句三元是全模块最重要的一个决策:client 可能为 null——"无 Key 且无代理"时根本不创建客户端。这个 null 不是错误状态,而是一等公民的"演示模式"标记:下游所有接口函数以 if (!client) 短路返回占位数据,连一次网络请求、一条错误提示都不会产生(头注释原话:"缺失时所有请求直接走 picsum 兜底,不发起真实请求(也避免无谓的错误提示)")。
这段代码教会我们什么:配置的三态(有 Key、有代理、全无)要在模块求值期收敛成一个可判空的单一事实,而不是在每个请求点重复判断。把"不可用"建模为 null 而非抛错,让"没有 Key"从异常降级为一种正常运行模式——这是演示/试用型应用处理凭证缺失的最优雅形态。
/**
* 认证 / 配额类错误必须向上抛出,不能静默降级为兜底数据。
*
* 降级只应用于「网络抖动」这类瞬时故障:Key 失效或配额耗尽时若仍然返回兜底图,
* 用户与页面层都无法区分「真实数据」与「假数据」,页面里的 429 熔断与配额提示
* 也会变成永远进不去的死分支。
*/
const isAuthOrLimitError = (err: unknown): boolean => {
const status = (err as { status?: number })?.status
return status === 401 || status === 403 || status === 429
}函数体只有两行,注释却有七行——注释与代码 3.5:1 的比例本身就是信息。代码层面:入参 unknown 而非 Error,因为 fetch 链路的失败既可能是 PexelsApiError(协议层抛出,带 status),也可能是 TypeError(网络层失败,无 status);(err as { status?: number })?.status 用可选属性访问兼容两种形态,status 为 undefined 时三个比较全为假,自然落入"可降级"。注释层面,它论证的是整个门面层最重要的不变量:401/403/429 必须上抛。理由链完整读一遍——Key 失效时若返回兜底图,页面上一切正常、数据全错;更隐蔽的是 429:封面调度器(src/utils/pexels-collection-cache.ts)依赖捕获 status 429 来触发熔断,若门面层把 429 吞成占位数据,调度器永远收不到信号,熔断与"配额已耗尽"提示成为死分支。对照 .codebuddy/plans/pexels-api-integration_a662121c.md 改造计划的初稿("任何失败回退 picsum 兜底"),可以确认这条不变量是落码阶段对计划的修正——先有设计、后有勘误的活标本(《16-决策记录ADR》ADR-002)。
这段代码教会我们什么:错误处理的第一原则不是"捕获一切让页面活着",而是"让上层有能力区分故障类型"。静默兜底消灭的不只是错误提示,还有上层做出正确反应所需的信息;三行函数配上完整论证的注释,胜过十行无注释的"健壮"代码。
export const getPexelsList = async (data: PexelsListQuery) => {
const page = Math.max(1, data.page ?? 1)
const perPage = Math.min(80, Math.max(1, data.size ?? 20))
// 无 Key:直接兜底,不发请求
if (!client) {
return {
list: generateFallbackWallpapers(page, perPage).map(toWallpaperItem),
total: FALLBACK_MAX_TOTAL
}
}
try {
const searchText = data.searchText?.trim()
const categoryId = data.categoryId && data.categoryId !== 'all' ? data.categoryId : ''
const query = searchText || categoryId
const res = query
? await client.search(query, { page, perPage })
: await client.curated({ page, perPage })
return {
list: res.items.map(toWallpaperItem),
total: Math.min(res.total, PEXELS_MAX_TOTAL)
}
} catch (err) {
// 401/403/429 交给调用方提示;仅网络抖动等瞬时故障才降级,页面不白屏
if (isAuthOrLimitError(err)) throw err
return {
list: generateFallbackWallpapers(page, perPage).map(toWallpaperItem),
total: FALLBACK_MAX_TOTAL
}
}
}这是全部 8 个门面函数的母版,值得从头到尾走一遍。第一行参数钳制:Math.max(1, ...) 把页码下限锁在 1——调用方可能传 0 或负数(页面层 query.page 初始或重置时若出错),而 Pexels 对 page=0 的行为未定义;Math.min(80, ...) 对应 Pexels 单页上限 80,配合 ?? 20 的默认值,三个防御写成一行。注意钳制做在门面层,协议层 search/curated 内部还会再做一次(《03-接口设计》第 1 节称之为"双重防御")——两处重复不是疏忽,因为门面层还要防护"未来直接 new PexelsClient 的调用方"。if (!client) 分支返回的形状与成功分支完全同构:{ list, total },这正是"页面层零 if(降级) 分支"能成立的物理基础;generateFallbackWallpapers(page, perPage) 按页号生成序号连续的 Picsum id,翻页不重复、回滚不变脸(src/utils/pexels.ts 第 432~438 行)。请求词路由 query = searchText || categoryId 实现了"搜索优先于分类"的业务规则;categoryId !== 'all' 的转换配合 buildParams 过滤空串,保证"全部分类"最终走 /curated 且 URL 上不出现 query=all 这类脏参数。Math.min(res.total, PEXELS_MAX_TOTAL) 是 8000 截断——没有它,"已加载 ≥ total 才停"的无限滚动判定会在 Pexels 的分页硬上限之外永远发注定失败的请求。catch 分支是 1.2 节不变量的执行点:isAuthOrLimitError 为真原样上抛,否则降级返回——注释里"页面不白屏"四个字是降级的唯一正当性来源。
这段代码教会我们什么:好门面的三个标志——出口形状恒定(成功、降级、失败三种结局要么同形、要么明确抛出)、入参不信调用方(钳制做在边界)、每条分支都有"为什么"可查。把 8 个函数写成同一个骨架,让评审者可以用读第一个函数的心智成本覆盖其余七个。
export const getPexelsFromId = async (id: string | number): Promise<WallpaperItem> => {
if (!client) return toWallpaperItem(generateFallbackWallpaper())
const item = await client.getById(id)
return toWallpaperItem(item)
}与 getPexelsList 对比,这里缺了一个 try/catch——有 Key 时的任何失败(404、401、429、网络错误)都直接向上抛,不降级。函数头的 JSDoc 给出了理由:"有 Key 但请求失败(404/网络异常)时直接向上抛,由调用方给出错误提示,避免静默返回一张与点击目标无关的随机图,造成『张冠李戴』"。推演一遍反面:用户点击第 3 张卡片,请求 404,门面若静默返回 generateFallbackWallpaper()(固定 id=1 的 Picsum 图),用户看到一张与点击毫无关系的占位图——比白屏更糟,因为它看起来"成功"了。无 Key 分支返回兜底则是安全的:此时所有列表数据本来就是同源占位图,详情占位与列表占位自洽,不存在"张冠李戴"(返回单张而非数组的细节也在 src/utils/pexels.ts 第 426 行有注释:"详情接口无 Key 时的占位,避免调用方对数组取下标")。同样的原则后来应用于 getVideoById(注释"静默返回同一条兜底视频,用户会看到与目标完全无关的内容")与 getCollectionMedia(注释"媒体与封面会被写进缓存并当作真实数据展示,因此持有 Key 时一律抛错")——三处推导路径一致:数据被消费的"指向性"越强,越不允许静默替换。
这段代码教会我们什么:降级不是接口级别的开关,而是数据语义级别的判断。"列表流"可以占位、"指向性详情"不可以——判断依据是"错误数据会不会被误认为正确数据"。一个接口该不该兜底,先问它的数据会被谁、以多强的指向性消费。
if (!client) {
// /collections 需要当前账号的认证态,前端直连场景下大多数无 key 用户看不到任何自己的集合
// 返回空数组比兜底占位更准确,避免误导用户
return { items: [], total: 0, totalPages: 0 }
}
try {
const res = await client.myCollections({ page, perPage })
...
} catch {
// 401/未授权:返回空数组
return { items: [], total: 0, totalPages: 0 }
}这是全矩阵中最特殊的一格:无 Key、请求失败、401 三种情形都返回空数组,而不是占位数据。它的逻辑起点是端点的权限语义——/v1/collections(我的合集)依赖 Pexels 账号的 Cookie 登录态,前端直连时请求方是浏览器而非任何已登录会话,绝大多数访客拿到的是 401 或空集。此时占位卡的文案「示例合集 N · 请配置 VITE_PEXELS_API_KEY 后查看 Pexels 真实合集」反而是误导:配置了 Key 也看不到"我的合集"(那需要登录 Pexels 账号,不是配 Key 能解决的)。空数组 + 页面层的提示文案("当前账号暂无合集 / 需要在 Pexels 登录")才是对现状最准确的呈现。注意 catch 连错误参数都没接(catch {),意味着连"要不要区分错误类型"的决策都做完了——不需要。这条设计也让 UI 层不需要为它准备第四种状态:src/views/collections/index.vue 的双 Tab 里,"我的合集"永远是"空列表 + 提示",与"用户真的没有合集"在视觉上同构(13 篇 D-10 将其定性为"诚实但无解的占位")。
这段代码教会我们什么:兜底数据的目标是"不误导",不是"不空白"。当真实答案是"没有"时,返回空集合比编造占位内容更接近真相;为占位而占位,会把架构限制伪装成用户问题。
src/api/pexels.ts 的 356 行可以压缩成三句话:通道计算(1.1)决定"请求从哪走",降级矩阵(1.3~1.5)决定"失败时返回什么",错误分级(1.2)决定"什么失败不返回"。它的全部决策都发生在"请求之前"与"响应之后",函数体内没有任何业务逻辑——这正是门面层的本分。完整的 8 函数 × 5 输入行为矩阵见《15-接口全量参考》第 3 节;给这套矩阵写测试的方法见《09-测试与质量保障》第 5 节第二优先级。
<template>
<div
class="relative"
ref="containerTarget"
:style="{
height: containerHeight + 'px' // 因为当前为 relative 布局,所以需要主动指定高度
}"
>
<!-- 因为列数不确定,所以需要根据列数计算每列的宽度,所以等待列宽计算完成,并且有了数据源之后进行渲染 -->
<template v-if="columnWidth && data.length">
<!-- 只渲染可视区域(±缓冲)内的 item,实现虚拟滚动:
数据量增大后 DOM 数量不再随数据线性增长,滚动性能不会劣化 -->
<div
v-for="item in visibleItems"
:key="item.key"
class="m-waterfall-item absolute duration-300"
:style="getItemStyle(item.index)"
:data-index="item.index"
>
<slot :item="item.data" :width="columnWidth" :index="item.index" />
</div>
</template>
</div>
</template>模板只有一层真实结构,四条注释把四个"为什么"全部钉死。容器 height 必须主动指定:卡片全部 absolute 脱离文档流后,父容器默认高度为 0,滚动条与 infinite-list 的触底观察都会失效——containerHeight 由布局算法取"列高最大值"写入,滚动条长度从第一屏起就是稳定的。v-if="columnWidth && data.length" 是初始化时序守卫:列宽没算出来(或容器不可见算出 0)时渲染没有意义的卡片,data 为空时渲染空集合——两个条件拦截的都是"布局未就绪"状态。v-for 的对象是 visibleItems 而非 data,虚拟裁剪就发生在这一层(2.4 节展开);:key="item.key" 用业务 id 而非数组下标,DOM 复用时 Vue 挪节点而非重建节点;duration-300 给的是"位置变化"的过渡——列数变化触发的重排动画由此而来。data-index 属性是测量阶段与 DOM 的唯一接口:高度读取按它映射回数据下标,与 DOM 渲染顺序解耦(裁剪后 DOM 顺序 ≠ 数据顺序)。作用域插槽只吐 item/width/index 三样,组件对卡片内容零假设——这是它作为通用积木的边界。
这段代码教会我们什么:虚拟滚动的 DOM 结构可以极简,但每个属性都有职责——height 承担滚动条、key 承担复用、data-index 承担测量回读。模板层的注释密度往往决定半年后还能不能改这个组件。
/** 可视区域外额外渲染的缓冲高度(px):避免滚动到边界时出现白块/闪动 */
const VISIBLE_BUFFER = 400
...
const props = withDefaults(defineProps<{
data: any[]
nodeKey?: string
column?: number
columnSpacing?: number
rowSpacing?: number
/**
* 可选:按「瀑布流容器宽度」自适应列数。
* 传入形如 [{ minWidth: 0, column: 2 }, { minWidth: 768, column: 5 }] 的数组,
* 组件会用 ResizeObserver 监听容器宽度并匹配列数。
* 不传时退化为固定 `column`。
*/
responsiveColumns?: ResponsiveColumn[]
}>(), {
column: 2,
columnSpacing: 20,
rowSpacing: 20,
responsiveColumns: () => []
})VISIBLE_BUFFER = 400 是虚拟滚动的手感参数:裁剪区间 = 视口上下各扩 400px。它权衡的是两个失败方向——太小,快速滚动时边界处会看到尚未渲染的白块;太大,DOM 数量回升、虚拟滚动贬值。400px 在一屏 600~900px 的移动端视口下意味着上下各约半屏缓冲,实测 DOM 稳定在十几到几十个(10 篇第 2 节)。props 的类型设计里最有信息量的是 data: any[]——严格说这是全项目罕见的 any,它的宽容是刻意的:瀑布流对卡片内容零感知(内容由插槽决定),唯一需要的结构信息是"数组长度变化了吗"与"引用换了吗"(2.5 节的 watch),强类型化 data 反而会把组件绑死在具体业务模型上。responsiveColumns 的文档注释值得当作 API 设计范本:它直接给了调用示例与退化行为("不传时退化为固定 column"),四个页面的调用方(首页 4 档、视频/合集 3 档)都能从这段注释推断出自己的梯度怎么配。默认值 column: 2 对应移动端首档——所有调用方都显式传了 responsiveColumns,这个默认值实际是最后防线。
这段代码教会我们什么:通用组件的 props 设计是"对未来的克制"——能不假设的都不假设(data 用 any + 插槽),但退化路径必须写清(responsiveColumns 的兜底行为);魔法数字(400)要么配注释说明权衡,要么在命名里自解释。
/**
* 每个 item 的定位结果,按 data 下标存储。
*
* 设计要点:
* 1. 必须是 **响应式** 的:早期实现把 `_style` 挂在 props.data 的元素上,
* 既污染了调用方传入的数据(props 应视为只读),又会在
* 「deep 监听 + 回调内改写数据」的组合下额外触发一轮完整重排。
* 2. 用数组而非 WeakMap:模板需要读取定位结果,而 WeakMap 不是响应式的,
* set 之后不会触发重新渲染。
*/
const itemStyles = ref<ItemStyle[]>([])这段注释记录了同一个问题被否决的两种解法,是组件内最有教学价值的一段。方案一(挂回 props.data 元素):布局结果与数据混存,破坏了"props 只读"的 Vue 契约,调用方传入的数组被组件私自加字段;更实际的灾难是响应式死循环——若外层用 deep watch 监听这个数组,组件回调里的 _style 写入会再次触发外层 watch,引发一轮多余的全量重排。方案二(WeakMap 按 data 元素索引定位):解决了数据污染,但 WeakMap 不是响应式容器,set 之后模板读不到变化、不会重渲染——"数据在、视图不动"。最终形态是"按 data 下标索引的响应式数组":下标与 data 天然对齐、追加时旧下标定位不变(这是追加重排路径的前提)、模板可读、且与 data 本身解耦。代价是"下标即身份"在整体替换时必须全量清空重建(clearLayout),不能依赖下标复用。getItemStyle(第 105~111 行)是这个结构的输出端,还有一条防护:"尚未完成定位时只给宽度,不输出 left / top,避免出现 left: undefinedpx 这类非法 CSS 值"——未定位 item 的 style 只有 width 一项,卡片短暂堆在文档流左上角等待测量。
这段代码教会我们什么:派生数据的存储位置是响应式系统的核心决策——挂回输入(污染 + 循环触发)、存非响应容器(视图失明)都是真实踩过的坑;"独立的、按输入索引的响应式数组"是这类"输入只读 + 输出派生"场景的通用答案。
/** 更新可视范围:计算「相对瀑布流顶部」的可见区间并裁剪渲染集合 */
const updateVisible = () => {
if (!containerTarget.value) return
const containerRect = containerTarget.value.getBoundingClientRect()
const parent = scrollParentRef.value || window
// 滚动容器视口的可视区间(浏览器视口坐标)
let viewTop
let viewBottom
if (parent === window) {
viewTop = 0
viewBottom = window.innerHeight
} else {
const rect = (parent as HTMLElement).getBoundingClientRect()
viewTop = rect.top
viewBottom = rect.top + (parent as HTMLElement).clientHeight
}
const start = viewTop - containerRect.top - VISIBLE_BUFFER
const end = viewBottom - containerRect.top + VISIBLE_BUFFER
// 候选集合:未定位的 item 始终渲染(供测量),已定位的按可视区间裁剪
const nextIndexes = []
for (let i = 0; i < props.data.length; i++) {
const style = itemStyles.value[i]
if (!style) {
nextIndexes.push(i)
continue
}
const bottom = style.top + (style.height || 0)
if (bottom >= start && style.top <= end) {
nextIndexes.push(i)
}
}
// 集合未变化时不更新,避免滚动时每一帧都触发重渲染
const current = visibleIndexes.value
if (current !== null && sameIndexes(current, nextIndexes)) return
visibleIndexes.value = nextIndexes
}这是虚拟滚动的心脏,按三个阶段读。坐标换算阶段:可见区间必须用"滚动容器视口"而非浏览器视口——瀑布流的滚动宿主是页面的 h-full overflow-auto 容器(02 篇第 6 节),window 分支只是回退。算法把滚动容器与瀑布流各自的 getBoundingClientRect().top 相减,得到"瀑布流顶相对视口顶的偏移",再把视口区间 [viewTop, viewBottom] 平移到瀑布流坐标系并各扩 400px 缓冲。这个写法对 window 与元素宿主统一成立,因为两者都基于 rect 相对差而非绝对 scrollTop。筛选阶段:遍历全部 item 的定位结果,bottom >= start && top <= end 是标准的区间相交判定——上下边缘各让出一半,保证"正在滚出/滚入"的 item 不闪;!style 的 item 无条件保留,注释说得直白:"未定位的 item 始终渲染(供测量)"——测量以 DOM 存在为前提,把未测量的 item 也裁掉会让布局永远无法完成,这是虚拟滚动实现里最容易犯的自指错误。收敛阶段:sameIndexes 逐项比对,集合没变就不赋值——滚动事件每帧都触发 updateVisible,但区间是按 400px 粒度滑动的,绝大多数帧的候选集合不变;没有这道闸门,每一帧都是一次响应式更新 + v-for diff。上游配套是 onScroll 的 rAF 节流(cancelAnimationFrame + requestAnimationFrame,注释"避免滚动时 layout thrashing")与 findScrollParent 对滚动祖先的运行时查找。
这段代码教会我们什么:虚拟滚动的三件套——正确的坐标系(相对差而非绝对值)、保留未测量项(打破测量自指)、集合比对免更新(把 O(n) 判定挡在响应式写入之前)。缺任何一件,要么白屏要么卡顿。
// 触发计算
watch(
// 监听「数组引用」+「长度」两个信号:
// - 切换分类时数组被整体替换(引用变化,长度可能不变)
// - 加载更多时是原地 push(引用不变,长度变化)
// 两者都要覆盖;同时避免使用 deep:true 造成的一轮多余重排
[() => props.data, () => props.data.length],
() => {
const isNewArray = props.data !== prevData
prevData = props.data
if (isNewArray) {
// 全新数据:清空定位结果与列高,从头布局
clearLayout()
relayoutAll()
} else {
// 追加数据:保留已有定位,仅确保新 item 渲染出来供测量
ensureNewItemsVisible()
relayoutAppend()
}
}
)如何区分"换了数据"与"加了数据"?这段的实现是教科书级的信号设计。两个监听源覆盖了两种变化的可达性:整体替换(列表页 medias.value = list)引用变化但长度恰好相等时(新页也是 18 条),只监听长度会漏报;原地追加(medias.value.push(...list))引用不变,只监听引用(浅比较)会漏报。进入回调后用 props.data !== prevData 做一次引用判定分流:新数组 → clearLayout()(清空 itemStyles 与列高)+ relayoutAll()(全量渲染 → nextTick 测量 → 定位 → 裁剪);旧数组 → ensureNewItemsVisible() 把未定位的新下标并入渲染集合(否则新 item 不在 DOM 里,测不到高度)+ relayoutAppend() 只测新 item,旧 item 的定位、列高累计、裁剪状态全部保留——这就是"翻到第 N 页时成本只与新增 20 条相关"的实现基础。注释同时否决了 deep: true:深度遍历大数组的开销之外,2.3 节提到的"回调改写数据 → 再触发"的循环风险正是 deep watch 放大的。配套的 prevData 是普通变量而非 ref——它只参与事件内的判定,不需要触发任何视图。
这段代码教会我们什么:对"数据源变化"做分支处理前,先枚举调用方的全部变更形态(替换、追加、清空),为每种形态选最小成本的路径;引用相等性是最廉价的"换数据"信号,配合长度信号可以零遗漏地覆盖 Vue 列表更新的两种惯例写法。
const matchColumnByWidth = () => {
if (!props.responsiveColumns.length || !containerTarget.value) {
return props.column
}
const width = containerTarget.value.offsetWidth
const matched = [...props.responsiveColumns]
.sort((a, b) => b.minWidth - a.minWidth)
.find((item) => width >= item.minWidth)
return matched?.column ?? props.column
}
/** ResizeObserver:监听容器宽度变化,自适应调整列数 */
let resizeObserver: ResizeObserver | null = null
let columnDebounceTimer: ReturnType<typeof setTimeout> | null = null
const setupResponsiveColumns = () => {
if (!props.responsiveColumns.length || !containerTarget.value) return
// 首次直接按当前宽度初始化列数(避免 observe 首帧回调再触发一次重排)
currentColumn.value = matchColumnByWidth()
resizeObserver = new ResizeObserver(() => {
clearTimeout(columnDebounceTimer!)
columnDebounceTimer = setTimeout(() => {
const next = matchColumnByWidth()
if (next !== currentColumn.value) {
currentColumn.value = next
}
}, 100)
})
resizeObserver.observe(containerTarget.value)
}列数自适应有三个容易写错的细节,这里全部处理了。其一,监听容器而非 window:ResizeObserver.observe(containerTarget.value) 观察的是瀑布流容器自身的宽度——侧边栏折叠、滚动条出现/消失、父级布局变化都会改变容器的实际可用宽度,而 window resize 只覆盖其一;matchColumnByWidth 里对候选档位先降序再 find(width >= minWidth),保证命中的是"满足条件的最宽档"(如首页 1280px 命中 5 列而非 4 列)。其二,100ms 防抖 + 值变化守卫:ResizeObserver 在布局抖动期会高频回调,防抖合并;next !== currentColumn.value 的守卫挡住"宽度变了但档位没变"的无效重排——1200px 到 1279px 的窗口拖动不触发任何布局。其三,首次回调规避:ResizeObserver observe 后首帧必然回调一次,setupResponsiveColumns 在 observe 之前先同步初始化 currentColumn,否则初始化会经历两次布局。与它配合的还有两处:onMounted 里"初始化完成后才注册列数 watch"(注释:"初始化阶段修改 currentColumn 不会触发额外重排")——时序错的话,初始化期的一次赋值会多触发一次全量重排;窗口 resize 走独立的 200ms 防抖 reset()(重新算列宽 + 清空定位 + 全量重排),与列数档位变化殊途同归。消费端的梯度假定也值得一记:四个页面各自传梯度(首页 [{0,2},{640,3},{1024,4},{1280,5}]),组件对"梯度断档"(如 [{0,2},{1024,3}] 中间 800px 也命中 2 列)不做纠偏——匹配语义是"降序找第一个满足项",梯度由调用方负责。
这段代码教会我们什么:响应式列数 = 观察正确的元素(容器)+ 正确的档位语义(降序首个满足)+ 三层去抖(防抖、值守卫、首帧规避)+ 注册时序(初始化完成后再订阅)。每个细节单独看都是小事,合起来决定"拖动窗口时是顺滑重排还是疯狂抖动"。
onUnmounted(() => {
clearTimeout(resetTimer!)
clearTimeout(resizeTimer!)
clearTimeout(columnDebounceTimer!)
cancelAnimationFrame(scrollRafId)
stopScrollListener?.()
stopColumnWatch?.()
resizeObserver?.disconnect()
resizeObserver = null
})虚拟滚动组件的清理项是普通组件的数倍:三个防抖定时器(reset/resize/columnDebounce)、一个 rAF 句柄、一个滚动监听 stop 函数(useEventListener 的返回值,因为绑定的是滚动祖先元素而非组件内节点,VueUse 的自动清理假定不成立)、一个延迟注册的列数 watch stop 函数、一个 ResizeObserver。为什么必须清?逐项推演:定时器不清,卸载后 reset 仍会执行、触碰已解绑的 DOM;rAF 不清,滚动后卸载的组件还会在下帧跑一次 updateVisible;ResizeObserver 不清,观察器持有容器强引用,内存泄漏且回调继续触发;滚动监听不清,滚动祖先(比组件活得久)上的监听会持续打到已卸载组件。这 7 行是"谁注册、谁注销"原则在复杂组件上的完整形态——对应地,每个句柄在注册时都被存进模块内变量(resetTimer/resizeTimer/columnDebounceTimer/scrollRafId/stopScrollListener/stopColumnWatch/resizeObserver),注册与注销在文件里形成清晰的对偶。09 篇档案的"清理类旧坑"(popup 双钩子解锁、popover 清延时器、message 清关闭定时器)说明这类教训在本项目是系统性存在的。
这段代码教会我们什么:组件越复杂,卸载清单越长——把"注册了什么"写成显式变量而非匿名闭包,卸载才能逐项对偶;虚拟滚动、观察器、跨组件监听三类资源的清理优先级高于一切视觉细节。
src/libs/waterfall/index.vue 的 544 行围绕一条主线组织:让"渲染的 DOM"与"可见的数据"解耦,同时保持"测量结果"可信。模板层(2.1)定下"只渲染 visibleItems"的基调;数据结构(2.3)保证定位结果响应式且不污染输入;裁剪算法(2.4)用相对坐标 + 未测项保留 + 集合比对三招保证既不白屏也不空转;数据变化分流(2.5)让增量路径只付新增成本;列数响应(2.6)与清理(2.7)处理边界与时序。它的七条历史旧坑(09 篇档案⑭~㉑)几乎都能在上述段落里找到对应的防御代码——这正是"注释档案"与"走读"互相印证的价值。核心算法的单测方案见《09-测试与质量保障》第 5 节第一优先级。
/**
* 合集相关的模块级缓存与封面按需加载调度。
*
* Pexels 合集接口不返回封面,列表页需要对每个合集额外发一次 collectionMedia(N+1),
* 而默认配额只有约 200 次/小时。这里集中做四件事:
*
* 1. **封面内存缓存**:同 ID 只请求一次,跨页面、跨滚动重建都直接命中。
* 2. **按需排队**:只加载瀑布流真正渲染出来的卡片(虚拟滚动天然把请求限制在视口附近),
* 不再对整页数据一次性补封面。
* 3. **并发与节流**:单批 3 个并发、批间延迟,避开突发速率限制。
* 4. **429 熔断 + 冷却自动恢复**:命中限流后暂停排队,冷却期过后允许重新尝试,
* 避免一次限流导致整个会话的封面永久丢失。
*
* 全部状态都是模块单例,跨组件实例共享;刷新页面后复位。
*/
import { getCollectionMedia } from '@/api/pexels'
import { message } from '@/libs'
const CONCURRENCY = 3
const BATCH_DELAY_MS = 250
const RATE_LIMIT_COOLDOWN_MS = 5 * 60 * 1000
const coverCache = new Map<string, string>()
const metaCache = new Map<string, unknown>()
const waiters = new Map<string, Set<(cover: string) => void>>()
const queue: string[] = []
const enqueued = new Set<string>()
let rateLimited = false
let rateLimitUntil = 0
let rateLimitNotified = false
let draining = false五个数据结构各司其职,先建立全景再读逻辑。coverCache(id → 封面 URL)是最终成果表,只有成功才写入;waiters(id → 回调集合)解决"同一封面多个订阅者"——合集列表里同一合集可能出现多张卡(分页重叠、DOM 复用),Set 保证一人不落;queue 与 enqueued 是"待拉取 + 已登记"对,Set 防重复入队、数组保序出队;metaCache 是另一项业务(合集元信息)借宿同一文件的缓存。三个布尔/时间戳构成熔断状态机,draining 是队列泵的防重入锁。三个常量是调优面板:3 并发 × 250ms 批间隔 ≈ 理论吞吐 12 次/秒上限,但受单请求延迟约束,实际是"每批 RTT + 250ms"的节奏;5 分钟冷却来自对"小时配额已耗尽时等待多久有意义"的判断——太短会立刻再吃 429,太长让短暂限流后的恢复变迟钝。import 的两个方向值得注意:它 import 门面层(@/api/pexels)而非协议层——遵守"调度在门面之上"的分层(07 篇第 5.3 节);它 import @/libs 的 message——全项目唯一的"业务用 UI"例外,且方向合法。
这段代码教会我们什么:写调度器先把"状态清单"摆齐——每个 Map/Set 都要能回答"谁写、谁读、何时删";文件头的编号宣言让后来者不必反推结构就能对上号。单例模块的注释第一句永远是生命周期("刷新页面后复位"),它决定了能否安全地不做淘汰。
/** 是否处于熔断冷却期;过期则自动复位 */
const isRateLimited = (): boolean => {
if (!rateLimited) return false
if (Date.now() >= rateLimitUntil) {
rateLimited = false
rateLimitNotified = false
return false
}
return true
}五行的函数消灭了一个定时器。朴素的熔断实现会在"进入熔断"时 setTimeout(5min, 复位)——多一个句柄要清理、多一个与页面生命周期纠缠的异步回调;这里换成"惰性判定":状态读取时才检查 Date.now() >= rateLimitUntil,过期就地复位并把 rateLimitNotified 清零(保证下个冷却期的告警能重新弹一次)。这个设计有一个不显眼的正确性收益:复位时机与读取时机对齐——如果用定时器,"定时器已触发但无人读取"期间的状态是模糊的;惰性判定下,任何时刻读取 isRateLimited() 得到的都是此刻的真实答案。三个读取点(fetchOne 捕获 429 后的下一次排队、drain 循环开头、requestCover 入口)全部经由它,熔断语义因此全局一致。代价是读取频率略增(每次调用做一次时间比较),相对收益可以忽略。
这段代码教会我们什么:带过期时间的状态用"读取时惰性校验"实现,比"定时器主动复位"少一个句柄、多一分一致性;"是否过期"永远可以在被问到的那一刻才计算。
const fetchOne = async (id: string) => {
try {
const res = await getCollectionMedia({ id, page: 1, perPage: 1 })
const first = res.items?.[0]
const cover = first ? (first.type === 'video' ? first.image : first.url) : ''
if (cover) coverCache.set(id, cover)
notify(id, cover || '')
} catch (err) {
if ((err as { status?: number })?.status === 429) {
rateLimited = true
rateLimitUntil = Date.now() + RATE_LIMIT_COOLDOWN_MS
if (!rateLimitNotified) {
rateLimitNotified = true
message('warn', 'Pexels API 配额已耗尽,合集封面暂停加载,约 5 分钟后自动重试。')
}
}
// 失败只丢封面,不影响列表本身;不写入缓存,下次仍可重试
notify(id, '')
}
}单次拉取的三条路径都有讲究。成功路径:perPage: 1 把请求压到最小——这是 N+1 治理的最后一环,每次封面请求只取一条媒体;封面提取按混排类型分支(视频用 image 封面帧、照片用 url),res.items?.[0] 的可选链对应 noUncheckedIndexedAccess 下的空集合防御;if (cover) 才写缓存——空封面(合集真的是空的)不写入,意味着空合集每次进视口都会重试一次,这是"失败不缓存"原则的对称延伸(宁可重试也不固化空结果),在合集数以百计、值是短字符串的量级下可接受。429 路径:置 rateLimited 与 rateLimitUntil,告警用 rateLimitNotified 守卫——同一冷却期只弹一次,否则一批 3 个并发全 429 时用户会连收三条相同消息。普通失败路径:notify 空串但不写缓存,注释"失败只丢封面,不影响列表本身"点出了这个模块的容灾边界——封面是装饰性数据,它的任何失败都不允许传导为列表错误。注意 429 与普通失败最终都走 notify(id, ''):订阅方(collection-item.vue)收到空串就维持占位色块,无感知失败。这里还有一个与 1.2 节的呼应:门面层把 429 上抛(getCollectionMedia 无降级),调度器才能在此捕获 (err as {status?:number})?.status === 429——错误分级与熔断是同一条链上的两环。
这段代码教会我们什么:装饰性数据的失败要"降维处理"(丢数据、不抛异常、不弹错),核心数据的失败要"升维处理"(上抛、显式提示);重复告警用一次性标志守卫;"失败不写缓存"是让系统保持自愈能力的关键克制。
const drain = async () => {
if (draining) return
draining = true
try {
while (queue.length) {
if (isRateLimited()) {
// 冷却期内放弃当前排队项:卡片下次进入视口时会重新排队
queue.splice(0).forEach((id) => {
enqueued.delete(id)
notify(id, '')
})
return
}
const batch = queue.splice(0, CONCURRENCY)
await Promise.all(batch.map(fetchOne))
if (queue.length) await sleep(BATCH_DELAY_MS)
}
} finally {
draining = false
}
}泵的四个设计点。单飞:draining 标志保证无论多少次 requestCover 并发触发 drain(),只有一个循环在跑——这个函数没有 async 锁的竞态问题,因为 JS 单线程下 if (draining) return 与 draining = true 之间不会被打断。批处理:queue.splice(0, CONCURRENCY) 每轮取 3 个并发执行,Promise.all 等齐后再睡 250ms 进入下一批——节奏是"批 RTT + 250ms",突发速率被人为摊平。熔断检查在循环开头:每一批开始前重问一次 isRateLimited(),命中则把剩余队列整体清空(splice(0) + 逐个 enqueued.delete + notify 空串)后 return——此处用 return 而非 break 语义上等价(两者都会经过 finally,draining 正确复位),但提前退出的意图更显式;清空时同步解除 enqueued 登记,使"卡片下次进入视口"能重新入队(requestCover 的入队条件是 !enqueued.has(id))。finally 复位:无论正常耗尽、熔断退出还是异常,draining = false 保证泵不会被卡死——漏掉它,一次未捕获异常会让所有后续封面永远排队。整条链与上游的配合也在这段里显形:requestCover 里的 void drain() 是"入队即点火",队列有货时泵自转,无货时立即空转退出。
这段代码教会我们什么:并发泵的三要素——防重入标志、批间节流、finally 复位;熔断的落点放在"每一批之前"而非"每次请求之前",能让熔断生效时的清理动作(清队列、解登记、通知空结果)在一处完成。
export const requestCover = (id: string, cb: (cover: string) => void): (() => void) => {
const cached = coverCache.get(id)
if (cached) {
cb(cached)
return () => {}
}
if (isRateLimited()) {
cb('')
return () => {}
}
let set = waiters.get(id)
if (!set) {
set = new Set()
waiters.set(id, set)
if (!enqueued.has(id)) {
enqueued.add(id)
queue.push(id)
void drain()
}
}
set.add(cb)
return () => {
set?.delete(cb)
}
}出口 API 只有这一个,它必须处理三种到达时序。缓存命中:同步回调——调用方(collection-item.vue 的 loadCover)在事件处理器里直接拿到 URL,无需 await;返回的"空取消函数"保持签名一致,调用方不必判空。熔断中:立即回调空串,卡片显示占位色块且不入队——冷却期内新卡片不会堆积无效订阅。排队订阅:waiters 挂回调、enqueued 防重复登记、queue.push + void drain() 点火。返回的取消函数捕获的是 set 的引用而非重新 waiters.get(id)——这里有一个精细的时序:若回调先被 notify(waiters 里该 id 被 delete),之后取消函数执行 set?.delete(cb) 删除的已经是"已从 Map 摘除的孤立 Set",无副作用;若 notify 尚未发生,则正常摘除,回调不会打到已卸载组件。消费端的用法(collection-item.vue 第 8089、109112 行)是这条契约的另一半:unsubscribe 在每次 loadCover 前先取消旧的(DOM 复用换 id 场景),onUnmounted 再取消一次——订阅方必须管理取消,这是"回调式异步 API"区别于 Promise 的责任划分:Promise 的结果无论是否被接收都会落地,回调则可能打到不存在的组件上,取消函数就是把"我不在了"告诉调度器的唯一通道。
这段代码教会我们什么:订阅式 API 的返回值设计(同步命中也要可调用、取消函数要捕获注册时的集合引用)决定消费端能不能写出无泄漏的代码;"命中即同步回调"让调用方免于 then/catch 的仪式感,接口的使用成本由此减半。
145 行完成了五层防波堤:缓存(同 id 只发一次)→ 按需(进视口才入队)→ 排队(3 并发 250ms)→ 熔断(429 后 5 分钟冷却)→ 取消(DOM 回收即反注册)。整个模块没有一处 class、没有一个定时器、没有一条日志,全部状态是六个 Map/Set 加四个布尔,全部复杂度被"单飞泵 + 惰性熔断 + 订阅取消"三个模式吸收。它是本项目"配额是一等公民"(《00-项目总览》第 3.1 节)最完整的代码注脚,也是 09 篇第三优先补测对象(fake timers 下测批处理节奏、熔断恢复、取消语义三组用例)。读完三个文件,最后回答一个总问题:为什么这三个文件最值得精读?因为它们分别示范了三种最难的代码——决策密集型(每个分支都是一次对业务的判断)、时序密集型(正确性依赖事件顺序)、状态密集型(多个状态变量互相配合)——而本项目用同一种手段应对了三者:把"为什么"写在代码旁边,让每一次维护都从考古开始、以传承结束。