实现函数缓存
基础知识
什么是函数缓存 (Memoization)? 函数缓存(也称“记忆化”)是一种编程技巧和优化策略。它通过存储昂贵函数调用的结果,并在后续使用相同输入再次调用该函数时,直接返回缓存的结果,而无需重新执行函数的计算过程。
核心思想:空间换时间 函数缓存的本质是一种“空间换时间”的权衡。它使用额外的内存空间(缓存存储)来避免重复的、耗时的计算,从而显著提高程序的执行速度。
适用场景 函数缓存最适用于纯函数 (Pure Functions)。纯函数是指满足以下两个条件的函数:
- 相同的输入,永远产生相同的输出。
- 没有副作用(不会修改函数外部的任何状态,如全局变量、DOM 等)。
常见的应用场景包括:
- 复杂的数学计算: 如斐波那契数列、阶乘等,这些计算中包含了大量的重复子问题。
- 递归操作: 许多递归算法在递归过程中会反复计算相同子节点的结果。
- 数据转换: 对一个大型数据结构进行昂贵的格式化或计算,如果源数据不变,结果也不应改变。
- 需要稳定结果的 API 请求: 对于返回静态数据的 API,可以在前端缓存其结果,避免不必要的网络请求。
核心思路
我们的目标是创建一个高阶函数 memoize(func)
,它接收一个需要被缓存的函数 func
,并返回一个经过缓存优化的新函数。
高阶函数与闭包:
memoize
函数是高阶函数,它返回的新函数将通过闭包“记住”一个用于存储结果的缓存对象cache
。这个cache
对象在多次调用之间是持久存在的。缓存存储: 我们可以使用一个普通对象
{}
或 ES6 的Map
来作为缓存。Map
在处理非字符串键时更具优势,但对于通用场景,使用对象并通过特定规则生成字符串键是一种常见且有效的方法。缓存键 (Cache Key) 的生成: 这是实现中最关键的一步。如何根据函数的输入参数,生成一个唯一的、可用于在缓存对象中查找的键?
- 如果函数只有一个简单的原始类型参数(如数字或字符串),可以直接使用该参数作为键。
- 如果函数有多个参数或参数是对象等复杂类型,我们需要一种可靠的方式将它们“序列化”成一个唯一的字符串。
JSON.stringify()
是一个通用且方便的选择。
缓存逻辑: 返回的新函数在被调用时,会执行以下逻辑:
- 第一步: 根据传入的参数生成一个缓存键
key
。 - 第二步: 检查
cache
对象中是否存在这个key
。 - 第三步 (缓存命中): 如果
key
存在,说明这个计算之前已经执行过。直接返回cache[key]
中存储的结果,函数结束。 - 第四步 (缓存未命中): 如果
key
不存在,说明这是一个新的计算。正常执行原始函数func
,将得到的result
存储到缓存中cache[key] = result
,然后再返回result
。
- 第一步: 根据传入的参数生成一个缓存键
代码实现
下面是一个通用的 memoize
函数实现。
/**
* 创建一个函数的记忆化(缓存)版本
* @param {Function} func - 需要被缓存的函数
* @returns {Function} - 返回一个新的、带缓存功能的函数
*/
function memoize(func) {
// 1. 创建一个闭包变量 cache,用于存储计算结果
const cache = {};
// 2. 返回一个新的函数
return function (...args) {
const context = this; // 保持 this 指向
// 3. 根据参数生成一个唯一的缓存键
// JSON.stringify 是一个通用的、能处理大多数情况的序列化方法
const key = JSON.stringify(args);
// 4. 检查缓存中是否存在该键
if (cache[key]) {
console.log(`从缓存中读取: key=${key}`);
return cache[key];
} else {
console.log(`首次计算: key=${key}`);
// 5. 如果缓存中没有,则执行原始函数
const result = func.apply(context, args);
// 6. 将计算结果存入缓存
cache[key] = result;
// 7. 返回结果
return result;
}
};
}
代码解析
缓存对象初始化:
const cache = {}
在memoize
函数作用域内创建缓存对象,利用闭包特性使所有返回的缓存函数共享该对象,实现结果持久化存储。
返回缓存函数结构:
memoize
返回新函数,通过rest
参数...args
收集所有传入参数,形成参数数组args
,支持任意参数组合的缓存。
缓存键生成(核心步骤):
const key = JSON.stringify(args)
使用JSON.stringify
将参数数组转换为唯一字符串键。- 示例:参数
[20, 30]
转换为"[20,30]"
,作为cache
对象的键。 - 局限性:无法处理函数、
Symbol
、循环引用等非序列化类型,但适用于大多数基础数据类型。
缓存命中判断:
if (cache[key])
检查缓存中是否存在对应键,若存在则直接返回缓存值,避免重复执行函数,提升性能(尤其对高计算成本函数)。
函数执行与上下文绑定:
- 缓存未命中时,通过
func.apply(context, args)
执行原始函数。 apply
确保:- 函数内部
this
指向调用时的context
。 - 参数数组
args
正确传递给原始函数。
- 函数内部
- 缓存未命中时,通过
结果写入缓存:
cache[key] = result
将计算结果存入缓存,键为参数序列化后的字符串,便于后续相同参数调用时直接获取。
结果返回机制:
- 缓存命中时返回缓存值,未命中时返回新计算结果,保证函数返回值的一致性和缓存的有效性。
使用示例:优化斐波那契数列
斐波那契数列的递归实现是测试函数缓存效果的绝佳例子。
// 一个效率极低的递归斐波那契函数
function fibonacci(n) {
if (n <= 1) {
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
// 使用我们的 memoize 函数进行包装
const memoizedFib = memoize(fibonacci);
// --- 性能对比 ---
console.time('未缓存的斐波那契');
console.log('结果:', fibonacci(40));
console.timeEnd('未缓存的斐波那契');
// 在大多数机器上,这需要几秒钟时间
console.log('\n--- 分割线 ---\n');
console.time('缓存后的斐波那契');
console.log('结果:', memoizedFib(40));
console.timeEnd('缓存后的斐波那契');
// 第一次调用,会逐级计算并缓存,速度可能与未缓存版本相似或稍慢
console.log('\n--- 分割线 ---\n');
console.time('第二次调用缓存后的斐波那契');
console.log('结果:', memoizedFib(40));
console.timeEnd('第二次调用缓存后的斐波那契');
// 第二次调用,直接从缓存中读取,几乎是瞬时完成!