前端知库

怎样实现函数只执行一次(once)和带缓存的 memoize?

once 用闭包里的一个标记记住是否调用过,第一次执行并保存结果,之后直接返回这个结果;memoize 用 Map 按参数算出的键缓存结果,默认只拿第一个参数当键,多参数要传自定义的键函数。缓存 Promise 时失败结果也会被缓存,需要在拒绝时删掉。

参考答案

简答

页面上的初始化函数可能被好几个地方调用,但只该真正执行一次,这就是 once。它返回一个包装函数,闭包里存一个“是否调用过”的标记和第一次的结果,之后再调用直接返回那个结果。

memoize 是把“同样的参数得到同样的结果”缓存起来,比如计算很慢的纯函数,或者递归求斐波那契数。它在闭包里放一个 Map,用参数算出一个键,命中就直接返回,没命中再调用原函数并存起来。

关键在键怎么算。常见实现默认只用第一个参数当键,多参数函数不传自定义键函数就会算错;对象参数按引用比较,内容相同的两个对象也算不同的键。包装时要用 fn.apply(this, args) 保留调用时的 this。

要注意,缓存只适合结果只取决于参数的函数。缓存 Promise 时,失败的 Promise 也会被存下来,之后每次都直接拿到失败,需要在拒绝时把它从缓存里删掉;缓存会一直增长,长期运行的页面要考虑上限。

once:用闭包记住是否调用过

先看 once,注意第一次调用的结果是怎样保存下来的。

JavaScript
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 第二个参数是计算键的函数,默认取第一个参数。示例对比了默认键和自定义键的差别。

JavaScript
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 也会被存下来。

JavaScript
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。