每天阅读一个 npm 模块(2)- mem
系列文章:
昨天阅读 username 3.0.0 版本的源码之后,根据自己的想法向作者 Sindre Sorhus 提出了 Pull Request,没想到今天 Sindre 接受了 PR 同时放弃了对 Node 4 的支持,升级至 4.0.0 版本,不过核心代码并有太大的变化 😊
一句话介绍
今天阅读的 npm 模块是 mem,它通过缓存函数的返回值从而减少函数的实际执行次数,进而提升性能,当前版本为 3.0.1,周下载量约为 350 万。
用法
const mem = require('mem');
// 同步函数缓存
let i = 0;
const counter = () => ++i;
const memoized = mem(counter);
memoized('foo');
//=> 1
memoized('foo');
//=> 1 参数相同,返回换成的结果 1
memoized('bar');
//=> 2 参数变化,counter 函数再次执行,返回 2
memoized('bar');
//=> 2
// 异步函数缓存
let j = 0;
const asyncCounter = () => Promise.resolve(++j);
const asyncmemoized = mem(asyncCounter);
asyncmemoized().then(a => {
console.log(a);
//=> 1
asyncmemoized().then(b => {
console.log(b);
//=> 1
});
});
上述用法是 mem 的核心功能,除此之外它还支持 设置缓存时间、自定义缓存 Hash 值、统计缓存命中数据等功能。
源码学习
哈希函数
为了让被 mem
处理过的函数对于相同的参数能返回同样的值,那么就必须对参数进行哈希处理,然后将哈希结果作为 key
,函数运行结果作为 value
缓存起来,举一个最简单的例子:
const cache = {};
// 缓存 arg1 的运行结果
const key1 = getHash(arg1);
cache[key1] = func(arg1);
// 缓存 arg2 的运行结果
const key2 = getHash(arg2);
cache[key2] = func(arg2);
其中的关键在于 getHash
这个哈希函数:如何处理不同的数据类型?如何处理对象间的比较?其实这也是面试中经常被问到的问题:如何进行深比较?来看看源代码中是怎么写的:
// 源代码 2-1: mem 的哈希函数
const defaultCacheKey = (...args) => {
if (args.length === 1) {
const [firstArgument] = args;
if (
firstArgument === null ||
firstArgument === undefined ||
(typeof firstArgument !== 'function' && typeof firstArgument !== 'object')
) {
return firstArgument;
}
}
return JSON.stringify(args);
};
从上面的代码中可以看到:
- 当只有一个参数,且参数为 null | undefined 或者类型不为 function | object 时,哈希函数直接将参数返回。
- 若不是上述情况,则返回参数经过
JSON.stringify()
的值。
首先可以复习一下 ES6 中定义了其中数据类型,包括 6 种原始类型(Boolean | Nunber | Null | Undefined | String| Symbol)和 Object 类型。源代码中的哈希函数需要对不同的类型加以区分是因为 Object 类型的直接比较结果和我们这里需要达成的效果不符合:
const object1 = {a: 1};
const object2 = {a: 1};
console.log(object1 === object2);
// => flase
// 期望效果
console.log(defaultCacheKey(object1) === defaultCacheKey(object2));
// => true
一开始我以为作者会通过判断不同的数据类型后再进行专门的处理(类似于 Lodash 的 _.isEqual() 实现),没想到采用的方法这么暴力:直接将 Object 类型的数据通过 JSON.stringify()
转化为字符串后进行处理!刚看到的我是惊呆了的 —— 以前只听有人开玩笑这么干,没想到真会这么做。
这种方法十分简单,而且可读性很高,但是会存在问题:
-
当对象结构复杂时,
JSON.stringify()
会消耗不少时间。 -
对于不同的正则对象,
JSON.stringify()
的结果均为{}
,与哈希函数的预期效果不符。console.log(JSON.stringify(/Sindre Sorhus/)); // => '{}' console.log(JSON.stringify(/Elvin Peng/)); // => '{}'
第一个问题还好,因为假如通过 JSON.stringify()
哈希时,性能存在问题的话,mem
支持传入自定义的哈希函数,可以通过自行编写高效哈希函数进行解决。
第二个问题属于函数功能不符合预期,需要进行 bugfix。
存储结构
不考虑额外参数时,对于同步函数的支持源代码可简化如下:
// 源代码 2-2 mem 核心逻辑
const mimicFn = require('mimic-fn');
const cacheStore = new WeakMap();
module.exports = (fn) => {
const memoized = function (...args) {
const cache = cacheStore.get(memoized);
const key = defaultCacheKey(...args);
if (cache.has(key)) {
const c = cache.get(key);
return c.data;
}
const ret = fn.call(this, ...args);
const setData = (key, data) => {
cache.set(key, {
data,
});
};
setData(key, ret);
return ret;
}
const retCache = new Map();
mimicFn(memoized, fn);
cacheStore.set(memoized, retCache);
return memoized;
}
整体逻辑十分清晰,主要是完成两个动作:
- 将类型为
Map
的retCache
作为函数执行结果的缓存,缓存的键值为defaultCacheKey
哈希后的结果。 - 将类型为
WeakMap
的cacheStore
作为整体的缓存,缓存的键值为函数本身。
通过上面两个动作形成的二级缓存实现了模块的核心功能,这里两个类型的选择非常值得探究。
retCache
选用 Map
类型而不用 Object
类型主要是因为 Map
的键值支持所有类型,而 Object
的键值只支持字符串,除此之外,关于缓存数据结构优选选择 Map
类型还有以下优点:
Map.size
属性可以方便的获得当前缓存的个数Map
类型支持clear()
|forEach()
等常用的工具函数Map
类型是默认可迭代的,即支持iterable protocol
cacheStore
选用 WeakMap
类型而不用 Map
类型主要是因为其具有不增加引用个数的优点,更有利于 Node.js 引擎的垃圾回收。
异步支持
本来还打算写一写关于异步支持的部分,不过现在已经是凌晨一点,想想还是算了吧,早点睡觉 😪
感兴趣的朋友可以自己阅读~
写在最后
除了上文提到的一个 Bug 之外,mem
还存在内存泄漏的可能性:当缓存的数据已过期后(即被缓存的时间大于设置的 maxAge)并不会被自动清除,这可能造成当缓存的数据过多之后其无效缓存占据的内存无法被及时释放,从而导致内存泄漏,具体的讨论可以见Issue #14: Memory leak: old results are not deleted from the cache。
在源代码 2-2 的解读中故意略去了 mimicFn(memoized, fn);
的作用,为什么呢?因为明天准备阅读 mimicFn 这个模块,希望大家能继续捧场。
关于我:毕业于华科,工作在腾讯,elvin 的博客 欢迎来访 ^_^
老哥,厉害啊,捧场捧场
@DCbryant 谢谢🙏 希望自己能坚持下去~
向你学习
来自酷炫的 CNodeMD
多谢好文章。补基础很好。有些地方没有看懂,先忙着,晚点回来继续看。
@1000copy 嗯嗯,有什么问题可以提出来一起交流 👍
看了几遍,终于看懂了,主要是对函数封装函数这套js的惯用法非常不习惯,之前看express源代码,其中的一个wrap函数,是类似的,就几行代码,看得我毛焦火辣的。
容易毛起:|。
当然,实际上从语义上来说,确实js的这些惯用法对API的使用者来说其实非常好用。
说一个问题。fn.callI(this,…args)之前不是应该有一个判断就是是否之前已经缓存,如果缓存就直接返回?是的,确认的看了github源代码,确实如此。
另外说些感受:
- 乍一看const [firstArgument] = args 的用法也是惊了,确实非常方便。语法上叫什么来着?我忘了。
- 对Map和WeakMap的解析,我觉得是不错的知识点,有场景就是容易理解。
这个源码解读,非常考基础,我觉得自己的基础真很烂。所以对我很有用。多谢你的文章。
看了下你的PR,https://github.com/sindresorhus/mimic-fn/pull/9
其中使用的package.json 内engine指定node的版本,这个保护措施,避免使用了低版本出现的低级错误,很预防性的措施,这个之前不知道,学到了。 :) UPDATE: 我试了下,尽管可以在engines指定node版本,但是npm并么有强制要求此版本限定。就是说,即使版本限定并不满足,也不会因此停止工作。 如下repo也验证是如此。https://github.com/spencer-brown/npm-engines-bug-repro 看来我高兴的太早了:(
一级缓存是否有必要。。直接在一个闭包不完了?
@yuu2lee4 这里其实也是闭包,例如 retCache
在 mem()
函数中的作用。 因为可能既对函数 foo()
包一层,也有可能对 bar()
包一层,同时这些函数又会传入不同的参数,所以仅有一级缓存的话实现不了这个能力~
// 源代码 2-2 mem 核心逻辑
const mimicFn = require('mimic-fn');
module.exports = (fn) => {
const cache = new Map();
const memoized = function (...args) {
const key = defaultCacheKey(...args);
if (cache.has(key)) {
const c = cache.get(key);
return c.data;
}
const ret = fn.call(this, ...args);
const setData = (key, data) => {
cache.set(key, {
data,
});
};
setData(key, ret);
return ret;
}
mimicFn(memoized, fn);
return memoized;
}
@yuu2lee4 嗯嗯,之前我的回复的确存在问题 😓
如果只要一级缓存的话的确就可以实现这些功能,但是其实 mem
还提供了清除缓存的功能,所以需要一个缓存来根据函数索引到之前的缓存:
module.exports.clear = fn => {
const cache = cacheStore.get(fn);
if (cache && typeof cache.clear === 'function') {
cache.clear();
}
};
一个打字错误
ES6 中定义了其中数据类型,包括 6 种原始类型(Boolean | Nunber | Null | Undefined | String| Symbol) Nunber ->Number