系列文章:
昨天阅读 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 的博客 欢迎来访 ^_^