一个阅读计划包含几种信息:书名、每周页数目标,以及偏好的阅读形式。程序处理数值之前,表单必须先表达这些信息的含义。同时,输入数据、接受数据、显示结果与保留数据供下次使用,是不同的步骤,各有不同的成功依据。
本单元逐步构建一个阅读计划示例,把DOM、事件与对象状态模型连接到表单控件、验证、Promise,以及 Fetch 使用的响应处理方式。
控件表达数值,标签表达含义
定义
表单控件与提交条目
表单控件是让用户输入或选择数值的界面。提交的表单条目通常把控件的 name 与当前值配对。id 标识文档中的元素,不能代替提交数据使用的名称。
应根据数值的含义选择控件:
| 控件 | 合适用途 | 重要区别 |
|---|---|---|
input type="text" | 简短书名 | 即使包含数字,其值仍是文本。 |
textarea | 跨越多行的问题 | 读取当前 value,而不是初始子文本。 |
input type="number" | 数值目标 | min、max 与 step 等约束描述允许的数值。 |
input type="email" 或 url | 具有指定语法的值 | 语法检查不能证明地址确实存在。 |
input type="checkbox" | 可独立选择的选项 | 可以同时选中多个复选框。 |
input type="radio" | 一组选项中的单一选择 | 同一表单内的控件以共用 name 组成一组。 |
select 与 option | 从菜单中选择 | 已选中的选项提供其值。 |
其他类型,例如 date、color、search 与 tel,会表达额外的输入意图,也可能改变浏览器界面。例如 tel 可以影响移动设备的键盘,但不会强制所有电话号码遵守同一格式。密码控件会遮蔽显示内容;这种外观不会决定表单的传送方式。
把标签的 for 与控件唯一的 id 配对,能够为文本与控件建立明确关系,也让读者通过标签选中控件。fieldset 把相关控件分组,legend 则为组命名。每个单选按钮仍需要自己的标签与不同的 ID。
例题
阅读计划表单
把以下标记放在独立 HTML 文档的 body 内。后面构建的脚本则放在表单之后。
<form id="reading-plan">
<p>
<label for="book">Book title</label>
<input id="book" name="book" type="text" maxlength="80" required>
</p>
<p>
<label for="pages">Pages this week</label>
<input id="pages" name="pages" type="number"
min="1" max="300" step="1" required>
</p>
<fieldset>
<legend>Preferred format</legend>
<input id="paper" name="format" type="radio" value="paper" checked>
<label for="paper">Paper</label>
<input id="digital" name="format" type="radio" value="digital">
<label for="digital">Digital</label>
</fieldset>
<p>
<label for="topic">Main topic</label>
<select id="topic" name="topic">
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="javascript">JavaScript</option>
</select>
</p>
<p>
<input id="discussion" name="discussion" type="checkbox" value="yes">
<label for="discussion">Include a discussion session</label>
</p>
<button type="submit">Record plan</button>
<button type="reset">Restore initial values</button>
</form>
<p id="plan-status" role="status"></p>
ID 支持标签配对与 DOM 选取。单选按钮共用 name="format",但各自的值区分不同选项。checked 属性设置初始的纸本选择。重置表单会恢复初始值,包括这个选择;不一定把所有控件都清空。
按钮类型同样重要。提交按钮会启动表单的提交行为;type="button" 则需要自己的事件逻辑。重置按钮恢复默认值。明确指定类型,可以避免添加辅助按钮时意外启动提交。
从控件取得名称与数值
new FormData(form) 会根据此刻能够参与提交的控件构建数据。启用的控件通常需要 name。未选中的复选框与单选按钮不产生条目;禁用的控件也被略过。有名称的只读文本控件仍可产生条目,因此只读与禁用是不同状态。
假设读者输入 Web foundations、选择 24 页、保留纸本选项、选择 HTML,并且没有选中讨论时段,数据条目如下:
book = Web foundations
pages = 24
format = paper
topic = html
pages 这项表单数据是字符串 "24"。是否转换类型,由程序决定。数值输入控件还提供 valueAsNumber;空值或无法转换的值会得到 NaN。FormData.get("format") 取得已选单选按钮的值。若有重复名称,getAll(name) 取得全部相应条目。data.has("discussion") 表达复选框是否提供了条目。
思考检查
哪个名称标识已选的阅读形式?
表单有 ID 分别为 paper 与 digital 的单选按钮,两者都名为 format。读者选中 digital 后,会提交哪个条目?若两个控件都改用 id="format",却没有 name,又会怎样?
解答 · 区分文档标识名称与数据条目名称
已选控件产生 format=digital。ID 用于文档内部,name 则成为数据键。让两个控件共用 ID 会使文档标识含糊,既不能建立预期的单选组,也不能补上提交所需的名称。应使用不同 ID 与共用 name。
原生提交与脚本提交
一般表单提交以 action 指定目标地址,以 method 选择数据的传送方式。没有指定 method 时通常使用 GET;没有指定 action 时通常使用当前文档的 URL。
| 方法 | 一般表单数据的位置 | 常见含义 |
|---|---|---|
| GET | 目标 URL 中经过编码的查询字符串 | 按提供的搜索条件获取结果。 |
| POST | 请求体 | 提交数据,交由服务器处理操作。 |
一般 POST 表单通常使用 application/x-www-form-urlencoded,文件上传表单则采用另一种编码。选择 POST 不会自动产生 JSON。脚本若要构建 JSON 请求,必须序列化对象,并选择匹配的内容类型。方法本身也不会创建文件,或定义应用程序的存储策略。
表单 submit 事件的处理函数可以调用 event.preventDefault(),再进行自己的处理。应处理表单事件,而不只是某个按钮的 click,让键盘提交沿用相同逻辑。用户启动提交时,浏览器正常的约束验证会先于 submit 事件进行;无效字段可能使事件无法到达处理函数。
验证检查的是明确规则
HTML 约束涵盖常见规则:required 拒绝必填字段的空值,min 与 max 限制数值范围,step="1" 则要求数值输入以整数步长变化。这些约束不能表达每项应用规则。例如只包含空格的必填文本字段,与具有实际字符的书名仍有区别。
有效性 API 提供不同操作:
checkValidity()返回适用约束是否成立。reportValidity()还要求浏览器提供验证反馈。setCustomValidity(message)设置应用程序特有的错误;空字符串会清除此自定义错误。
相关输入变化时,必须清除自定义错误,否则修正后的控件仍可能无效。应在字段附近提供有用解释;:valid、:invalid、:required、:optional、:enabled、:disabled、:read-only 与 :focus 可以配合视觉呈现。input[type="text"] 这类属性选择器则针对指定类型的控件。样式标示状态,却不定义验证规则。客户端检查提供即时反馈;真正接收数据的服务仍须独立验证它接受的值。
异步工作仍有执行次序
事件处理函数会依次执行 JavaScript 指令。等待输入输出则不同:操作可以开始、交回控制权,稍后才提供结果。在浏览器的主要 JavaScript 线程上,每次只执行一个回调函数。JavaScript 继续其他工作时,浏览器可以管理网络活动。
回调函数是交给其他代码、由对方调用的函数。周围的操作决定它何时执行,因此回调不一定是异步。嵌套回调能够表达依赖工作,但层数增加时,执行顺序与错误路径会更难追踪。
定义
Promise 与异步后续工作
Promise 代表稍后产生的结果。它最初处于 pending,之后可以 fulfilled 并提供值,或 rejected 并提供原因。.then() 添加后续工作并返回新的 Promise;若处理函数抛出错误,下一个 Promise 就被拒绝。.catch() 处理链中的拒绝结果。
在 .then() 处理函数中返回下一项异步操作,会让链等待该操作。若没有 return,内部操作可能独自继续,外层链却已向前执行。async 函数也返回 Promise;其中的 await 暂停该函数的后续工作,直到等待的 Promise 完成,但不会冻结整个浏览器。等待的步骤若被拒绝,效果如同抛出错误,可以用 try/catch 处理。
例题
追踪同步指令、微任务与定时器
把脚本视为当前任务,先记录同步输出,再考虑已安排的后续工作。
console.log("A");
setTimeout(() => console.log("D"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("B");
这段脚本的输出为 A、B、C、D。同步指令先完成,已完成 Promise 的后续工作以微任务执行,早于稍后的定时器任务。零延迟定时器只是安排后续工作,不会中断当前脚本,也不保证精确的执行时间。重复工作可以使用 setInterval;保留返回的 ID,并在应该停止时把它交给 clearInterval。
思考检查
加上 async 是否让每行都等待?
一个 async 函数调用 request(...),随即读取一个将由 .then() 处理函数赋值的变量。为什么仍可能读到旧值?如何直接表达这项依赖关系?
解答 · 等待真正需要的操作结果
把外层函数声明为 async,不会自动等待其中每个 Promise。没有 await 的调用之后,程序会继续执行。应以 const response = await request(...) 取得响应,再等待响应体转换,然后才使用数据。另一种做法是返回依次完成这些依赖步骤的 Promise 链。
Fetch 把取得响应与读取响应体分成两步
AJAX 指异步获取或提交数据,并在不重新导航整页的情况下更新网页的模式。其历史名称提及 XML,但这种模式也适用于 JSON 与文本。XMLHttpRequest 提供较早的请求接口;Fetch 则用 Promise 表达响应流程。
浏览器的 fetch() 返回一个代表 Response 的 Promise。Promise 完成表示已取得响应,不表示每项应用操作都成功。状态为 404 或 500 的响应通常仍使 Promise 完成,因此必须检查 response.ok 或 response.status。状态码为 200 至 299 时,ok 为 true。
读取响应体是另一个步骤:await response.json() 读取并解析 JSON,await response.text() 则取得文本。即使 HTTP 状态是 200,JSON 解析仍可能失败。读取方法会消耗响应体,因此应选择需要的表示,而不是反复读取同一响应。
| 失败情况 | 出现位置 | 判断方法 |
|---|---|---|
| 网络或浏览器访问失败 | Fetch 的 Promise 被拒绝。 | 这次调用没有取得可用响应。 |
| HTTP 错误状态 | 取得响应,但 ok === false。 | 检查状态及服务规定的错误表示。 |
| JSON 无效 | response.json() 被拒绝。 | 收到字节与解析预期表示,是不同步骤。 |
| 应用规则拒绝数据 | 服务按其约定报告拒绝。 | 保留用户输入,并解释适用规则。 |
源由协议方案、主机与端口组成。浏览器的 CORS 规则与接收服务的响应头,共同管制跨源响应的访问;改变请求方法不会授予这种权限。实践本地 HTTP 示例时,应通过开发服务器打开网页。file: 文档与 HTTP 文档的加载环境不同。
完全位于内存的响应模型
例题
逐步追踪同一存储实例
以下函数使用真正的 Response 对象,提供一个简单请求接口。状态保留在返回函数的闭包内。资源字符串 memory:reading-plan 选择这个模型,函数不会发出网络请求。
function createReadingStore() {
let plan = null;
const reply = (status, data) => new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" }
});
return async function mockFetch(resource, options = {}) {
await Promise.resolve();
if (resource !== "memory:reading-plan") {
return reply(404, { error: "Unknown resource" });
}
const method = options.method ?? "GET";
if (method === "GET") return reply(200, { plan });
if (method !== "PUT") return reply(405, { error: "Unsupported method" });
let incoming;
try {
incoming = JSON.parse(options.body);
} catch {
return reply(400, { error: "Expected JSON" });
}
if (incoming === null || typeof incoming !== "object" ||
typeof incoming.book !== "string" ||
incoming.book.trim().length === 0 || incoming.book.length > 80 ||
!Number.isInteger(incoming.pages) ||
incoming.pages < 1 || incoming.pages > 300 ||
!["paper", "digital"].includes(incoming.format)) {
return reply(422, { error: "Invalid reading plan" });
}
plan = {
book: incoming.book.trim(),
pages: incoming.pages,
format: incoming.format
};
return reply(200, { plan, storage: "memory" });
};
}
const request = createReadingStore();
模型以 GET 读取当前值,以 PUT 替换该值。其服务约定验证三个字段;表单内的主题与讨论选项用于演示控件类型,不会由此模型存储。临时 incoming 对象先通过验证,plan 才会改变,因此无效输入不会破坏先前计划。
返回函数名为 mockFetch,明确表示其角色。它是请求与响应处理的教学模型,并非浏览器 Fetch API 的完整实现。
在模型定义后执行以下程序;它建立独立的 sample,逐项等待并观察响应。
async function traceStore() {
const sample = createReadingStore();
const initial = await sample("memory:reading-plan");
console.log(initial.status, (await initial.json()).plan);
const write = await sample("memory:reading-plan", {
method: "PUT",
body: JSON.stringify({ book: "Web sketches", pages: 12, format: "digital" })
});
console.log(write.status, (await write.json()).plan.book);
const read = await sample("memory:reading-plan");
console.log(read.status, (await read.json()).plan.pages);
const invalid = await sample("memory:reading-plan", {
method: "PUT",
body: JSON.stringify({ book: "Web sketches", pages: 0, format: "digital" })
});
console.log(invalid.status, (await invalid.json()).error);
const afterFailure = await sample("memory:reading-plan");
console.log(afterFailure.status, (await afterFailure.json()).plan.pages);
const fresh = await createReadingStore()("memory:reading-plan");
console.log(fresh.status, (await fresh.json()).plan);
}
traceStore().catch(error => console.log(error.message));
| 操作 | 状态 | 观察结果 |
|---|---|---|
| 初次 GET | 200 | null |
| 有效 PUT | 200 | Web sketches |
| 成功后 GET | 200 | 12 页 |
| 零页数 PUT | 422 | Invalid reading plan |
| 失败后 GET | 200 | 仍是 12 页 |
| 新实例 GET | 200 | null |
验证先于替换,所以拒绝写入不会清除旧计划。最后一次读取使用另一个闭包,显示各实例的内存并非共享或持久存储。
把经过验证的表单接到模型
下一段脚本应放在上一段及表单之后。自定义书名规则拒绝只有空格的文本。处理函数等待结果时,提交按钮会禁用;无论成功或失败,finally 块都会恢复按钮。
const form = document.querySelector("#reading-plan");
const book = document.querySelector("#book");
const pages = document.querySelector("#pages");
const status = document.querySelector("#plan-status");
const submit = form.querySelector('button[type="submit"]');
book.addEventListener("input", () => book.setCustomValidity(""));
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (book.value.trim() === "") {
book.setCustomValidity("Enter a book title with visible characters.");
book.reportValidity();
return;
}
const data = new FormData(form);
const plan = {
book: book.value.trim(),
pages: pages.valueAsNumber,
format: data.get("format")
};
submit.disabled = true;
status.textContent = "Recording...";
try {
const response = await request("memory:reading-plan", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(plan)
});
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
const receipt = await response.json();
status.textContent = `Recorded in memory: ${receipt.plan.book}`;
form.reset();
} catch (error) {
status.textContent = error.message;
} finally {
submit.disabled = false;
}
});
只有响应检查与 JSON 读取都成功后,处理函数才清空表单。若有错误,已输入的值仍可供修正。结果使用 textContent 呈现,因此书名会作为文本显示。
要检查当前内存中的值,可在前面的脚本之后执行以下附加函数:
async function inspectPlan() {
const response = await request("memory:reading-plan");
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
const data = await response.json();
console.log(data.plan);
}
inspectPlan().catch(error => console.log(error.message));
先等待写入,再启动依赖该结果的读取,便表达了因果次序:验证、更新模型、接收确认,然后读取。若写入尚未完成便开始读取,就没有表达这项依赖关系。禁用提交按钮只限制一般重复点击,不能成为所有调用者或多个标签页之间的通用排序规则。
常见错误
必须按存储约定解释确认响应
这个模型更新闭包变量后便返回成功。同一实例的 GET 可以取得该值;重新加载网页或创建另一个 store,却会把 plan 再次初始化为 null。持久化存储需要服务定义存储操作及其完成保证;成功响应本身不会说明这项保证。
练习
思考检查
练习 1:预测提交条目
书名控件名为 book,并设为 readonly;页数控件名为 pages,并已禁用。纸本已选中,讨论选项未选中。哪些控件会向 new FormData(form) 提供条目?只读是否让接收服务可以信任书名?
解答 · 练习 1 解答
有名称的只读书名控件会提供其值;禁用的页数控件不会。已选纸本单选按钮提供 format=paper;未选讨论复选框没有条目。只读影响的是这个界面,并不免除接收服务的验证责任,因为请求可以独立于表单构建。
思考检查
练习 2:追踪验证状态
书名字段有三个空格。解释 required 为什么不足、处理函数如何标示错误,以及 input 监听器为什么要清除自定义消息。
解答 · 练习 2 解答
原始值有字符,因此 required 约束本身未必拒绝它。应用程序需要有实际内容的书名,所以检查去除首尾空白后的值。setCustomValidity 记录这项规则的错误,reportValidity 则要求反馈。下一次输入时清除自定义错误,才能评估修正后的值,而非保留过时的错误。
思考检查
练习 3:辨认失败步骤
情况 A 取得 HTTP 500 响应,其 JSON 有效;情况 B 取得 HTTP 200,但响应体文本是 not JSON;情况 C 在取得可用响应之前失败。每种情况由哪个 Promise 或检查发现?
解答 · 练习 3 解答
A 由响应状态检查发现,Fetch 的 Promise 仍可完成。B 在读取 JSON 的 Promise 被拒绝时发现。C 则是 Fetch 操作本身被拒绝。三者最后可能交由同一 catch 显示错误,但它们属于计算过程中的不同阶段。
思考检查
练习 4:区分已存储的值与持久性
一个 store 接受有效的 24 页计划。同一实例的 GET 取得该计划,新建 createReadingStore() 实例的 GET 却得到 null。解释两个结果;界面若要承诺重新加载后仍保留计划,还需要什么事实?
解答 · 练习 4 解答
第一个实例在自己的闭包内保留计划;新实例则有另一个初始化为 null 的闭包。要承诺跨越重新加载保留数据,必须有持久化存储机制,并有依据证明存储操作已按这种含义完成。模型的 storage: "memory" 确认,刻意描述较短的状态寿命。