如何用Async/Await和Promise.all运行并发请求

884 阅读8分钟

简介

在这篇文章中,我想谈谈JavaScript中的asyncawaitPromise.all 。首先,我将谈论并发性与并行性,以及为什么我们将在这篇文章中以并行性为目标。然后,我将谈论如何使用asyncawait 来实现一个串行的并行算法,以及如何通过使用Promise.all 来使其并行工作。最后,我将使用Salesforce的Lightning Web Components创建一个示例项目,我将使用Harvard的Art Gallery API建立一个艺术画廊。

并发性与并行性

我想快速地谈一下并发性和并行性之间的区别。你可以把并发性与单线程CPU如何处理多个任务联系起来。单线程CPU通过在进程之间快速切换来模拟并行性,以至于看起来像是有多件事情在同时发生。并行性是指一个CPU有多个内核,实际上可以在完全相同的时间内运行两个任务。另一个很好的例子是这样的。

并发是指两行顾客从一个收银台点菜(各行轮流点菜);并行是指两行顾客从两个收银台点菜(每行有自己的收银台)。[1]

知道这个区别有助于我们考虑从算法的角度有哪些选择。我们的目标是并行地发出这些HTTP请求。由于JavaScript实现的一些限制和浏览器的可变性,我们实际上并不能确定我们的算法是并发运行还是并行运行。幸运的是,我根本就不需要改变我们的算法。底层的JavaScript事件循环将使代码看起来像是在并行运行,这对本文来说已经足够了

串行中的异步/等待

为了理解这个并行算法,我将首先使用async和await来构建一个串行算法。如果你在IDE中写这段代码,你很可能会收到一个通知,说在循环中使用await是一个错过的优化机会*--*你的IDE是正确的。

(async () => {
    const urls = [        "https://example.com/posts/1/",        "https://example.com/posts/1/tags/",    ];
    
    const data = [];
  for (url of urls) {
    await fetch(url)
      .then((response) => response.json())
      .then((jsonResponse) => data.push(jsonResponse));
  }

  console.log(data);
})();

你可能会实现这样的算法的一个原因是,如果你需要从两个不同的URL中获取数据,然后将这些数据混合在一起,创建你的最终对象。在上面的代码中,你可以想象,我们正在收集关于一个帖子的一些数据,然后抓取关于帖子标签的数据,最后将这些数据合并到你以后实际使用的对象中。

虽然这段代码可以工作,但你可能会注意到,我们在每次获取数据时都会await 。你会看到类似的东西。

  • 开始取帖一
  • 等待获取第一篇文章的完成
  • 获取帖子一的响应
  • 开始获取第一篇文章的标签
  • 等待post one标签的完成
  • 获得post one标签的响应

问题是我们在开始下一个请求之前,要连续地等待每个网络请求的完成。没有必要这样做。计算机完全有能力同时执行一个以上的网络请求。

那么,我们怎样才能使这种算法变得更好呢?

并行的异步/等待

让这个算法变得更快,最简单的方法是在fetch 命令前删除await 关键字。这将告诉JavaScript以并行方式开始执行所有的请求。但是为了暂停执行并等待所有的承诺返回,我们需要等待一些东西。我们将使用Promise.all 来做到这一点。

当我们使用await Promise.all ,JavaScript将等待传递给Promise.all 的整个承诺数组的解析。只有这样,它才会同时返回所有的结果。重写后的结果是这样的。

(async () => {
    const urls = [
        "https://example.com/posts/1/",
        "https://example.com/posts/1/tags/",
    ];
    
  const promises = urls.map((url) =>
    fetch(url).then((response) => response.json())
  );

  const data = await Promise.all(promises);

  console.log(data);
})();

这段代码将把每个URL映射成一个promise ,然后await ,让所有这些承诺完成。现在,当我们传递await Promise.all 部分的代码时,我们可以确定这两个获取请求已经解决,并且响应在数据数组中的位置是正确的。所以data[0] 将是我们的帖子数据,data[1] 将是我们的标签数据。

一个例子

现在我们有了所有必要的构件来实现我们的预取图片库,让我们来构建它。

下面是我为这篇文章建立的应用程序的截图,这里是关于哈佛艺术博物馆API文档的链接[2]。如果你想跟着做,你需要申请自己的API密钥。这个过程在我看来是非常自动的,因为你只需填写一个谷歌表格,然后在你的电子邮件中立即收到你的API密钥。

Harvard Gallery Screenshot

它看起来不大,但当你浏览画廊时,它会自动预取下几页的数据。这样一来,用户在观看画廊时就不会看到实际数据的任何加载时间。图片只有在它们显示在页面上时才会被加载。虽然这些图片是在事后加载的,但页面的实际数据是即时加载的,因为它被缓存在组件中。最后,作为对自己的挑战,我在这个项目中使用了Salesforce的Lightning Web组件*,这*对我来说是一项全新的技术。让我们开始构建该组件。

下面是我在学习Lightning Web组件时使用的一些资源。如果你想跟着学习,那么你至少需要设置你的本地开发环境,并创建一个 "hello world "的Lightning Web组件。

设置一个本地开发环境

创建一个 "Hello World "Lightning Web组件

LWC样本库

LWC组件参考

好了,现在你的环境已经设置好了,而且你已经创建了你的第一个LWC,让我们开始吧。顺便说一下,本文的所有代码都可以在我的GitHub repo[7]中找到。

顺便提一下。如果你是React背景的人,Lightning Web Components比你可能习惯的组件更有限制。例如,你不能在组件属性中使用JavaScript表达式,即下面例子中的图片src。

<template for:each={records} for:item="record">
    <img src={record.images[0].baseimageurl}>
</template>

其原因是,当你强迫你的所有代码发生在JavaScript文件中,而不是在HTML模板文件中,你的代码变得更容易测试。所以,让我们把这归结为 "这对测试更有利",然后继续我们的生活。

为了创建这个画廊,我们需要建立两个组件。第一个组件用于显示每个画廊的图片,第二个组件用于预取和分页。

第一个组件是这两个组件中比较简单的。在VSCode中,执行命令SFDX: Create Lightning Web Component ,并将该组件命名为harvardArtMuseumGalleryItem 。这将为我们创建三个文件:一个HTML、JavaScript和XML文件。这个组件不需要对XML文件做任何改动,因为这个项目本身在任何Salesforce管理页面中都是不可见的。

接下来,将HTML文件的内容改为如下。

# force-app/main/default/lwc/harvardArtMuseumGalleryItem/harvardArtMuseumGalleryItem.html

<template>
    <div class="gallery-item" style={backgroundStyle}></div>
    {title}
</template>

注意,在这个HTML文件中,样式属性被设置为{backgroundStyle} ,这是我们的JavaScript文件中的一个函数,所以让我们来处理这个函数。

将JS文件的内容改为如下:

# force-app/main/default/lwc/harvardArtMuseumGalleryItem/harvardArtMuseumGalleryItem.js

import { LightningElement, api } from 'lwc';

export default class HarvardArtMuseumGalleryItem extends LightningElement {
    @api
    record;

    get image() {
        if (this.record.images && this.record.images.length > 0) {
            return this.record.images[0].baseimageurl;
        }

        return "";
    }

    get title() {
        return this.record.title;
    }

    get backgroundStyle() {
        return `background-image:url('${this.image}');`
    }
}

这里有几件事情需要注意。首先,记录属性用@api 来装饰,这使得我们可以从其他组件中赋值给这个属性。请留意主画廊组件上的这个记录属性。另外,由于我们不能在HTML文件中使用JavaScript表达式,我也把背景图片的内联CSS带入了JavaScript文件。这使我能够使用字符串插值的图像。图像函数本身并没有什么特别之处*--*只是一个简单的方法,让我从我们从哈佛艺术馆API收到的记录中获得第一个图像URL。

这个组件的最后一步是添加一个没有为我们自动创建的CSS文件。所以在harvardArtMuseumGalleryItem目录下创建harvardArtMuseumGalleryItem.css 。你不需要告诉应用程序使用这个文件,因为它的存在就会自动包含。

将你新创建的CSS文件的内容改为如下:

# force-app/main/default/lwc/harvardArtMuseumGalleryItem/harvardArtMuseumGalleryItem.css

.gallery-item {
    height: 150px;
    width: 100%;
    background-size: cover;
}

现在,我们的忙碌工作已经结束了,我们可以进入实际的画廊了。

再次在VSCode中运行SFDX: Create Lightning Web Component ,并将该组件命名为harvardArtMuseumGallery 。这将再次生成我们的HTML、JavaScript和XML文件。这次我们需要密切关注XML文件。XML文件是告诉Salesforce我们的组件被允许放在哪里,以及我们如何在组件中存储我们的API密钥。

# force-app/main/default/lwc/harvardArtMuseumGallery/harvardArtMuseumGallery.js-meta.xml

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="<http://soap.sforce.com/2006/04/metadata>">
    <apiVersion>51.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__HomePage</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__HomePage">
            <property name="harvardApiKey" type="String" default=""></property>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

在这个XML文件中,有三个关键事项需要注意。第一个是isExposed ,它将允许我们的组件在Salesforce管理员中被找到。第二个是target ,它说明我们的组件可以在Salesforce网站的哪些区域使用。这个说的是,我们允许我们的组件显示在主页类型的页面上。最后,在添加组件时,targetConfigs 部分将显示一个文本框。在那里,我们可以粘贴我们的API密钥(如以下截图所示)。你可以在这里找到关于这个XML文件的更多信息。Example of Entering the API Key Into the Salesforce UI

接下来,我们来处理一下HTML和CSS文件:

# force-app/main/default/lwc/harvardArtMuseumGallery/harvardArtMuseumGallery.html

<template>
    <lightning-card title="HelloWorld" icon-name="custom:custom14">
        <div class="slds-m-around_medium">
          <h1>Harvard Gallery</h1>
          <div class="gallery-container">
            <template for:each={records} for:item="record">
              <div key={record.index} class="row">
                <template for:each={record.value} for:item="item">
                  <c-harvard-art-museum-gallery-item if:true={item} key={item.id} record={item}></c-harvard-art-museum-gallery-item>
                </template>
              </div>
            </template>
          </div>
          <div class="pagination-container">
            <button type="button" onclick={previousPage}>&lt;</button>
            <span class="current-page">
              {currentPage}
            </span>
            <button type="button" onclick={nextPage}>&gt;</button>
          </div>
        </div>
      </lightning-card>
</template>

这其中大部分是标准的HTML,有一些自定义的组件。我希望你最注意的一行是标签和它的记录属性。你会记得,这是我们在画廊项目的JavaScript文件中用@api 装饰的属性。@api 装饰使我们能够通过这个属性传递记录。

接下来,是CSS文件:

# force-app/main/default/lwc/harvardArtMuseumGallery/harvardArtMuseumGallery.css

h1 {
  font-size: 2em;
  font-weight: bolder;
  margin-bottom: .5em;
}

.gallery-container .row {
  display: flex;
}

c-harvard-art-museum-gallery-item {
  margin: 1em;
  flex-grow: 1;
  width: calc(25% - 2em);
}

.pagination-container {
  text-align: center;
}

.pagination-container .current-page {
  display: inline-block;
  margin: 0 .5em;
}

我把最有趣的部分留到了最后。这个JavaScript文件包括我们的预取逻辑和页面滚动算法:

# force-app/main/default/lwc/harvardArtMuseumGallery/harvardArtMuseumGallery.js

import { LightningElement, api } from "lwc";

const BASE_URL =
  "<https://api.harvardartmuseums.org/object?apikey=$1&size=8&hasimage=1&page=$2>";

export default class HarvardArtMuseumGallery extends LightningElement {
  @api harvardApiKey;

  error;
  records;
  currentPage = 1;
  pagesCache = [];

  chunkArray(array, size) {
    let result = [];
    for (let value of array) {
      let lastArray = result[result.length - 1];
      if (!lastArray || lastArray.length === size) {
        result.push([value]);
      } else {
        lastArray.push(value);
      }
    }

    return result.map((item, index) => ({ value: item, index: index }));
  }

  nextPage() {
    this.currentPage++;
    this.changePage(this.currentPage);
  }

  previousPage() {
    if (this.currentPage > 1) {
      this.currentPage--;
      this.changePage(this.currentPage);
    }
  }

  connectedCallback() {
    this.changePage(1);
  }

  async changePage(page) {
    let lowerBound = ((page - 3) < 0) ? 0 : page - 3;
    const upperBound = page + 3;

    // Cache the extra pages
    const promises = [];
    for (let i = lowerBound; i <= upperBound; i++) {
      promises.push(this.getRecords(i));
    }

    Promise.all(promises).then(() => console.log('finished caching pages'));

    // Now this.pages has all the data for the current page and the next/previous pages
    // The idea is that we will start the previous promises in order to prefrech the pages
    // and here we will wait for the current page to either be delivered from the cache or
    // the api call
    this.records = await this.getRecords(page);
  }

  async getRecords(page) {
    if (page in this.pagesCache) {
      return Promise.resolve(this.pagesCache[page]);
    }

    const url = BASE_URL.replace("$1", this.harvardApiKey).replace("$2", page);
    return fetch(url)
      .then((response) => {
        if (!response.ok) {
          this.error = response;
        }

        return response.json();
      })
      .then((responseJson) => {
        this.pagesCache[page] = this.chunkArray(responseJson.records, 4);
        return this.pagesCache[page];
      })
      .catch((errorResponse) => {
        this.error = errorResponse;
      });
  }
}

请注意,我们正在用@api 来装饰harvardApiKey。这就是我们的XML文件中的targetConfig 属性将被注入到我们的组件中。这个文件中的大部分代码都是为了方便更换页面和分块响应,这样我们就可以得到四个画廊项目的行。请注意changePage 以及getRecords :这就是神奇的地方。首先,注意changePage ,从当前请求的页面开始计算一个页面范围。如果当前请求的页面是第五页,那么我们将缓存从第二页到第八页的所有页面。然后我们在这些页面上循环,为每一个页面创建一个承诺。

Promise.all 最初,我在想,我们需要在await ,以避免两次加载一个页面。但后来我意识到,为了不等待所有的页面从API返回,这是一个很低的成本。所以目前的算法如下:

  1. 用户请求第五页。
  2. 界限被计算为第二页到第八页,并为这些请求创建承诺。
  3. 由于我们没有等待承诺的返回,我们将再次请求第五页并进行额外的API请求(但这只发生在不在缓存中的页面)。
  4. 所以我们说,用户进展到了第六页。
  5. 界限被计算为第三至第九页,并为这些请求创建承诺。
  6. 由于我们在缓存中已经有了第二页到第八页,而且我们没有等待这些承诺,第六页将立即从缓存中加载,而第九页的承诺正在被履行(因为它是缓存中唯一缺少的页面)。

结论

这就是你所拥有的!我们已经探索了并发性和并行性。我们学习了如何在串行中建立一个异步/等待流程(你永远不应该这样做)。然后我们将我们的串行流程升级为并行流程,并学习了如何在继续之前等待所有承诺的解决。最后,我们使用async/await和Promise.all ,为哈佛艺术博物馆建立了一个Lightning Web组件。(尽管在这种情况下,我们不需要Promise.all ,因为如果我们不等待所有的承诺解决再继续下去的话,算法会更好。)

谢谢你的阅读,欢迎在下面留下任何评论和问题。