具有承诺的优雅异步编程

朋友们,美好的一天!

承诺(promises)是JavaScript的一项相对较新的功能,可让您将操作的执行延迟到上一个操作完成或对操作未成功执行做出响应。这有助于正确确定异步操作的顺序。本文讨论了承诺如何工作,如何在Web API中使用它们以及如何编写自己的承诺。

条件:基本的计算机知识,JS基础知识。
目标:了解什么是诺言以及如何使用它们。

什么是诺言?


我们在课程的第一篇文章中简要回顾了承诺,在这里我们将更详细地考虑它们。

本质上,promise是表示操作的中间状态的对象-它“承诺”将来将返回结果。尚不确切知道何时完成操作以及何时返回结果,但是可以保证,当操作完成时,您的代码将对结果执行某些操作或妥善处理错误。

通常,异步操作要花费多少时间(不要太长!),与立即响应完成操作的可能性相比,对我们而言,它不那么感兴趣。而且,当然,很高兴知道其余代码没有被阻塞。

返回承诺的Web API是最常见的承诺之一。让我们看一个假设的视频聊天应用程序。该应用程序具有一个包含用户好友列表的窗口,单击用户名称(或头像)旁边的按钮可发起视频通话。

按钮处理程序调用getUserMedia()访问用户的摄像头和麦克风。从getUserMedia()联系用户获得许可(使用设备,如果用户有多个麦克风或摄像头,要使用哪个设备,只有语音呼叫等)开始,getUserMedia()不仅希望用户做出决定,而且还会释放设备(如果当前正在使用)。此外,用户可能不会立即响应。所有这些都会导致时间上的大量延迟。

如果从主线程发出了使用设备的许可请求,则浏览器将被阻止,直到getUserMedia()完成。这是不可接受的。没有承诺,浏览器中的所有内容都会变为“不可点击”,直到用户允许使用麦克风和摄像头。因此,getUserMedia()不会等待用户的决定并为从源(相机和麦克风)创建的流返回MediaStream,而是返回一个承诺,即MediaStream在可用时立即进行处理。

视频聊天应用程序代码可能如下所示:

function handle CallButton(evt){
    setStatusMessage('Calling...')
    navigator.mediaDevices.getUserMedia({video: true, audio: true})
    .then(chatStream => {
        selfViewElem.srcObject = chatStream
        chatStream.getTracks().forEach(track => myPeerConnection.addTrack(track, chatStream))
        setStatusMessage('Connected')
    }).catch(err => {
        setStatusMessage('Failed to connect')
    })
}

该函数从调用setStatusMessage()开始,显示消息“正在调用...”,该消息表示正在尝试进行呼叫。然后调用getUserMedia(),以请求包含视频和音频轨道的流。流形成后,将安装视频元素以显示来自摄像机的流,称为“自视图”,然后将音轨添加到WebRTC RTCPeerConnection,这是与另一个用户的连接。之后,状态将更新为“已连接”。

如果getUserMedia()失败,则启动catch块。它使用setStatusMessage()来显示错误消息。

请注意,即使尚未收到视频流,也将返回对getUserMedia()的调用。即使handleCallButton()函数将控制权返回给调用它的代码,一旦getUserMedia()完成执行,它也会调用处理程序。在应用程序“理解”广播已经开始之前,getUserMedia()将处于待机模式。

注意:您可以在文章“信号和视频通话”中了解更多有关此的信息本文提供的代码比示例中使用的代码更完整。

回调函数问题


要了解为什么Promise是一个好的解决方案,有必要研究一下编写回调的旧方法以查看其问题所在。

例如,考虑订购比萨饼。成功的比萨订单包含必须按顺序执行的几个步骤,一个接一个地执行:

  1. 选择馅料。如果您考虑了很长时间,这可能会花费一些时间;如果您改变主意并订购咖喱,这可能会失败。
  2. 我们下订单。烹饪披萨需要一些时间,如果餐厅缺少必要的食材,可能会失败。
  3. 我们吃披萨吃。例如,如果我们无法为订单付款,则接收比萨可能会失败。

使用回调函数的老式伪代码可能如下所示:

chooseToppings(function(toppings){
    placeOrder(toppings, function(order){
        collectOrder(order, function(pizza){
            eatPizza(pizza)
        }, failureCallback)
    }, failureCallback)
}, failureCallback)

这样的代码很难阅读和维护(通常称为“回调的地狱”或“回调的地狱”)。必须在每个嵌套级别调用failureCallback()函数。还有其他问题。

我们用诺言


承诺使代码更简洁,更易于理解和支持。如果我们使用异步承诺重写伪代码,则会得到以下信息:

chooseToppings()
.then(function(toppings){
    return placeOrder(toppings)
})
.then(function(order){
    return collectOrder(order)
})
.then(function(pizza){
    eatPizza(pizza)
})
.catch(failureCallback)

这样更好-我们可以看到会发生什么,我们使用一个.catch()块来处理所有错误,该函数不会阻塞主流(因此我们可以在等待披萨的同时玩视频游戏),保证每个操作都在上一个完成后执行。由于每个承诺都会返回一个承诺,因此我们可以使用.then链。太好了吧?

使用箭头功能,可以进一步简化伪代码:

chooseToppings()
.then(toppings =>
    placeOrder(toppings)
)
.then(order =>
    collectOrder(order)
)
.then(pizza =>
    eatPizza(pizza)     
)
.catch(failureCallback)

甚至像这样:

chooseToppings()
.then(toppings => placeOrder(toppings))
.then(order => collectOrder(order))
.then(pizza => eatPizza(pizza))
.catch(failureCallback)

之所以有效,是因为()=> x与()=> {return x}相同。

您甚至可以执行此操作(由于函数只是传递参数,因此我们不需要分层):

chooseToppings().then(placeOrder).then(collectOrder).then(eatPizza).catch(failureCallback)

这样的代码比伪代码更难阅读,不能与更复杂的结构一起使用。

注意:通过使用async / await,可以进一步改善伪代码,这将在以后的文章中进行讨论。

从本质上讲,promise与事件侦听器相似,但有一些区别:

  • 一个承诺只能实现一次(成功或失败)。操作完成后,不能执行两次,也不能从成功切换为失败,反之亦然。
  • 如果Promise已完成,并且我们添加了一个回调函数来处理其结果,则尽管该事件发生在添加函数之前,但仍将调用此函数。

基本的Promise语法:一个真实的例子


知道承诺很重要,因为许多Web API在执行潜在复杂任务的函数中使用它们。使用现代网络技术需要使用诺言。稍后,我们将学习如何编写自己的Promise,但是现在,让我们看一些可以在Web API中找到的简单示例。

在第一个示例中,我们使用fetch()方法从网络上获取图像,blob()方法将响应主体的内容转换为Blob对象,并在<img>元素内显示该对象。该示例与第一篇文章中的示例非常相似,但是我们会做些不同。

注意:如果您仅从文件中运行该示例(即使用file:// URL),则以下示例将不起作用。您需要通过本地服务器或使用在线解决方案(例如GlitchGitHub页面)来运行它

1.首先,上传我们将收到HTML图像

2.将<script>元素添加<body>的末尾

3.在<script>元素内,添加以下行:

let promise = fetch('coffee.jpg')

使用图像URL作为参数的fetch()方法用于从网络获取图像。作为第二个参数,我们可以使用设置指定一个对象,但是现在我们将自己局限于一个简单的选项。我们将fetch()返回的promise存储在promise变量中。如前所述,promise是代表中间状态的对象-给定状态的正式名称待定。

4.为了处理成功完成诺言的结果(在本例中,当Response返回时),我们调用.then()方法仅当promise成功完成并且返回Response对象时,.then()块中的回调函数(通常称为执行程序)才启动-他们说promise已完成(已实现,已满)。响应作为参数传递。

注意。.then()块的操作类似于AddEventListener()事件侦听器的操作。它仅在事件发生后(履行承诺后)触发。两者之间的区别在于.then()只能被调用一次,而侦听器被设计用于多次调用。

收到响应后,我们调用blob()方法将响应转换为Blob对象。看起来像这样:

response => response.blob()

...这是以下内容的简称:

function (response){
    return response.blob()
}

好,足够的话。在第一行之后添加以下内容:

let promise2 = promise.then(response => response.blob())

5.每次调用.then()都会创建一个新的Promise。这非常有用:由于blob()也返回了一个promise,我们可以通过在第二个promise上调用.then()来处理Blob对象。鉴于我们想做的事情比调用方法和返回结果还要复杂,我们需要将函数的主体括在花括号中(否则,将引发异常):

在代码末尾添加以下内容:

let promise3 = promise2.then(myBlob => {

})

6.让我们填写执行函数的主体。将以下行添加到大括号中:

let objectURL = URL.createObjectURL(myBlob)
let image = document.createElement('img')
image.src = objectURL
document.body.appendChild(image)

在这里,我们调用URL.createObjectURL()方法,并将其作为Blob参数传递,该参数返回第二个Promise。我们获得了指向该对象的链接。然后,我们创建一个<img>元素,使用对象链接的值设置src属性,并将其添加到DOM中,以便图像显示在页面上。

如果保存HTML并将其加载到浏览器中,您将看到图像按预期显示。做得好!

注意。您可能已经注意到这些示例有些人为的。您可以不用fetch()和blob()方法,并为<img>对应的URL分配coffee.jpg。我们以这种方式通过一个简单的示例来演示如何兑现承诺。

失败反应


我们忘记了一些东西-我们没有错误处理程序,以防万一其中一个承诺失败(将被拒绝)。我们可以使用.catch()方法添加错误处理程序。让我们这样做:

let errorCase = promise3.catch(e => {
    console.log('There has been a problem with your fetch operation: ' + e.message)
})

为了实际操作,请指定错误的图像URL并重新加载页面。错误消息将出现在控制台中。

在这种情况下,如果我们不添加.catch()块,则控制台中还将显示一条错误消息。但是.catch()允许我们以所需的方式处理错误。在实际的应用程序中,您的.catch()块可能会尝试重新拍摄图像或显示默认图像,或者要求用户选择其他图像或执行其他操作。

注意。观看现场演示源代码)。

块合并


实际上,我们用来编写代码的方法并不是最佳的。我们故意采用这种方式,以便您可以了解每个阶段发生的情况。如前所示,我们可以组合.then()块(和.catch()块)。我们的代码可以按以下方式重写(另请参见GitHub上的simple-fetch-chained.html):

fetch('coffee.jpg')
.then(response => response.blob())
.then(myBlob => {
    let objectURL = URL.createObjectURL(myBlob)
    let image = document.createElement('img')
    image.src = objectURL
    document.body.appendChild(image)
})
.catch(e => {
    console.log('There has been a problem with your fetch operation: ' + e.message)
})

请记住,promise返回的值将作为参数传递给.then()块的下一个函数执行器。

注意。promise中的.then()/。Catch()块是同步try ... catch块的异步等效项。请记住:同步try ... catch在异步代码中不起作用。

无条件术语结论


让我们来盘点一下,并写一份简短的指南,以备将来使用。为了巩固知识,我们建议您多次阅读上一节。

1.当创建诺言时,他们说它处于期望状态。
2.兑现承诺后,他们说承诺已完成(已解决):

  1. 1.成功完成诺言称为履行。它返回承诺末尾从.then()通过链获得的值。.then()块中的执行函数包含了promise返回的值。
  2. 2.未成功完成的承诺称为拒绝。它返回原因,错误消息导致拒绝承诺。可以通过promise末尾的.catch()块获得此原因。

在多个承诺后运行代码


我们学习了使用诺言的基础知识。现在,让我们看一下更高级的功能。 .then()中的链很好,但是如果我们要一个接一个地调用多个Promise块怎么办?

您可以使用标准的Promise.all()方法执行此操作。此方法将一个Promise数组作为参数,并在满足数组中的所有Promise时返回一个新的Promise。看起来像这样:

Promise.all([a,b,c]).then(values => {
        ...
})

如果所有的诺言都得到满足,则.then()块的数组执行程序将作为参数传递。如果至少没有兑现承诺,则整个区块将被拒绝。

这会很有帮助。想象一下,我们收到了有关用页面上的内容动态填充用户界面的信息。在许多情况下,比部分显示信息更合理的是接收所有信息并在页面上显示它。

让我们看另一个示例:
1.下载页面模板,并将<script>标记放在结束</ body>标记之前。

2.下载源文件(coffee.jpgtea.jpgdescription.txt)或将其替换为您的。

3.在脚本中,我们首先定义一个函数,该函数返回传递给Promise.all()的Promise。如果在完成三个fetch()操作之后运行Promise.all(),这将很容易做到。我们可以执行以下操作:

let a = fetch(url1)
let b = fetch(url2)
let c = fetch(url3)

Promise.all([a, b, c]).then(values => {
    ...
})

兑现承诺后,“ values”变量将包含三个Response对象,每个完成的fetch()操作中一个。

但是,我们不希望这样。fetch()操作何时完成对我们来说并不重要。我们真正想要的是加载的数据。这意味着我们要在接收到表示图像和有效文本字符串的有效blob之后运行Promise.all()块。我们可以编写一个执行此操作的函数;将以下内容添加到您的<script>元素中:

function fetchAndDecode(url, type){
    return fetch(url).then(response => {
        if(type === 'blob'){
            return response.blob()
        } else if(type === 'text'){
            return response.text()
        }
    }).catch(e => {
        console.log('There has been a problem with your fetch operation ' + e.message)
    })
}

它看起来有点复杂,所以让我们逐步看一下代码:

1.首先,我们声明一个函数并将其URL和结果文件的类型传递给它。

2.该函数的结构类似于我们在第一个示例中看到的结构-我们调用fetch()函数来获取特定URL处的文件,然后将文件传输到另一个返回已解码(读取)响应主体的Promise。在前面的示例中,它始终是blob()方法。

3.有两个区别:

  • 首先,第二个回报承诺取决于价值的类型。在执行函数中,我们使用if ... else if语句根据需要解码的文件类型返回promise(在这种情况下,我们在blob和text之间进行选择,但是该示例可以轻松扩展为与其他类型一起使用)。
  • -, «return» fetch(). , (.. , blob() text(), , , ). , return .

4.最后,我们调用.catch()方法来处理传递给.all()的数组中的promise中可能发生的任何错误。如果其中一项承诺被拒绝,那么catch块将使我们知道问题出在什么方面。 .all()块仍将执行,但不会显示有错误的结果。如果要拒绝.all(),请在末尾添加.catch()块。

函数内部的代码是异步的,并且基于Promise,因此整个函数的工作方式类似于Promise。

4.接下来,我们调用函数三次,以开始接收和解码图像和文本的过程,并将每个promise放入一个变量中。添加以下内容:

let coffee = fetchAndDecode('coffee.jpg', 'blob')
let tea = fetchAndDecode('tea.jpg', 'blob')
let description = fetchAndDecode('description.txt', 'text')

5.接下来,我们声明一个Promise.all()块,仅在所有三个Promise都完成后,才执行某些代码。在.then()中添加一个带有空执行程序函数的块:

Promise.all([coffee, tea, description]).then(values => {

})

您可以看到它以一个promise数组作为参数。承包商必须在所有三个诺言都兑现之后才能开始;发生这种情况时,每个承诺的结果(解码后的响应主体)将被放置在一个数组中,这可以表示为[咖啡结果,茶结果,描述结果]。

6.最后,将以下内容添加到执行程序中(这里我们使用一个相当简单的同步代码将结果放入变量中(从blob创建URL对象)并在页面上显示图像和文本):

console.log(values)
//       
let objectURL1 = URL.createObjectURL(values[0])
let objectURL2 = URL.createObjectURL(values[1])
let descText = values[2]

//  
let image1 = document.createElement('img')
let image2 = document.createElement('img')
image1.src = objectURL1
image2.src = objectURL2
document.body.appendChild(image1)
document.body.appendChild(image2)

//  
let para = document.createElement('p')
para.textContent = descText
document.body.appendChild(para)

保存更改并重新加载页面。您应该看到所有用户界面组件都在加载中,尽管看起来并不吸引人。

注意。如果您有任何困难,可以将您的代码版本与我们的代码版本进行比较-这是实时演示源代码

注意。为了改进此代码,您可以遍历显示的元素,接收和解码每个元素,然后遍历Promise.all()中的结果,并根据其类型启动不同的功能来显示每个结果。这将使您可以处理任意数量的元素。

另外,您可以确定结果文件的类型,而不必显式指定type属性。例如,这可以通过response.headers.get('content-type')用于检查HTTP协议Content-Type标头

兑现/拒绝诺言后运行代码


通常,您可能需要在诺言完成后执行代码,而不管它是被执行还是被拒绝。以前,我们必须在.then()块和.catch()块中都包含相同的代码,例如:

myPromise
.then(response => {
    doSomething(response)
    runFinalCode()
})
.catch(e => {
    returnError(e)
    runFinalCode()
})

在现代浏览器中,可以使用.finally()方法,该方法允许您在Promise完成后运行代码,从而避免了重复,并使代码更加优雅。上一个示例中的代码可以重写如下:

myPromise
.then(response => {
    doSomething(response)
})
.catch(e => {
    returnError(e)
})
.finally(() => {
    runFinalCode()
})

您可以在实际示例中看到此方法的使用- 实时演示promise-finally.html源代码)。它与上一个示例中的Promise.all()相同,除了我们在fetchAndDecode()函数中将finally()添加到链的末尾:

function fetchAndDecode(url, type){
    return fetch(url).then(response => {
        if(type === 'blob'){
            return response.blob()
        } else if(type === 'text'){
            return response.text()
        }
    }).catch(e => {
        console.log(`There has been a problem with your fetch operation for resource "${url}": ${e.message}`)
    }).finally(() => {
        console.log(`fetch attempt for "${url}" finished.`)
    })
}

我们将收到一条有关每次尝试获取文件已完成的消息。

注意。then()/ catch()/ finally()与同步try()/ catch()/ finally()异步等效。

写下自己的承诺


好消息是您已经知道该怎么做。当您将多个诺言与.then()组合,或将它们组合以提供特定功能时,您将创建自己的异步和基于诺言的函数。以上一个示例中的fetchAndDecode()函数为例。

结合各种基于promise的API来创建特定功能是使用promise的最常见方法,这表明了现代API的灵活性和强大功能。但是,还有另一种方法。

构造函数承诺()


您可以使用Promise()构造函数创建自己的Promise。在您具有需要“超大”的旧异步API中的代码的情况下,这可能是必要的。这样做是为了能够与现有的旧代码,库或“框架”以及新的基于承诺的代码同时工作。

让我们看一个简单的示例-在这里将setTimeout()调用包装在promise中-这将在2秒钟内启动该函数,该函数将使用字符串“ Success!”来实现promise(使用传递的resolve()调用)。

let timeoutPromise = new Promise((resolve, reject) => {
    setTimeout(function(){
        resolve('Success!')
    }, 2000)
})

resolve()和reject()是被调用以实现或拒绝新诺言的函数。在这种情况下,使用字符串“ Success!”来实现promise。

当您调用此承诺时,可以向其添加.then()块,以进一步处理“成功!”行。因此,我们可以在消息中输出一行:

timeoutPromise
.then((message) => {
    alert(message)
})

... 或者:

timeoutPromise.then(alert)

观看现场演示源代码)。

给定的示例不是非常灵活-承诺只能通过简单的一行来实现,我们没有错误处理程序-reject()(实际上setTimeout()不需要错误处理程序,因此在这种情况下没有关系)。

注意。为什么要解决()而不是实现()?目前,答案是这样的:很难解释。

我们拒绝承诺


我们可以使用reject()方法创建被拒绝的诺言-就像resolve(),reject()接受一个简单值一样,但是与resolve()不同,该简单值不是结果,而是拒绝的原因,即 传递给.catch()块的错误。

让我们通过添加一个拒绝承诺的条件以及接收成功消息以外的消息的能力来扩展前面的示例。

前面的示例为例,将其重写为:

function timeoutPromise(message, interval){
    return new Promise((resolve, reject) => {
        if(message === '' || typeof message !== 'string'){
            reject('Message is empty or not a string')
        } else if(interval < 0 || typeof interval !== number){
            reject('Interval is negative or not a number')
        } else{
            setTimeout(function(){
                resolve(message)
            }, interval)
        }
    })
}

在这里,我们将两个参数传递给函数-消息和间隔(时间延迟)。在一个函数中,我们返回一个Promise对象。

在Promise构造函数中,我们使用if ... else结构执行一些检查:

  1. 首先,我们检查消息。如果它为空或不是字符串,则我们拒绝承诺并报告错误。
  2. 其次,我们检查时间延迟。如果它是负数或不是数字,那么我们也会拒绝承诺并报告错误。
  3. 最后,如果两个参数都正确,则使用setTimeout()在特定时间(间隔)后显示消息。

由于timeoutPromise()返回了promise,我们可以在其中添加.then()、. catch()等以改善其功能。让我们这样做:

timeoutPromise('Hello there!', 1000)
.then(message => {
    alert(message)
}).catch(e => {
    console.log('Error: ' + e)
})

保存更改并运行代码后,一秒钟后您将看到一条消息。现在尝试传递一个空字符串作为消息,或者传递一个负数作为间隔。由于拒绝承诺,您会看到一条错误消息。

注意。您可以在GitHub上找到此示例的版本-custom-promise2.html)。

更现实的例子。


我们研究的示例使理解该概念变得容易,但是它并不是真正的异步。尽管使用setTimeout()模拟了示例中的异步,但它仍然显示了用合理的工作流程,良好的错误处理等功能创建承诺的有用性。

使用Promise()构造函数的有用异步应用程序的一个示例是idb库(具有可用性的IndexedDB)杰克·阿奇博尔德(Jake Archibald)。该库允许您使用带有承诺的旧样式编写的IndexedDB API(基于用于在客户端存储和检索数据的API回调函数)。如果查看主库文件,您会发现它使用了我们上面检查的相同技术。以下代码将许多IndexedDB方法使用的基本查询模型转换为Promise兼容模型:

function promisifyRequest(request){
    return new Promise(function(resolve, reject){
        request.onsuccess = function(){
            resolve(request.result)
        }

        request.onerror = function(){
            reject(request.error)
        }
    })
}

这将添加一些事件处理程序,这些事件处理程序将根据需要实现或拒绝承诺:

  • 当请求成功时成功处理程序将根据请求的结果来履行承诺。
  • 当请求失败时,onerror处理程序将拒绝带有错误请求的promise。

结论


当我们不知道函数将返回什么值或需要多长时间时,承诺是创建异步应用程序的好方法。它们允许您使用一系列异步操作,而无需使用深度嵌套的回调函数,并且它们支持同步try ... catch语句的错误处理样式。

所有现代浏览器都支持该承诺。一个例外是Opera Mini和IE11及其更早版本。

在本文中,我们没有考虑承诺的所有功能,仅考虑了最有趣和最有用的功能。如果您想了解有关诺言的更多信息,您将了解其他功能和技术。

大多数现代Web API都是基于Promise的,因此Promise必须是已知的。在这些Web API中,我们可以命名为WebRTCWeb Audio APIMedia Capture和Streams等。Promise将越来越流行,因此对它们的研究和理解是掌握现代JavaScript的重要一步。

另请参阅“每个人都应该知道的JavaScript承诺中的常见错误

感谢您的关注。

欢迎进行建设性的批评。

All Articles