怎样实现函数只执行一次(once)和带缓存的 memoize?
once 用闭包里的一个标记记住是否调用过,第一次执行并保存结果,之后直接返回这个结果;memoize 用 Map 按参数算出的键缓存结果,默认只拿第一个参数当键,多参数要传自定义的键函数。缓存 Promise 时失败结果也会被缓存,需要在拒绝时删掉。
简答
页面上的初始化函数可能被好几个地方调用,但只该真正执行一次,这就是
once。它返回一个包装函数,闭包里存一个“是否调用过”的标记和第一次的结果,之后再调用直接返回那个结果。
memoize是把“同样的参数得到同样的结果”缓存起来,比如计算很慢的纯函数,或者递归求斐波那契数。它在闭包里放一个Map,用参数算出一个键,命中就直接返回,没命中再调用原函数并存起来。关键在键怎么算。常见实现默认只用第一个参数当键,多参数函数不传自定义键函数就会算错;对象参数按引用比较,内容相同的两个对象也算不同的键。包装时要用
fn.apply(this, args)保留调用时的this。要注意,缓存只适合结果只取决于参数的函数。缓存 Promise 时,失败的 Promise 也会被存下来,之后每次都直接拿到失败,需要在拒绝时把它从缓存里删掉;缓存会一直增长,长期运行的页面要考虑上限。
once:用闭包记住是否调用过
先看 once,注意第一次调用的结果是怎样保存下来的。
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
fn = null; // 执行后释放原函数,避免一直持有它引用的变量
}
return result;
};
}
let count = 0;
const init = once((x) => ++count + x);
console.log(init(10), init(20), count); // 11 11 1
第二次调用传了 20,结果仍是第一次的 11,count 也只加了一次。为什么 called = true 写在调用 fn 之前?如果 fn 内部又调用了包装函数,或者 fn 抛了错,先置标记能保证它不会被执行第二次。代价是第一次抛错后,之后的调用都返回 undefined,需要“失败可重试”的话就把置标记挪到调用成功之后。
called、result、fn 都是闭包变量,每次调用 once 生成一套独立的状态,原理见《什么是闭包?它有哪些实际应用?》。
memoize:用 Map 按键缓存结果
下面的 memoize 第二个参数是计算键的函数,默认取第一个参数。示例对比了默认键和自定义键的差别。
function memoize(fn, resolver = (...args) => args[0]) {
const cache = new Map();
function memoized(...args) {
const key = resolver.apply(this, args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
}
memoized.cache = cache;
return memoized;
}
let calls = 0;
const slowSquare = memoize((n) => {
calls++;
return n * n;
});
console.log(slowSquare(4), slowSquare(4), calls); // 16 16 1
const add = memoize((a, b) => a + b);
console.log(add(1, 2), add(1, 100)); // 3 3
const add2 = memoize((a, b) => a + b, (a, b) => `${a},${b}`);
console.log(add2(1, 2), add2(1, 100)); // 3 101
const fib = memoize((n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
console.log(fib(50)); // 12586269025
add(1, 100) 返回了 3,这是错误结果。默认键只看第一个参数,两次调用的键都是 1,第二次直接命中了第一次的缓存。传入把所有参数拼起来的键函数后,add2 才算对。
为什么用 cache.has 判断而不是 cache.get(key) !== undefined?因为原函数可能本来就返回 undefined,用 get 判断会把这种结果当成没缓存,每次都重新算。为什么用 Map 不用普通对象?Map 的键可以是任何值,数字 1 和字符串 '1' 是不同的键,对象也能当键,而且不会碰到 __proto__ 这类继承属性名。
fib(50) 能瞬间算出来,因为递归里调用的 fib 就是缓存过的版本,每个 n 只真正计算一次,总共是 O(n) 次计算。不加缓存的递归是指数级的。
缓存 Promise 时要处理失败
异步函数的返回值是 Promise,memoize 存的是 Promise 本身。这对成功的请求很好,同时发起的重复请求会共用同一个 Promise。可是失败的 Promise 也会被存下来。
function memoize(fn, resolver = (...args) => args[0]) {
const cache = new Map();
function memoized(...args) {
const key = resolver.apply(this, args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
}
memoized.cache = cache;
return memoized;
}
function memoizeAsync(fn) {
const cache = new Map();
return function (key) {
if (cache.has(key)) return cache.get(key);
const p = Promise.resolve(fn.call(this, key)).catch((err) => {
cache.delete(key); // 失败后删除,下次调用会重新请求
throw err;
});
cache.set(key, p);
return p;
};
}
async function main() {
let requests = 0;
const failing = () => {
requests++;
return Promise.reject(new Error('网络错误'));
};
const naive = memoize(failing);
await naive(1).catch(() => {});
await naive(1).catch(() => {});
console.log(requests); // 1
requests = 0;
const safe = memoizeAsync(failing);
await safe(1).catch(() => {});
await safe(1).catch(() => {});
console.log(requests); // 2
}
main();
第一段里请求只发了一次,第二次调用直接拿到了缓存里那个已经失败的 Promise,网络恢复后也不会重试。memoizeAsync 在 Promise 拒绝时把它从缓存里删掉,所以第二次调用重新发了请求。成功的结果仍然会被缓存,同时发起的相同请求也只发一次。
常见追问
参数是对象时怎样生成缓存键?
Map按引用比较对象,同一个对象能命中,内容相同的新对象不能。可以在键函数里用JSON.stringify把参数转成字符串,但它对属性顺序敏感,也处理不了函数、循环引用等值。更稳妥的做法是挑出决定结果的几个字段拼成键。
缓存一直增长怎么办?
给缓存设上限,超出时淘汰最久没用的,也就是 LRU 策略,见《如何利用 Map 实现固定容量的 LRU 缓存?》。如果键是对象、并且希望对象不再使用后缓存能被回收,可以把
Map换成WeakMap,但WeakMap的键只能是对象或非注册的 Symbol。