打开网页与按下按钮都可以改变画面,但背后的工作未必相同。打开网页时,浏览器可能向服务器请求文档;按钮也可能完全在浏览器内改变已有的文档。另一个按钮则可能先向服务器获取更多数据,再更新文档的一部分。理解这些差别,就能在选择框架之前解释网页应用程序的运行方式。
本节会追踪一个小型阅读列表网页的初次加载与本地交互,辨认请求指向的资源、负责每项变化的程序,以及所产生状态的生命周期。
1. 网页应用程序由多个部分协作
定义
客户端、服务器与资源
客户端主动发出服务请求。在本节例子中,浏览器担任客户端。服务器接收请求并产生响应。资源是以 URL 标识的对象,例如 HTML 文档、图片、样式表或一组数据。
客户端与服务器描述的是角色,并非两种指定的实体计算机。一台笔记本电脑可以同时运行浏览器与本地网页服务器进程。浏览器仍然负责请求资源,服务器进程仍然负责处理这些请求。
服务器也不一定只发送磁盘上已有的文件。它可以运行程序来构造响应。例如,阅读列表服务可以选取可供阅读的书籍并生成 HTML,也可以返回 JSON 表示,供浏览器的 JavaScript 更新列表。两种情况都有请求、响应,以及资源的一种表示。
Web 描述这套通信与文档环境。Internet 是底层的网络基础设施,HTTP 则是使用这套设施的一种应用层协议。网页应用程序也可以在私有网络中运行,或通过同一台计算机上的本地服务器运行。
2. 把 URL 理解为有结构的地址
考虑以下示例地址:
https://reading.example.test/shelf/books?topic=web#practice
| 部分 | 值 | 作用 |
|---|---|---|
| 协议方案 | https | 选用 HTTPS,即通过加密连接传输的 HTTP。 |
| 主机 | reading.example.test | 标识服务器名称。 |
| 路径 | /shelf/books | 标识该服务器 URL 空间中的资源。 |
| 查询 | topic=web | 提供额外的请求参数。 |
| 片段 | practice | 标识返回资源的某个部分,供客户端导航。 |
一般 HTTP 请求不会把片段发送至服务器。浏览器获取文档后可以使用片段,例如滚动至 id 为 practice 的元素。路径可以由应用程序路由处理,因此单凭路径不能判定服务器磁盘上存在同名文件。
协议方案也决定默认端口:HTTPS 通常使用 443,HTTP 通常使用 80。明确指定端口的地址,例如 http://localhost:8080/,则选用另一个端口。localhost 指正在运行浏览器的计算机,适合用来访问在该计算机上开发时运行的服务器。
例题
解析相关资源的地址
假设当前文档的 URL 是 https://reading.example.test/shelf/index.html,而且文档没有 base 元素。
theme.css解析为同一主机上的/shelf/theme.css。/assets/book.svg从该主机的根目录开始解析,得到/assets/book.svg。../about.html解析为/about.html。#practice保持文档位置,并选取其practice片段。
开头的斜线会改变路径解析的起点。因此,把 HTML 文件移至另一个目录,可能改变相对链接;相对于根目录的链接则在同一主机内保持原有路径。
思考检查
哪个部分会传到服务器?
比较 /shelf/books?topic=html#examples 与 /shelf/books?topic=css#examples。服务器可以利用哪个部分区分这两个请求?如果只把 #examples 改成 #exercises,新的片段会传到服务器吗?
解答 · 比较查询与片段
两个查询分别是 topic=html 与 topic=css。服务器生成响应时可以使用这些查询值。片段不属于 HTTP 的请求目标。只改变同一份文档的片段,通常是在该文档内导航,无须向服务器请求另一份文档。
3. 追踪一次文档请求
输入 HTTPS 地址会启动数项工作。浏览器识别主机,按需要获取其网络地址,并建立连接;HTTP 随后提供请求与响应的通信方式。部分工作可以沿用先前访问或缓存的结果,因此以下是概念上的流程,并非每次访问都必须逐项重新执行。
Browser Server
| |
|--- request the /shelf/index.html resource ->|
| | choose or generate content
|<-- status, headers and HTML body -----------|
| |
| parse HTML |
|--- request a referenced stylesheet -------->|
|<-- stylesheet response ---------------------|
| build the displayed page |
请求包含方法、目标与标头;部分方法也会带有主体。响应包含状态、标头,而且通常带有主体。以下以简化的 HTTP/1.1 形式示范:
例题
解读一次 HTML 请求与响应
假设浏览器向 reading.example.test 请求 /shelf/index.html,而服务器成功返回文档,没有重定向。先读请求,再判断浏览器可以从响应得知什么。
GET /shelf/index.html HTTP/1.1
Host: reading.example.test
Accept: text/html
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<!doctype html>
<html lang="en">
<head><title>Reading shelf</title></head>
<body><h1>Reading shelf</h1></body>
</html>
空白行把标头与主体分隔。Content-Type 标识这种表示的媒体类型,让接收端正确解读主体。HTTP/2 与 HTTP/3 采用不同的消息编码方式,但仍保留方法、状态、标头与响应数据这些概念。
GET 指明获取资源的操作,路径标识所请求的资源,Host 则标识服务器名称。响应的 200 表示成功,内容类型告诉浏览器应把主体解读为 UTF-8 HTML。解析主体后,得到以“Reading shelf”为标题的网页。请求描述所索取的内容,响应则提供结果与资源表示。
HTML 文档可以引用 CSS、JavaScript、字体与图片。读者看见的网页由这些资源组合而成,未必只来自一个响应。多个请求的执行时间可以重叠,也可以复用缓存资源。反过来说,一张图片的请求失败,并不表示 HTML 文档的请求也失败。
| 状态 | 所表达的结果 | 接下来值得观察的数据 |
|---|---|---|
200 | 请求成功。 | 检查内容类型与返回内容。 |
404 | 找不到所请求的资源。 | 检查完整路径、拼写与相对链接的解析结果。 |
500 | 服务器处理请求时发生错误。 | 开发该服务器时,检查服务端的失败原因。 |
这些状态代表不同的结果。更改 CSS 颜色不能修正错误的资源路径;修正路径也不能修复在生成响应时失败的服务器程序。
4. 分清结构、呈现与行为
HTML、CSS 与 JavaScript 相互协作,但分别回答不同的问题。
| 技术 | 主要职责 | 阅读列表例子 |
|---|---|---|
| HTML | 赋予内容结构与意义。 | 标题、书籍列表与按钮。 |
| CSS | 控制呈现方式与布局。 | 间距、颜色、方便阅读的行长与响应式分栏。 |
| JavaScript | 执行程序逻辑并响应事件。 | 计算按钮次数,或在收到数据后更新列表。 |
HTML 是标记语言,元素描述文档的结构。CSS 提供呈现规则。JavaScript 执行指令,并可以改变浏览器对文档的表示,也就是文档对象模型(Document Object Model,DOM)。框架同样在这个环境内运行;理解各部分的基本角色,有助于解释框架生成了什么。
浏览器运行客户端 JavaScript。服务器也可以运行 JavaScript,例如使用 Node.js,但两者属于不同的运行环境。服务器脚本只改变自身某个变量,不能直接改变已显示在浏览器中的网页。它必须通过响应或其他支持的通信机制,发送浏览器可以使用的结果。
5. 一个完整的本地交互
例题
追踪点击与重新加载后的计数
把以下独立文档保存为 reading-counter.html,并在浏览器中打开。计数值存放在当前文档的 JavaScript 状态中;按钮不会发出网络请求。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reading counter</title>
<style>
body { max-width: 38rem; margin: 2rem auto; padding: 0 1rem; }
button { font: inherit; padding: 0.5rem 1rem; }
</style>
</head>
<body>
<main>
<h1>Reading counter</h1>
<p>Pages recorded: <output id="count" aria-live="polite">0</output></p>
<button id="record" type="button">Record one page</button>
</main>
<script>
let count = 0;
const display = document.querySelector("#count");
const button = document.querySelector("#record");
button.addEventListener("click", () => {
count = count + 1;
display.textContent = String(count);
});
</script>
</body>
</html>
元素写在脚本之前,因此执行 querySelector 时已经存在。选择器 #count 与 #record 分别匹配相应的 ID。事件处理函数只注册一次,每当点击发生时便执行。textContent 改变输出元素的文字,不会加入 HTML 标记。
令 表示点击 次后的计数值。初始条件是 ,每次点击都执行
输出元素呈现这个状态。程序先改变 count,再更新显示的文字。如果移除第二项指令,变量仍会改变,但读者会继续看见旧值。
| 操作 | JavaScript 状态 | 输出文字 | 事件处理函数发出的服务器请求 |
|---|---|---|---|
| 初始文档 | count = 0 | 0 | 无 |
| 第一次点击 | count = 1 | 1 | 无 |
| 第二次点击 | count = 2 | 2 | 无 |
| 重新加载文档 | 再次为 count = 0 | 0 | 处理函数本身仍不发出请求。 |
重新加载会建立新文档,并再次执行初始化。取决于打开方式,浏览器可能重新读取文件,也可能加载服务器提供的版本。两种情况下,这个程序都不会保存先前的计数。要跨越重新加载保留数值,需要持久化存储机制;要跨设备共享数值,则需要合适的共享服务与通信。
常见错误
画面改变不代表曾经联系服务器
画面可能因为浏览器的事件处理函数改变了 DOM 而更新。单凭这项观察,不能判定数据曾经发送或保存。判断服务器是否参与之前,应先辨认程序执行的路径,或检查网络活动。
思考检查
先预测计数结果,再动手试验
点击三次,重新加载,再点击一次。每个阶段会显示什么值?如果移除 display.textContent = String(count),结果会有什么改变?
解答 · 追踪初始化与更新
三次点击后显示 3。重新加载会把新的 count 初始化为 0,文档显示 0;再点击一次便显示 1。如果没有 textContent 赋值,内存中的计数仍会随点击改变,但显示文字保持为 0。模型中的值与可见的表示是不同的对象,程序必须维持两者一致。
6. 观察负责的层次来诊断问题
浏览器开发者工具可以检验解释,无须只凭外观推断所有事情。
- 在文档检查器中找出输出元素。按下按钮,观察其文本节点改变。
- 网页由本地服务器提供时,在 Network 面板检查初始文档请求,并比较其响应与所引用资源的请求。
- 清空网络记录,再按计数按钮。所提供的处理函数不会引起请求。
- 重新加载,把原始响应或文件内容与当前的 DOM 比较。原始代码加载后,脚本仍可以改变 DOM。
网页看起来有问题时,先提出精确的问题:资源是否已获取?文档结构是否正确?样式规则是否生效?处理函数是否更新了预期的状态与元素?不同问题会指向不同的证据。
练习
1. 辨认工作的执行位置
对以下每项操作,指出主要负责的层次:HTML、CSS、浏览器 JavaScript 或服务器程序,并解释理由。
- 把书名标记为标题。
- 画面变窄时,把两栏改成一栏。
- 点击后增加本地阅读计数。
- 服务只返回符合查询参数的书籍。
解答 · 解答 1:各部分的职责
- HTML 以标题元素标明内容的角色。
- CSS 描述布局如何响应可用空间。
- 浏览器 JavaScript 处理事件、更新变量并改变 DOM。
- 服务器程序解读请求中的查询,并准备符合条件的表示。浏览器 JavaScript 可以显示结果,但只接收结果并不等于执行了服务器的筛选工作。
2. 在明确假设下计算资源数目
一份文档引用一份样式表、一份脚本与两张不同的图片。假设缓存为空、没有重定向、这些文件不再引用其他资源,而且每个资源的请求都成功。连同文档本身,共需要多少次资源请求?这是否表示所有请求必须逐一执行?
解答 · 解答 2:计算请求,不预设执行时序
共有 次请求,即文档与四个引用资源。这个数目并未确定执行时序。浏览器发现依赖资源后,可以同时请求其中部分资源,无须等待每个请求完成才开始下一个。
3. 解释重新加载的结果
学习者按计数按钮五次,关闭标签页,再打开同一网页。他预期看见 5,却看见 0。这是否必然表示点击处理函数有错?指出程序中可以解释这个结果的证据。
解答 · 解答 3:状态的生命周期
处理函数可以完全正确。let count = 0 会为每份新文档初始化状态,而且处理函数没有使用存储或服务器通信。关闭文档会结束该实例的状态,新的加载则建立另一个从零开始的实例。正确的本地更新与持久化存储是不同要求。
4. 解析目录改变后的地址
一份 HTML 文件由 /shelf/index.html 移至 /courses/web/index.html。文档没有 base 元素。比较移动前后,theme.css 与 /assets/book.svg 各会解析为什么地址。
解答 · 解答 4:相对路径与相对于根目录的路径
theme.css 由 /shelf/theme.css 变为 /courses/web/theme.css,因为它相对于文档所在目录解析。/assets/book.svg 在同一主机上保持该路径,因为开头的斜线表示由主机根目录开始解析。移动文档不会自动移动这两个资源。
接着阅读语义化 HTML 文档,清楚表达网页的结构与意义。