每天阅读一个 npm 模块(2)- mem

3,864 阅读5分钟

系列文章:

  1. 每天阅读一个 npm 模块(1)- username

昨天阅读 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);
};

从上面的代码中可以看到:

  1. 当只有一个参数,且参数为 null | undefined 或者类型不为 function | object 时,哈希函数直接将参数返回。
  2. 若不是上述情况,则返回参数经过 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() 转化为字符串后进行处理!刚看到的我是惊呆了的 —— 以前只听有人开玩笑这么干,没想到真会这么做。

这种方法十分简单,而且可读性很高,但是会存在问题:

  1. 当对象结构复杂时,JSON.stringify() 会消耗不少时间。

  2. 对于不同的正则对象,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;
}

整体逻辑十分清晰,主要是完成两个动作:

  1. 将类型为 MapretCache 作为函数执行结果的缓存,缓存的键值为 defaultCacheKey 哈希后的结果。
  2. 将类型为 WeakMapcacheStore 作为整体的缓存,缓存的键值为函数本身。

通过上面两个动作形成的二级缓存实现了模块的核心功能,这里两个类型的选择非常值得探究。

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 这个模块,希望大家能继续捧场。

关于我:毕业于华科,工作在腾讯,elvinn 的博客 欢迎来访 ^_^