使用memoizee缓存函数提升性能,竟引发了indexOf的性能问题

壹 ❀ 引
公司前端组基本每个月会举行一次前端月会,用于做前端组基础设施以及其它重要信息的同步,会议最后一个环节就会分享本月前端同学在开发中所遇到的奇怪bug
,或者一些有趣的问题。在分享的问题中,我发现一个关于缓存库memoizee
引发的性能问题还挺有意思,毕竟一个提升性能问题的库居然还能引发其它性能缺陷,经典矛盾文学了,废话不多说,本文开始。
贰 ❀ 使用memoizee提升性能
我们抛开react 17
中常用的useMemo
类似的缓存函数hook
,在react 16
版本中,对于一个计算量较大的函数,可能很多同学都会想到借用缓存函数来做结果缓存处理,以达到性能提升的目的,而不同于自己造的轮子,现有的缓存函数库memoizee就是不错的推荐。
说在前面,所有的性能提升无非围绕空间换时间或者时间换空间来展开,而函数缓存就是典型的空间换时间,且思路都是将接受的参数作为key
,计算的结果作为value
,并形成key-value
键值对存入一个对象。当下次再调用这个函数,且参数相同时,我们就能从对象中直接取回结果返回,从而避免重复的复杂的逻辑计算。
一个最简单的函数缓存例子:
// 用于缓存每个key的计算结果
const res = {};
const memoize = (num)=>{
// 假设之前已经计算过了,直接返回
if(res[num]!==undefined){
return res[num];
};
// 新参数?重新计算,并做缓存
const square = num*num;
res[num] = square;
return square;
};
console.log(memoize(2));// 4
console.log(memoize(3));// 9
// 这一次就走了缓存
console.log(memoize(2));// 4
上面这个例子虽然有缓存作用,但本质上是对于参数的缓存,它的功能非常单一,只能用于求数字的平方。那假设我现在要求数字的加法,或者数字的除法,我们岂不是得自己定义很多个这样的缓存函数?所以本质上,我们其实希望有一个函数,能起到一个包装器的作用,我们传入的任意函数都能被这个包装器转成缓存函数,同时在执行时也能对于相同参数做到缓存效果。那么三方库memoizee
的作用就是如此。
简单科普下用法,毕竟本文的核心是分享使用memoizee
所带来的性能问题,基本用法如下,更多用法请参照文档:
import memoize from 'memoizee'
const o1 = {a:1,b:2};
const o2 = o1;
const o3 = {a:1,b:2};
const fn = function (obj) {
console.log(1);
return obj.a + obj.b;
};
// fn作为参数,得到了一个有缓存效果的fn
const memoizeFn = memoize(fn);
memoizeFn(o1);
// o2与o1是同一个对象,走缓存
memoizeFn(o2);
// o3是一个新对象,不走缓存
memoizeFn(o3);
使用memoizee
还有一个好处就是,我们函数的参数不一定都是数字字符串这类的基本类型,有时候还可能是一个对象。比如上述例子我们借用了memoizee
生成了一个带有缓存效果函数memoizeFn
,它所接收的参数就是对象,我们通过fn
内部的console
用于检验到底有没有走缓存,效果很明显,console
一共执行两次,分别由参数o1
与o3
触发。所以借用三方库的好处就是,很多的边界场景它都有帮你考虑。
叁 ❀ memoizee引发的性能问题
前面说了,无论是memoizee
还是我们自定义的缓存函数,本质上性能的提升都离不开空间换时间,缓存后直接拿结果虽然快,但随着缓存的结果越来越多,一千,一万到数十万,memoizee
是否真的能符合我的高性能的预期呢?一个简单的例子来颠覆你的认知:
const fn = function (a) {
return a * a;
};
// 使用缓存
console.time('使用缓存');
const memoizeFn = memoize(fn);
for (let i = 0; i < 100000; i++) {
memoizeFn(i);
}
memoizeFn(90000);
console.timeEnd('使用缓存');
// 不使用缓存
console.time('不使用缓存');
for (let i = 0; i < 100000; i++) {
// 单纯执行,啥也不缓存
fn(i);
}
fn(90000);
console.timeEnd('不使用缓存');

上述代码分为两部分,使用了memoize
做缓存,我们模拟了10W次执行,然后再次执行memoizeFn(90000)
以达到取缓存的效果。而不使用缓存的部分则是现执行现使用,不走任何缓存。而让人惊讶的时,使用缓存的代码耗时6.5S,而没缓存的部分仅需2.74ms,后者比前者快2442倍!
我知道你现在心里已经产生了疑惑,我们再来做个更有趣的对比,在文章开头我们写了一个劣质的缓存函数,没关系,我们稍加改造,如下:
console.time('使用自定义的缓存');
const res = {};
const memoizeFn_ = (num)=>{
// 假设之前已经计算过了,直接返回
if(res[num]!==undefined){
return res[num];
};
// 新参数?重新计算,并做缓存
const square = num*num;
res[num] = square;
return square;
};
for (let i = 0; i < 100000; i++) {
// 单纯执行,啥也不缓存
memoizeFn_(i);
}
memoizeFn_(90000);
console.timeEnd('使用自定义的缓存');

使用自定义缓存毕竟有存储和查询的操作,所以耗时上肯定比不使用缓存要稍微慢一点,但整体耗时只差2ms,到这里我们可以断定memoizee
在实现上一定有猫腻,遇事不决读源码,于是我们发现了memoizee
中的如下代码:
module.exports = function () {
var lastId = 0, argsMap = [], cache = [];
return {
get: function (args) {
// 注意这一句代码,这里使用了indexOf用来查询之前这个参数有没有执行过
var index = indexOf.call(argsMap, args[0]);
return index === -1 ? null : cache[index];
}
// 删除了部分不相关的代码
};
};
不卖关子,当使用memoizee
做缓存,且函数参数只有一个时,memoizee
的get
查询实现其实借用了indexOf
。站在时间复杂度层面,从数组中遍历查询一个元素,最快是O(1)
,最坏是O(N)
,这种情况一般以最坏的情况来作为时间复杂度,因此时间复杂度是O(N)
。
那为什么上面那个例子耗时这么逆天?那是因为缓存函数本身就是边执行边查询边缓存的操作,打个最简单的比方,假设执行到1000,那么它就要查之前缓存的999有没有缓存过,以我们提供的10W为标准,其实在memoizee
中真就执行了10W次indexOf
,且越往后面执行查询的代价就越大,耗时这么久自然就好理解了。

上图就是当参数执行到58152
时,需要查之前存的5W多个缓存,你要之后后面还有把这种操作执行4W多次,且缓存还是递增的。
另外,memoizee
在一个参数或者多个参数时,get
的实现逻辑其实不同,但尴尬的是,不管几个参数,其实都是借用indexOf
,下图就是我改为多个参数的代码断点:
get: function (args) {
var index = 0, set = map, i;
while (index < length - 1) {
i = indexOf.call(set[0], args[index]);
if (i === -1) return null;
set = set[1][i];
++index;
}
i = indexOf.call(set[0], args[index]);
if (i === -1) return null;
return set[1][i] || null;
},

叁 ❀ 解决
说到这里,有些同学可能都疑惑了,我使用memoizee
本身就是为了提升性能,结果你memoizee
自己就有性能问题,那到底用不用?或者说怎么用?
其实我们使用缓存函数本质是为了减少那种特别复杂的逻辑处理,比如上面只是求一个数字的平方的处理就根本没必要使用缓存,不走缓存瞬间快几千倍。
其次,由于memoizee
在查询缓存时借用了indexOf
,站在量大的数据面前性能问题是无法避免的,而其它同事之所以遇到这个问题,是因为某个客户在项目中对于工作项不同类型定义了多个属性配置,而每个配置下又支持自定义N个工作项属性,在程序经常有根据工作项属性ID
去查对应工作项属性的逻辑,所以这一查直接卡爆了。
还记得我们上面三段代码的对比吗?我们自定义的缓存函数之所以快,是因为我们使用的是cache[key]
,即便你cache
存了几十万条数据,通过对象直接读取key
的时间复杂度其实是O(1)
,所以针对项目中的需求,性能优化小组的同学自己定义了一个根据ID
查询工作项属性的工具函数:
export const getterCache = (fn) => {
const cache = new Map();
return (...args) => {
const [uuid] = args;
// 这里的时间复杂度是O(1)
let data = cache.get(uuid);
if (data === undefined) {
// 没有缓存
data = fn.apply(this, args); // 执行原函数获取值
cache.set(uuid, data);
}
return data;
};
};
好处就是借用了Map
的get
方法,相对于indexOf
的O(N)
,脑补一下就知道能快不少了。
重回memoizee
,会议结论是,谨慎使用memoizee
做函数缓存(不再推荐),若你的函数结果本身就不复杂,那更不要使用了,而对于调用非常庞大的场景,你可能还得手动定义缓存函数,另一点,由于公司react
已升到17,所以现在大部分缓存已经使用了useMemo
,也暂未发现性能损耗的问题。后续有空再看看useMemo
是如何实现的缓存吧,本文到这里结束。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· winform 绘制太阳,地球,月球 运作规律
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 上周热点回顾(3.3-3.9)