第 14 章: HTTP 请求

正如 第 11 章 中提到的,万维网上的通信通过 HTTP 协议进行。一个简单的 请求可能看起来像这样

GET /files/fruit.txt HTTP/1.1
Host: eloquentjavascript.net
User-Agent: The Imaginary Browser

它请求 eloquentjavascript.net 服务器上的 files/fruit.txt 文件。此外,它还指定此请求使用 HTTP 协议的 1.1 版 ― 1.0 版仍然在使用中,但工作方式略有不同。HostUser-Agent 行遵循一种模式:它们以标识其所含信息的词语开头,后跟冒号和实际信息。这些被称为 '头信息'。User-Agent 头信息告诉服务器哪个浏览器(或其他类型的程序)正在用来发出请求。通常还会发送其他类型的头信息,例如,说明客户端能够理解的文档类型,或其首选的语言。

给定以上请求,服务器可能会发送以下 响应

HTTP/1.1 200 OK
Last-Modified: Mon, 23 Jul 2007 08:41:56 GMT
Content-Length: 24
Content-Type: text/plain

apples, oranges, bananas

第一行再次表明 HTTP 协议的版本,后跟请求的状态。在本例中,状态码为 200,意思是 'OK,没有发生异常情况,我正在发送文件给你'。后面跟着几个头信息,表示(在本例中)文件最后修改的时间,它的长度,以及它的类型(纯文本)。在头信息之后,你会得到一个空行,然后是文件本身。

除了以 GET 开头的请求,它表明客户端只是想获取一个文档外,还可以使用 POST 来表示将随请求一起发送一些信息,服务器预计会以某种方式处理这些信息。 1


当您单击链接、提交表单,或以其他方式鼓励您的浏览器转到新页面时,它会执行 HTTP 请求,并立即卸载旧页面以显示新加载的文档。在典型情况下,这正是您想要的 ― 这是网络传统的工作方式。但是,有时 JavaScript 程序希望与服务器通信而无需重新加载页面。例如,控制台中的 '加载' 按钮可以在不离开页面的情况下加载文件。

为了能够执行此类操作,JavaScript 程序必须自行发出 HTTP 请求。现代浏览器为此提供了一个接口。与打开新窗口一样,此接口也受一些限制。为了防止脚本执行任何危险操作,它只允许对当前页面所在的域发出 HTTP 请求。


在大多数浏览器上,用于发出 HTTP 请求的对象可以通过 new XMLHttpRequest() 创建。早期的 Internet Explorer 版本,最初发明了这些对象,需要您执行 new ActiveXObject("Msxml2.XMLHTTP"),或者在更早的版本上执行 new ActiveXObject("Microsoft.XMLHTTP")ActiveXObject 是 Internet Explorer 对各种浏览器加载项的接口。我们已经习惯了编写不兼容性包装器,所以让我们再做一次

function makeHttpObject() {
  try {return new XMLHttpRequest();}
  catch (error) {}
  try {return new ActiveXObject("Msxml2.XMLHTTP");}
  catch (error) {}
  try {return new ActiveXObject("Microsoft.XMLHTTP");}
  catch (error) {}

  throw new Error("Could not create HTTP request object.");
}

show(typeof(makeHttpObject()));

包装器尝试使用三种方式创建对象,使用 trycatch 来检测哪些失败。如果所有方法都不起作用,这在旧浏览器或安全性设置严格的浏览器中可能会发生,它会引发错误。

那么为什么这个对象被称为 XML HTTP 请求?这是一个有点误导性的名称。 XML 是一种存储文本数据的方式。它使用类似于 HTML 的标签和属性,但结构更严谨,更灵活 ― 为了存储您自己的数据类型,您可以定义您自己的 XML 标签类型。这些 HTTP 请求对象具有一些用于处理检索到的 XML 文档的内置功能,这就是它们名称中包含 XML 的原因。它们也可以处理其他类型的文档,在我的经验中,它们也经常用于非 XML 请求。


现在我们有了 HTTP 对象,我们可以用它来发出类似于上面示例的请求。

var request = makeHttpObject();
request.open("GET", "files/fruit.txt", false);
request.send(null);
print(request.responseText);

open 方法用于配置请求。在本例中,我们选择对 fruit.txt 文件发出 GET 请求。这里给出的 URL 是相对的,它不包含 http:// 部分或服务器名称,这意味着它将在当前文档所在的服务器上查找文件。第三个参数 false,将在稍后讨论。在调用 open 之后,可以使用 send 方法发出实际的请求。当请求是 POST 请求时,要发送到服务器的数据(作为字符串)可以传递给此方法。对于 GET 请求,应该只传递 null

在发出请求后,请求对象的 responseText 属性包含检索到的文档的内容。服务器发回的头信息可以使用 getResponseHeadergetAllResponseHeaders 函数进行检查。第一个查找特定头信息,第二个提供包含所有头信息的字符串。这些偶尔可能有用,可以获取有关文档的一些额外信息。

print(request.getAllResponseHeaders());
show(request.getResponseHeader("Last-Modified"));

如果出于某种原因,您想将头信息添加到发送到服务器的请求中,可以使用 setRequestHeader 方法。它接受两个字符串作为参数,头信息的名称和值。

响应代码,在示例中为 200,可以在 status 属性下找到。当出现错误时,这个神秘的代码将表明它。例如,404 表示您请求的文件不存在。 statusText 包含对状态的略微不那么神秘的描述。

show(request.status);
show(request.statusText);

当您想检查请求是否成功时,将 status200 进行比较通常就足够了。理论上,服务器在某些情况下可能会返回代码 304,表示浏览器已将其存储在 '缓存' 中的旧版文档仍然是最新的。但浏览器似乎通过将 status 设置为 200 来屏蔽您,即使它实际上是 304。此外,如果您正在通过非 HTTP 协议 2(例如 FTP)发出请求,则 status 将不可用,因为协议不使用 HTTP 状态码。


当请求按上面的示例完成时,对 send 方法的调用直到请求完成才返回。这很方便,因为它意味着 responseText 在调用 send 之后可用,我们可以立即开始使用它。不过,存在一个问题。当服务器速度很慢或文件很大时,执行请求可能需要相当长的时间。只要这种情况正在发生,程序就会处于等待状态,这会导致整个浏览器处于等待状态。在程序完成之前,用户无法执行任何操作,甚至无法滚动页面。在速度快、可靠的本地网络上运行的页面可能会采用这种执行请求的方式。另一方面,在浩瀚无垠、不可靠的互联网上的页面不应这样做。

open 的第三个参数为 true 时,请求被设置为 '异步'。这意味着 send 会立即返回,而请求会在后台进行。

request.open("GET", "files/fruit.xml", true);
request.send(null);
show(request.responseText);

但请稍等片刻,然后…

print(request.responseText);

'稍等片刻' 可以用 setTimeout 或类似的方法实现,但有一个更好的方法。请求对象有一个 readyState 属性,表示它所处的状态。当文档完全加载时,它将变为 4,在此之前它将具有较小的值 3。要对状态的变化做出反应,可以将对象的 onreadystatechange 属性设置为一个函数。每当状态发生变化时,都会调用此函数。

request.open("GET", "files/fruit.xml", true);
request.send(null);
request.onreadystatechange = function() {
  if (request.readyState == 4)
    show(request.responseText.length);
};

当请求对象检索到的文件是 XML 文档时,请求的 responseXML 属性将保存此文档的表示形式。此表示形式类似于 第 12 章 中讨论的 DOM 对象,只是它没有 HTML 特定的功能,例如 styleinnerHTMLresponseXML 为我们提供了一个文档对象,其 documentElement 属性引用 XML 文档的外部标签。

var catalog = request.responseXML.documentElement;
show(catalog.childNodes.length);

此类 XML 文档可用于与服务器交换结构化信息。它们的格式 ― 标签包含在其他标签内 ― 通常非常适合存储难以表示为简单平面文本的事物。不过,DOM 接口对于提取信息来说相当笨拙,而且 XML 文档通常非常冗长:fruit.xml 文档看起来很长,但它只表示 '苹果是红色的,橙子是橙色的,香蕉是黄色的'。


作为 XML 的替代方案,JavaScript 程序员想出了一个名为 JSON 的东西。它使用 JavaScript 值的基本符号以更简化的方式表示 '分层' 信息。JSON 文档是一个包含单个 JavaScript 对象或数组的文件,它反过来包含任意数量的其他对象、数组、字符串、数字、布尔值或 null 值。例如,请查看 fruit.json

request.open("GET", "files/fruit.json", true);
request.send(null);
request.onreadystatechange = function() {
  if (request.readyState == 4)
    print(request.responseText);
};

可以使用 eval 函数将此类文本转换为普通的 JavaScript 值。在调用 eval 之前应该在它周围添加括号,因为否则 JavaScript 可能会将对象(用大括号括起来)解释为代码块,并产生错误。

function evalJSON(json) {
  return eval("(" + json + ")");
}
var fruit = evalJSON(request.responseText);
show(fruit);

在对一段文本运行eval时,您必须牢记这意味着您允许该文本运行它想要的任何代码。由于 JavaScript 仅允许我们向自己的域名发出请求,因此您通常会确切知道要获取的文本类型,这不是问题。在其他情况下,它可能不安全。


例 14.1

编写一个名为serializeJSON的函数,该函数在给定 JavaScript 值时,会生成一个包含该值 JSON 表示形式的字符串。像数字和布尔值这样的简单值可以直接传递给String函数以将其转换为字符串。对象和数组可以通过递归处理。

识别数组可能很棘手,因为它的类型是"object"。您可以使用instanceof Array,但这仅适用于在您自己的窗口中创建的数组——其他数组将使用来自其他窗口的Array原型,并且instanceof将返回false。一个简单的技巧是将constructor属性转换为字符串,并查看它是否包含"function Array"

在转换字符串时,您必须注意转义其中的特殊字符。如果在字符串周围使用双引号,则要转义的字符是\"\\\f\b\n\t\r\v4

function serializeJSON(value) {
  function isArray(value) {
    return /^\s*function Array/.test(String(value.constructor));
  }

  function serializeArray(value) {
    return "[" + map(serializeJSON, value).join(", ") + "]";
  }
  function serializeObject(value) {
    var properties = [];
    forEachIn(value, function(name, value) {
      properties.push(serializeString(name) + ": " +
                      serializeJSON(value));
    });
    return "{" + properties.join(", ") + "}";
  }
  function serializeString(value) {
    var special =
      {"\"": "\\\"", "\\": "\\\\", "\f": "\\f", "\b": "\\b",
       "\n": "\\n", "\t": "\\t", "\r": "\\r", "\v": "\\v"};
    var escaped = value.replace(/[\"\\\f\b\n\t\r\v]/g,
                                function(c) {return special[c];});
    return "\"" + escaped + "\"";
  }

  var type = typeof value;
  if (type == "object" && isArray(value))
    return serializeArray(value);
  else if (type == "object")
    return serializeObject(value);
  else if (type == "string")
    return serializeString(value);
  else
    return String(value);
}

print(serializeJSON(fruit));

serializeString中使用的技巧类似于我们在第 10 章中的escapeHTML函数中看到的技巧。它使用一个对象来查找每个字符的正确替换。其中一些,例如"\\\\",看起来很奇怪,因为需要在结果字符串中为每个反斜杠放置两个反斜杠。

还要注意,属性的名称被引用为字符串。对于其中的一些,这是不必要的,但对于具有空格和其他奇怪内容的属性名称,这是必需的,因此代码只采取简单的做法,将所有内容都引用。


在进行大量请求时,我们当然不想每次都重复整个opensendonreadystatechange仪式。一个非常简单的包装器可能如下所示

function simpleHttpRequest(url, success, failure) {
  var request = makeHttpObject();
  request.open("GET", url, true);
  request.send(null);
  request.onreadystatechange = function() {
    if (request.readyState == 4) {
      if (request.status == 200)
        success(request.responseText);
      else if (failure)
        failure(request.status, request.statusText);
    }
  };
}

simpleHttpRequest("files/fruit.txt", print);

该函数检索它接收到的 url,并使用它作为第二个参数接收到的函数调用内容。当给出第三个参数时,它用于指示失败——非200状态代码。

为了能够执行更复杂的请求,该函数可以被设置为接受额外的参数来指定方法(GETPOST)、可选的字符串作为数据发布、添加额外标头的方法等等。当您有这么多个参数时,您可能希望将它们作为第 9 章中看到的参数对象传递。


一些网站利用客户端运行的程序和服务器运行的程序之间的密集通信。对于这些系统,将某些 HTTP 请求视为对服务器上运行的函数的调用可能是实用的。客户端向标识这些函数的 URL 发出请求,并将参数作为 URL 参数或POST数据给出。然后服务器调用该函数,并将结果放入它发送回的 JSON 或 XML 文档中。如果您编写了一些方便的辅助函数,这将使调用服务器端函数几乎与调用客户端函数一样容易……当然,除了您不会立即获得它们的结果。

  1. 这些不是唯一的请求类型。还有HEAD,用于仅请求文档的标头,而不是其内容,PUT,用于将文档添加到服务器,以及DELETE,用于删除文档。这些不受浏览器使用,通常也不受网络服务器支持,但是——如果您添加服务器端程序来支持它们——它们可能很有用。
  2. 不仅XMLHttpRequest名称中的“XML”部分具有误导性——该对象还可以用于通过除 HTTP 之外的其他协议进行请求,因此Request是我们剩下的唯一有意义的部分。
  3. 0(“未初始化”)是对象在open在它上面调用之前的状态。调用open将其移动到1(“打开”)。调用send使其进入2(“已发送”)。当服务器响应时,它进入3(“接收”)。最后,4表示“已加载”。
  4. 我们已经看到了\n,它是一个换行符。\t是一个制表符,\r是一个“回车符”,一些系统在换行符之前或代替换行符使用它来指示行尾。\b(退格)、\v(垂直制表符)和\f(换页)在处理旧打印机时很有用,但在处理 Internet 浏览器时却不太有用。