发布于 2026-01-06 4 阅读
0

最大化性能:如何在 JavaScript 中缓存异步函数

最大化性能:如何在 JavaScript 中缓存异步函数

什么是记忆化?

记忆化是一种巧妙的加速函数的方法,它通过存储和重用函数过去的计算结果来实现这一点。在 JavaScript 中,它就像使用一个内存系统:如果函数再次被问到相同的问题,它会快速地从内存中检索答案,而不是重新计算。这节省了时间,使代码更加高效。

同步函数的记忆化

将同步函数缓存化非常简单。您只需创建一个缓存对象,并将函数的结果存储在其中。然后,在调用函数之前,您可以检查缓存中是否包含给定参数的结果。如果包含,则可以返回缓存值。否则,您可以执行计算并将结果存储到缓存中。

理解斐波那契函数,记忆化最常见的例子

let functionCalled = 0;
const fibonacci = (n) => {
  if (n <= 1) return 1;
  functionCalled++;
  return fibonacci(n - 1) + fibonacci(n - 2);
};

console.log(fibonacci(10)); // 89
console.log(functionCalled); // 88
Enter fullscreen mode Exit fullscreen mode

在深入探讨记忆化细节之前,了解斐波那契函数非常重要。在我们的示例中,我们创建了一个名为 fibonacci 的函数,它接受一个数字作为参数n,并返回斐波那契数列中的第 n 个数字。

斐波那契数列是一个令人着迷的数学序列,其中每个数字都是前两个数字之和。它从 0 和 1 开始,并由此展开成一个令人惊叹的模式。以下是斐波那契数列的前 10 个数字,供您参考:1、2、3、5、8、13、21、34、55、89。

斐波那契挑战
现在,让我们用我们创建的斐波那契函数来具体说明一下。当我们用数字10调用斐波那契函数时,它神奇地返回了89。然而,真正令人惊讶的是,为了计算出这个结果,该函数仅仅被调用了88次。这个数字看起来很小,但有趣的地方就在这里。

如果我们用数字20来测试斐波那契函数,它会给出一个非常令人惊讶的结果——10,946!这确实是斐波那契数列中的第 20 个数字。然而,问题在于:为了得到这个结果,函数被调用了惊人的10,945次。没错,你没看错!我们只增加了 10 个输入数字,但函数调用次数却从88 次飙升至10,945 次。随着输入数字的增加,函数调用次数呈指数级增长,这是一个非常严重的问题。

为了解决这个问题,我们可以实现记忆化。请看下面修改后的代码:

let functionCalled = 0;
const fibonacci = (n, memo = {}) => {
  if (n in memo) return memo[n];
  if (n <= 1) return 1;
  functionCalled++;
  memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
  return memo[n];
};

console.log(fibonacci(10)); // 89
console.log(functionCalled); // 9
Enter fullscreen mode Exit fullscreen mode

现在,有了记忆化,当我们调用 fibonacci(10) 时,它仍然返回 89,但函数只被调用了 9 次。这相比之前的版本有了显著的改进,尤其是在处理较大数字时。如果我们调用 fibonacci(20),函数只被调用了19次。这相比之前的版本有了巨大的改进,之前的版本中函数被调用了10,945次。

让我们来分析一下代码,以便更清楚地理解:

  1. 该函数接受两个参数:` n`nmemo`memo`。`n` 参数是我们要计算其斐波那契值的数字。`memo` 参数是一个对象,用于存储先前函数调用的结果。我们将使用此对象来存储函数的结果,并在需要时检索它们。
  2. 该函数检查 memo 对象是否包含给定数字的结果。如果包含,则返回缓存值。否则,继续计算结果。
  3. 如果该数字小于或等于 1,则函数返回 1。这是函数的基本情况。

现在,有了这样的背景,让我们继续探索记忆化的奇妙世界,以及它如何通过优化这些计算来解决问题。

缓存异步函数

对异步函数进行缓存会带来一些独特的挑战。考虑以下模拟 API 调用的异步函数:

const fakeAPICall = async (userId) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve({ userId, name: "John Doe" }), 100);
  });
};

Promise.all([
    fakeAPICall(1),
    fakeAPICall(1),
    fakeAPICall(1)
]).then(console.log); 
// [{userId: 1, name: 'John Doe'}, ...]
Enter fullscreen mode Exit fullscreen mode

虽然这段代码能够按预期运行,但执行后会产生三次独立的 API 调用。这效率很低,既然只需一次调用就能重用检索到的数据,为什么还要进行三次调用呢?为了解决这个问题,我们可以像斐波那契数列示例那样,将数据存储在一个变量中,并在下次使用相同输入调用函数时从缓存中返回该变量的数据。

让我们通过一个实际例子来说明这种改进:

let cache = {};

const fakeAPICall = async (userId) => {
  if (userId in cache) return Promise.resolve(cache[userId]);

  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const response = { userId, name: "John Doe" };
      cache[userId] = response;
      resolve(response);
    }, 100);
  });
};

Promise.all([
    fakeAPICall(1), 
    fakeAPICall(1), 
    fakeAPICall(1)
]).then(console.log); 
// [{ userId: 1, name: 'John Doe' }, ...]
Enter fullscreen mode Exit fullscreen mode

这看起来很有希望;我们成功地将数据缓存到了全局缓存变量中。当进行第二次和第三次 API 调用时,我们可以高效地检索缓存的数据。然而,这种方法存在一个关键问题。

当我们使用相同的用户 ID 重复进行 API 调用时,问题依然存在。为了演示这个问题,让我们在 fakeAPICall 函数中添加一条 console.log 语句并观察其行为:

let cache = {};

const fakeAPICall = async (userId) => {
  if (userId in cache) return Promise.resolve(cache[userId]);

  console.log("API call made");
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const response = { userId, name: "John Doe" };
      cache[userId] = response;
      resolve(response);
    }, 100);
  });
};

Promise.all([
    fakeAPICall(1), 
    fakeAPICall(1), 
    fakeAPICall(1)
]).then(console.log); 
// [{ userId: 1, name: 'John Doe' }, ...]
Enter fullscreen mode Exit fullscreen mode

执行这段代码后,我们发现“API 调用已完成”这条信息被记录了三次,这不是我们想要的结果。理想情况下,我们希望只执行一次 API 调用,并利用缓存的数据进行后续调用。

在深入探讨解决方案之前,首先需要了解这个问题出现的原因。当我们同时发起三个 API 调用时,第一个调用会发现缓存为空,因此会发起一个 API 请求来检索数据。由于 API 调用是异步的,需要一些时间才能完成。在此期间,会触发第二个和第三个函数调用。由于缓存仍然为空,这些调用也会发起新的 API 请求。因此,我们在控制台中看到了三次“API 调用已完成”的日志记录。

一种可能的解决方案是将 API 调用改为同步调用,这确实可以解决问题。然而,这并非明智之举。同步 API 调用可能会阻塞主线程,导致应用程序响应速度降低,用户体验不佳。

自己试试

请稍作停顿,思考一下这个问题的解决方案。如果您已经找到了解决方案,那真是太好了。如果您还没有找到,也不用担心。在下一节中,我将提供一个清晰有效的解决方案来解决这个问题。

解决方案:缓存异步函数

解决这一难题的关键在于,当已知所需数据尚未缓存时,尽量减少冗余的 API 调用。为此,我们将引入一个名为“承诺队列”的概念。

它的运作方式如下:

  1. 首次发起 API 调用时,它会检查缓存。如果数据不存在,则调用会像往常一样继续获取数据。
  2. 在初始 API 调用进行期间,任何后续调用都不会被丢弃。相反,它们会被添加到 Promise 队列中,耐心等待数据可用。
  3. 当第一个 API 调用完成并获取到数据后,它不仅会解析原始 Promise,还会解析队列中的所有 Promise。这确保了所有后续调用都能收到它们等待的数据。

这样,我们只需发出一次 API 调用,缓存的数据就能高效地与所有待处理的请求共享。

为了更详细地了解这个解决方案,让我们来看一个实际例子。

let cache = {};
let promiseQueue = [];
let inProgress = false;

const fakeAPICall = async (userId) => {
  if (userId in cache) return Promise.resolve(cache[userId]);

  if (inProgress) {
    return new Promise((resolve) =>  promiseQueue.push(resolve));
  }

  console.log("API call made");
  inProgress = true;
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const response = { userId, name: "John Doe" };
      cache[userId] = response;
      resolve(response);

      // Process the promise queue
      promiseQueue.forEach((resolve) => resolve(response));

      // Reset the promise queue
      promiseQueue = [];
      inProgress = false;
    }, 100);
  });
};

Promise.all([
    fakeAPICall(1), 
    fakeAPICall(1), 
    fakeAPICall(1)
]).then(console.log); 
// [{ userId: 1, name: 'John Doe' }, ...]
Enter fullscreen mode Exit fullscreen mode

运行这段代码后,你会发现“API 调用已完成”这条消息只记录了一次。这正是我们想要的结果。我们有效地减少了 API 调用次数,每次调用只执行一次,并高效地利用缓存数据来处理所有后续请求。

让我们来分析一下代码,以便更清楚地理解:

  1. 首次 API 调用:首次调用 API 时,程序会检查缓存是否为空。如果缓存为空,则会触发 API 请求以获取数据。至关重要的是,在发出 API 请求之前,我们会将名为 inProgress 的变量设置为“true”。这会向后续函数调用发出信号,表明 API 请求已在进行中。
  2. 后续调用排队:当第二个和第三个函数调用到来时,我们不会立即拒绝或解决它们。相反,我们会为每个调用返回一个 Promise,并将相应的“resolve”函数添加到名为 `promiseQueue` 的队列中。这一步骤至关重要。如果不显式地解决或拒绝 Promise,它将一直处于等待状态,调用者(在本例中为 `Promise.all` 方法)将等待其被解决或拒绝。
  3. 待处理 Promise 的解析:一旦第一个 API 调用成功完成并检索到数据,我们就可以解析存储在 promiseQueue 中的所有 Promise。这意味着所有后续等待的调用都将收到它们所等待的相同数据。随后,我们将清空 promiseQueue 并将 inProgress 变量设置回“false”。

重要提示:错误处理和可扩展性

在结束之前,为了确保我们解决方案的稳健性和可扩展性,有必要解决几个关键问题。

  1. 错误处理:为了简化示例,我们重点关注了核心功能。然而,在实际应用中,实现错误处理至关重要。这包括处理 API 请求失败或任何意外错误的情况。妥善的错误处理对于维护应用程序的可靠性至关重要。
  2. 用户可扩展性:目前的实现方案是针对单个用户 ID 设计的,使用全局的 inProgress 变量和队列。如果您打算同时使用不同的用户 ID 执行该功能,这种设计可能会导致问题。为了使解决方案具有可扩展性并适用于多个用户,您需要考虑一种更动态的方法,该方法能够处理每个用户的请求及其对应的队列。

结论

综上所述,记忆化是提升同步和异步函数性能的强大工具。通过理解其中的挑战并实现 Promise 队列,您可以高效地记忆异步函数,最终提高 JavaScript 代码的效率。这种优化可以加快执行速度、降低资源消耗,并带来更流畅的用户体验。因此,不妨将这些原则应用到您的代码中,您将收获更高效、响应更迅速的应用程序。感谢阅读!


如果你还没读过,一定要读!

更多内容请访问Dev.to。
来抓我吧

YouTube、 GitHub、 LinkedIn、 Medium、 Stackblitz、 Hashnode、 HackerNoon

文章来源:https://dev.to/devsmitra/maximizing-performance-how-to-memoize-async-functions-in-javascript-4on8