如何手写 memoize 函数?缓存 key 应该怎么设计?
简化版
memoize 用来缓存纯函数的计算结果。相同入参再次调用时直接返回缓存,避免重复计算。基础实现可以用 Map 存 key 和结果,key 可以由第一个参数、JSON 序列化或自定义 resolver 生成。边界包括:函数必须尽量纯、对象参数 key 不稳定、缓存可能无限增长、this 要保留、异步结果要考虑失败缓存。
详细版
memoize 的核心是“参数 → key → 结果”。
| key 方案 | 优点 | 缺点 |
|---|---|---|
| 第一个参数 | 简单 | 只适合单参数 |
JSON.stringify(args) | 通用一点 | 对函数、循环引用、属性顺序有坑 |
| 自定义 resolver | 最灵活 | 调用者要设计 |
| WeakMap 嵌套 | 对对象友好 | 实现复杂 |
function memoize<T extends (...args: any[]) => any>(fn: T, resolver?: (...args: Parameters<T>) => unknown): T {
const cache = new Map<unknown, ReturnType<T>>()
return function (this: unknown, ...args: Parameters<T>) {
const key = resolver ? resolver(...args) : JSON.stringify(args)
if (cache.has(key)) return cache.get(key)!
const result = fn.apply(this, args)
cache.set(key, result)
return result
} as T
}
memoize 只适合缓存“同样输入得到同样输出”的函数,不能随便包有副作用的函数。
完整版教学
一、memoize 解决什么问题
某些函数计算昂贵,例如递归、复杂格式化、规则匹配和数据转换。如果相同输入会反复出现,就可以缓存结果。
典型例子是斐波那契递归:
const fib = memoize((n: number): number => {
if (n <= 1) return n
return fib(n - 1) + fib(n - 2)
})
二、为什么强调纯函数
纯函数意味着相同输入总是得到相同输出,而且没有外部副作用。memoize 默认依赖这个前提。
如果函数读取当前时间、随机数、全局变量或接口状态,缓存结果可能错误。
三、基础实现
基础实现只需要 Map。
function simpleMemoize(fn: Function) {
const cache = new Map()
return (...args: unknown[]) => {
const key = JSON.stringify(args)
if (cache.has(key)) return cache.get(key)
const value = fn(...args)
cache.set(key, value)
return value
}
}
这足够展示思路,但不是工程最稳写法。
四、key 设计是核心
JSON.stringify 有很多坑:对象属性顺序可能影响字符串,函数和 undefined 会被特殊处理,循环引用会报错,大对象序列化本身也有成本。
因此成熟 memoize 通常允许传 resolver。
const getUser = memoize(fetchUser, userId => userId)
调用者最了解业务 key,强行通用反而容易错。
五、对象参数怎么办
对象参数如果按引用区分,可以使用 WeakMap。这样对象被释放时,缓存也不会强行阻止 GC。
| 参数类型 | 推荐 key |
|---|---|
| 基础类型 | 直接作为 Map key |
| 对象按引用 | WeakMap |
| 对象按内容 | 稳定序列化或业务 id |
六、缓存淘汰问题
memoize 缓存如果无限增长,会变成内存泄漏。工程上可加:
- 最大缓存数量。
- TTL 过期。
- LRU 淘汰。
- 手动 clear。
手写题如果时间充足,可以主动提这点。
七、常见误区与追问
- 误区:memoize 可以优化所有函数。 只有重复输入多、计算成本高、结果稳定时才有价值。
- 误区:JSON.stringify 永远能当 key。 循环引用、函数、属性顺序和序列化成本都是坑。
- 误区:缓存越多越好。 无限缓存可能造成内存增长,应该有淘汰策略。
- 追问:异步函数能 memoize 吗? 可以缓存 Promise,但 reject 后是否删除缓存要按业务决定。
- 追问:如何保留 this? 用
fn.apply(this, args)调用原函数。 - 追问:对象参数用 Map 有什么问题? Map 会强引用对象,长期缓存可能阻止对象释放。
八、加强记忆
memoize 记成“纯函数结果缓存”。重点不是 Map,而是 key 设计、缓存边界和适用条件。面试回答只要把 resolver、this、淘汰和异步失败说清楚,就很完整。