第 4 版现已推出。在此阅读

第 18 章HTTP 和 表单

通信必须是无状态的 [...] 这样,客户端到服务器的每个请求都必须包含理解该请求所需的所有信息,并且不能利用服务器上存储的任何上下文。

Roy Fielding, 架构风格和基于网络的软件架构设计
Picture of a web form on a medieval scroll

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

协议

如果你在浏览器的地址栏中输入eloquentjavascript.net/18_http.html,浏览器首先会查找与eloquentjavascript.net关联的服务器地址,并尝试在其默认的 HTTP 端口 80 上打开一个 TCP 连接。如果服务器存在并接受连接,浏览器可能会发送类似以下内容

GET /18_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: Mon, 08 Jan 2018 10:29:45 GMT

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

浏览器获取响应中空行之后的的部分,即主体(不要与 HTML <body> 标签混淆),并将其显示为 HTML 文档。

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

GET /18_http.html HTTP/1.1

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

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

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

在实践中,许多网站使用 HTTP 版本 2,它支持与版本 1.1 相同的概念,但要复杂得多,因此它可以更快。浏览器将在与给定服务器通信时自动切换到适当的协议版本,并且请求的结果与使用哪个版本无关。由于版本 1.1 更直观且更容易操作,因此我们将重点介绍它。

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

HTTP/1.1 200 OK

以 2 开头的状态代码表示请求成功。以 4 开头的代码表示请求存在问题。404 可能最著名的 HTTP 状态代码——它表示找不到该资源。以 5 开头的代码表示服务器发生了错误,请求不应承担责任。

请求或响应的第一行后面可以跟随任意数量的标头。它们是格式为name: value的行,指定有关请求或响应的额外信息。这些标头是示例响应的一部分

Content-Length: 65585
Content-Type: text/html
Last-Modified: Thu, 04 Jan 2018 14:05:30 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>

此代码描述了一个包含两个字段的表单:一个小字段询问姓名,另一个大字段用于写入消息。当你点击“发送”按钮时,表单将被提交,这意味着其字段的内容将被打包到 HTTP 请求中,并且浏览器将导航到该请求的结果。

<form> 元素的method 属性为GET(或省略)时,表单中的信息将作为查询字符串添加到action URL 的末尾。浏览器可能会发出对该 URL 的请求

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

问号表示 URL 中路径部分的结尾和查询的开始。它后面跟着成对的名称和值,分别对应于表单字段元素的name 属性和这些元素的内容。与符号(&)用于分隔这些对。

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

console.log(encodeURIComponent("Yes?"));
// → Yes%3F
console.log(decodeURIComponent("Yes%3F"));
// → Yes?

如果我们将前面示例中 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 与它们交互。

Fetch

浏览器 JavaScript 可以发出 HTTP 请求的接口称为fetch。由于它相对较新,它方便地使用了 Promise(这在浏览器接口中很少见)。

fetch("example/data.txt").then(response => {
  console.log(response.status);
  // → 200
  console.log(response.headers.get("Content-Type"));
  // → text/plain
});

调用fetch 返回一个 Promise,它解析为一个Response 对象,其中包含有关服务器响应的信息,例如其状态代码和标头。标头封装在一个类似于Map 的对象中,它将 key(标头名称)视为不区分大小写,因为标头名称不应该区分大小写。这意味着headers.get("Content-Type")headers.get("content-TYPE") 将返回相同的值。

请注意,即使服务器以错误代码响应,由fetch 返回的 Promise 也会成功解析。如果出现网络错误,或者无法找到请求所针对的服务器,它可能也会被拒绝。

fetch 的第一个参数是要请求的 URL。当该 URL 不以协议名(例如http:)开头时,它被视为相对的,这意味着它将根据当前文档进行解释。当它以斜杠(/)开头时,它将替换当前路径,即服务器名称之后的路径。如果没有,当前路径中直到最后一个斜杠字符(包括它)的部分将被放在相对 URL 的前面。

要获取响应的实际内容,可以使用它的text 方法。由于初始 Promise 在收到响应的标头后立即解析,并且读取响应主体可能需要更长时间,因此这将再次返回一个 Promise。

fetch("example/data.txt")
  .then(resp => resp.text())
  .then(text => console.log(text));
// → This is the content of data.txt

一种类似的方法,名为 json,返回一个 Promise,该 Promise 解析为将主体解析为 JSON 时获得的值,如果主体不是有效的 JSON,则拒绝。

默认情况下,fetch 使用 GET 方法发出请求,并且不包含请求主体。您可以通过将包含额外选项的对象作为第二个参数传递来以不同的方式配置它。例如,此请求尝试删除 example/data.txt

fetch("example/data.txt", {method: "DELETE"}).then(resp => {
  console.log(resp.status);
  // → 405
});

405 状态码表示“方法不允许”,这是 HTTP 服务器表示“我不能这样做”的方式。

要添加请求主体,您可以包含 body 选项。要设置标头,可以使用 headers 选项。例如,此请求包含一个 Range 标头,该标头指示服务器仅返回响应的一部分。

fetch("example/data.txt", {headers: {Range: "bytes=8-19"}})
  .then(resp => resp.text())
  .then(console.log);
// → the content

浏览器会自动添加一些请求标头,例如“Host”以及服务器确定主体大小所需的标头。但添加自己的标头通常很有用,可以用来包含诸如身份验证信息或告知服务器您希望接收的格式之类的内容。

HTTP 沙箱

在网页脚本中再次发出 HTTP 请求会再次引发安全问题。控制脚本的人可能与脚本在其计算机上运行的人的利益不一致。更具体地说,如果我访问themafia.org,我不希望它的脚本能够使用来自我浏览器的识别信息向mybank.com发出请求,并指示将我所有的钱转入某个随机帐户。

出于这个原因,浏览器通过禁止脚本向其他域(例如themafia.orgmybank.com 之类的名称)发出 HTTP 请求来保护我们。

在构建想要出于合法原因访问多个域的系统时,这可能是一个令人讨厌的问题。幸运的是,服务器可以在其对浏览器的响应中包含一个类似的标头,明确地指示浏览器可以接受来自其他域的请求

Access-Control-Allow-Origin: *

欣赏 HTTP

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

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

在考虑远程过程调用时,HTTP 只是一个通信媒介,您很可能会编写一个完全隐藏它的抽象层。

另一种方法是围绕资源和 HTTP 方法的概念构建您的通信。与其使用名为 addUser 的远程过程,不如使用对 /users/larryPUT 请求。与其将该用户的属性编码在函数参数中,不如定义一个 JSON 文档格式(或使用现有的格式)来表示用户。创建新资源的 PUT 请求的主体就是这样的文档。通过对资源的 URL(例如 /user/larry)发出 GET 请求来获取资源,这将再次返回表示资源的文档。

第二种方法使您更容易使用 HTTP 提供的一些功能,例如支持缓存资源(在客户端保存副本以进行快速访问)。HTTP 中使用的概念经过精心设计,可以提供一套有用的原则,可以围绕这些原则设计您的服务器接口。

安全性和 HTTPS

在互联网上传输的数据往往会走一条漫长而危险的路。要到达目的地,它必须经过从咖啡店 Wi-Fi 热点到各种公司和国家控制的网络的任何东西。在其路径上的任何一点,它都可能被检查甚至被修改。

如果某些内容必须保密,例如您电子邮件帐户的密码,或者必须在未修改的情况下到达目的地,例如您通过银行网站转账的帐户号,则简单的 HTTP 还不够好。

安全 HTTP 协议(用于以https:// 开头的 URL)以一种难以阅读和篡改的方式包装 HTTP 流量。在交换数据之前,客户端会通过要求服务器证明它拥有浏览器识别的证书颁发机构颁发的加密证书来验证服务器是否如其所述。接下来,通过连接发送的所有数据都以一种应该防止窃听和篡改的方式进行加密。

因此,当 HTTPS 正确工作时,它可以防止其他人冒充您尝试与之交谈的网站以及窥视您的通信。它并不完美,并且发生过各种事件,其中 HTTPS 由于伪造或盗取的证书和软件故障而失败,但它比简单的 HTTP 安全得多。

表单字段

表单最初是为了在 JavaScript 出现之前的 Web 而设计的,允许网站在 HTTP 请求中发送用户提交的信息。这种设计假设与服务器的交互总是通过导航到新页面来完成的。

但它们的元素与页面的其他部分一样是 DOM 的一部分,表示表单字段的 DOM 元素支持一些其他元素中不存在的属性和事件。这使得能够使用 JavaScript 程序来检查和控制此类输入字段,并执行诸如向表单添加新功能或在 JavaScript 应用程序中使用表单和字段作为构建块之类的操作。

网页表单由任何数量的输入字段组成,这些输入字段分组在 <form> 标记中。HTML 允许多种不同的字段样式,从简单的开/关复选框到下拉菜单和用于文本输入的字段。本书不会尝试全面讨论所有字段类型,但我们将从一个粗略的概述开始。

许多字段类型使用 <input> 标记。此标记的 type 属性用于选择字段的样式。以下是一些常用的 <input> 类型

text单行文本字段
passwordtext 相同,但会隐藏输入的文本
checkbox开/关开关
radio(部分)多选字段
file允许用户从他们的计算机中选择一个文件

表单字段不一定要出现在 <form> 标记中。您可以将它们放在页面的任何位置。此类无表单字段不能提交(只有表单本身才能提交),但在使用 JavaScript 响应输入时,我们通常也不希望以正常方式提交字段。

<p><input type="text" value="abc"> (text)</p>
<p><input type="password" value="abc"> (password)</p>
<p><input type="checkbox" checked> (checkbox)</p>
<p><input type="radio" value="A" name="choice">
   <input type="radio" value="B" name="choice" checked>
   <input type="radio" value="C" name="choice"> (radio)</p>
<p><input type="file"> (file)</p>

这些元素的 JavaScript 接口因元素的类型而异。

多行文本字段有自己的标记 <textarea>,主要是因为使用属性来指定多行起始值会很尴尬。<textarea> 标记需要一个匹配的 </textarea> 结束标记,并使用这两个标记之间的文本(而不是 value 属性)作为起始文本。

<textarea>
one
two
three
</textarea>

最后,<select> 标记用于创建允许用户从多个预定义选项中选择的字段。

<select>
  <option>Pancakes</option>
  <option>Pudding</option>
  <option>Ice cream</option>
</select>

每当表单字段的值发生变化时,它都会触发 "change" 事件。

焦点

与 HTML 文档中的大多数元素不同,表单字段可以获得键盘焦点。单击或以其他方式激活它们时,它们会成为当前活动元素并接收键盘输入。

因此,您只能在文本字段获得焦点时才能在其中键入。其他字段对键盘事件的响应不同。例如,<select> 菜单尝试移动到包含用户输入的文本的选项,并通过上下移动选择来响应箭头键。

我们可以使用 focusblur 方法从 JavaScript 控制焦点。第一个将焦点移动到它调用的 DOM 元素上,第二个会移除焦点。document.activeElement 中的值对应于当前获得焦点的元素。

<input type="text">
<script>
  document.querySelector("input").focus();
  console.log(document.activeElement.tagName);
  // → INPUT
  document.querySelector("input").blur();
  console.log(document.activeElement.tagName);
  // → BODY
</script>

对于某些页面,用户希望立即与表单字段进行交互。可以使用 JavaScript 在文档加载时将此字段设为焦点,但 HTML 还提供 autofocus 属性,该属性可以产生相同的效果,同时让浏览器知道我们想要实现的目标。这使浏览器可以选择在不合适时禁用此行为,例如当用户将焦点放在其他地方时。

浏览器传统上还允许用户通过按下 tab 键在文档中移动焦点。我们可以使用 tabindex 属性影响元素获得焦点的顺序。以下示例文档将使焦点从文本输入跳到“确定”按钮,而不是先经过帮助链接

<input type="text" tabindex=1> <a href=".">(help)</a>
<button onclick="console.log('ok')" tabindex=2>OK</button>

默认情况下,大多数类型的 HTML 元素无法获得焦点。但是,您可以向任何元素添加 tabindex 属性,使其可获得焦点。tabindex 为 -1 使制表符跳过某个元素,即使它通常可以获得焦点。

禁用字段

所有表单字段都可以通过它们的 disabled 属性被禁用。它是一个可以无需指定值的属性——只要存在该属性,就会禁用该元素。

<button>I'm all right</button>
<button disabled>I'm out</button>

禁用的字段无法获得焦点或更改,浏览器会将它们显示为灰色和褪色。

当程序正在处理由某个按钮或其他控件引起的需要与服务器通信并可能需要一段时间才能完成的操作时,禁用该控件直到操作完成是一个好主意。这样,当用户变得不耐烦并再次点击它时,他们就不会意外地重复他们的操作。

整个表单

当一个字段包含在<form>元素中时,它的DOM元素将有一个form属性链接回表单的DOM元素。<form>元素反过来有一个名为elements的属性,它包含一个类似数组的集合,其中包含其中的字段。

表单字段的name属性决定了表单提交时其值将如何被识别。它也可以用作访问表单elements属性的属性名,该属性既充当类似数组的对象(可以通过数字访问),也充当映射(可以通过名称访问)。

<form action="example/submit.html">
  Name: <input type="text" name="name"><br>
  Password: <input type="password" name="password"><br>
  <button type="submit">Log in</button>
</form>
<script>
  let form = document.querySelector("form");
  console.log(form.elements[1].type);
  // → password
  console.log(form.elements.password.type);
  // → password
  console.log(form.elements.name.form == form);
  // → true
</script>

当按下具有type属性为submit的按钮时,它将导致表单提交。当表单字段获得焦点时按下enter键具有相同的效果。

提交表单通常意味着浏览器导航到表单action属性指示的页面,使用GETPOST请求。但在发生这种情况之前,会触发"submit"事件。你可以使用JavaScript处理此事件,并通过在事件对象上调用preventDefault来阻止这种默认行为。

<form action="example/submit.html">
  Value: <input type="text" name="value">
  <button type="submit">Save</button>
</form>
<script>
  let form = document.querySelector("form");
  form.addEventListener("submit", event => {
    console.log("Saving value", form.elements.value.value);
    event.preventDefault();
  });
</script>

在JavaScript中拦截"submit"事件有各种用途。我们可以编写代码来验证用户输入的值是否有意义,并立即显示错误消息而不是提交表单。或者我们可以完全禁用提交表单的常规方式,就像示例中一样,并让我们的程序处理输入,可能使用fetch将它发送到服务器而不重新加载页面。

文本字段

<textarea>标签或<input>标签(类型为textpassword)创建的字段共享一个公共接口。它们的DOM元素有一个value属性,它以字符串值的形式保存其当前内容。将此属性设置为另一个字符串将更改字段的内容。

文本字段的selectionStartselectionEnd属性提供了有关文本中光标和选定内容的信息。当没有选中任何内容时,这两个属性包含相同的数字,指示光标的位置。例如,0表示文本的开头,10表示光标位于第10个字符之后。当选中字段的一部分时,这两个属性将不同,为我们提供所选文本的开始和结束位置。与value一样,这些属性也可以被写入。

假设你正在写一篇关于卡塞克姆维的文章,但在拼写他的名字时遇到了一些麻烦。以下代码连接了一个<textarea>标签,该标签带有一个事件处理程序,当按下F2时,它会为你插入字符串“Khasekhemwy”。

<textarea></textarea>
<script>
  let textarea = document.querySelector("textarea");
  textarea.addEventListener("keydown", event => {
    // The key code for F2 happens to be 113
    if (event.keyCode == 113) {
      replaceSelection(textarea, "Khasekhemwy");
      event.preventDefault();
    }
  });
  function replaceSelection(field, word) {
    let from = field.selectionStart, to = field.selectionEnd;
    field.value = field.value.slice(0, from) + word +
                  field.value.slice(to);
    // Put the cursor after the word
    field.selectionStart = from + word.length;
    field.selectionEnd = from + word.length;
  }
</script>

replaceSelection函数用给定的单词替换文本字段中当前选中的部分内容,然后将光标移动到该单词之后,以便用户可以继续键入。

文本字段的"change"事件不会在每次键入时触发。相反,当字段失去焦点且其内容已更改时,它才会触发。要立即响应文本字段的变化,你应该注册一个"input"事件的处理程序,该事件在用户每次键入字符、删除文本或以其他方式操作字段内容时触发。

以下示例显示了一个文本字段和一个计数器,显示字段中当前文本的长度

<input type="text"> length: <span id="length">0</span>
<script>
  let text = document.querySelector("input");
  let output = document.querySelector("#length");
  text.addEventListener("input", () => {
    output.textContent = text.value.length;
  });
</script>

复选框和单选按钮

复选框字段是一个二元切换。它的值可以通过它的checked属性提取或更改,该属性包含一个布尔值。

<label>
  <input type="checkbox" id="purple"> Make this page purple
</label>
<script>
  let checkbox = document.querySelector("#purple");
  checkbox.addEventListener("change", () => {
    document.body.style.background =
      checkbox.checked ? "mediumpurple" : "";
  });
</script>

<label>标签将文档的一部分与输入字段关联。点击标签上的任何位置都会激活该字段,从而使它获得焦点,并在它是复选框或单选按钮时切换它的值。

单选按钮类似于复选框,但它隐式地链接到具有相同name属性的其他单选按钮,以便一次只能激活其中一个。

Color:
<label>
  <input type="radio" name="color" value="orange"> Orange
</label>
<label>
  <input type="radio" name="color" value="lightgreen"> Green
</label>
<label>
  <input type="radio" name="color" value="lightblue"> Blue
</label>
<script>
  let buttons = document.querySelectorAll("[name=color]");
  for (let button of Array.from(buttons)) {
    button.addEventListener("change", () => {
      document.body.style.background = button.value;
    });
  }
</script>

CSS 查询中给querySelectorAll的方括号用于匹配属性。它选择name属性为"color"的元素。

选择字段

选择字段在概念上类似于单选按钮 - 它们也允许用户从一组选项中进行选择。但单选按钮让我们控制选项的布局,<select>标签的外观由浏览器决定。

选择字段也有一个变体,它更像是复选框列表,而不是单选框。当给出multiple属性时,<select>标签将允许用户选择任意数量的选项,而不仅仅是一个选项。这将在大多数浏览器中以与普通选择字段不同的方式显示,普通选择字段通常被绘制为一个下拉控件,只有当你打开它时才会显示选项。

每个<option>标签都有一个值。这个值可以用value属性定义。当没有给出该值时,选项中的文本将被计入其值。<select>元素的value属性反映了当前选中的选项。但是,对于multiple字段,此属性没有太大意义,因为它只会给出当前选中选项中一个的值。

<select>字段的<option>标签可以通过字段的options属性以类似数组的方式访问。每个选项都有一个名为selected的属性,它指示该选项当前是否被选中。该属性也可以被写入以选择或取消选择选项。

此示例从multiple选择字段中提取选定的值,并使用它们从各个位来组成一个二进制数。按住control(或 Mac 上的command)键可以选择多个选项。

<select multiple>
  <option value="1">0001</option>
  <option value="2">0010</option>
  <option value="4">0100</option>
  <option value="8">1000</option>
</select> = <span id="output">0</span>
<script>
  let select = document.querySelector("select");
  let output = document.querySelector("#output");
  select.addEventListener("change", () => {
    let number = 0;
    for (let option of Array.from(select.options)) {
      if (option.selected) {
        number += Number(option.value);
      }
    }
    output.textContent = number;
  });
</script>

文件字段

文件字段最初是作为一种通过表单从用户机器上传文件的方式设计的。在现代浏览器中,它们还提供了一种从JavaScript程序读取此类文件的方式。该字段充当一种看门人。该脚本不能简单地开始从用户计算机上读取私有文件,但如果用户在这样的字段中选择了一个文件,浏览器会将此操作解释为脚本可以读取该文件的含义。

文件字段通常看起来像一个按钮,上面标注着“选择文件”或“浏览”,旁边是有关所选文件的信息。

<input type="file">
<script>
  let input = document.querySelector("input");
  input.addEventListener("change", () => {
    if (input.files.length > 0) {
      let file = input.files[0];
      console.log("You chose", file.name);
      if (file.type) console.log("It has type", file.type);
    }
  });
</script>

文件字段元素的files属性是一个类似数组的对象(同样,不是真正的数组),包含在字段中选择的. 它最初为空。没有简单地使用file属性的原因是文件字段还支持multiple属性,这使得可以同时选择多个文件。

files对象中的对象具有诸如name(文件名)、size(文件大小,以字节为单位,字节是8位块)和type(文件的媒体类型,例如text/plainimage/jpeg)等属性。

它没有包含文件内容的属性。获取它需要更复杂的操作。由于从磁盘读取文件可能需要时间,因此该接口必须是异步的,以避免冻结文档。

<input type="file" multiple>
<script>
  let input = document.querySelector("input");
  input.addEventListener("change", () => {
    for (let file of Array.from(input.files)) {
      let reader = new FileReader();
      reader.addEventListener("load", () => {
        console.log("File", file.name, "starts with",
                    reader.result.slice(0, 20));
      });
      reader.readAsText(file);
    }
  });
</script>

读取文件是通过创建一个FileReader对象、为它注册一个"load"事件处理程序,并调用它的readAsText方法来完成的,该方法将我们想要读取的文件传递给它。加载完成后,阅读器将result属性包含文件的内容。

FileReaders 还会在因任何原因读取文件失败时触发"error"事件。错误对象本身将最终出现在阅读器的error属性中。这个接口是在 promises 成为语言的一部分之前设计的。你可以像这样将它包装在一个 promise 中

function readFileText(file) {
  return new Promise((resolve, reject) => {
    let reader = new FileReader();
    reader.addEventListener(
      "load", () => resolve(reader.result));
    reader.addEventListener(
      "error", () => reject(reader.error));
    reader.readAsText(file);
  });
}

在客户端存储数据

带有少量JavaScript的简单HTML页面可以成为“迷你应用程序”的绝佳格式 - 这些是自动执行基本任务的小型辅助程序。通过将一些表单字段与事件处理程序连接起来,你可以完成从厘米和英寸之间的转换到从主密码和网站名称计算密码的任何事情。

当这样的应用程序需要在会话之间记住某些东西时,你不能使用JavaScript绑定 - 这些绑定在每次页面关闭时都会被丢弃。你可以设置一个服务器,将其连接到互联网,并让你的应用程序在服务器上存储一些东西。我们将在第20章中看到如何做到这一点。但这会增加很多额外的工作和复杂性。有时只需将数据保留在浏览器中就足够了。

localStorage对象可用于以一种能够在页面重新加载后继续存在的方式存储数据。此对象允许你使用名称对字符串值进行归档。

localStorage.setItem("username", "marijn");
console.log(localStorage.getItem("username"));
// → marijn
localStorage.removeItem("username");

localStorage中的值会一直存在,直到它被覆盖、使用removeItem删除或用户清除其本地数据为止。

来自不同域的网站获得不同的存储空间。这意味着,原则上,特定网站在localStorage中存储的数据只能被同一网站上的脚本读取(和覆盖)。

浏览器确实会对网站可以在localStorage中存储的数据大小设置限制。这种限制,加上用垃圾数据填充人们的硬盘并不是真正有利可图,可以防止该功能占用太多空间。

以下代码实现了一个简陋的记事本应用程序。它保存一组命名笔记,并允许用户编辑笔记和创建新笔记。

Notes: <select></select> <button>Add</button><br>
<textarea style="width: 100%"></textarea>

<script>
  let list = document.querySelector("select");
  let note = document.querySelector("textarea");

  let state;
  function setState(newState) {
    list.textContent = "";
    for (let name of Object.keys(newState.notes)) {
      let option = document.createElement("option");
      option.textContent = name;
      if (newState.selected == name) option.selected = true;
      list.appendChild(option);
    }
    note.value = newState.notes[newState.selected];

    localStorage.setItem("Notes", JSON.stringify(newState));
    state = newState;
  }
  setState(JSON.parse(localStorage.getItem("Notes")) || {
    notes: {"shopping list": "Carrots\nRaisins"},
    selected: "shopping list"
  });

  list.addEventListener("change", () => {
    setState({notes: state.notes, selected: list.value});
  });
  note.addEventListener("change", () => {
    setState({
      notes: Object.assign({}, state.notes,
                           {[state.selected]: note.value}),
      selected: state.selected
    });
  });
  document.querySelector("button")
    .addEventListener("click", () => {
      let name = prompt("Note name");
      if (name) setState({
        notes: Object.assign({}, state.notes, {[name]: ""}),
        selected: name
      });
    });
</script>

该脚本从localStorage中存储的"Notes"值获取其初始状态,或者,如果该值缺失,则创建一个示例状态,其中只包含一个购物清单。读取localStorage中不存在的字段将返回null。将null传递给JSON.parse将使其解析字符串"null"并返回null。因此,||运算符可用于在此类情况下提供默认值。

setState方法确保 DOM 显示给定状态并将新状态存储到localStorage中。事件处理程序调用此函数以切换到新状态。

示例中使用Object.assign的目的是创建一个新对象,该对象是旧的state.notes的克隆,但添加或覆盖了一个属性。Object.assign接受其第一个参数,并将来自任何其他参数的所有属性添加到其中。因此,如果传递给它一个空对象,它将填充一个新对象。第三个参数中的方括号表示法用于创建一个属性,其名称基于某个动态值。

还有一个类似于localStorage的对象,称为sessionStorage。两者之间的区别在于,sessionStorage的内容在每个会话结束时都会被遗忘,对于大多数浏览器来说,这意味着在浏览器关闭时被遗忘。

总结

在本章中,我们讨论了 HTTP 协议的工作原理。客户端发送一个请求,其中包含一个方法(通常为GET)和一个标识资源的路径。然后,服务器决定如何处理该请求并返回一个状态码和一个响应主体。请求和响应都可能包含提供附加信息的标头。

浏览器 JavaScript 可以通过称为fetch的接口发出 HTTP 请求。发出请求如下所示

fetch("/18_http.html").then(r => r.text()).then(text => {
  console.log(`The page starts with ${text.slice(0, 15)}`);
});

浏览器发出GET请求以获取显示网页所需的资源。页面中还可以包含表单,表单允许将用户输入的信息作为新页面的请求发送,当表单提交时。

HTML 可以表示各种类型的表单字段,例如文本字段、复选框、多选字段和文件选择器。

可以使用 JavaScript 检查和操作这些字段。它们在更改时会触发"change"事件,在输入文本时会触发"input"事件,并在获得键盘焦点时会接收键盘事件。value(对于文本和选择字段)或checked(对于复选框和单选按钮)等属性用于读取或设置字段的内容。

当提交表单时,会触发其上的"submit"事件。JavaScript 处理程序可以对该事件调用preventDefault以禁用浏览器的默认行为。表单字段元素也可能出现在表单标签之外。

当用户从本地文件系统中的文件选择器字段中选择了一个文件时,可以使用FileReader接口从 JavaScript 程序访问该文件的内容。

localStoragesessionStorage对象可用于以一种在页面重新加载后仍然存在的方式保存信息。第一个对象永远保存数据(或直到用户决定清除数据),第二个对象保存数据直到浏览器关闭。

练习

内容协商

HTTP 可以做的一件事称为内容协商Accept请求标头用于告诉服务器客户端希望获取哪种类型的文档。许多服务器会忽略此标头,但当服务器知道多种编码资源的方式时,它可以查看此标头并发送客户端首选的编码方式。

URL https://eloquent.javascript.ac.cn/author被配置为根据客户端的请求,以纯文本、HTML 或 JSON 格式响应。这些格式由标准化的媒体类型text/plaintext/htmlapplication/json标识。

发送请求以获取此资源的所有三种格式。使用传递给fetch的选项对象中的headers属性将名为Accept的标头设置为所需的媒体类型。

最后,尝试请求媒体类型application/rainbows+unicorns,看看它会产生什么状态码。

// Your code here.

将您的代码基于本章前面fetch示例。

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

JavaScript 工作台

构建一个界面,允许人们键入和运行 JavaScript 代码片段。

<textarea>字段旁边放置一个按钮,当按下该按钮时,它会使用我们在第 10 章中看到的Function构造函数将文本包装在一个函数中并调用它。将函数的返回值或它引发的任何错误转换为字符串,并在文本字段下方显示它。

<textarea id="code">return "hi";</textarea>
<button id="button">Run</button>
<pre id="output"></pre>

<script>
  // Your code here.
</script>

使用document.querySelectordocument.getElementById访问在您的 HTML 中定义的元素。按钮上"click""mousedown"事件的事件处理程序可以获取文本字段的value属性,并对其调用Function

确保将Function的调用和对其结果的调用都包装在一个try块中,以便您可以捕获它产生的异常。在这种情况下,我们真的不知道我们正在寻找哪种类型的异常,因此捕获所有异常。

输出元素的textContent属性可用于用字符串消息填充它。或者,如果您想保留旧内容,则可以使用document.createTextNode创建一个新的文本节点,并将其附加到该元素。请记住在末尾添加一个换行符,以便所有输出都不会出现在一行上。

康威的生命游戏

康威的生命游戏是一个简单的模拟,它在网格上创建人工“生命”,每个网格单元格要么活着,要么死亡。每一代(回合),都会应用以下规则

邻居定义为任何相邻的细胞,包括对角线上的相邻细胞。

请注意,这些规则同时应用于整个网格,而不是逐个方块应用。这意味着邻居的计数是基于一代开始时的状况,在本代期间发生的邻居细胞变化不应影响给定细胞的新状态。

使用您认为合适的任何数据结构来实现此游戏。使用Math.random来最初用随机模式填充网格。将其显示为复选框字段的网格,旁边有一个按钮用于进入下一代。当用户选中或取消选中复选框时,他们的更改应在计算下一代时包括在内。

<div id="grid"></div>
<button id="next">Next generation</button>

<script>
  // Your code here.
</script>

为了解决更改在概念上同时发生的问题,请尝试将一代的计算视为一个纯函数,该函数接受一个网格并产生一个代表下一回合的新网格。

矩阵的表示可以按照第 6 章中所示的方式进行。您可以使用两个嵌套循环来计算活邻居,在两个维度上循环遍历相邻坐标。注意不要计算场外的细胞,也不要计算中心细胞,因为我们正在计算它的邻居。

确保复选框的更改在下一代生效可以通过两种方式实现。事件处理程序可以注意到这些更改,并将当前网格更新以反映它们,或者您可以在计算下一回合之前从复选框中的值生成一个新的网格。

如果您选择使用事件处理程序,您可能希望附加标识每个复选框对应位置的属性,以便可以轻松地找出要更改哪个单元格。

要绘制复选框网格,您可以使用 `<table>` 元素(参见 第 14 章),或者简单地将它们全部放在同一个元素中,并在行之间放置 `<br>`(换行)元素。