同步JavaScript
要理解什么是异步JavaScript,我们应该从确切理解同步 JavaScript 开始。
先看一个简单的例子(运行它,这是源码):
1 | const btn = document.querySelector('button'); |
这段代码, 一行一行的顺序执行:
- 先取得一个在DOM里面的
<button>
引用。 - 点击按钮的时候,添加一个
click
事件监听器:alert()
消息出现。- 一旦alert 结束,创建一个
<p>
元素。 - 给它的文本内容赋值。
- 最后,把这个段落放进网页。
每一个操作在执行的时候,其他任何事情都没有发生 — 网页的渲染暂停. JavaScript is single threaded. 任何时候只能做一件事情, 只有一个主线程,其他的事情都阻塞了,直到前面的操作完成。
所以上面的例子,点击了按钮以后,段落不会创建,直到在alert消息框中点击ok,段落才会出现。
异步JavaScript
就前面提到的种种原因(比如,和阻塞相关)很多网页API特性使用异步代码,特别是从外部的设备上获取资源,譬如,从网络获取文件,访问数据库,从网络摄像头获得视频流,或者向VR头罩广播图像。
为什么使用异步代码这么难?看一个例子,当你从服务器获取一个图像,通常你不可能立马就得到,这需要时间,虽然现在的网络很快。这意味着下面的伪代码可能不能正常工作:
1 | var response = fetch('myImage.png'); |
因为你不知道下载图片会多久,所以第二行代码执行的时候可能报错(可能间歇的,也可能每次)因为图像还没有就绪。取代的方法就是,代码必须等到 response
返回才能继续往下执行。
在JavaScript代码中,你经常会遇到两种异步编程风格:老派 callbacks
,新派 promise
。下面就来分别介绍。
异步callbacks
异步 callbacks
其实就是函数,只不过是作为参数传递给那些在后台执行的其他函数. 当那些后台运行的代码结束,就调用 callbacks
函数,通知你工作已经完成,或者其他有趣的事情发生了。使用 callbacks
有一点老套,在一些老派但经常使用的API里面,你会经常看到这种风格。
举个例子,异步 callback
就是 addEventListener()
第二个参数(前面的例子):
1 | btn.addEventListener('click', () => { |
第一个参数是侦听的事件类型,第二个就是事件发生时调用的回调函数。.
当我们把回调函数作为一个参数传递给另一个函数时,仅仅是把回调函数定义作为参数传递过去 — 回调函数并没有立刻执行,回调函数会在包含它的函数的某个地方异步执行,包含函数负责在合适的时候执行回调函数。
你可以自己写一个容易的,包含回调函数的函数。来看另外一个例子,用 XMLHttpRequest
API (运行它, 源代码) 加载资源:
1 | function loadAsset(url, type, callback) { |
创建 displayImage()
函数,简单的把blob传递给它,生成 objectURL
,然后再生成一个 image
元素,把 objectURL
作为 image
的源地址,最后显示这张图片。 然后,我们创建 loadAsset()
函数,把URL,type,和回调函数同时都作为参数。函数用 XMLHttpRequest
(通常缩写 “XHR”) 获取给定URL的资源,在获得资源响应后再把响应作为参数传递给回调函数去处理。 (使用 onload
事件处理) ,有点烧脑,是不是?!
回调函数用途广泛 — 他们不仅仅可以用来控制函数的执行顺序和函数之间的数据传递,还可以根据环境的不同,将数据传递给不同的函数,所以对下载好的资源,你可以采用不同的操作来处理,譬如 processJSON()
, displayText()
, 等等。
请注意,不是所有的回调函数都是异步的 — 有一些是同步的。一个例子就是使用 Array.prototype.forEach()
来遍历数组 (运行, 源代码):
1 | const gods = ['Apollo', 'Artemis', 'Ares', 'Zeus']; |
在这个例子中,我们遍历一个希腊神的数组,并在控制台中打印索引和值。forEach()
需要的参数是一个回调函数,回调函数本身带有两个参数,数组元素和索引值。它无需等待任何事情,立即运行。
Promises
Promises
是新派的异步代码,现代的web APIs经常用到。 fetch()
API就是一个很好的例子, 它基本上就是一个现代版的,更高效的 XMLHttpRequest
。看个例子:
1 | fetch('products.json').then(function(response) { |
这里 fetch()
只需要一个参数— 资源的网络 URL — 返回一个 promise
. promise
是表示异步操作完成或失败的对象。可以说,它代表了一种中间状态。 本质上,这是浏览器说“我保证尽快给您答复”的方式,因此得名“promise
”。
这两种可能的结果都还没有发生,因此 fetch
操作目前正在等待浏览器试图在将来某个时候完成该操作的结果。然后我们有三个代码块链接到 fetch()
的末尾:
- 两个
then()
块。两者都包含一个回调函数,如果前一个操作成功,该函数将运行,并且每个回调都接收前一个成功操作的结果作为输入,因此您可以继续对它执行其他操作。每个 .then()
块返回另一个promise
,这意味着可以将多个.then()
块链接到另一个块上,这样就可以依次执行多个异步操作。 - 如果其中任何一个
then()
块失败,则在末尾运行catch()
块——与同步try...catch
类似,catch()
提供了一个错误对象,可用来报告发生的错误类型。但是请注意,同步try...catch
不能与promise
一起工作,尽管它可以与async
/await
一起工作,稍后您将了解到这一点。
事件队列
像 promise
这样的异步操作被放入事件队列中,事件队列在主线程完成处理后运行,这样它们就不会阻止后续JavaScript代码的运行。排队操作将尽快完成,然后将结果返回到JavaScript环境。
Promises 对比 callbacks
promises
与旧式 callbacks
有一些相似之处。它们本质上是一个返回的对象,您可以将回调函数附加到该对象上,而不必将回调作为参数传递给另一个函数。
然而,Promise
是专门为异步操作而设计的,与旧式回调相比具有许多优点:
- 您可以使用多个
then()
操作将多个异步操作链接在一起,并将其中一个操作的结果作为输入传递给下一个操作。这种链接方式对回调来说要难得多,会使回调以混乱的“末日金字塔”告终。 (也称为回调地狱)。 Promise
总是严格按照它们放置在事件队列中的顺序调用。- 错误处理要好得多——所有的错误都由块末尾的一个.catch()块处理,而不是在“金字塔”的每一层单独处理。
异步代码的本质
让我们研究一个示例,它进一步说明了异步代码的本质,展示了当我们不完全了解代码执行顺序以及将异步代码视为同步代码时可能发生的问题。下面的示例与我们之前看到的非常相似( 运行它 和 源代码)。一个不同之处在于,我们包含了许多 console.log()
语句,以展示代码将在其中执行的顺序。
1 | console.log ('Starting'); |
浏览器将会执行代码,看见第一个 console.log()
输出(Starting
) ,然后创建 image
变量。
然后,它将移动到下一行并开始执行 fetch()
块,但是,因为 fetch()
是异步执行的,没有阻塞,所以在 promise
相关代码之后程序继续执行,从而到达最后的 console.log()
语句(All done!
)并将其输出到控制台。
只有当 fetch()
块完成运行返回结果给.then()
,我们才最后看到第二个 console.log()
消息 (It worked ;
)) . 所以 这些消息 可能以 和你预期不同的顺序出现:
- Starting
- All done!
- It worked : )
如果你感到疑惑,考虑下面这个小例子:
1 | console.log("registering click handler"); |
这在行为上非常相似——第一个和第三个 console.log()
消息将立即显示,但是第二个消息将被阻塞,直到有人单击鼠标按钮。前面的示例以相同的方式工作,只是在这种情况下,第二个消息在 promise
链上被阻塞,直到获取资源后再显示在屏幕上,而不是单击。
要查看实际情况,请尝试获取示例的本地副本,并将第三个 console.log()
调用更改为以下命令:
1 | console.log ('All done! ' + image.src + 'displayed.'); |
此时控制台将会报错,而不会显示第三个 console.log
的信息:
1 | TypeError: image is undefined; can't access its "src" property |
这是因为:浏览器运行第三个 console.log()
的时候,fetch()
语句块还没有完成,因此 image
还没有赋值。