系列文章:
昨天閱讀 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 的部落格 歡迎來訪 ^_^