简介
在这篇文章中,我想谈谈JavaScript中的async 、await 、Promise.all 。首先,我将谈论并发性与并行性,以及为什么我们将在这篇文章中以并行性为目标。然后,我将谈论如何使用async 和await 来实现一个串行的并行算法,以及如何通过使用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密钥。

它看起来不大,但当你浏览画廊时,它会自动预取下几页的数据。这样一来,用户在观看画廊时就不会看到实际数据的任何加载时间。图片只有在它们显示在页面上时才会被加载。虽然这些图片是在事后加载的,但页面的实际数据是即时加载的,因为它被缓存在组件中。最后,作为对自己的挑战,我在这个项目中使用了Salesforce的Lightning Web组件*,这*对我来说是一项全新的技术。让我们开始构建该组件。
下面是我在学习Lightning Web组件时使用的一些资源。如果你想跟着学习,那么你至少需要设置你的本地开发环境,并创建一个 "hello world "的Lightning Web组件。
创建一个 "Hello World "Lightning Web组件
好了,现在你的环境已经设置好了,而且你已经创建了你的第一个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文件的更多信息。
接下来,我们来处理一下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}><</button>
<span class="current-page">
{currentPage}
</span>
<button type="button" onclick={nextPage}>></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返回,这是一个很低的成本。所以目前的算法如下:
- 用户请求第五页。
- 界限被计算为第二页到第八页,并为这些请求创建承诺。
- 由于我们没有等待承诺的返回,我们将再次请求第五页并进行额外的API请求(但这只发生在不在缓存中的页面)。
- 所以我们说,用户进展到了第六页。
- 界限被计算为第三至第九页,并为这些请求创建承诺。
- 由于我们在缓存中已经有了第二页到第八页,而且我们没有等待这些承诺,第六页将立即从缓存中加载,而第九页的承诺正在被履行(因为它是缓存中唯一缺少的页面)。
结论
这就是你所拥有的!我们已经探索了并发性和并行性。我们学习了如何在串行中建立一个异步/等待流程(你永远不应该这样做)。然后我们将我们的串行流程升级为并行流程,并学习了如何在继续之前等待所有承诺的解决。最后,我们使用async/await和Promise.all ,为哈佛艺术博物馆建立了一个Lightning Web组件。(尽管在这种情况下,我们不需要Promise.all ,因为如果我们不等待所有的承诺解决再继续下去的话,算法会更好。)
谢谢你的阅读,欢迎在下面留下任何评论和问题。