最近刷到一道题,业务场景很真实,写完之后发现自己走了不少弯路。
复盘下来有个感受:不是知识不够,是知识的"激活条件"没建立起来。 每一步用到的概念我都学过,但在具体场景里,没能在正确的位置正确地使用。
这篇文章把这个过程完整记录下来,包括弯路。
题目
业务场景:差旅系统需要实现一个权限检查函数,判断用户是否有权限执行某个操作。权限规则从服务端获取,需要缓存结果避免重复请求。
type Permission = 'view' | 'edit' | 'approve' | 'cancel'
// 已提供——每次调用都会发网络请求
declare function fetchUserPermissions(userId: string): Promise<Permission[]>
// 要求实现
function createPermissionChecker(userId: string) {
// 返回一个函数,调用时检查用户是否有某个权限
// 权限列表只请求一次,后续复用缓存
}
// 使用方式
const checker = createPermissionChecker('U001')
await checker('view') // true or false
await checker('edit') // true or false(不再发请求,用缓存)
第一折:闭包存缓存——我以为写对了
看到"缓存结果、复用"这几个字,我立刻想到了闭包。
之前练过闭包的题:闭包变量能跨越多次函数调用保持状态,用来存缓存再合适不过。
function createPermissionChecker(userId: string) {
let cachedPermissions: Permission[] | null = null // 闭包变量
return async function(permission: Permission): Promise<boolean> {
if (!cachedPermissions) {
cachedPermissions = await fetchUserPermissions(userId) // 第一次发请求
}
return cachedPermissions.includes(permission) // 后续用缓存
}
}
逻辑清晰:第一次调用发请求存结果,后续直接读缓存。
我觉得写完了。
然后被追问:如果两个请求同时发出呢?
await Promise.all([checker('view'), checker('edit')])
盯着代码看了几秒,发现问题——
两个调用同时执行,同时检查 cachedPermissions === null,同时判断为 true,然后各自发了一次请求:
checker('view'):cachedPermissions = null → 发请求 ✗
checker('edit'):cachedPermissions = null → 又发请求 ✗
这就是竞态条件(Race Condition) 。
缓存的逻辑本身没错,但它保护不了异步并发的时间窗口——两个调用都在请求还没回来之前就完成了"检查",各自都以为自己是第一个。
这一折的收获:闭包能存缓存,但存什么决定了能不能防住竞态。
第二折:withLimit(1) 控制并发——方向对,但太重
我想到了并发控制。
思路是:用 withLimit 限制同时只有一个请求在飞,第二个调用等第一个完成再执行,自然就能读到缓存了。
// 伪代码思路
const limitedFetch = withLimit(1, fetchUserPermissions)
function createPermissionChecker(userId: string) {
let cachedPermissions: Permission[] | null = null
return async function(permission: Permission) {
if (!cachedPermissions) {
cachedPermissions = await limitedFetch(userId)
}
return cachedPermissions.includes(permission)
}
}
方向对——让第二个调用"等"第一个。
但这个解法太重了。
withLimit 是为了管理多个不同请求的并发数量设计的,背后维护着一个任务队列。而这里的场景极其简单:只有一个请求,只需要防止它被重复发出。
用并发控制队列来解决这个问题,像是为了锁一扇门造了整套门禁系统。
而且仔细想,即使加了 withLimit(1),竞态问题依然没有被根本解决——两次调用还是可能在"检查缓存"和"写入缓存"之间发生时序交叉。
这一折的收获:识别"解法的重量"。能用更轻的方式解决,就不需要引入复杂机制。更重要的是要找到问题的本质:这里的根本问题不是"并发数量",而是"同一个请求被重复创建"。
第三折:缓存 Promise 本身——顿悟
提示来了:缓存的不是结果,而是 Promise 本身。
当时愣了一下。
Promise 也可以被存起来?——当然可以,Promise 就是一个普通的 JavaScript 对象,是一个值。
// 缓存结果——必须等 await 完成才有值可存
let result: Permission[] | null = null
result = await fetchUserPermissions(userId) // 加了 await,等完成才赋值
// 缓存 Promise——请求发出的瞬间就有值了
let promise: Promise<Permission[]> | null = null
promise = fetchUserPermissions(userId) // 不加 await,存 Promise 本身
这两行的区别,就是整道题的核心:
- 缓存结果:必须等
await完成才有值可存,在这之前的并发调用都看到null - 缓存 Promise:请求发出的瞬间就有值了——一个
pending状态的 Promise
pending 状态的 Promise 也是可以被 await 的。它会在请求完成后返回结果。
两个调用同时 await 同一个 Promise,最终都拿到同一份数据,网络请求只发了一次。
完整实现
function createPermissionChecker(userId: string) {
let permissionsPromise: Promise<Permission[]> | null = null
return async function(permission: Permission): Promise<boolean> {
if (!permissionsPromise) {
// 第一次:创建 Promise 并存起来(不等结果,直接存)
permissionsPromise = fetchUserPermissions(userId)
}
// 每次都 await 同一个 Promise
const permissions = await permissionsPromise
return permissions.includes(permission)
}
}
这是 Promise 缓存模式的核心。
关键:Promise 是"可共享的等待凭证"
// 假设 fetchUserPermissions 需要 1 秒完成
let permissionsPromise: Promise<Permission[]> | null = null
// 第一次调用
checker('view') // 创建 Promise,开始网络请求,进入 pending
// 50ms 后,第二次调用(请求还没完成)
checker('edit') // permissionsPromise 已存在,直接 await 同一个
两个调用如何"汇合"到同一个 await
时间线:
─────────────────────────────────────────►
checker('view'):
发现 null → 创建 Promise → 发请求 ────────────────► 完成
↓
await 这个 Promise ─────────► 拿到结果
checker('edit'):
发现已有 Promise ───────► await 同一个 Promise ─────► 拿到相同结果
(不需要再发请求)
核心机制:await 不是"重新执行请求",而是"订阅这个 Promise 的完成事件"。
为什么多个 await 不会导致多次请求
const p = fetchUserPermissions(userId) // 只发一次请求
// 下面两个都 await 同一个 p,不会触发新请求
const r1 = await p
const r2 = await p // 复用结果,不重新执行
fetchUserPermissions 只被调用了一次。后续的 await 只是等待那个已经飞出去的请求回来,而不是再发一个新请求。
一句话总结
缓存结果 = 请求完成后才有东西可缓存,并发调用在"空窗期"各自发请求
缓存 Promise = 请求发出的瞬间就有东西可缓存,后续调用都"挂号"等同一个结果
Promise 在这里充当了一个共享的占位符:谁先创建谁发请求,后来的都排队等这一个结果。
执行流程对比:
❌ 缓存结果的问题(竞态):
checker('view'):null → 发请求,await 中...
checker('edit'):null → 又发请求,await 中... ← 竞态!
请求发了两次
✅ 缓存 Promise:
checker('view'):null → 创建 Promise 存起来(pending)→ await 等结果
checker('edit'):Promise 已存在 → 直接 await 同一个 Promise
请求只发了一次 ✅
延伸:请求失败怎么办?
上面的实现有一个隐患:如果请求失败,permissionsPromise 里存的是一个 rejected Promise,后续调用会一直拿到错误,没有机会重试。
处理方式:失败时把缓存清掉。
function createPermissionChecker(userId: string) {
let permissionsPromise: Promise<Permission[]> | null = null
return async function(permission: Permission): Promise<boolean> {
if (!permissionsPromise) {
permissionsPromise = fetchUserPermissions(userId).catch(err => {
permissionsPromise = null // 失败时清除缓存,下次调用重新发请求
return Promise.reject(err)
})
}
const permissions = await permissionsPromise
return permissions.includes(permission)
}
}
这个模式叫什么
Promise 缓存(Promise Memoization) 。
在实际项目里很常见,往往藏在不起眼的地方:
- 应用初始化时加载配置,只请求一次,全局复用
- 获取当前用户信息,首次加载后缓存
- 权限系统、Feature Flag 的初始化
核心不变:把 Promise 存在闭包里,而不是把结果存在闭包里。
面试快速参考
Q:如何实现一个只发一次网络请求的权限检查器?
核心:缓存 Promise 而非缓存结果,防止并发竞态。
function createPermissionChecker(userId: string) {
let permissionsPromise: Promise<Permission[]> | null = null
return async (permission: Permission): Promise<boolean> => {
if (!permissionsPromise) {
permissionsPromise = fetchUserPermissions(userId)
}
return (await permissionsPromise).includes(permission)
}
}
Q:追问——请求失败时如何处理?
在 .catch 里重置 permissionsPromise = null,允许下次调用重新发请求。
Q:这道题考察了哪些知识点?
- 闭包:跨调用保存状态
- Promise 是值:可以被赋值、传递、在 pending 状态被 await
- 竞态条件:异步并发的时间窗口问题
- 解法选型:识别"解法的重量",用最轻的方式解决问题