第 3 版 已发布。点击此处阅读

第 17 章
HTTP

网络背后的梦想是创建一个共同的信息空间,我们通过共享信息来交流。它的普适性至关重要:超文本链接可以指向任何东西,无论是个人、本地还是全球,无论是草稿还是经过精心打磨的。

蒂姆·伯纳斯-李,万维网:一个非常简短的个人历史

超文本传输协议,已在第 12 章中提到,是万维网上请求和提供数据的机制。本章将更详细地描述该协议,并解释浏览器 JavaScript 如何访问它。

协议

如果您在浏览器的地址栏中输入eloquentjavascript.net/17_http.html,浏览器首先会查找与eloquentjavascript.net关联的服务器地址,并尝试在端口 80 上打开到它的 TCP 连接,这是 HTTP 流量的默认端口。如果服务器存在并接受连接,浏览器会发送类似于以下内容的信息

GET /17_http.html HTTP/1.1
Host: eloquentjavascript.net
User-Agent: Your browser's name

然后,服务器通过相同的连接进行响应。

HTTP/1.1 200 OK
Content-Length: 65585
Content-Type: text/html
Last-Modified: Wed, 09 Apr 2014 10:48:09 GMT

<!doctype html>
... the rest of the document

然后,浏览器获取响应中空行后的部分,并将其显示为 HTML 文档。

客户端发送的信息称为请求。它从以下行开始

GET /17_http.html HTTP/1.1

第一个词是请求的方法GET 表示我们想要获取指定的资源。其他常见方法包括DELETE 用于删除资源,PUT 用于替换资源,以及POST 用于向资源发送信息。请注意,服务器没有义务执行它收到的每个请求。如果您走到一个随机的网站,并告诉它DELETE 它的主页,它很可能会拒绝。

方法名之后的部分是请求所应用的资源的路径。在最简单的情况下,资源仅仅是服务器上的一个文件,但协议并不要求它必须是文件。资源可以是任何可以以文件方式传输的东西。许多服务器动态生成它们产生的响应。例如,如果您打开twitter.com/marijnjh,服务器会在其数据库中查找名为marijnjh 的用户,如果找到,它将为该用户生成一个个人资料页面。

在资源路径之后,请求的第一行提到HTTP/1.1,表示它使用的 HTTP 协议的版本。

服务器的响应也将以版本开头,然后是响应的状态,首先是一个三位数的状态码,然后是一个人类可读的字符串。

HTTP/1.1 200 OK

以 2 开头的状态码表示请求成功。以 4 开头的代码表示请求有误。404 可能是最著名的 HTTP 状态码——它表示请求的资源未找到。以 5 开头的代码表示服务器发生错误,而请求本身没有问题。

请求或响应的第一行之后可以跟随任意数量的标头。这些是格式为“名称:值”的行,用于指定有关请求或响应的额外信息。这些标头是示例响应的一部分

Content-Length: 65585
Content-Type: text/html
Last-Modified: Wed, 09 Apr 2014 10:48:09 GMT

这告诉我们响应文档的大小和类型。在本例中,它是一个大小为 65,585 字节的 HTML 文档。它还告诉我们该文档最后修改的时间。

在大多数情况下,客户端或服务器决定在请求或响应中包含哪些标头,尽管一些标头是必需的。例如,Host 标头,用于指定主机名,应该包含在请求中,因为服务器可能在一个 IP 地址上服务多个主机名,如果没有该标头,服务器将不知道客户端试图与哪个主机通信。

在标头之后,请求和响应都可能包含一个空行,然后是一个主体,其中包含正在发送的数据。GETDELETE 请求不发送任何数据,但PUTPOST 请求会发送。同样,一些响应类型,例如错误响应,不需要主体。

浏览器和 HTTP

正如我们在示例中看到的,当我们在浏览器的地址栏中输入 URL 时,浏览器会发出请求。当生成的 HTML 页面引用其他文件,例如图像和 JavaScript 文件时,这些文件也会被提取。

一个中等复杂的网站可以轻松地包含 10 到 200 个资源。为了能够快速提取这些资源,浏览器会同时发出多个请求,而不是一个接一个地等待响应。这些文档始终使用GET 请求进行提取。

HTML 页面可能包含表单,允许用户填写信息并将其发送到服务器。这是一个表单的示例

<form method="GET" action="example/message.html">
  <p>Name: <input type="text" name="name"></p>
  <p>Message:<br><textarea name="message"></textarea></p>
  <p><button type="submit">Send</button></p>
</form>

此代码描述了一个包含两个字段的表单:一个小的字段询问姓名,一个大的字段用于写入消息。当您点击发送按钮时,这些字段中的信息将被编码为查询字符串。当<form> 元素的method 属性为GET(或省略)时,查询字符串将被附加到action URL,并且浏览器将向该 URL 发出一个GET 请求。

GET /example/message.html?name=Jean&message=Yes%3F HTTP/1.1

查询字符串的开头由问号表示。之后是一对名称和值,分别对应表单字段元素的name 属性和这些元素的内容。使用与符号(&)来分隔这些对。

在之前的 URL 中编码的实际消息是“Yes?”,即使问号被一个奇怪的代码替换。查询字符串中的一些字符必须进行转义。问号,表示为%3F,就是其中之一。似乎有一条不成文的规则,每个格式都需要自己的转义字符方式。这种方式被称为URL 编码,使用百分号后跟两个十六进制数字来编码字符代码。在本例中,3F(十进制表示为 63)是问号字符的代码。JavaScript 提供了encodeURIComponentdecodeURIComponent 函数来对这种格式进行编码和解码。

console.log(encodeURIComponent("Hello & goodbye"));
// → Hello%20%26%20goodbye
console.log(decodeURIComponent("Hello%20%26%20goodbye"));
// → Hello & goodbye

如果我们将之前示例中 HTML 表单的method 属性更改为POST,则提交表单时发出的 HTTP 请求将使用POST 方法,并将查询字符串放在请求主体中,而不是将其添加到 URL 中。

POST /example/message.html HTTP/1.1
Content-length: 24
Content-type: application/x-www-form-urlencoded

name=Jean&message=Yes%3F

按照惯例,GET 方法用于没有副作用的请求,例如进行搜索。更改服务器上内容的请求,例如创建新帐户或发布消息,应该用其他方法表示,例如POST。客户端软件,例如浏览器,知道它不应该盲目地发出POST 请求,但通常会隐式地发出GET 请求——例如,为了预取它认为用户很快就会需要的资源。

下一章将回到表单,并讨论如何使用 JavaScript 对它们进行脚本化。

XMLHttpRequest

浏览器 JavaScript 用于发出 HTTP 请求的接口称为XMLHttpRequest(注意不一致的大小写)。它是由微软在 20 世纪 90 年代后期为其 Internet Explorer 浏览器设计的。在此期间,XML 文件格式在商业软件领域非常流行——微软一直处于领先地位的世界。事实上,它如此流行,以至于 XML 的缩写被添加到了 HTTP 接口名称的前面,而该接口与 XML 没有任何关系。

不过,这个名称并非完全没有道理。该接口允许您将响应文档解析为 XML(如果您需要)。将两个不同的概念(发出请求和解析响应)合并成一个东西是糟糕的设计,当然,但情况就是这样。

XMLHttpRequest 接口被添加到 Internet Explorer 时,它使人们能够使用 JavaScript 做以前非常难做到的事情。例如,网站开始在用户在文本字段中输入内容时显示建议列表。脚本会在用户键入内容时将文本通过 HTTP 发送到服务器。服务器拥有一个可能的输入数据库,它会将数据库条目与部分输入进行匹配,并将可能的完成结果发回给用户以显示。这被认为是惊人的——人们习惯于等待每个与网站的交互的完整页面重新加载。

当时另一个重要的浏览器,Mozilla(后来的 Firefox),不想落后。为了让人们能够在浏览器中做同样巧妙的事情,Mozilla 复制了该接口,包括虚假名称。下一代浏览器遵循了这个例子,如今XMLHttpRequest 已经成为事实上的标准接口。

发送请求

为了发出一个简单的请求,我们使用XMLHttpRequest 构造函数创建一个请求对象,并调用它的opensend 方法。

var req = new XMLHttpRequest();
req.open("GET", "example/data.txt", false);
req.send(null);
console.log(req.responseText);
// → This is the content of data.txt

open 方法配置请求。在本例中,我们选择对example/data.txt 文件发出一个GET 请求。不以协议名称(例如http:)开头的 URL 是相对的,这意味着它们相对于当前文档进行解释。当它们以斜杠(/)开头时,它们会替换当前路径,即服务器名称后面的部分。当它们不以斜杠开头时,当前路径中直到并包括最后一个斜杠字符的部分将放在相对 URL 的前面。

打开请求后,我们可以使用send 方法发送请求。send 的参数是请求主体。对于GET 请求,我们可以传递null。如果open 的第三个参数是falsesend 只有在收到我们请求的响应后才会返回。我们可以读取请求对象的responseText 属性以获取响应主体。

响应中包含的其他信息也可以从该对象中提取。状态码可以通过status 属性访问,人类可读的状态文本可以通过statusText 访问。标头可以通过getResponseHeader 读取。

var req = new XMLHttpRequest();
req.open("GET", "example/data.txt", false);
req.send(null);
console.log(req.status, req.statusText);
// → 200 OK
console.log(req.getResponseHeader("content-type"));
// → text/plain

请求头名称不区分大小写。通常它们以每个单词的首字母大写,例如“Content-Type”,但“content-type”和“cOnTeNt-TyPe”指的是相同的请求头。

浏览器会自动添加一些请求头,例如“Host”和服务器用来确定请求体大小的请求头。但你可以使用 `setRequestHeader` 方法添加自己的请求头。这仅在高级使用场景下需要,并需要与你通信的服务器合作——服务器可以随意忽略它无法处理的请求头。

异步请求

在之前看到的例子中,请求在调用 `send` 返回时就已经完成。这很方便,因为它意味着 `responseText` 等属性可以直接使用。但这也意味着我们的程序在浏览器和服务器通信期间处于暂停状态。当连接不好、服务器速度慢或文件太大时,这可能需要相当长的时间。更糟的是,由于程序暂停期间没有事件处理程序可以触发,整个文档将变得无响应。

如果我们在 `open` 中将第三个参数设置为 `true`,请求将是异步的。这意味着当我们调用 `send` 时,唯一立即发生的事情是请求被安排发送。我们的程序可以继续执行,浏览器将在后台处理数据的发送和接收。

但只要请求正在运行,我们就无法访问响应。我们需要一个机制来通知我们何时数据可用。

为此,我们必须监听请求对象的 `“load”` 事件。

var req = new XMLHttpRequest();
req.open("GET", "example/data.txt", true);
req.addEventListener("load", function() {
  console.log("Done:", req.status);
});
req.send(null);

就像在 第 15 章 中使用 `requestAnimationFrame` 一样,这迫使我们使用异步编程风格,将请求完成后需要执行的操作封装在一个函数中,并在适当的时候安排调用该函数。我们将在 后面 回到这个问题。

获取 XML 数据

当 `XMLHttpRequest` 对象检索到的资源是一个 XML 文档时,对象的 `responseXML` 属性将保存解析后的文档表示。这个表示与 第 13 章 中讨论的 DOM 非常相似,只是它没有像 `style` 属性这样的特定于 HTML 的功能。`responseXML` 保持的对象对应于 `document` 对象。它的 `documentElement` 属性引用 XML 文档的外部标签。在以下文档(*example/fruit.xml*)中,应该是 `<fruits>` 标签

<fruits>
  <fruit name="banana" color="yellow"/>
  <fruit name="lemon" color="yellow"/>
  <fruit name="cherry" color="red"/>
</fruits>

我们可以像这样检索这样的文件

var req = new XMLHttpRequest();
req.open("GET", "example/fruit.xml", false);
req.send(null);
console.log(req.responseXML.querySelectorAll("fruit").length);
// → 3

XML 文档可以用来与服务器交换结构化信息。它们的格式——标签嵌套在其他标签内——非常适合存储大多数类型的数据,至少比纯文本文件好。但是,DOM 接口对于提取信息来说相当笨拙,而且 XML 文档往往很冗长。通常使用 JSON 数据进行通信是一个更好的主意,JSON 数据更易于程序和人阅读和写入。

var req = new XMLHttpRequest();
req.open("GET", "example/fruit.json", false);
req.send(null);
console.log(JSON.parse(req.responseText));
// → {banana: "yellow", lemon: "yellow", cherry: "red"}

HTTP 沙箱

在网页脚本中发出 HTTP 请求再次引发了安全问题。控制脚本的人可能与脚本运行的计算机上的用户没有相同的利益。更具体地说,如果我访问 *themafia.org*,我不希望它的脚本能够使用我的浏览器中的身份信息向 *mybank.com* 发出请求,并指示将我的所有资金转入某个随机的黑帮账户。

网站可以通过多种方式保护自己免受此类攻击,但这需要付出努力,而且许多网站没有做到这一点。出于这个原因,浏览器通过禁止脚本对其他(如 *themafia.org* 和 *mybank.com* 等名称)发出 HTTP 请求来保护我们。

当构建需要访问多个域以实现合法目的的系统时,这可能是一个令人讨厌的问题。幸运的是,服务器可以在其对浏览器的响应中包含类似于这样的请求头,以明确指示浏览器可以从其他域发出请求

Access-Control-Allow-Origin: *

抽象请求

第 10 章 中,在我们实现 AMD 模块系统时,我们使用了一个名为 `backgroundReadFile` 的假设函数。它接受一个文件名和一个函数,并在完成文件获取后使用文件的内容调用该函数。以下是该函数的简单实现

function backgroundReadFile(url, callback) {
  var req = new XMLHttpRequest();
  req.open("GET", url, true);
  req.addEventListener("load", function() {
    if (req.status < 400)
      callback(req.responseText);
  });
  req.send(null);
}

这个简单的抽象使得使用 `XMLHttpRequest` 进行简单的 `GET` 请求变得更加容易。如果你正在编写一个必须发出 HTTP 请求的程序,最好使用一个帮助函数,这样你就不必在整个代码中重复丑陋的 `XMLHttpRequest` 模式。

函数参数的名称 `callback` 是一个经常用来描述此类函数的术语。回调函数被传递给其他代码,以便为该代码提供一种“稍后调用我们”的方式。

编写一个针对应用程序正在执行的操作定制的 HTTP 工具函数并不难。上一个函数只执行 `GET` 请求,并且不让我们控制请求头或请求体。你可以为 `POST` 请求编写另一个变体,或者编写一个支持各种类型请求的更通用的变体。许多 JavaScript 库也为 `XMLHttpRequest` 提供了包装器。

上一个包装器的主要问题是它对失败的处理。当请求返回一个表示错误(400 及以上)的状态码时,它什么也不做。在某些情况下这可能没问题,但想象一下,我们给页面添加了一个“正在加载”指示器来表明我们正在获取信息。如果请求由于服务器崩溃或连接短暂中断而失败,页面将只是停留在那里,误导性地看起来它正在执行某些操作。用户将等待一段时间,变得不耐烦,并认为该网站毫无用处。

我们还应该有一个选项,让我们在请求失败时收到通知,以便我们可以采取适当的操作。例如,我们可以删除“正在加载”消息,并通知用户出了问题。

异步代码中的错误处理比同步代码中的错误处理更棘手。因为我们经常需要延迟部分工作,将其放在回调函数中,`try` 块的作用域变得毫无意义。在以下代码中,异常将不会被捕获,因为调用 `backgroundReadFile` 会立即返回。然后控制离开 `try` 块,并且给定的函数直到稍后才会被调用。

try {
  backgroundReadFile("example/data.txt", function(text) {
    if (text != "expected")
      throw new Error("That was unexpected");
  });
} catch (e) {
  console.log("Hello from the catch block");
}

要处理失败的请求,我们必须允许将一个额外的函数传递给我们的包装器,并在请求出错时调用该函数。或者,我们可以使用以下约定:如果请求失败,则将描述问题的额外参数传递给常规回调函数。以下是一个例子

function getURL(url, callback) {
  var req = new XMLHttpRequest();
  req.open("GET", url, true);
  req.addEventListener("load", function() {
    if (req.status < 400)
      callback(req.responseText);
    else
      callback(null, new Error("Request failed: " +
                               req.statusText));
  });
  req.addEventListener("error", function() {
    callback(null, new Error("Network error"));
  });
  req.send(null);
}

我们添加了一个 `“error”` 事件的处理程序,它将在请求完全失败时发出信号。当请求以表示错误的状态码完成时,我们还会使用一个错误参数调用回调函数。

使用 `getURL` 的代码必须检查是否给出了错误,如果发现错误,则处理它。

getURL("data/nonsense.txt", function(content, error) {
  if (error != null)
    console.log("Failed to fetch nonsense.txt: " + error);
  else
    console.log("nonsense.txt: " + content);
});

这对于异常没有帮助。当将多个异步操作链接在一起时,链中的任何位置的异常(除非你将每个处理函数都包装在自己的 `try/catch` 块中)仍然会出现在顶层,并以粗俗的方式中止你的操作链。

承诺

对于复杂的项目,使用纯回调风格编写异步代码很难做到正确。很容易忘记检查错误,或者让意外异常以粗暴的方式中止程序。此外,当错误必须流经多个回调函数和 `catch` 块时,安排正确的错误处理非常乏味。

已经有很多尝试用额外的抽象来解决这个问题。其中比较成功的一个叫做承诺。承诺将一个异步操作封装在一个对象中,可以传递这个对象,并告诉它在操作完成或失败时执行某些操作。这个接口将成为下一版 JavaScript 语言的一部分,但现在已经可以作为库使用了。

承诺的接口并不完全直观,但它很强大。本章只粗略地描述它。你可以在 www.promisejs.org 找到更详细的介绍。

要创建一个承诺对象,我们调用 `Promise` 构造函数,并向它传递一个初始化异步操作的函数。构造函数会调用该函数,并向它传递两个参数,这两个参数本身也是函数。第一个函数应该在操作成功完成时被调用,第二个函数应该在操作失败时被调用。

再次,以下是我们的 `GET` 请求包装器,这次返回的是一个承诺。我们现在就简单地称它为 `get`。

function get(url) {
  return new Promise(function(succeed, fail) {
    var req = new XMLHttpRequest();
    req.open("GET", url, true);
    req.addEventListener("load", function() {
      if (req.status < 400)
        succeed(req.responseText);
      else
        fail(new Error("Request failed: " + req.statusText));
    });
    req.addEventListener("error", function() {
      fail(new Error("Network error"));
    });
    req.send(null);
  });
}

请注意,函数本身的接口现在变得简单多了。你给它一个 URL,它返回一个承诺。该承诺充当请求结果的句柄。它有一个 `then` 方法,你可以用两个函数调用它:一个处理成功,一个处理失败。

get("example/data.txt").then(function(text) {
  console.log("data.txt: " + text);
}, function(error) {
  console.log("Failed to fetch data.txt: " + error);
});

到目前为止,这只是另一种表达我们已经表达过的相同内容的方式。只有当你需要将操作链接在一起时,承诺才会产生重大差异。

调用 `then` 会产生一个新的承诺,其结果(传递给成功处理程序的值)取决于我们传递给 `then` 的第一个函数的返回值。这个函数可能返回另一个承诺,以表明正在执行更多异步工作。在这种情况下,`then` 返回的承诺本身将等待处理程序函数返回的承诺,并在它被解析时以相同的值成功或失败。当处理程序函数返回一个非承诺值时,`then` 返回的承诺将立即成功,并将该值作为其结果。

这意味着你可以使用 `then` 来转换承诺的结果。例如,这将返回一个承诺,其结果是给定 URL 的内容,并将其解析为 JSON

function getJSON(url) {
  return get(url).then(JSON.parse);
}

最后一次调用 `then` 没有指定失败处理程序。这是允许的。错误将被传递给 `then` 返回的承诺,这正是我们想要的——`getJSON` 不知道在出错时该怎么做,但希望它的调用者知道。

为了举例说明如何使用 Promise,我们将构建一个程序,该程序从服务器获取多个 JSON 文件,并在获取过程中显示“加载”字样。JSON 文件包含关于人员的信息,其中包含链接到其他人员文件的属性,例如 fathermotherspouse

我们希望获取 example/bert.json 中配偶的母亲姓名。如果出现问题,我们希望删除“加载”文本并改为显示错误消息。以下是用 Promise 实现此操作的方法

<script>
  function showMessage(msg) {
    var elt = document.createElement("div");
    elt.textContent = msg;
    return document.body.appendChild(elt);
  }

  var loading = showMessage("Loading...");
  getJSON("example/bert.json").then(function(bert) {
    return getJSON(bert.spouse);
  }).then(function(spouse) {
    return getJSON(spouse.mother);
  }).then(function(mother) {
    showMessage("The name is " + mother.name);
  }).catch(function(error) {
    showMessage(String(error));
  }).then(function() {
    document.body.removeChild(loading);
  });
</script>

生成的程序相对紧凑且易读。catch 方法类似于 then,但它只期望一个失败处理程序,并在成功的情况下将结果未修改地传递。类似于 try 语句的 catch 子句,在捕获失败后,控制流程将继续正常执行。这样,即使出现错误,最后的 then(它会删除加载消息)始终会执行。

您可以将 Promise 接口视为实现了异步控制流的专用语言。实现此目标所需额外的函数调用和函数表达式使得代码看起来有些别扭,但与我们自己处理所有错误处理相比,它并没有那么别扭。

了解 HTTP

在构建一个需要在浏览器中运行的 JavaScript 程序(客户端)和服务器上的程序(服务器端)之间通信的系统时,有几种不同的方法可以对这种通信进行建模。

一种常用的模型是远程过程调用。在此模型中,通信遵循普通函数调用的模式,只是函数实际上运行在另一台机器上。调用它涉及向服务器发送一个请求,该请求包含函数的名称和参数。对该请求的响应包含返回值。

在远程过程调用方面思考时,HTTP 仅仅是通信的载体,您很有可能编写一个完全隐藏它的抽象层。

另一种方法是围绕资源和 HTTP 方法构建您的通信。不是调用名为 addUser 的远程过程,而是使用 PUT 请求发送到 /users/larry。不是将该用户的属性编码为函数参数,而是定义一个文档格式或使用现有的格式来表示用户。然后,用于创建新资源的 PUT 请求的正文就是这样一个文档。通过向资源的 URL 发送 GET 请求(例如,/user/larry)来获取资源,该请求会返回表示该资源的文档。

这种第二种方法使得更容易使用 HTTP 提供的一些功能,例如支持缓存资源(在客户端保留一份副本)。它还可以帮助您保持界面的连贯性,因为资源比一堆函数更容易理解。

安全性和 HTTPS

通过互联网传输的数据往往会经过漫长而危险的路途。为了到达目的地,它必须经过各种网络,从咖啡馆的 Wi-Fi 网络到各种公司和国家控制的网络。在途中的任何一点,它都可能被检查甚至被修改。

如果有些东西必须保持秘密,例如您电子邮件帐户的密码,或者它必须未经修改地到达目的地,例如您从银行网站转账的帐户号码,那么普通的 HTTP 就不够了。

安全 HTTP 协议(其 URL 以 https:// 开头)以一种难以读取和篡改的方式封装 HTTP 流量。首先,客户端通过要求服务器证明其拥有证书颁发机构颁发的浏览器认可的加密证书来验证服务器的身份。其次,所有经过连接的数据都以一种应该防止窃听和篡改的方式进行加密。

因此,当 HTTPS 正常工作时,它可以防止有人冒充您试图与之通信的网站,以及有人窥探您的通信。它并不完美,并且发生过各种事件,HTTPS 由于伪造或被盗的证书以及软件故障而无法正常工作。尽管如此,普通的 HTTP 仍然很容易被搞乱,而破坏 HTTPS 需要那种只有国家或复杂的犯罪组织才能企图做到的努力。

总结

在本章中,我们了解到 HTTP 是一种用于通过互联网访问资源的协议。客户端发送一个请求,该请求包含一个方法(通常是 GET)和一个标识资源的路径。然后,服务器决定如何处理该请求,并使用状态代码和响应正文进行响应。请求和响应都可能包含提供额外信息的标头。

浏览器发出 GET 请求以获取显示网页所需的资源。网页也可能包含表单,允许将用户输入的信息作为提交表单时发出的请求的一部分发送出去。您将在下一章中详细了解这一点。

浏览器 JavaScript 可以用来发出 HTTP 请求的接口称为 XMLHttpRequest。您通常可以忽略该名称中的“XML”部分(但您仍然需要键入它)。它可以使用两种方式 - 同步,它会阻塞所有操作直到请求完成,异步,它需要一个事件处理程序来注意响应已到达。在几乎所有情况下,异步都是首选。发出请求看起来像这样

var req = new XMLHttpRequest();
req.open("GET", "example/data.txt", true);
req.addEventListener("load", function() {
  console.log(req.status);
});
req.send(null);

异步编程很棘手。Promise 是一种接口,它通过帮助将错误条件和异常路由到正确的处理程序,以及通过抽象掉这种编程风格中一些更重复和容易出错的元素,使得它更容易一些。

练习

内容协商

HTTP 可以做的一件事,但我们在本章中没有讨论,叫做内容协商。请求的 Accept 标头可用于告诉服务器客户端希望获取哪种类型的文档。许多服务器会忽略此标头,但当服务器知道编码资源的各种方法时,它可以查看此标头并发送客户端首选的方法。

URL eloquentjavascript.net/author 被配置为根据客户端的要求以纯文本、HTML 或 JSON 格式进行响应。这些格式由标准化的媒体类型 text/plaintext/htmlapplication/json 标识。

发送请求以获取此资源的所有三种格式。使用 XMLHttpRequest 对象的 setRequestHeader 方法将名为 Accept 的标头设置为前面给出的其中一种媒体类型。确保您在调用 open 后但在调用 send 之前设置标头。

最后,尝试请求媒体类型 application/rainbows+unicorns,看看会发生什么。

// Your code here.

请参阅本章中使用 XMLHttpRequest 的各种示例,了解发出请求所涉及的方法调用。如果您想使用同步请求(通过将 open 的第三个参数设置为 false),您可以这样做。

请求一个虚假的媒体类型将返回一个代码为 406 的响应,“不可接受”,这是服务器在无法满足 Accept 标头时应该返回的代码。

等待多个 Promise

Promise 构造函数有一个 all 方法,该方法接受一个 Promise 数组,并返回一个 Promise,该 Promise 等待数组中所有 Promise 完成。然后它成功,并生成一个结果值数组。如果数组中的任何 Promise 失败,则 all 返回的 Promise 也将失败(带有来自失败 Promise 的失败值)。

尝试将类似于此的功能实现为自己定义的常规函数,名为 all

请注意,在 Promise 解决(成功或失败)后,它将无法再次成功或失败,并且对解决它的函数的进一步调用将被忽略。这可以简化您处理 Promise 失败的方式。

function all(promises) {
  return new Promise(function(success, fail) {
    // Your code here.
  });
}

// Test code.
all([]).then(function(array) {
  console.log("This should be []:", array);
});
function soon(val) {
  return new Promise(function(success) {
    setTimeout(function() { success(val); },
               Math.random() * 500);
  });
}
all([soon(1), soon(2), soon(3)]).then(function(array) {
  console.log("This should be [1, 2, 3]:", array);
});
function fail() {
  return new Promise(function(success, fail) {
    fail(new Error("boom"));
  });
}
all([soon(1), fail(), soon(3)]).then(function(array) {
  console.log("We should not get here");
}, function(error) {
  if (error.message != "boom")
    console.log("Unexpected failure:", error);
});

传递给 Promise 构造函数的函数将必须对给定数组中的每个 Promise 调用 then。当其中一个成功时,需要发生两件事。结果值需要存储在结果数组的正确位置,并且我们必须检查这是否是最后一个挂起的 Promise,如果它是,则完成我们自己的 Promise。

后者可以使用计数器完成,计数器初始化为输入数组的长度,并且每次 Promise 成功时减去 1。当它达到 0 时,我们就完成了。确保考虑输入数组为空(因此永远不会有 Promise 解决)的情况。

处理失败需要一些思考,但事实证明非常简单。只需将包装 Promise 的失败函数传递给数组中的每个 Promise,以便其中一个失败会触发整个包装器的失败。