
1. 从一次线上事故说起为什么 FUI 资源管理值得单独聊前阵子我在做一个前端 UI 框架的资源调度模块内部代号 FUIFrontend UI Infrastructure。上线第三天监控面板上出现了一个很诡异的现象同一个数据源在 200ms 内被请求了 7 次缓存命中率显示 92%但用户侧反馈页面偶尔会闪一下旧数据。排查了两个小时最后定位到问题出在资源释放的时机上——组件卸载时没有正确取消正在进行的异步请求导致迟到的响应把新数据覆盖了。这件事让我意识到FUI 层面的资源接入远不是“调个接口拿数据”这么简单。它涉及三个核心问题取消组件销毁或依赖变化时如何终止进行中的任务、缓存相同请求如何复用结果、何时失效、迟到结果请求已经发出但结果回来时上下文已经变了怎么办。而解决这三个问题的关键抽象就是Provider和Lease这两个概念。Provider 负责“提供资源”它知道怎么获取数据、怎么缓存、怎么判断缓存是否有效。Lease 负责“租借资源”它代表一次具体的使用请求持有对某个资源的引用并且可以在不需要时主动释放。两者配合就能把取消、缓存、迟到结果这三件事统一到一个清晰的模型里。这篇文章适合正在做前端架构、数据层封装、或者任何涉及异步资源调度的开发者。不管你用的是 React、Vue 还是自研框架只要你在处理“组件需要数据、数据来自异步源、组件可能随时不需要了”这个场景Provider 和 Lease 的思路都能直接套用。我会从设计思路讲到具体实现再到踩过的坑和排查技巧尽量把每个决策背后的“为什么”说清楚。2. 整体设计思路Provider 和 Lease 到底怎么分工2.1 为什么不用简单的 Promise 缓存 Map最直觉的做法是搞一个全局的Mapstring, Promise请求前先查 Map有就直接返回没有就发请求然后塞进去。这个方案在 demo 阶段能用但一上生产就暴露问题。第一个问题是缓存失效。Map 里的 Promise 一旦 resolve你就没法知道这个数据是什么时候拿的、还能不能用。你可能会说“加个时间戳呗”但时间戳只能做 TTL做不了“依赖变化时精确失效”。比如用户切换了筛选条件旧条件的缓存应该立刻失效而不是等 5 分钟。第二个问题是取消。Promise 本身是不可取消的虽然有些库提供了 abort 能力但那是基于 AbortController 的Promise 对象本身没有 cancel 方法。当组件卸载时你没法告诉那个正在飞的请求“别回来了”。结果就是迟到的响应回来时组件已经没了轻则内存泄漏重则像我最开始遇到的那样覆盖新数据。第三个问题是引用计数。同一个资源可能被多个组件同时使用如果第一个组件卸载时就取消请求第二个组件就遭殃了。你需要知道“还有没有别人在用这个资源”这就是 Lease 要解决的问题。2.2 Provider 的职责边界Provider 是一个资源提供者它的核心接口大概长这样interface ProviderT { // 获取资源返回一个 Lease acquire(key: string, options?: AcquireOptions): LeaseT; // 主动使某个 key 的缓存失效 invalidate(key: string): void; // 清空所有缓存 clear(): void; }注意acquire返回的是 Lease 而不是直接返回数据或 Promise。这个设计很关键它把“获取资源”和“使用资源”解耦了。调用方拿到 Lease 后可以订阅数据变化、可以在不需要时 release、可以随时查看当前状态。Provider 内部维护一个缓存表key 到缓存条目的映射。每个缓存条目包含当前数据、加载状态、错误信息、正在进行的请求如果有、以及一个 Lease 引用计数。2.3 Lease 的职责边界Lease 是一次租借的凭证它代表“我在用这个资源”。接口大概是这样interface LeaseT { // 当前数据可能为 undefined还没加载完 readonly data: T | undefined; // 当前状态idle / loading / success / error readonly status: Status; // 订阅变化 subscribe(listener: () void): () void; // 释放租借 release(): void; }Lease 的release是核心。当引用计数降到 0 时Provider 可以决定是否取消正在进行的请求、是否保留缓存。这里有个设计决策引用计数为 0 时要不要取消请求我的选择是“延迟取消”——给一个短暂的宽限期比如 100ms如果在这期间又有新的 acquire就复用同一个请求否则才真正取消。这个宽限期解决了一个常见场景列表项快速滚动时组件频繁挂载卸载如果不加宽限期请求会被反复取消和重发浪费带宽。2.4 取消、缓存、迟到结果如何统一处理把这三个问题放到 Provider Lease 模型里看就清晰了取消Lease.release 触发引用计数减一计数为 0 且超过宽限期后Provider 调用 AbortController.abort() 取消请求。缓存Provider 内部维护缓存条目acquire 时先查缓存命中且未失效则直接复用未命中则发起新请求。迟到结果每个请求关联一个“代际号”generation当缓存被 invalidate 或依赖变化时代际号递增。请求回来时对比代际号如果不匹配就丢弃结果不写入缓存。这个模型的好处是三个问题用同一套机制解决不需要各自为政。代际号这个设计我是从数据库的 MVCC 里借鉴的思路很像用版本号来判断一个写入是否还有效。3. 核心细节解析缓存策略、取消机制与迟到结果处理3.1 缓存条目的数据结构设计缓存条目是整个 Provider 的核心数据结构我最终定下来的结构是这样的interface CacheEntryT { key: string; data: T | undefined; status: idle | loading | success | error; error: Error | undefined; // 当前正在进行的请求的控制器 abortController: AbortController | undefined; // 代际号每次 invalidate 递增 generation: number; // 引用计数 refCount: number; // 宽限期定时器 releaseTimer: ReturnTypetypeof setTimeout | undefined; // 订阅者集合 listeners: Set() void; // 缓存创建时间用于 TTL createdAt: number; // 最后一次成功获取数据的时间 lastFetchedAt: number | undefined; }这里有几个细节值得展开。generation字段是处理迟到结果的关键。每次调用invalidate(key)时generation 加一。请求发起时记录当前的 generation响应回来时对比如果 generation 变了说明这个请求的结果已经过时直接丢弃。releaseTimer是宽限期的实现。当 refCount 降到 0 时不立即取消请求而是设置一个定时器。定时器触发时再检查 refCount如果还是 0 才真正取消。这个设计我在实际项目里验证过对于快速切换 tab、列表虚拟滚动这类场景能减少 60% 以上的无效请求。listeners用 Set 而不是数组是因为订阅和取消订阅的操作很频繁Set 的删除是 O(1)数组是 O(n)。在订阅者多的场景下比如一个资源被几十个组件同时使用这个差异很明显。3.2 取消机制AbortController 的正确用法取消请求依赖 AbortController但用的时候有几个坑。第一个坑是不是所有请求库都支持 signal。fetch 原生支持axios 从 0.22 版本开始支持但一些老的库或者自研的请求封装可能不支持。如果不支持你需要在请求库层面做适配把 signal 传进去并在 abort 时 reject 一个特定错误。第二个坑是abort 后的错误处理。AbortController.abort() 会让 fetch 抛出一个AbortError这个错误不应该被当作真正的错误展示给用户。我的做法是在 catch 里判断错误类型try { const response await fetch(url, { signal }); // ... } catch (err) { if (err.name AbortError) { // 这是主动取消不更新状态不通知订阅者 return; } // 真正的错误更新 status 为 error entry.status error; entry.error err; notifyListeners(entry); }第三个坑是abort 的时机。如果你在 release 时立即 abort那么当组件因为父组件重渲染而短暂卸载又挂载时请求会被取消然后重发。这就是为什么我加了宽限期。宽限期的时长需要根据场景调整列表滚动场景 100ms 比较合适页面级切换可以到 300ms。注意AbortController 一旦 abort就不能再用于新的请求。每次发起新请求都要创建新的 AbortController 实例。我见过有人复用一个 controller结果第二次请求直接就被 abort 了排查了半天。3.3 缓存失效策略TTL、依赖失效与手动失效缓存失效有三种触发方式我分别说一下实现和适用场景。TTL 失效是最简单的缓存条目创建时记录createdAtacquire 时检查Date.now() - createdAt ttl超过就视为失效。TTL 适合那些变化不频繁、对实时性要求不高的数据比如用户配置、字典表。TTL 的时长设置有个经验值如果数据一天变一次TTL 设 5 分钟到 1 小时都行如果数据几分钟变一次TTL 设 30 秒到 2 分钟。设太短等于没缓存设太长用户看到旧数据。依赖失效是更精确的方式。每个缓存条目可以关联一组“依赖 key”当某个依赖被 invalidate 时所有依赖它的条目也一起失效。比如“用户列表”依赖“当前筛选条件”当筛选条件变化时用户列表缓存自动失效。实现上可以用一个反向索引MapdependencyKey, SetcacheKey。手动失效就是直接调用provider.invalidate(key)适用于用户主动刷新、提交表单后需要刷新列表等场景。三种方式可以组合使用。我的默认策略是TTL 兜底防止缓存永远不失效依赖失效做精确控制手动失效做补充。3.4 迟到结果的代际号方案迟到结果的处理是很多人容易忽略的。考虑这个场景用户快速切换筛选条件从 A 切到 B 再切回 A。如果 A 的请求比较慢B 的请求比较快那么可能出现 B 的结果先回来、A 的结果后回来的情况。如果不做处理最终展示的会是 A 的旧结果但用户当前选的是 B。代际号方案的核心是每次上下文变化时递增代际号请求回来时对比代际号不匹配就丢弃。具体实现async function fetchData(entry: CacheEntry, key: string) { const currentGeneration entry.generation; const controller new AbortController(); entry.abortController controller; entry.status loading; notifyListeners(entry); try { const data await fetchFromSource(key, controller.signal); // 关键检查代际号是否变化 if (entry.generation ! currentGeneration) { // 上下文已变化丢弃这个结果 return; } entry.data data; entry.status success; entry.lastFetchedAt Date.now(); notifyListeners(entry); } catch (err) { if (err.name AbortError) return; if (entry.generation ! currentGeneration) return; entry.status error; entry.error err; notifyListeners(entry); } }这个方案有个变体不用代际号而是对比请求发起时的“上下文快照”。但快照需要深拷贝成本高代际号更轻量。代际号的缺点是它只能判断“有没有变化”不能判断“变化成了什么”但对于丢弃迟到结果这个需求来说足够了。4. 实操过程从零实现一个可用的 Provider4.1 基础骨架搭建先把 Provider 的骨架搭起来。我用 TypeScript 写因为类型系统能帮你在编译期发现很多问题。class ResourceProviderT { private cache new Mapstring, CacheEntryT(); private fetcher: (key: string, signal: AbortSignal) PromiseT; private ttl: number; private gracePeriod: number; constructor(options: { fetcher: (key: string, signal: AbortSignal) PromiseT; ttl?: number; gracePeriod?: number; }) { this.fetcher options.fetcher; this.ttl options.ttl ?? 5 * 60 * 1000; this.gracePeriod options.gracePeriod ?? 100; } acquire(key: string): LeaseT { let entry this.cache.get(key); if (!entry || this.isExpired(entry)) { entry this.createEntry(key); this.cache.set(key, entry); this.fetchData(entry, key); } entry.refCount; if (entry.releaseTimer) { clearTimeout(entry.releaseTimer); entry.releaseTimer undefined; } return new LeaseImpl(entry, this); } private isExpired(entry: CacheEntryT): boolean { if (entry.status ! success) return false; return Date.now() - entry.createdAt this.ttl; } private createEntry(key: string): CacheEntryT { return { key, data: undefined, status: idle, error: undefined, abortController: undefined, generation: 0, refCount: 0, releaseTimer: undefined, listeners: new Set(), createdAt: Date.now(), lastFetchedAt: undefined, }; } // ... fetchData, invalidate, release 等方法 }这里acquire的逻辑是先查缓存没有或过期就创建新条目并发起请求然后引用计数加一如果之前有释放定时器取消它。注意isExpired只在 status 为 success 时才判断因为 loading 和 error 状态的条目不应该被 TTL 判定为过期。4.2 Lease 的实现与引用计数管理Lease 的实现相对简单主要是把 release 委托给 Providerclass LeaseImplT implements LeaseT { private released false; constructor( private entry: CacheEntryT, private provider: ResourceProviderT ) {} get data() { return this.entry.data; } get status() { return this.entry.status; } subscribe(listener: () void): () void { this.entry.listeners.add(listener); return () { this.entry.listeners.delete(listener); }; } release(): void { if (this.released) return; this.released true; this.provider.release(this.entry); } }released标志位是防止重复 release。我见过有人忘记加这个标志结果组件卸载时 release 被调了两次引用计数变成负数缓存条目被提前清理其他还在用的组件就崩了。Provider 的 release 方法release(entry: CacheEntryT): void { entry.refCount--; if (entry.refCount 0) return; // 引用计数为 0启动宽限期 entry.releaseTimer setTimeout(() { if (entry.refCount 0) return; // 宽限期结束真正取消 if (entry.abortController) { entry.abortController.abort(); entry.abortController undefined; } // 根据策略决定是否保留缓存 if (entry.status error) { this.cache.delete(entry.key); } }, this.gracePeriod); }这里有个策略选择引用计数为 0 时要不要删除缓存我的做法是 success 状态保留下次 acquire 可以直接用error 状态删除避免下次 acquire 拿到错误状态。loading 状态在 abort 后变成 idle也保留下次 acquire 会重新发起请求。4.3 缓存失效与代际号递增的联动invalidate 方法需要同时处理缓存清理和代际号递增invalidate(key: string): void { const entry this.cache.get(key); if (!entry) return; entry.generation; entry.status idle; entry.data undefined; entry.error undefined; if (entry.abortController) { entry.abortController.abort(); entry.abortController undefined; } notifyListeners(entry); // 如果还有引用立即重新获取 if (entry.refCount 0) { this.fetchData(entry, key); } }注意这里先 abort 旧请求再发起新请求。如果不 abort旧请求的结果回来时虽然会被代际号拦截但浪费了带宽。abort 之后旧请求的 catch 里会收到 AbortError直接 return不会影响新请求。代际号递增的时机很关键。必须在 abort 之前递增否则如果 abort 触发的 catch 先执行代际号还没变旧结果可能被写入。虽然 JavaScript 是单线程的abort 触发的 reject 是异步的但为了逻辑清晰还是先递增再 abort。4.4 订阅通知与批量更新notifyListeners 的实现要考虑性能。如果一次操作触发了多个条目的变化逐个通知会导致多次重渲染。我的做法是用微任务做批量通知let pendingNotifications new SetCacheEntryany(); let notificationScheduled false; function notifyListeners(entry: CacheEntryany) { pendingNotifications.add(entry); if (!notificationScheduled) { notificationScheduled true; Promise.resolve().then(() { const entries Array.from(pendingNotifications); pendingNotifications.clear(); notificationScheduled false; for (const e of entries) { for (const listener of e.listeners) { listener(); } } }); } }这个批量通知机制在 React 的 useSyncExternalStore 场景下特别有用。如果不做批量一次 invalidate 可能触发多个组件的多次重渲染React 会警告“Too many re-renders”。用微任务合并后同一轮事件循环内的多次通知只触发一次渲染。提示微任务批量通知有个边界情况——如果 listener 里又触发了新的通知会在下一轮微任务处理。这通常是期望的行为但要注意不要形成无限循环。我一般会在 listener 里加个保护如果连续触发超过 10 次就打印警告。5. 常见问题与排查技巧实录5.1 请求被意外取消的排查思路请求被意外取消是最常见的问题表现是数据一直加载不出来或者偶尔闪一下又没了。排查思路按这个顺序来第一检查 release 的调用时机。在 React 里useEffect 的清理函数会在依赖变化时执行如果你在清理函数里调了 release那么依赖变化时就会取消请求。这是期望的行为但如果你发现请求被取消了但组件还在可能是依赖数组写错了导致 effect 频繁重建。第二检查宽限期是否太短。如果宽限期是 0那么组件卸载后立即取消。在快速切换场景下这会导致请求反复取消重发。把宽限期调到 100-300ms 试试。第三检查是否有多个 Provider 实例。如果你在组件内部 new 了一个 Provider那么每个组件实例都有自己的缓存和引用计数A 组件 release 不会影响 B 组件但也不会共享缓存。Provider 应该是单例或者通过 Context 共享的。第四检查 AbortController 是否被复用。前面说过abort 过的 controller 不能再用。如果你在 fetchData 里复用了 entry.abortController第二次请求会立即被 abort。5.2 缓存不生效的几种典型原因缓存不生效的表现是每次 acquire 都发新请求监控上看到请求量居高不下。原因通常有这几个key 不稳定。如果 key 里包含了随机数、时间戳、或者每次渲染都新建的对象那么每次 acquire 的 key 都不一样自然命中不了缓存。key 应该是稳定的字符串由业务参数拼接而成。TTL 设得太短。如果 TTL 是 0 或者几毫秒缓存刚创建就过期了。检查 TTL 的单位我见过有人把秒当成毫秒传进去结果 TTL 是 5000 秒约 1.4 小时也有人把毫秒当成秒TTL 是 5 毫秒。status 判断有误。isExpired 只在 success 时判断如果 status 一直是 loading缓存永远不会被判定为过期但也不会被复用因为 acquire 时如果 status 是 loading会直接返回现有 entry 而不发新请求这其实是期望的行为。如果你发现 loading 状态的 entry 被复用了但数据一直没回来检查 fetchData 是否正常执行。缓存被提前清理。release 时如果 status 是 error我会删除缓存。如果你发现缓存频繁被删检查请求是否频繁失败。5.3 迟到结果覆盖新数据的修复方案迟到结果覆盖新数据是最隐蔽的问题因为它在测试环境很难复现往往在线上高并发时才出现。表现是用户看到的数据和当前筛选条件不匹配刷新后正常。修复方案就是前面说的代际号。但要注意几个细节第一代际号必须在所有可能改变上下文的操作中递增包括 invalidate、依赖变化、手动刷新。漏掉任何一个都会导致迟到结果漏网。第二代际号对比必须在写入缓存之前。我见过有人在写入之后才对比那就晚了旧数据已经写进去了。第三代际号对比要同时覆盖 success 和 error 分支。如果旧请求失败了但代际号已经变了这个错误也不应该展示给用户。第四如果用了批量通知代际号对比要在通知之前。否则订阅者会先收到旧数据的通知再收到新数据的通知中间闪一下。5.4 常见问题速查表问题现象可能原因排查方法解决方案请求被意外取消宽限期太短打印 release 和 abort 的时间戳增大 gracePeriod 到 100-300ms缓存不生效key 不稳定打印每次 acquire 的 key用稳定字符串拼接 key缓存不生效TTL 单位错误检查 TTL 数值和单位统一用毫秒迟到结果覆盖代际号未递增在 invalidate 里打印 generation所有上下文变化都递增代际号内存泄漏Lease 未 release用 WeakRef 或 FinalizationRegistry 监控确保组件卸载时 release重复请求多个 Provider 实例打印 Provider 的实例 id用单例或 Context 共享通知过多未批量通知统计 listener 调用次数用微任务批量通知abort 后仍更新未判断 AbortError在 catch 里打印错误类型判断 err.name AbortError 后 return5.5 几个我踩过的坑和独家技巧坑一在 render 期间 acquire。React 的 render 应该是纯函数如果在 render 里调 acquire每次渲染都会增加引用计数但 release 只在卸载时调一次导致引用计数永远不归零缓存永远不释放。正确做法是在 useEffect 或 useLayoutEffect 里 acquire。坑二release 后继续使用 Lease。Lease release 后entry 可能被清理此时再访问 lease.data 会拿到 undefined。我一般会在 Lease 里加一个 released 检查release 后访问 data 抛出一个明确的错误而不是静默返回 undefined。坑三缓存穿透。如果大量请求同一个不存在的 key每次都会发请求因为 error 状态不缓存导致请求量暴增。解决方案是对 error 状态也做短期缓存比如 5 秒内不重复请求同一个 key。技巧一用 WeakMap 存 Lease。如果 Lease 和组件实例一一对应可以用 WeakMap 存组件被 GC 时 Lease 自动释放。但要注意 WeakMap 的 key 必须是对象且不能是原始值。技巧二开发环境加日志。在 acquire、release、invalidate、fetchData 的关键路径上加 console.log打印 key、refCount、generation、status。线上出问题时这些日志能帮你快速定位。我一般用环境变量控制开发环境全开生产环境只打 error。技巧三用 performance.mark 做性能分析。在 fetchData 开始和结束时打 mark然后用 performance.measure 测量耗时。如果发现某个 key 的请求耗时异常可以针对性优化。技巧四缓存预热。对于首屏关键数据可以在应用启动时主动 acquire 一次然后立即 release这样数据会提前加载到缓存里首屏渲染时直接命中。注意 release 后宽限期结束会 abort所以预热时要确保请求已经完成或者把宽限期设长一点。6. 从 FUI 到通用资源调度这个模型的扩展性Provider Lease 这个模型不只适用于前端 UI 框架。我在做后端服务的时候发现它同样适用于数据库连接池、RPC 客户端、文件句柄管理这些场景。核心思想是一样的资源是有限的、需要被复用、需要被正确释放、需要处理并发访问。扩展的方向有几个。一是多级缓存Provider 内部可以再套一层内存缓存和一层持久化缓存acquire 时先查内存再查持久化。二是优先级调度不同 key 的请求可以有不同的优先级高优先级的请求先发。三是熔断降级当某个 key 的请求连续失败时Provider 可以暂时拒绝新的 acquire直接返回降级数据。但扩展的时候要注意不要过度设计。我见过有人把 Provider 做成了一个完整的响应式框架结果代码复杂度爆炸维护成本极高。Provider 的核心职责就是“提供资源”其他的都是锦上添花。先把取消、缓存、迟到结果这三件事做扎实再考虑扩展。最后分享一个我在实际项目中的体会缓存策略没有银弹必须根据业务场景调。同一个 Provider在列表页可能 TTL 设 30 秒在详情页可能设 5 分钟在配置页可能设 1 小时。我的做法是把 TTL 做成 acquire 的参数调用方根据场景传不同的值。这样 Provider 保持通用业务方自己控制缓存时长。这个设计在多个项目里验证下来灵活性最好也最容易排查问题——出问题时一看 acquire 的参数就知道用的什么策略。