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

第 18 章
表单和表单字段

我将在今天,在医生的宴会上,
尽到我应尽的义务。
但有一件事!为了保险起见,我恳求你,
至少给我一两行。

梅菲斯特菲利斯,在歌德的《浮士德》中

表单在上一章中简要介绍,作为通过 HTTP 提交用户提供的信息的一种方式。它们是为没有 JavaScript 的 Web 而设计的,假设与服务器的交互总是通过导航到一个新页面来完成的。

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

字段

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

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

text 单行文本字段
password text 相同,但隐藏输入的文本
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 属性,它会产生相同的效果,但会让浏览器知道我们想要实现的目标。这使得浏览器能够在不合适的情况下禁用该行为,例如,当用户已将焦点移至其他元素时。

<input type="text" autofocus>

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

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

默认情况下,大多数类型的 HTML 元素无法获得焦点。但是,你可以向任何元素添加 tabindex 属性,这将使其可获得焦点。

禁用字段

所有表单字段都可以通过其 disabled 属性禁用,该属性也作为元素 DOM 对象上的一个属性存在。

<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>
  var 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>
  var form = document.querySelector("form");
  form.addEventListener("submit", function(event) {
    console.log("Saving value", form.elements.value.value);
    event.preventDefault();
  });
</script>

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

文本字段

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

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

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

<textarea></textarea>
<script>
  var textarea = document.querySelector("textarea");
  textarea.addEventListener("keydown", function(event) {
    // The key code for F2 happens to be 113
    if (event.keyCode == 113) {
      replaceSelection(textarea, "Khasekhemwy");
      event.preventDefault();
    }
  });
  function replaceSelection(field, word) {
    var 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 = field.selectionEnd =
      from + word.length;
  }
</script>

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

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

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

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

复选框和单选按钮

复选框字段是一个简单的二进制切换。可以通过其 checked 属性提取或更改其值,该属性保存一个布尔值。

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

<label> 标签用于将一段文本与输入字段关联起来。其 for 属性应引用字段的 id。单击标签将激活该字段,这会使它获得焦点,并在它是复选框或单选按钮时切换其值。

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

Color:
<input type="radio" name="color" value="mediumpurple"> Purple
<input type="radio" name="color" value="lightgreen"> Green
<input type="radio" name="color" value="lightblue"> Blue
<script>
  var buttons = document.getElementsByName("color");
  function setColor(event) {
    document.body.style.background = event.target.value;
  }
  for (var i = 0; i < buttons.length; i++)
    buttons[i].addEventListener("change", setColor);
</script>

document.getElementsByName 方法为我们提供了所有具有给定 name 属性的元素。该示例循环遍历这些元素(使用常规的 for 循环,而不是 forEach,因为返回的集合不是真正的数组),并为每个元素注册一个事件处理程序。请记住,事件对象具有一个 target 属性,它引用触发事件的元素。这在像这样的一些事件处理程序中通常很有用,这些处理程序将在不同的元素上被调用,并且需要某种方式来访问当前目标。

选择字段

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

选择字段还有一个变体,它更像是一组复选框,而不是单选框。当给出multiple属性时,<select>标签将允许用户选择任意数量的选项,而不仅仅是一个选项。

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

在大多数浏览器中,这将与非multiple选择字段不同,非multiple选择字段通常以下拉控件的形式绘制,只有在打开时才会显示选项。

<select>标签的size属性用于设置同时可见的选项数量,这使您可以显式控制下拉菜单的外观。例如,将size属性设置为"3"将使字段显示三行,无论它是否启用了multiple选项。

每个<option>标签都有一个值。该值可以使用value属性定义,但如果没有给出该属性,选项内的文本将被视为选项的值。<select>元素的value属性反映当前选定的选项。但是,对于multiple字段,此属性意义不大,因为它只给出当前选定选项之一的值。

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

以下示例从multiple选择字段中提取选定值,并使用它们从单个位组成二进制数。按住 Ctrl(或 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>
  var select = document.querySelector("select");
  var output = document.querySelector("#output");
  select.addEventListener("change", function() {
    var number = 0;
    for (var i = 0; i < select.options.length; i++) {
      var option = select.options[i];
      if (option.selected)
        number += Number(option.value);
    }
    output.textContent = number;
  });
</script>

文件字段

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

文件字段通常看起来像一个带有“选择文件”或“浏览”之类的标签的按钮,旁边有关于所选文件的信息。

<input type="file">
<script>
  var input = document.querySelector("input");
  input.addEventListener("change", function() {
    if (input.files.length > 0) {
      var 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(文件大小,以字节为单位)和type(文件的媒体类型,例如text/plainimage/jpeg)之类的属性。

它没有的属性是包含文件内容的属性。获取它会更复杂一些。由于从磁盘读取文件可能需要时间,因此界面必须是异步的,以避免冻结文档。您可以将FileReader构造函数视为类似于XMLHttpRequest,但用于文件。

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

读取文件是通过创建FileReader对象、为其注册"load"事件处理程序以及调用其readAsText方法来完成的,该方法为其提供我们要读取的文件。加载完成后,阅读器的result属性将包含文件的内容。

该示例使用Array.prototype.forEach来遍历数组,因为在普通循环中,从事件处理程序获取正确的filereader对象会很麻烦。这些变量将由循环的所有迭代共享。

当以任何原因无法读取文件时,FileReader还会触发"error"事件。错误对象本身将最终出现在阅读器的error属性中。如果您不想记住另一个不一致的异步接口的细节,可以将其包装在Promise(参见第 17 章)中,如下所示

function readFile(file) {
  return new Promise(function(succeed, fail) {
    var reader = new FileReader();
    reader.addEventListener("load", function() {
      succeed(reader.result);
    });
    reader.addEventListener("error", function() {
      fail(reader.error);
    });
    reader.readAsText(file);
  });
}

可以通过调用slice并将其结果(一个所谓的blob对象)传递给文件阅读器来读取文件的某一部分。

在客户端存储数据

带有少量 JavaScript 的简单 HTML 页面可以成为“小型应用程序”的绝佳媒介 - 这些小型应用程序自动执行日常工作。通过将一些表单字段与事件处理程序连接,您可以执行从摄氏度和华氏度之间转换到从主密码和网站名称计算密码的任何操作。

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

您可以通过将字符串数据放在localStorage对象中以一种在页面重新加载后仍然存在的方式来存储字符串数据。这个对象允许您以名称(也是字符串)的方式对字符串值进行归档,如以下示例所示

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

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

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

浏览器还会对网站可以在localStorage中存储的数据量进行限制,通常为几兆字节。该限制,以及填充人们硬盘驱动器上的垃圾数据实际上并没有什么利润,可以防止此功能占用太多空间。

以下代码实现了一个简单的笔记应用程序。它将用户的笔记保存为一个对象,将笔记标题与内容字符串关联起来。这个对象被编码为 JSON 并存储在localStorage中。用户可以选择一个<select>字段中的笔记,并在<textarea>中更改该笔记的文本。可以通过单击按钮添加笔记。

Notes: <select id="list"></select>
<button onclick="addNote()">new</button><br>
<textarea id="currentnote" style="width: 100%; height: 10em">
</textarea>

<script>
  var list = document.querySelector("#list");
  function addToList(name) {
    var option = document.createElement("option");
    option.textContent = name;
    list.appendChild(option);
  }

  // Initialize the list from localStorage
  var notes = JSON.parse(localStorage.getItem("notes")) ||
              {"shopping list": ""};
  for (var name in notes)
    if (notes.hasOwnProperty(name))
      addToList(name);

  function saveToStorage() {
    localStorage.setItem("notes", JSON.stringify(notes));
  }

  var current = document.querySelector("#currentnote");
  current.value = notes[list.value];

  list.addEventListener("change", function() {
    current.value = notes[list.value];
  });
  current.addEventListener("change", function() {
    notes[list.value] = current.value;
    saveToStorage();
  });

  function addNote() {
    var name = prompt("Note name", "");
    if (!name) return;
    if (!notes.hasOwnProperty(name)) {
      notes[name] = "";
      addToList(name);
      saveToStorage();
    }
    list.value = name;
    current.value = notes[name];
  }
</script>

该脚本将notes变量初始化为存储在localStorage中的值,或者如果该值丢失,则初始化为一个简单对象,其中只有一个空的"购物清单"笔记。从localStorage中读取不存在的字段将产生null。将null传递给JSON.parse将使其解析字符串"null"并返回null。因此,可以使用||运算符在这种情况下提供默认值。

每当笔记数据发生变化(添加新笔记或更改现有笔记时),都会调用saveToStorage函数来更新存储字段。如果此应用程序旨在处理数千个笔记,而不是少数几个笔记,这将过于昂贵,我们必须想出一种更复杂的方式来存储它们,例如为每个笔记分配一个单独的存储字段。

当用户添加新笔记时,代码必须显式更新文本字段,即使<select>字段有一个执行相同操作的"change"处理程序。这是必要的,因为"change"事件仅在用户更改字段的值时触发,而不是脚本执行此操作时触发。

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

摘要

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

可以使用 JavaScript 检查和操作此类字段。它们在更改时触发"change"事件,在键入文本时触发"input"事件,以及各种键盘事件。这些事件允许我们注意到用户何时与字段交互。诸如value(对于文本和选择字段)或checked(对于复选框和单选按钮)之类的属性用于读取或设置字段的内容。

当表单提交时,它的"submit"事件会被触发。JavaScript 处理程序可以在该事件上调用preventDefault以阻止提交发生。表单字段元素不必包含在<form>标签中。

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

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

练习

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属性,并在其上调用new Function

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

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

自动完成

扩展文本字段,以便当用户键入时,在字段下方显示建议值列表。你有一个可用的可能值的数组,并且应该显示以键入的文本开头的那些值。当点击一个建议时,用它替换文本字段的当前值。

<input type="text" id="field">
<div id="suggestions" style="cursor: pointer"></div>

<script>
  // Builds up an array with global variable names, like
  // 'alert', 'document', and 'scrollTo'
  var terms = [];
  for (var name in window)
    terms.push(name);

  // Your code here.
</script>

更新建议列表的最佳事件是"input",因为当字段内容更改时,它将立即触发。

然后遍历术语数组,看看它们是否以给定的字符串开头。例如,你可以调用indexOf并查看结果是否为零。对于每个匹配的字符串,向建议<div>添加一个元素。你可能也应该在每次开始更新建议时清空它,例如通过将其textContent设置为空字符串。

你可以为每个建议元素添加一个"click"事件处理程序,或者为包含它们的外部<div>添加一个,并查看事件的target属性以找出哪个建议被点击了。

要从 DOM 节点中获取建议文本,你可以查看它的textContent,或者在创建元素时设置一个属性来显式存储文本。

康威的生命游戏

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

邻居定义为任何相邻的单元格,包括对角线上的相邻单元格。

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

使用你认为合适的任何数据结构实现这个游戏。使用Math.random在网格上随机生成一个初始模式。将其显示为一个复选框字段的网格,旁边有一个按钮可以前进到下一代。当用户选中或取消选中复选框时,它们的更改应在计算下一代时包括在内。

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

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

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

网格的表示可以使用第 7 章和第 15 章中所示的任何方法。统计活邻居可以使用两个嵌套循环,循环遍历相邻坐标。注意不要统计场外的单元格,并且忽略中心单元格,因为我们要统计它的邻居。

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

如果你选择使用事件处理程序,你可能想要添加属性来标识每个复选框对应的位置,这样便于找出要更改哪个单元格。

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