译者:飞龙
第十九章:HTTP
与表单
超文本传输协议(HyperText Transfer Protocol
),在第十三章中介绍,是在万维网上请求和提供数据的机制。本章更详细地描述了该协议,并解释了浏览器JavaScript
如何访问它。
协议
如果你在浏览器的地址栏中输入[eloquentjavascript.net/18_http.xhtml](http://eloquentjavascript.net/18_http.xhtml)
,浏览器首先查找与eloquent [javascript.net](http://javascript.net)
关联的服务器地址,并尝试在端口80
(HTTP
流量的默认端口)上打开一个TCP
连接。如果服务器存在并接受连接,浏览器可能会发送类似这样的内容:
GET /18_http.xhtml HTTP/1.1
Host: eloquentjavascript.net
User-Agent: Your browser's name
然后服务器通过相同的连接进行响应。
HTTP/1.1 200 OK
Content-Length: 87320
Content-Type: text/html
Last-Modified: Fri, 13 Oct 2023 10:05:41 GMT
<!doctype html>
--snip--
浏览器获取空行后响应的部分,即它的主体
(不要与HTML <body>
标签混淆),并将其显示为HTML
文档。
客户端发送的信息称为请求
。它以这一行开始:
GET /18_http.xhtml HTTP/1.1
第一个词是请求的方法
。GET
意味着我们想要获取
指定的资源。其他常见的方法有DELETE
用于删除资源,PUT
用于创建或替换资源,POST
用于向其发送信息。请注意,服务器并不一定有义务执行它收到的每一个请求。如果你走到一个随机网站并告诉它删除其主页,它可能会拒绝。
方法名称之后的部分是请求应用于的资源
的路径。在最简单的情况下,资源只是服务器上的一个文件,但协议并不要求它必须是。资源可以是任何可以被转移好像
它是一个文件的东西。许多服务器生成的响应是动态生成的。例如,如果你打开[
github.com/marijnh](https://github.com/marijnh)
,服务器会在其数据库中查找名为marijnh
的用户,如果找到了,它会为该用户生成一个个人资料页面。
在资源路径之后,请求的第一行提到HTTP/1.1
,以指示它使用的HTTP
协议的版本。
实际上,许多网站使用HTTP
版本2
,它支持与版本1.1
相同的概念,但更加复杂,因此可以更快。浏览器在与特定服务器交互时会自动切换到适当的协议版本,无论使用哪个版本,请求的结果都是相同的。由于版本1.1
更加简单且更易于操作,我们将使用它来说明该协议。
服务器的响应也会以一个版本开头,后面跟着响应的状态,首先是一个三位数的状态代码,然后是一个可读的字符串。
HTTP/1.1 200 OK
以2
开头的状态码表示请求成功。以4
开头的代码意味着请求存在问题。最著名的HTTP
状态码可能是404
,表示找不到该资源。以5
开头的代码表示服务器发生了错误,请求没有错。
请求或响应的第一行后面可以跟随任意数量的头部
。这些是形如name: value
的行,指定有关请求或响应的额外信息。这些头部是示例响应的一部分:
Content-Length: 87320
Content-Type: text/html
Last-Modified: Fri, 13 Oct 2023 10:05:41 GMT
这告诉我们响应文档的大小和类型。在这种情况下,它是一个87,320
字节的HTML
文档。它还告诉我们该文档最后一次修改的时间。
客户端和服务器可以自由决定在请求或响应中包含哪些头部。但其中一些对于正常工作是必要的。例如,如果响应中没有Content-Type
头部,浏览器将不知道如何显示该文档。
在头部之后,请求和响应都可能包含一个空行,后面跟着一个主体
,其中包含实际发送的文档。GET
和DELETE
请求不发送任何数据,但PUT
和POST
请求会发送。一些响应类型,如错误响应,也不需要主体。
浏览器和HTTP
正如我们所见,当我们在地址栏中输入URL
时,浏览器会发起请求。当生成的HTML
页面引用其他文件,例如图像和JavaScript
文件时,它也会检索这些文件。
一个中等复杂的网站可以轻松包含10
到200
个资源。为了能够快速获取这些资源,浏览器会同时发起多个GET
请求,而不是一个个等待响应。
HTML页面可以包含表单
,允许用户填写信息并将其发送到服务器。这是一个表单的示例:
<form method="GET" action="example/message.xhtml">
<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
(或被省略)时,表单中的信息将作为查询字符串
添加到动作URL
的末尾。浏览器可能会向这个URL
发起请求:
GET /example/message.xhtml?name=Jean&message=Yes%3F HTTP/1.1
问号表示URL
路径部分的结束和查询的开始。它后面跟随名称和值的对,分别对应于表单字段元素的name
属性和这些元素的内容。一个&
符号用于分隔这些对。
URL中编码的实际消息是“Yes?
”,但问号被一个奇怪的代码替换。一些查询字符串中的字符必须被转义。问号(以%3F
表示)就是其中之一。似乎有一种不成文的规则,即每种格式都需要自己的一种字符转义方式。这种称为URL编码
的方式使用一个百分号,后跟两个十六进制(基数为16)数字来编码字符代码。在这种情况下,3F
在十进制中是63
,是问号字符的代码。JavaScript
提供了encodeURIComponent
和decodeURIComponent
函数来编码和解码这种格式。
console.log(encodeURIComponent("Yes?"));
// → Yes%3F
console.log(decodeURIComponent("Yes%3F"));
// → Yes?
如果我们将之前看到的HTML
表单的method
属性更改为POST
,提交表单时发出的HTTP
请求将使用POST
方法,并将查询字符串放入请求的主体中,而不是将其添加到URL
中。
POST /example/message.xhtml 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
。
fetch("example/data.txt").then(response => {
console.log(response.status);
// → 200
console.log(response.headers.get("Content-Type"));
// → text/plain
});
调用fetch
返回一个Promise
,该Promise
解析为一个Response
对象,包含有关服务器响应的信息,例如其状态码和响应头。响应头被封装在一个类似Map
的对象中,该对象将其键(头部名称)视为不区分大小写,因为头部名称不应区分大小写。这意味着headers.get("Content-Type")
和headers.get("content-TYPE")
将返回相同的值。
请注意,即使服务器返回了错误代码,fetch
返回的Promise
仍然会成功解决。如果发生网络错误或请求所针对的服务器无法找到,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
,返回一个解析为JSON
时的值的promise
,如果不是有效的JSON
,则会拒绝。
默认情况下,fetch
使用GET
方法发出请求,并且不包含请求主体。你可以通过将带有额外选项的对象作为第二个参数传递来配置它。例如,这个请求试图删除example/data.txt
:
fetch("example/data.txt", {method: "DELETE"}).then(resp => {
console.log(resp.status);
// → 405
});
405
状态码表示“方法不被允许”,这是HTTP
服务器表明“抱歉,我无法做到”的方式。
要为PUT
或POST
请求添加请求主体,可以包含一个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.org
和mybank.com
)发出HTTP
请求来保护我们。
当构建希望出于合法理由访问多个域的系统时,这可能是一个烦人的问题。幸运的是,服务器可以在其响应中包含这样的头部,以明确向浏览器指示请求可以来自另一个域:
Access-Control-Allow-Origin: *
理解HTTP
当构建一个需要在浏览器中运行的JavaScript
程序(客户端)与服务器上的程序(服务器端)之间进行通信的系统时,有几种不同的方式来建模这种通信。
一种常用的模型是远程过程调用
。在这个模型中,通信遵循正常函数调用的模式,只不过函数实际上是在另一台机器上运行。调用它涉及向服务器发出请求,包括函数的名称和参数。对此请求的响应包含返回的值。
在考虑远程过程调用时,HTTP
只是一个通信的载体,你很可能会编写一个完全隐藏它的抽象层。
另一种方法是围绕资源和HTTP
方法的概念构建你的通信。你使用PUT
请求而不是远程过程addUser
,针对/users/larry
。你不再在函数参数中编码用户的属性,而是定义一个JSON
文档格式(或使用现有格式)来表示用户。然后,用于创建新资源的PUT
请求的主体就是这样的文档。通过向资源的URL
(例如,/users/larry
)发出GET
请求来获取资源,这又会返回表示该资源的文档。
这种第二种方法使得使用HTTP
提供的一些特性变得更加容易,比如支持资源缓存(在客户端保留资源副本以便快速访问)。HTTP
中的概念设计良好,可以为设计你的服务器接口提供一套有用的原则。
安全性和HTTPS
在互联网上传输的数据往往要经历一条漫长而危险的道路。为了到达目的地,它必须穿越从咖啡店Wi-Fi
热点到各种公司和国家控制的网络。在其路线的任何点,它都可能被检查甚至被修改。
如果某些东西保持秘密很重要,比如你的电子邮件账户密码,或者它需要在传输到目的地时不被修改,比如你通过银行网站转账的账户号码,那么普通HTTP
就不够安全。
安全HTTP
协议用于以https://
开头的URL
,以一种更难以阅读和篡改的方式封装HTTP
流量。在交换数据之前,客户端通过要求服务器证明其拥有浏览器识别的证书颁发机构颁发的加密证书,来验证服务器的身份。接下来,通过连接传输的所有数据都以一种应该能防止窃听和篡改的方式进行加密。
因此,当它正常工作时,HTTPS
可以防止其他人冒充你想要交流的网站并且
监视你的通信。它并不完美,也发生过由于伪造或盗用证书和软件故障导致HTTPS
失败的各种事件,但它比普通HTTP
安全得多
。
表单字段
表单最初是为预JavaScript
网页设计的,目的是允许网站通过HTTP
请求发送用户提交的信息。这个设计假设与服务器的交互总是通过导航到新页面来进行。
然而,表单元素是DOM
的一部分,就像页面的其余部分一样,表示表单字段的DOM
元素支持许多其他元素所不具备的属性和事件。这使得使用JavaScript
程序检查和控制这些输入字段成为可能,并且可以进行诸如向表单添加新功能或在JavaScript
应用程序中将表单和字段作为构建块等操作。
一个网页表单由任意数量的输入字段组成,这些字段被分组在一个<form>
标签中。HTML
允许多种不同样式的字段,从简单的开关复选框到下拉菜单和文本输入字段。本书不会试图全面讨论所有字段类型,但我们将首先提供一个粗略的概述。
许多字段类型使用<input>
标签。该标签的type
属性用于选择字段的样式。以下是一些常用的<input>
类型:
text | 单行文本字段 |
---|---|
password | 与文本相同,但隐藏输入的文本 |
checkbox | 开/关切换开关 |
color | 一种颜色 |
date | 日历日期 |
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="color" value="orange"> (color)</p>
<p><input type="date" value="2023-10-13"> (date)</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>
使用此HTML
代码创建的字段看起来是这样的:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0304-01.jpg
这些元素的JavaScript
接口根据元素的类型而有所不同。
多行文本字段有自己的标签<textarea>
,主要是因为使用属性指定多行起始值会显得尴尬。<textarea>
标签需要一个匹配的</textarea>
结束标签,并使用这两个标签之间的文本作为起始文本,而不是值属性。
<textarea>
one
two
three
</textarea>
最后,<select>
标签用于创建一个字段,允许用户从多个预定义选项中进行选择。
<select>
<option>Pancakes</option>
<option>Pudding</option>
<option>Ice cream</option>
</select>
这样的字段看起来是这样的:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0305-01.jpg
每当表单字段的值发生变化时,它将触发一个“change”
事件。
焦点
与 HTML 文档中的大多数元素不同,表单字段可以获得键盘焦点
。当点击、通过TAB
移动或以其他方式激活时,它们将成为当前活动元素并接收键盘输入。
因此,你只能在文本字段获得焦点时输入内容。其他字段对键盘事件的响应方式不同。例如,<select>
菜单会尝试移动到包含用户输入文本的选项,并通过上下箭头键移动选择。
我们可以通过JavaScript使用focus
和blur
方法控制焦点。第一个方法将焦点移动到调用的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
移动焦点到下一个可聚焦元素,按SHIFT-TAB
返回到上一个元素。默认情况下,元素按照它们在文档中出现的顺序被访问。可以使用tabindex
属性来改变这个顺序。以下示例文档将允许焦点从文本输入跳转到OK
按钮,而不是先经过帮助链接:
<input type="text" tabindex=1> <a href=".">(help)</a>
<button onclick="console.log('ok')" tabindex=2>OK</button>
默认情况下,大多数类型的HTML元素不能被聚焦。你可以给任何元素添加tabindex
属性使其可聚焦。tabindex
为0
的元素可以被聚焦而不影响聚焦顺序。
禁用字段
所有表单字段都可以通过其disabled
属性被禁用。这是一个可以不带值指定的属性——只要存在,元素就会被禁用。
<button>I'm all right</button>
<button disabled>I'm out</button>
被禁用的字段无法聚焦或更改,浏览器会使其看起来呈灰色和褪色。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0306-01.jpg
当一个程序正在处理由某个按钮或其他控件引起的可能需要与服务器通信并因此耗时的操作时,禁用该控件直到操作完成是个好主意。这样,当用户不耐烦再次点击时,他们不会不小心重复他们的操作。
整个表单
当一个字段包含在<form>
元素中时,其DOM元素将有一个form
属性链接回该表单的DOM元素。反过来,<form>
元素有一个名为elements
的属性,包含了内部字段的类似数组的集合。
表单字段的name
属性决定了在提交表单时其值的识别方式。它也可以在访问表单的elements
属性时用作属性名称,该属性既可以作为类似数组的对象(通过数字访问),也可以作为映射(通过名称访问)。
<form action="example/submit.xhtml">
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>
当一个具有提交类型属性的按钮被按下时,会导致表单被提交。在表单字段聚焦时按下ENTER
键也会产生相同的效果。
正常提交表单意味着浏览器导航到由表单的action
属性指示的页面,使用GET
或POST
请求。但在这之前,会触发一个“submit
”事件。你可以用JavaScript处理这个事件,并通过在事件对象上调用preventDefault
来阻止这种默认行为。
<form>
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>
标签创建的字段共享一个公共接口。它们的DOM元素具有一个value
属性,该属性作为字符串值持有当前内容。将此属性设置为另一个字符串会更改字段的内容。
文本字段的selectionStart
和selectionEnd
属性提供了关于光标和文本选择的信息。当没有选中任何内容时,这两个属性的值相同,指示光标的位置。例如,0
表示文本的开始,10
表示光标位于第10
个字符之后。当字段的部分内容被选中时,这两个属性的值会不同,显示所选文本的起始和结束位置。与值一样,这些属性也可以被写入。
想象一下,你正在写一篇关于哈塞克赫姆维(Khasekhemwy
),第二王朝的最后一位法老的文章,但在拼写他的名字时遇到了一些困难。以下代码连接了一个<textarea>
标签,并添加了一个事件处理程序,当你按下F2
时,会为你插入字符串“Khasekhemwy
”。
<textarea></textarea>
<script>
let textarea = document.querySelector("textarea");
textarea.addEventListener("keydown", event => {
if (event.key == "F2") {
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>
在传递给querySelectorAll
的CSS查询中的方括号用于匹配属性。它选择name
属性为“color”的元素。
选择字段
选择字段在概念上类似于单选按钮——它们也允许用户从一组选项中选择。但是,单选按钮的选项布局由我们控制,而<select>
标签的外观则由浏览器决定。
选择字段也有一个更像复选框而不是单选框的变体。当给定multiple
属性时,<select>
标签将允许用户选择任意数量的选项,而不仅仅是单个选项。常规选择字段被绘制为下拉
控件,只有在打开时才会显示非活动选项,而启用multiple
的字段则同时显示多个选项,允许用户单独启用或禁用它们。
每个<option>
标签都有一个值。这个值可以通过value
属性定义。当没有给出该属性时,选项内的文本将作为其值。<select>
元素的value
属性反映当前选定的选项。然而,对于多个字段来说,这个属性并不太有意义,因为它只会给出当前所选选项中的一个
的值。
<select>
字段的<option>
标签可以通过字段的options
属性作为类数组对象访问。每个选项都有一个名为selected
的属性,指示该选项当前是否被选中。该属性也可以被写入,以选择或取消选择一个选项。
此示例从多个选择字段中提取所选值,并利用这些值组成一个二进制数字。按住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>
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
属性,这使得可以同时选择多个文件。
文件中的对象具有诸如name
(文件名)、size
(文件的字节大小,8位的块)和type
(文件的媒体类型,例如text/plain
或image/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
对象,为其注册一个“加载”事件处理程序,并调用其readAsText
方法,同时传入我们想要读取的文件。一旦加载完成,读取器的result
属性将包含文件的内容。
FileReaders
在读取文件失败的任何原因时也会触发“错误”事件。错误对象本身将最终出现在阅读器的error
属性中。这个接口在承诺成为语言的一部分之前设计。你可以像这样将其封装在一个承诺中:
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绑定——每次关闭页面时,这些绑定都会被丢弃。你可以设置一个服务器,将其连接到互联网,并让你的应用程序在那里存储某些内容(我们将在第二十章中看到如何做到这一点)。但那是很多额外的工作和复杂性。有时候,仅仅将数据保存在浏览器中就足够了。
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", () => {
let {selected} = state;
setState({
notes: {...state.notes, [selected]: note.value},
selected
});
});
document.querySelector("button")
.addEventListener("click", () => {
let name = prompt("Note name");
if (name) setState({
notes: {...state.notes, [name]: ""},
selected: name
});
});
</script>
脚本从localStorage
中的Notes
值获取其初始状态,或者如果缺少该值,则创建一个只有购物清单的示例状态。从localStorage
读取不存在的字段将返回null
。将null
传递给JSON.parse
将使其解析字符串“null”并返回null
。因此,??
运算符可以用于在这种情况下提供默认值。
setState
方法确保 DOM 显示给定状态,并将新状态存储到localStorage
。事件处理程序调用此函数以移动到新状态。
示例中的...
语法用于创建一个新的对象,该对象是旧状态.notes
的克隆,但添加或覆盖了一个属性。它首先使用扩展语法添加旧对象中的属性,然后设置一个新属性。对象字面量中的方括号语法用于创建一个属性,其名称基于某个动态值。
还有一个对象,类似于localStorage
,称为sessionStorage
。两者之间的区别在于sessionStorage
的内容在每个会话
结束时会被遗忘,对于大多数浏览器而言,这意味着每当浏览器关闭时。
概要
在这一章中,我们讨论了 HTTP 协议的工作原理。客户端
发送一个请求,该请求包含一个方法(通常是GET
)和一个标识资源的路径。然后服务器
决定如何处理该请求,并以状态码和响应体进行响应。请求和响应都可以包含提供附加信息的头。
浏览器 JavaScript 进行 HTTP 请求的接口称为fetch
。发起请求的方式如下:
fetch("/18_http.xhtml").then(r => r.text()).then(text => {
console.log(`The page starts with ${text.slice(0, 15)}`);
});
浏览器通过发起GET
请求来获取显示网页所需的资源。页面还可以包含表单,当表单被提交时,用户输入的信息会作为新页面请求发送。
HTML 可以表示多种类型的表单字段,如文本字段、复选框、多选字段和文件选择器。这些字段可以通过 JavaScript 进行检查和操作。字段变化时会触发“变化”事件,输入文本时会触发“输入”事件,并在具有键盘焦点时接收键盘事件。像value
(对于文本和选择字段)或checked
(对于复选框和单选按钮)这样的属性用于读取或设置字段的内容。
当表单被提交时,会在其上触发“提交”事件。JavaScript 处理程序可以在该事件上调用preventDefault
来禁用浏览器的默认行为。表单字段元素也可以出现在表单标签之外。
当用户在文件选择字段中从本地文件系统选择一个文件时,可以使用FileReader
接口在 JavaScript 程序中访问该文件的内容。
localStorage
和sessionStorage
对象可以用来以在页面重载时仍然保留信息的方式保存数据。第一个对象会永久保存数据(或者直到用户决定清除它),而第二个则在浏览器关闭之前保存数据。
练习
内容协商
HTTP 的一项功能称为内容协商
。Accept
请求头用于告诉服务器客户端希望获得哪种类型的文档。许多服务器会忽略该头,但当服务器知道有多种方式对资源进行编码时,它可以查看该头并发送客户端所偏好的格式。
URL[eloquentjavascript.net/author](https://eloquentjavascript.net/author)
被配置为根据客户端请求的内容返回纯文本、HTML 或 JSON。这些格式通过标准化的媒体类型
text/plain、text/html和application/json来识别。
发送请求以获取此资源的所有三种格式。使用传递给fetch
的选项对象中的headers
属性,将名为Accept
的头设置为所需的媒体类型。
最后,尝试请求媒体类型application/rainbows+unicorns
,看看会产生哪个状态码。
一个 JavaScript 工作台
构建一个接口,允许用户输入和运行 JavaScript 代码片段。
在<textarea>
字段旁边放一个按钮,当按下时,使用我们在第十章中看到的Function
构造函数将文本包装在一个函数中并调用它。将函数的返回值或它引发的任何错误转换为字符串并显示在文本字段下方。
康威的生命游戏
康威的生命游戏是一个简单的模拟,它在一个网格上创建人工“生命”,每个单元格要么是活的,要么是死的。在每一代(轮次)中,应用以下规则:
-
任何活细胞如果邻居少于两个或多于三个活邻居则死亡。
-
任何活细胞如果有两个或三个活邻居,则可以存活到下一代。
-
任何有恰好三个活邻居的死细胞变为活细胞。
邻居
被定义为任何相邻的单元格,包括对角相邻的单元格。
请注意,这些规则是同时应用于整个网格,而不是逐个方格。这意味着邻居的计数是基于这一代开始时的情况,而在这一代中邻居单元格的变化不应影响给定单元格的新状态。
使用你认为合适的数据结构来实现这个游戏。使用Math.random
最初以随机模式填充网格。将其显示为复选框字段的网格,并在旁边放置一个按钮以推进到下一代。当用户勾选或取消勾选复选框时,他们的更改应在计算下一代时考虑在内。
我看着眼前的各种颜色。我看着我的空白画布。然后,我尝试像塑造诗句的词语一样应用颜色,像塑造音乐的音符一样。
—胡安·米罗
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0318-01.jpg
第二十章:项目:一个像素艺术编辑器
之前章节的材料为你构建一个基本的web
应用程序提供了所有必要的元素。在这一章,我们将正是这样做。
我们的应用程序将是一个像素绘制程序,允许你通过操控放大的图像视图逐个像素地修改图像,图像视图显示为一个有色方块的网格。你可以使用该程序打开图像文件,用鼠标或其他指针设备在其上涂鸦,并保存它们。这就是它的外观:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0319-01.jpg
在计算机上绘画是很棒的。你不需要担心材料、技巧或天赋。你只需开始涂抹,看看最终会得到什么。
组件
应用程序的界面上方显示一个大的<canvas>
元素,下面有多个表单字段。用户通过从<select>
字段中选择一个工具,然后在画布上点击、触摸或拖动来绘制图像。有用于绘制单个像素或矩形的工具、填充区域的工具以及从图像中选取颜色的工具。
我们将把编辑器界面结构化为多个组件
,这些对象负责DOM
的一部分,并且可以包含其他组件。
应用程序的状态由当前图像、选定工具和选定颜色组成。我们将设置这样一个环境,使得状态存在于一个单一的值中,而界面组件始终根据当前状态来决定它们的外观。
为了理解这点的重要性,让我们考虑另一种选择——在整个界面中分散状态的片段。在某种程度上,这更容易编程。我们可以直接放入一个颜色字段,并在需要知道当前颜色时读取其值。
但随后我们添加了颜色选择器——一个工具,允许你点击图像以选择给定像素的颜色。为了保持颜色字段显示正确的颜色,该工具必须知道颜色字段的存在,并在每次选择新颜色时更新它。如果你再添加另一个地方使颜色可见(也许鼠标光标可以显示它),你也必须更新你的颜色更改代码,以保持同步。
实际上,这会造成一个问题,即界面中的每个部分都需要了解所有其他部分,这并不是很模块化。对于像本章中的小型应用程序,这可能不是问题。对于更大的项目,这可能会变成一个真正的噩梦。
为了原则上避免这个噩梦,我们将严格遵循数据流
。有一个状态,界面是基于该状态绘制的。界面组件可以通过更新状态来响应用户的操作,此时这些组件有机会与这个新状态同步。
在实践中,每个组件被设置为在接收到新状态时,也会通知其子组件,前提是那些需要被更新。设置这个有点麻烦。使其更方便是许多浏览器编程库的主要卖点。但对于像这样的一个小应用,我们可以在没有这种基础设施的情况下完成。
对状态的更新以对象形式表示,我们称之为动作
。组件可以创建这样的动作
并分发
它们——将其交给中心状态管理函数。该函数计算下一个状态,然后界面组件更新为这个新状态。
我们正在将运行用户界面的杂乱任务进行结构化。尽管与DOM
相关的部分仍然充满了副作用,但它们由一个概念上简单的主干支撑:状态更新周期。状态决定了DOM
的外观,DOM
事件改变状态的唯一方式是通过向状态分发动作
。
这种方法有许多
变体,每种都有其自身的优点和问题,但它们的核心思想是相同的:状态变化应通过单一的、明确的通道进行,而不是随处发生。
我们的组件将是符合接口的类。它们的构造函数接受一个状态——这可能是整个应用状态或较小的值(如果不需要访问所有内容)——并利用它构建一个dom
属性。这是表示组件的DOM
元素。大多数构造函数还将接受一些不会随时间变化的其他值,例如它们可以用于分发动作
的函数。
每个组件都有一个syncState
方法,用于将其同步到新的状态值。该方法接受一个参数,即状态,其类型与构造函数的第一个参数相同。
状态
应用状态将是一个具有picture
、tool
和color
属性的对象。picture
本身是一个对象,存储图片的宽度、高度和像素内容。像素按行存储在一个单一数组中,从上到下。
class Picture {
constructor(width, height, pixels) {
this.width = width;
this.height = height;
this.pixels = pixels;
}
static empty(width, height, color) {
let pixels = new Array(width * height).fill(color);
return new Picture(width, height, pixels);
}
pixel(x, y) {
return this.pixels[x + y * this.width];
}
draw(pixels) {
let copy = this.pixels.slice();
for (let {x, y, color} of pixels) {
copy[x + y * this.width] = color;
}
return new Picture(this.width, this.height, copy);
}
}
我们希望能够将图片视为一个不可变的值,原因将在本章后面再提到。但我们有时也需要一次更新一大堆像素。为此,该类具有一个draw
方法,期望接收一个更新的像素数组——包含x
、y
和颜色属性的对象——并使用这些像素覆盖创建一张新图片。此方法使用没有参数的slice
来复制整个像素数组——切片的开始默认为0
,结束默认为数组的长度。
空方法使用了我们之前未见过的两种数组功能。数组构造函数可以用一个数字调用,以创建一个给定长度的空数组。然后可以使用fill
方法将该数组填充为给定的值。这些用于创建一个所有像素都具有相同颜色的数组。
颜色以字符串形式存储,包含传统的CSS
颜色代码,由井号(#
)后跟六个十六进制(基数16
)数字组成——两个用于红色成分,两个用于绿色成分,两个用于蓝色成分。这是一种相对隐晦且不方便的书写颜色方式,但这是HTML
颜色输入字段使用的格式,并且可以在画布绘制上下文的fillStyle
属性中使用,因此在我们将在此程序中使用颜色的方式上,这足够实用。
黑色,所有组件均为零,写作#000000
,而亮粉色看起来像#ff00ff
,其中红色和蓝色成分的最大值为255,以十六进制数字(使用a
到f
表示数字10到15)表示为ff
。
我们将允许接口以对象的形式分发动作,这些对象的属性会覆盖之前状态的属性。当用户更改颜色字段时,可以分发一个对象,如{color: field.value}
,从中这个更新函数可以计算出一个新的状态。
function updateState(state, action) {
return {...state, ...action};
}
这种模式中,使用对象扩展先添加现有对象的属性,然后覆盖其中一些属性,在使用不可变对象的JavaScript代码中很常见。
DOM构建
界面组件的主要功能之一是创建DOM结构。我们同样不想直接使用冗长的DOM方法,因此这里是稍微扩展版的elt
函数:
function elt(type, props, ...children) {
let dom = document.createElement(type);
if (props) Object.assign(dom, props);
for (let child of children) {
if (typeof child != "string") dom.appendChild(child);
else dom.appendChild(document.createTextNode(child));
}
return dom;
}
这个版本与我们在第十六章中使用的版本之间的主要区别在于,它将属性
分配给DOM节点,而不是属性值
。这意味着我们不能用它来设置任意属性,但我们可以
用它来设置值不是字符串的属性,例如onclick
,可以设置为一个函数以注册点击事件处理程序。
这允许我们以这种方便的方式注册事件处理程序:
<body>
<script>
document.body.appendChild(elt("button", {
onclick: () => console.log("click")
}, "The button"));
</script>
</body>
画布
我们将定义的第一个组件是界面的一部分,它将图片显示为一个彩色方块的网格。这个组件负责两件事:显示一张图片并将该图片上的指针事件传递给应用程序的其余部分。
因此,我们可以将其定义为一个只知道当前图片的组件,而不是整个应用程序状态。因为它不知道整个应用程序的工作方式,所以不能直接分发动作。相反,在响应指针事件时,它调用由创建它的代码提供的回调函数,该函数将处理特定于应用程序的部分。
const scale = 10;
class PictureCanvas {
constructor(picture, pointerDown) {
this.dom = elt("canvas", {
onmousedown: event => this.mouse(event, pointerDown),
ontouchstart: event => this.touch(event, pointerDown)
});
this.syncState(picture);
}
syncState(picture) {
if (this.picture == picture) return;
this.picture = picture;
drawPicture(this.picture, this.dom, scale);
}
}
我们将每个像素绘制为10x10的方块,具体由比例常量决定。为了避免不必要的工作,组件跟踪其当前图片,仅在syncState
获得新图片时进行重绘。
实际的绘制函数根据比例和图片大小设置画布的大小,并用一系列方块填充,每个方块对应一个像素。
function drawPicture(picture, canvas, scale) {
canvas.width = picture.width * scale;
canvas.height = picture.height * scale;
let cx = canvas.getContext("2d");
for (let y = 0; y < picture.height; y++) {
for (let x = 0; x < picture.width; x++) {
cx.fillStyle = picture.pixel(x, y);
cx.fillRect(x * scale, y * scale, scale, scale);
}
}
}
当鼠标左键在图片画布上被按下时,组件调用pointerDown
回调,传递被点击的像素位置——以图片坐标表示。这将用于实现鼠标与图片的交互。回调可以返回另一个回调函数,以在按钮按下时,指针移动到不同的像素时收到通知。
PictureCanvas.prototype.mouse = function(downEvent, onDown) {
if (downEvent.button != 0) return;
let pos = pointerPosition(downEvent, this.dom);
let onMove = onDown(pos);
if (!onMove) return;
let move = moveEvent => {
if (moveEvent.buttons == 0) {
this.dom.removeEventListener("mousemove", move);
} else {
let newPos = pointerPosition(moveEvent, this.dom);
if (newPos.x == pos.x && newPos.y == pos.y) return;
pos = newPos;
onMove(newPos);
}
};
this.dom.addEventListener("mousemove", move);
};
function pointerPosition(pos, domNode) {
let rect = domNode.getBoundingClientRect();
return {x: Math.floor((pos.clientX - rect.left) / scale),
y: Math.floor((pos.clientY - rect.top) / scale)};
}
由于我们知道像素的大小,并且可以使用getBoundingClientRect
找到画布在屏幕上的位置,因此可以将鼠标事件坐标(clientX
和clientY
)转换为图片坐标。这些坐标总是向下取整,以便指向特定的像素。
对于触摸事件,我们需要做类似的事情,但使用不同的事件,并确保在“touchstart”事件上调用preventDefault
以防止平移。
PictureCanvas.prototype.touch = function(startEvent, onDown) {
let pos = pointerPosition(startEvent.touches[0], this.dom);
let onMove = onDown(pos);
startEvent.preventDefault();
if (!onMove) return;
let move = moveEvent => {
let newPos = pointerPosition(moveEvent.touches[0], this.dom);
if (newPos.x == pos.x && newPos.y == pos.y) return;
pos = newPos;
onMove(newPos);
};
let end = () => {
this.dom.removeEventListener("touchmove", move);
this.dom.removeEventListener("touchend", end);
};
this.dom.addEventListener("touchmove", move);
this.dom.addEventListener("touchend", end);
};
对于触摸事件,clientX
和clientY
在事件对象上并不可用,但我们可以使用touches
属性中第一个触摸对象的坐标。
应用程序
为了能够逐步构建应用程序,我们将主组件实现为围绕图片画布和动态工具与控件集合的外壳,并将其传递给构造函数。
控件
是出现在图片下方的界面元素。它们将作为组件构造函数的数组提供。
工具
用于绘制像素或填充区域。应用程序将可用工具的集合显示为一个<select>
字段。当前选择的工具决定用户使用指针设备与图片互动时会发生什么。可用工具的集合作为一个对象提供,该对象将下拉字段中显示的名称映射到实现这些工具的函数。这些函数接收图片位置、当前应用程序状态和分发函数作为参数。它们可能返回一个移动处理函数,当指针移动到不同的像素时,以新的位置和当前状态作为参数被调用。
class PixelEditor {
constructor(state, config) {
let {tools, controls, dispatch} = config;
this.state = state;
this.canvas = new PictureCanvas(state.picture, pos => {
let tool = tools[this.state.tool];
let onMove = tool(pos, this.state, dispatch);
if (onMove) return pos => onMove(pos, this.state);
});
this.controls = controls.map(
Control => new Control(state, config));
this.dom = elt("div", {}, this.canvas.dom, elt("br"),
...this.controls.reduce(
(a, c) => a.concat(" ", c.dom), []));
}
syncState(state) {
this.state = state;
this.canvas.syncState(state.picture);
for (let ctrl of this.controls) ctrl.syncState(state);
}
}
传递给PictureCanvas
的指针处理程序使用适当的参数调用当前选定的工具,并且如果返回一个移动处理程序,则还会调整它以接收状态。
所有控件都在this.controls
中构建并存储,以便在应用程序状态变化时进行更新。对reduce
的调用在控件的DOM元素之间引入空格。这样,它们看起来不会那么紧凑。
第一个控件是工具选择菜单。它创建一个<select>
元素,为每个工具设置一个选项,并设置一个“change”事件处理程序,当用户选择不同工具时更新应用程序状态。
class ToolSelect {
constructor(state, {tools, dispatch}) {
this.select = elt("select", {
onchange: () => dispatch({tool: this.select.value})
}, ...Object.keys(tools).map(name => elt("option", {
selected: name == state.tool
}, name)));
this.dom = elt("label", null, " Tool: ", this.select);
}
syncState(state) { this.select.value = state.tool; }
}
通过将标签文本和字段包装在<label>
元素中,我们告诉浏览器该标签属于该字段,这样你可以点击标签来聚焦该字段。
我们还需要能够更改颜色,因此让我们添加一个控制项。具有颜色类型属性的HTML<input>
元素为我们提供了一个专门用于选择颜色的表单字段。这样的字段值始终是#RRGGBB
格式的CSS颜色代码(红、绿和蓝组件,每种颜色两个数字)。当用户与之互动时,浏览器将显示颜色选择器界面。
根据浏览器的不同,颜色选择器可能看起来像这样:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0327-01.jpg
此控件创建一个这样的区域,并将其与应用程序状态的颜色属性保持同步。
class ColorSelect {
constructor(state, {dispatch}) {
this.input = elt("input", {
type: "color",
value: state.color,
onchange: () => dispatch({color: this.input.value})
});
this.dom = elt("label", null, " Color: ", this.input);
}
syncState(state) { this.input.value = state.color; }
}
绘图工具
在我们能够绘制任何内容之前,我们需要实现控制画布上鼠标或触摸事件功能的工具。
最基本的工具是绘图工具,它将你点击或轻触的任何像素更改为当前选定的颜色。它派发一个操作,将图片更新为一个版本,其中所指向的像素被赋予当前选定的颜色。
function draw(pos, state, dispatch) {
function drawPixel({x, y}, state) {
let drawn = {x, y, color: state.color};
dispatch({picture: state.picture.draw([drawn])});
}
drawPixel(pos, state);
return drawPixel;
}
函数立即调用drawPixel
函数,但也返回它,以便在用户拖动或滑动图片时,对新触摸的像素再次调用。
为了绘制更大的形状,快速创建矩形是很有用的。矩形工具在你开始拖动的点和你拖动到的点之间绘制一个矩形。
function rectangle(start, state, dispatch) {
function drawRectangle(pos) {
let xStart = Math.min(start.x, pos.x);
let yStart = Math.min(start.y, pos.y);
let xEnd = Math.max(start.x, pos.x);
let yEnd = Math.max(start.y, pos.y);
let drawn = [];
for (let y = yStart; y <= yEnd; y++) {
for (let x = xStart; x <= xEnd; x++) {
drawn.push({x, y, color: state.color});
}
}
dispatch({picture: state.picture.draw(drawn)});
}
drawRectangle(start);
return drawRectangle;
}
这个实现中的一个重要细节是,在拖动时,矩形是在原始
状态下在图片上重新绘制的。这样,你可以在创建矩形时将其变大或变小,而不会在最终图片中留下中间的矩形。这是不可变图片对象有用的原因之一——稍后我们将看到另一个原因。
实现填充功能稍微复杂一些。这是一个工具,可以填充指针下的像素以及所有具有相同颜色的相邻像素。“相邻”意味着直接水平或垂直相邻,而不是对角线相邻。这张图片展示了在标记像素处使用填充工具时上色的像素集合:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0329-01.jpg
有趣的是,我们将要做的方式有点像第七章中的路径查找代码。尽管那段代码在图形中搜索以找到路线,这段代码则在网格中搜索以找到所有“连接”的像素。跟踪一组分支可能路线的问题是类似的。
const around = [{dx: -1, dy: 0}, {dx: 1, dy: 0},
{dx: 0, dy: -1}, {dx: 0, dy: 1}];
function fill({x, y}, state, dispatch) {
let targetColor = state.picture.pixel(x, y);
let drawn = [{x, y, color: state.color}];
let visited = new Set();
for (let done = 0; done < drawn.length; done++) {
for (let {dx, dy} of around) {
let x = drawn[done].x + dx, y = drawn[done].y + dy;
if (x >= 0 && x < state.picture.width &&
y >= 0 && y < state.picture.height &&
!visited.has(x + "," + y) &&
state.picture.pixel(x, y) == targetColor) {
drawn.push({x, y, color: state.color});
visited.add(x + "," + y);
}
}
}
dispatch({picture: state.picture.draw(drawn)});
}
绘制的像素数组同时充当函数的工作列表。对于每个到达的像素,我们必须查看是否有任何相邻像素具有相同的颜色并且尚未被覆盖。随着新像素的添加,循环计数器落后于绘制数组的长度。它前面的任何像素仍需要被探索。当它追上长度时,就没有未探索的像素了,函数也就完成了。
最终的工具是一个颜色选择器,它允许你在图片上指向一个颜色,以将其用作当前绘图颜色。
function pick(pos, state, dispatch) {
dispatch({color: state.picture.pixel(pos.x, pos.y)});
}
保存与加载
当我们完成了我们的杰作时,我们会想把它保存下来。我们应该添加一个按钮,用于将当前图片作为图像文件下载。这个控件提供了这个按钮:
class SaveButton {
constructor(state) {
this.picture = state.picture;
this.dom = elt("button", {
onclick: () => this.save()
}, " Save");
}
save() {
let canvas = elt("canvas");
drawPicture(this.picture, canvas, 1);
let link = elt("a", {
href: canvas.toDataURL(),
download: "pixelart.png"
});
document.body.appendChild(link);
link.click();
link.remove();
}
syncState(state) { this.picture = state.picture; }
}
该组件跟踪当前图片,以便在保存时可以访问它。为了创建图像文件,它使用一个<canvas>
元素,在其上绘制图片(每个像素按一比一的比例)。
canvas
元素上的toDataURL
方法创建一个使用data:
方案的URL。与http:
和https:
URL不同,数据 URL在URL中包含整个资源。它们通常非常长,但它们允许我们在浏览器中创建指向任意图片的有效链接。
为了让浏览器实际下载图片,我们接着创建一个链接元素,指向这个 URL,并带有download
属性。这样的链接在被点击时,会使浏览器显示文件保存对话框。我们将该链接添加到文档中,模拟一次点击,然后再将其移除。你可以用浏览器技术做很多事情,但有时候实现方式相当奇怪。
而且情况还会更糟。我们还希望能够将现有的图像文件加载到我们的应用程序中。为此,我们再次定义一个按钮组件。
class LoadButton {
constructor(_, {dispatch}) {
this.dom = elt("button", {
onclick: () => startLoad(dispatch)
}, " Load");
}
syncState() {}
}
function startLoad(dispatch) {
let input = elt("input", {
type: "file",
onchange: () => finishLoad(input.files[0], dispatch)
});
document.body.appendChild(input);
input.click();
input.remove();
}
要访问用户计算机上的文件,我们需要用户通过文件输入字段选择文件。但我们不希望加载按钮看起来像文件输入字段,因此我们在按钮点击时创建文件输入,然后假装这个文件输入被点击了。
当用户选择一个文件时,我们可以使用FileReader
来访问其内容,再次以数据 URL 的形式。该 URL 可以用来创建一个<img>
元素,但由于我们无法直接访问该图像中的像素,因此无法从中创建Picture
对象。
function finishLoad(file, dispatch) {
if (file == null) return;
let reader = new FileReader();
reader.addEventListener("load", () => {
let image = elt("img", {
onload: () => dispatch({
picture: pictureFromImage(image)
}),
src: reader.result
});
});
reader.readAsDataURL(file);
}
为了访问像素,我们必须首先将图片绘制到<canvas>
元素上。canvas
上下文具有getImageData
方法,允许脚本读取其像素。因此,一旦图片在canvas
上,我们就可以访问它并构建一个Picture
对象。
function pictureFromImage(image) {
let width = Math.min(100, image.width);
let height = Math.min(100, image.height);
let canvas = elt("canvas", {width, height});
let cx = canvas.getContext("2d");
cx.drawImage(image, 0, 0);
let pixels = [];
let {data} = cx.getImageData(0, 0, width, height);
function hex(n) {
return n.toString(16).padStart(2, "0");
}
for (let i = 0; i < data.length; i += 4) {
let [r, g, b] = data.slice(i, i + 3);
pixels.push("#" + hex(r) + hex(g) + hex(b));
}
return new Picture(width, height, pixels);
}
我们将把图像的大小限制在100*×*100
像素,因为任何更大的图片在我们的显示器上看起来都会显得巨大
,并可能会减慢界面速度。
getImageData
返回的对象的数据属性是一个颜色分量数组。对于由参数指定的矩形中的每个像素,它包含四个值,代表像素颜色的红、绿、蓝和alpha
分量,数值范围在0到255之间。alpha
部分表示不透明度——当它为0时,像素完全透明,而当它为255时,像素完全不透明。对于我们的目的,我们可以忽略它。
每个组件的两个十六进制数字,如我们在颜色标记法中使用的,正好对应于0到255的范围——两个基数为16的数字可以表示16² = 256个不同的数字。数字的toString
方法可以接受一个基数作为参数,因此n.toString(16)
会生成一个基数为16的字符串表示。我们必须确保每个数字占用两个数字,因此十六进制辅助函数调用padStart
在必要时添加前导0。
我们现在可以加载和保存了!这只剩下一个功能,我们就完成了。
撤销历史
因为编辑过程的一半是犯小错误并纠正它们,绘图程序中的一个重要功能是撤销历史。
为了能够撤销更改,我们需要存储图像的先前版本。由于图像是不可变值,这很简单。但这确实需要在应用程序状态中添加一个额外的字段。
我们将添加一个done
数组来保留图像的先前版本。维护这个属性需要一个更复杂的状态更新函数,以将图像添加到数组中。
不过,我们并不想存储每个
更改——只存储时间间隔一定的更改。为了做到这一点,我们需要一个第二个属性doneAt
,用来跟踪我们上次在历史中存储图像的时间。
function historyUpdateState(state, action) {
if (action.undo == true) {
if (state.done.length == 0) return state;
return {
...state,
picture: state.done[0],
done: state.done.slice(1),
doneAt: 0
};
} else if (action.picture &&
state.doneAt < Date.now() - 1000) {
return {
...state,
...action,
done: [state.picture, ...state.done],
doneAt: Date.now()
};
} else {
return {...state, ...action};
}
}
当操作是撤销操作时,函数从历史记录中获取最近的图像,并将其设为当前图像。它将doneAt
设置为零,以确保下一个更改将图像存回历史中,让你在需要时可以恢复到这个图像。
否则,如果操作包含新图像,而我们上次存储的时间超过了一秒(1,000毫秒),则done
和doneAt
属性会更新以存储之前的图像。
撤销按钮组件并没有太多功能。它在被点击时分发撤销操作,当没有可以撤销的内容时则禁用自身。
class UndoButton {
constructor(state, {dispatch}) {
this.dom = elt("button", {
onclick: () => dispatch({undo: true}),
disabled: state.done.length == 0
}, " Undo");
}
syncState(state) {
this.dom.disabled = state.done.length == 0;
}
}
让我们绘图。
为了设置应用程序,我们需要创建一个状态、一组工具、一组控件和一个调度函数。我们可以将它们传递给PixelEditor
构造函数来创建主要组件。由于我们在练习中需要创建多个编辑器,我们首先定义一些绑定。
const startState = {
tool: "draw",
color: "#000000",
picture: Picture.empty(60, 30, "#f0f0f0"),
done: [],
doneAt: 0
};
const baseTools = {draw, fill, rectangle, pick};
const baseControls = [
ToolSelect, ColorSelect, SaveButton, LoadButton, UndoButton
];
function startPixelEditor({state = startState,
tools = baseTools,
controls = baseControls}) {
let app = new PixelEditor(state, {
tools,
controls,
dispatch(action) {
state = historyUpdateState(state, action);
app.syncState(state);
}
});
return app.dom;
}
在解构对象或数组时,可以在绑定名称后使用=
来为绑定提供默认值,当属性缺失或为undefined
时使用。startPixelEditor
函数利用这一点接受一个具有多个可选属性的对象作为参数。例如,如果你不提供tools
属性,tools
将绑定到baseTools
。
这就是我们如何在屏幕上获得实际编辑器的方式:
<div></div>
<script>
document.querySelector("div").appendChild(startPixelEditor({}));
</script>
为什么这会如此困难?
浏览器技术真是令人惊叹。它提供了一套强大的接口构建块、样式和操作它们的方法,以及检查和调试应用程序的工具。你为浏览器编写的软件可以在地球上几乎每台计算机和手机上运行。
与此同时,浏览器技术非常荒谬。你必须学习大量愚蠢的技巧和晦涩的事实才能掌握它,而它提供的默认编程模型问题重重,以至于大多数程序员宁愿用几层抽象来掩盖它,而不是直接处理它。
尽管情况确实在改善,但主要以添加更多元素来解决不足的形式进行——这创造了更多的复杂性。被一百万个网站使用的功能是无法真正替代的。即使可以替代,也很难决定用什么来替代。
技术从来不会在真空中存在——我们受限于我们的工具以及产生这些工具的社会、经济和历史因素。这可能让人感到恼火,但一般来说,努力建立对现有
技术现实如何运作及其原因的良好理解,比愤怒抗争或期待另一种现实要更具生产力。
新的抽象可以
是有帮助的。我在本章中使用的组件模型和数据流约定是一种粗略的形式。如前所述,有一些库试图使用户界面编程变得更愉快。在写作时,React
和Svelte
是流行的选择,但还有一整套这样的框架。如果你对编写网页应用感兴趣,我建议你调查一下其中的一些,以了解它们是如何运作的,以及它们提供了什么好处。
练习
我们的程序仍然有改进的空间。让我们增加一些功能作为练习。
键盘绑定
为应用程序添加键盘快捷键。工具名称的首字母选择该工具,而CTRL-Z
或COMMAND-Z
则激活撤销。
通过修改PixelEditor
组件来做到这一点。在包裹的<div>
元素中添加一个tabIndex
属性值为0
,以便它可以接收键盘焦点。注意,属性
对应于tabindex
属性
被称为tabIndex
,I
大写,而我们的elt
函数期望属性名称。直接在该元素上注册键盘事件处理程序。这意味着你必须点击、触摸或通过标签切换到应用程序,然后才能用键盘与之交互。
请记住,键盘事件有ctrlKey
和metaKey
(在Mac上为COMMAND
)属性,你可以使用它们来查看这些键是否被按下。
高效绘图
在绘图过程中,我们的应用程序大部分工作都发生在drawPicture
中。创建一个新状态并更新其余的DOM并不太昂贵,但重绘画布上的所有像素则需要相当多的工作。
找到一种方法,通过仅重绘实际改变的像素来加速PictureCanvas
的syncState
方法。
请记住,drawPicture
也被保存按钮使用,因此如果你更改它,请确保更改不会破坏旧的使用方式,或者创建一个不同名称的新版本。
还要注意,通过设置<canvas>
元素的宽度或高度属性来更改其大小,会清除它,使其再次完全透明。
圆形
定义一个名为圆形
的工具,当你拖动时绘制一个填充的圆。圆心位于拖动或触摸手势开始的点,其半径由拖动的距离决定。
适当的线条
这比前面的三个练习要复杂,需要你设计一个解决非平凡问题的方案。在开始这个练习之前,确保你有足够的时间和耐心,并且不要因为最初的失败而气馁。
在大多数浏览器中,当你选择绘图工具并快速拖动图像时,你不会得到一条闭合的线条。而是会得到带有间隙的点,因为"mousemove"
或"touchmove"
事件没有足够快地触发以击中每个像素。
改进绘图工具,使其绘制完整的线条。这意味着你需要让动作处理函数记住上一个位置,并将其与当前的位置连接起来。
为此,由于像素之间的距离可以是任意的,你需要编写一个通用的绘线函数。
两个像素之间的线条是一个连接的像素链,尽可能地直,从起点到终点。对角相邻的像素算作连接。倾斜的线条应该像左侧的图片,而不是右侧的图片。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0336-01.jpg
最后,如果我们有一段代码可以在两个任意点之间绘制一条线,我们也可以用它来定义一个线条工具,该工具在拖动的起点和终点之间绘制一条直线。
第三部分:Node
一位学生问:“古代程序员只使用简单的机器而没有编程语言,然而他们却写出了美丽的程序。我们为什么要使用复杂的机器和编程语言?”傅子回答:“古代的建筑者只用木棍和泥土,然而他们却建造出了美丽的茅屋。”
—元马大师,编程之书
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0338-01.jpg
第二十一章:NODE.JS
到目前为止,我们只在一个环境中使用了JavaScript语言:浏览器。本章和下一章将简要介绍Node.js,这是一个允许你在浏览器外应用JavaScript技能的程序。通过它,你可以构建从小型命令行工具到支持动态网站的HTTP服务器的各种应用。
这些章节旨在教你Node.js使用的主要概念,并给你足够的信息来编写有用的程序。它们并不试图全面或深入地介绍该平台。
如果你想跟着本章的代码运行,你需要安装Node.js版本18或更高版本。要做到这一点,请访问[nodejs.org](https://nodejs.org)
并按照你操作系统的安装说明进行操作。你还可以在那里找到Node.js的进一步文档。
背景
在构建通过网络通信的系统时,你管理输入和输出的方式——即从网络和硬盘读取和写入数据的方式——会对系统对用户或网络请求的响应速度产生很大影响。
在这样的程序中,异步编程通常是有帮助的。它允许程序同时与多个设备发送和接收数据,而无需复杂的线程管理和同步。
Node最初的构想是为了使异步编程变得简单方便。JavaScript非常适合像Node这样的系统。它是少数几种没有内置输入输出方式的编程语言之一。因此,JavaScript能够很好地适应Node对网络和文件系统编程的相当奇特的方法,而不会导致两个不一致的接口。在2009年,当Node被设计时,人们已经在浏览器中进行基于回调的编程,因此围绕该语言的社区已经习惯了异步编程风格。
node
命令
当Node.js安装在系统上时,它提供了一个名为node
的程序,用于运行JavaScript文件。假设你有一个名为hello.js
的文件,包含以下代码:
let message = "Hello world";
console.log(message);
然后你可以像这样从命令行运行node
以执行程序:
$ node hello.js
Hello world
Node中的console.log
方法与浏览器中的作用类似。它输出一段文本。但在Node中,这段文本会发送到进程的标准输出流,而不是浏览器的JavaScript控制台。当从命令行运行node
时,这意味着你会在终端中看到记录的值。
如果你运行node
而不指定文件,它会给你一个提示,你可以在此输入JavaScript代码并立即看到结果。
$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$
进程绑定,就像控制台绑定一样,在Node中是全局可用的。它提供了多种方式来检查和操作当前程序。exit
方法结束进程,并可以指定一个退出状态代码,这告诉启动Node的程序(在此情况下为命令行shell)程序是否成功完成(代码零)或遇到错误(任何其他代码)。
要查找传递给你脚本的命令行参数,你可以读取process.argv
,它是一个字符串数组。请注意,它还包括node
命令的名称和你的脚本名称,因此实际参数从索引2开始。如果showargv.js
包含语句console.log(process.argv)
,你可以这样运行它:
$ node showargv.js one --and two
["node", "/tmp/showargv.js", "one", "--and", "two"]
所有标准的JavaScript全局绑定,如Array
、Math
和JSON
,在Node的环境中也存在。与浏览器相关的功能,如document
或prompt
,则不存在。
模块
除了我提到的绑定,如console
和process
,Node在全局作用域中几乎没有其他绑定。如果你想访问内置功能,你必须请求模块系统。
Node最初使用基于require
函数的CommonJS模块系统,我们在第十章中看到过。当你加载*.js
文件时,它仍然会默认使用此系统。
但今天,Node也支持更现代的ES模块系统。当脚本的文件名以*.mjs
结尾时,它被视为这样的模块,你可以在其中使用import
和export
(但不能使用require
)。我们将在本章中使用ES模块。
当导入一个模块时——无论是使用require
还是import
——Node需要将给定的字符串解析为一个实际可以加载的文件。以/
、./
或../
开头的名称将相对于当前模块的路径解析为文件。在这里,.
表示当前目录,../
表示上一级目录,/
表示文件系统的根目录。如果你从文件/tmp/robot/robot.mjs
请求"./graph.mjs"
,Node将尝试加载文件/tmp/robot/graph.mjs
。
当导入的字符串看起来不是相对路径或绝对路径时,假定它指的是内置模块或安装在node_modules
目录中的模块。例如,从node:fs
导入将为你提供Node的内置文件系统模块。导入robot
可能会尝试加载在node_modules/robot/
中找到的库。通常会使用NPM安装这些库,我们稍后会回到这个话题。
让我们建立一个由两个文件组成的小项目。第一个文件叫做main.mjs
,它定义了一个可以从命令行调用的脚本,用于反转字符串。
import {reverse} from "./reverse.mjs";
// Index 2 holds the first actual command line argument
let argument = process.argv[2];
console.log(reverse(argument));
文件reverse.mjs
定义了一个用于反转字符串的库,可以被这个命令行工具和需要直接访问字符串反转功能的其他脚本使用。
export function reverse(string) {
return Array.from(string).reverse().join("");
}
请记住,export
用于声明一个绑定是模块接口的一部分。这允许main.mjs
导入并使用该函数。
现在我们可以这样调用我们的工具:
$ node main.mjs JavaScript
tpircSavaJ
使用NPM安装
NPM在第十章中介绍,是一个在线JavaScript模块库,其中许多模块是专门为Node编写的。当你在计算机上安装Node时,你还会得到npm
命令,可以用来与这个库进行交互。
NPM的主要用途是下载包。我们在第十章中看到了ini
包。我们可以使用NPM在我们的计算机上获取并安装该包。
$ npm install ini
added 1 package in 723ms
$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }
运行npm install
后,NPM将创建一个名为node_modules
的目录。该目录下将包含一个ini
目录,其中包含库。你可以打开它并查看代码。当我们导入ini
时,该库被加载,我们可以调用它的解析属性来解析配置文件。
默认情况下,NPM会在当前目录下安装包,而不是在中心位置。如果你习惯于其他包管理器,这可能看起来不寻常,但它有其优势——它让每个应用程序完全控制其安装的包,并更容易管理版本以及在删除应用程序时进行清理。
包文件
在运行npm install
安装某个包后,你会发现当前目录下不仅有一个node_modules
目录,还有一个名为package.json
的文件。建议每个项目都有这样的文件。你可以手动创建它,或运行npm init
。此文件包含关于项目的信息,例如其名称和版本,并列出其依赖项。
从第七章的机器人模拟,作为第十章练习中的模块化,可能会有一个这样的package.json
文件:
{
"author": "Marijn Haverbeke",
"name": "eloquent-javascript-robot",
"description": "Simulation of a package-delivery robot",
"version": "1.0.0",
"main": "run.mjs",
"dependencies": {
"dijkstrajs": "¹.0.1",
"random-item": "¹.0.0"
},
"license": "ISC"
}
当你运行npm install
而不指定要安装的包时,NPM将安装package.json
中列出的依赖项。当你安装一个未在依赖项中列出的特定包时,NPM会将其添加到package.json
中。
版本
package.json
文件列出了程序自身的版本以及其依赖项的版本。版本是处理包独立演变的方式,编写的代码在某个时间点与包的版本兼容,可能在后来的修改版本中不再兼容。
NPM要求其包遵循一种称为语义版本控制
的模式,该模式在版本号中编码了一些关于哪些版本是兼容
(不破坏旧接口)的信息。语义版本由三个用句点分隔的数字组成,例如2.3.0
。每次添加新功能时,中间的数字必须增加。每当破坏兼容性时,即现有代码使用的包可能与新版本不兼容,首个数字必须增加。
在package.json
中,依赖项版本号前的插入符号(^
)表示可以安装与给定数字兼容的任何版本。例如,“².3.0
”意味着允许任何大于或等于2.3.0
且小于3.0.0
的版本。
npm
命令也用于发布新包或包的新版本。如果在包含package.json
文件的目录中运行npm publish
,它将把JSON文件中列出的名称和版本的包发布到注册表。任何人都可以向NPM发布包——但仅限于尚未使用的包名,因为如果随机用户可以更新现有包,那就不好了。
本书不会深入探讨NPM的使用细节。请参考 www.npmjs.com
以获取更多文档和包搜索方法。
文件系统模块
Node中最常用的内置模块之一是node:fs
模块,它代表“文件系统”。它导出用于处理文件和目录的函数。
例如,名为readFile
的函数读取一个文件,然后调用回调函数传递文件内容。
import {readFile} from "node:fs";
readFile("file.txt", "utf8", (error, text) => {
if (error) throw error;
console.log("The file contains:", text);
});
readFile
的第二个参数指示用于将文件解码为字符串的字符编码
。文本可以以多种方式编码为二进制数据,但大多数现代系统使用UTF-8
。除非你有理由相信使用了其他编码,否则在读取文本文件时传递“utf8
”。如果不传递编码,Node会假设你对二进制数据感兴趣,并会返回一个Buffer
对象,而不是字符串。这个对象类似数组,包含表示文件中字节(8位数据块)的数字。
import {readFile} from "node:fs";
readFile("file.txt", (error, buffer) => {
if (error) throw error;
console.log("The file contained", buffer.length, "bytes.",
"The first byte is:", buffer[0]);
});
一个类似的函数writeFile
用于将文件写入磁盘。
import {writeFile} from "node:fs";
writeFile("graffiti.txt", "Node was here", err => {
if (err) console.log(`Failed to write file: ${err}`);
else console.log("File written.");
});
在这里不需要指定编码——writeFile
会假设当它接收到一个字符串而不是Buffer
对象时,应使用其默认字符编码(UTF-8
)将其作为文本写出。
node:fs
模块包含许多其他有用的函数:readdir
会将目录中的文件作为字符串数组返回,stat
会检索文件信息,rename
会重命名文件,unlink
会删除文件,等等。有关具体信息,请查看 nodejs.org
的文档。
这些函数中的大多数将回调函数作为最后一个参数,调用时要么带有错误(第一个参数),要么带有成功结果(第二个参数)。正如我们在第十一章
中看到的,这种编程风格有缺点——最大的一个是错误处理变得冗长且容易出错。
node:fs/promises
模块导出了与旧的node:fs
模块大部分相同的函数,但使用了Promise
而不是回调函数。
import {readFile} from "node:fs/promises";
readFile("file.txt", "utf8")
.then(text => console.log("The file contains:", text));
有时你并不需要异步操作,它反而会造成困扰。node:fs
中的许多函数也有同步变体,其名称后面加上Sync
。比如,readFile
的同步版本叫做readFileSync
。
import {readFileSync} from "node:fs";
console.log("The file contains:",
readFileSync("file.txt", "utf8"));
请注意,在执行这样的同步操作时,程序会完全停止。如果它应该响应用户或网络上的其他机器,被阻塞在同步操作上可能会造成令人烦恼的延迟。
HTTP 模块
另一个核心模块叫做node:http
。它提供了运行 HTTP 服务器的功能。
启动 HTTP 服务器所需的就是这些:
import {createServer} from "node:http";
let server = createServer((request, response) => {
response.writeHead(200, {"Content-Type": "text/html"});
response.write(`
<h1>Hello!</h1>
<p>You asked for <code>${request.url}</code></p>`);
response.end();
});
server.listen(8000);
console.log("Listening! (port 8000)");
如果你在自己的机器上运行这个脚本,你可以将你的 Web 浏览器指向http://localhost:8000/hello
以向你的服务器发出请求。它将以一个小的 HTML 页面响应。
传递给createServer
的函数在每次客户端连接到服务器时被调用。请求和响应绑定是表示传入和传出数据的对象。第一个包含有关请求的信息,例如它的url
属性,告诉我们请求是发往哪个 URL 的。
当你在浏览器中打开该页面时,它会向你自己的计算机发送请求。这会导致服务器函数运行并发送回响应,你随后可以在浏览器中看到。
要向客户端发送内容,你需要在响应对象上调用方法。第一个是writeHead
,它将写出响应头(见第十八章
)。你需要提供状态码(在这种情况下是200
,表示“OK”)和一个包含头部值的对象。示例将Content-Type
头设置为通知客户端我们将返回一个 HTML 文档。
接下来,实际的响应主体(文档本身)通过response.write
发送。如果你想逐块发送响应,可以多次调用此方法,例如,随着数据的可用性将数据流式传输给客户端。最后,response.end
表示响应结束。
对server.listen
的调用使服务器开始在8000
端口等待连接。这就是为什么你必须连接到localhost:8000
来与此服务器进行通信,而不是仅仅使用localhost
,因为那将使用默认端口80
。
当你运行这个脚本时,进程只是静静地等待。当一个脚本正在监听事件时——在这种情况下,是网络连接——node
不会在达到脚本末尾时自动退出。要关闭它,请按CTRL-C
。
一个真正的 Web 服务器通常比示例中的服务器做得更多——它查看请求的方法(method
属性)以查看客户端正在尝试执行什么操作,并查看请求的 URL 以找出正在对哪个资源执行此操作。我们将在本章后面看到一个更高级的服务器。
node:http
模块还提供了一个请求函数,可用于发起 HTTP 请求。然而,与我们在第十八章
中看到的fetch
相比,它使用起来要繁琐得多。幸运的是,fetch
在 Node 中也作为全局绑定可用。除非你想做一些非常具体的事情,例如在数据通过网络传入时逐块处理响应文档,否则我建议使用fetch
。
流
HTTP 服务器可以写入的响应对象是一个可写流
对象的示例,这是 Node 中广泛使用的概念。这些对象具有write
方法,可以传入一个字符串或Buffer
对象,以将内容写入流中。它们的end
方法关闭流,并且在关闭之前可以选择性地接收一个值以写入流。这两个方法也可以接受一个回调作为额外参数,当写入或关闭完成时会调用该回调。
可以使用来自node:fs
模块的createWriteStream
函数创建一个指向文件的可写流。然后,可以在生成的对象上使用write
方法逐块写入文件,而不是像writeFile
那样一次性写入。
可读流
稍微复杂一些。HTTP 服务器回调的请求参数是一个可读流。从流中读取是通过事件处理程序而不是方法来完成的。
在 Node 中发出事件的对象有一个名为on
的方法,类似于浏览器中的addEventListener
方法。你给它一个事件名称和一个函数,它会注册该函数,以便在发生给定事件时调用。
可读流
有data
和end
事件。每当数据到达时,第一种事件会被触发,而第二种事件则在流结束时被调用。该模型最适合于流式
处理数据,即使整个文档尚不可用,也能立即处理。可以通过使用node:fs
中的createReadStream
函数将文件作为可读流进行读取。
这段代码创建了一个服务器,读取请求体并将其以全大写文本流回客户端:
import {createServer} from "node:http";
createServer((request, response) => {
response.writeHead(200, {"Content-Type": "text/plain"});
request.on("data", chunk =>
response.write(chunk.toString().toUpperCase()));
request.on("end", () => response.end());
}).listen(8000);
传递给数据处理程序的chunk
值将是一个二进制Buffer
。我们可以通过使用其toString
方法将其解码为 UTF-8 编码的字符,从而转换为字符串。
下面的代码在启动大写服务器时运行,将向该服务器发送请求,并输出收到的响应:
fetch("http://localhost:8000/", {
method: "POST",
body: "Hello server"
}).then(resp => resp.text()).then(console.log);
// → HELLO SERVER
文件服务器
让我们结合关于 HTTP 服务器和文件系统操作的新知识,创建两者之间的桥梁:一个允许远程访问文件系统的 HTTP 服务器。这样的服务器有各种用途——它允许 Web 应用程序存储和共享数据,或者可以为一组人提供对一堆文件的共享访问。
当我们将文件视为 HTTP 资源时,HTTP 方法GET
、PUT
和DELETE
可以分别用于读取、写入和删除文件。我们将请求中的路径解释为请求所指向的文件的路径。
我们可能不想共享整个文件系统,因此我们将这些路径解释为从服务器的工作目录开始,这就是服务器启动时所在的目录。如果我从/tmp/public/
(或者在 Windows 上为C:\tmp\public\
)运行服务器,那么对/file.txt
的请求应该指向/tmp/public/file.txt
(或C:\tmp\public\file.txt
)。
我们将逐步构建程序,使用一个名为methods
的对象来存储处理各种 HTTP 方法的函数。方法处理程序是异步函数,它们将请求对象作为参数,并返回一个解析为描述响应的对象的承诺。
import {createServer} from "node:http";
const methods = Object.create(null);
createServer((request, response) => {
let handler = methods[request.method] || notAllowed;
handler(request).catch(error => {
if (error.status != null) return error;
return {body: String(error), status: 500};
}).then(({body, status = 200, type = "text/plain"}) => {
response.writeHead(status, {"Content-Type": type});
if (body?.pipe) body.pipe(response);
else response.end(body);
});
}).listen(8000);
async function notAllowed(request) {
return {
status: 405,
body: `Method ${request.method} not allowed.`
};
}
这启动了一个只返回405
错误响应的服务器,该代码用于指示服务器拒绝处理给定的方法。
当请求处理程序的承诺被拒绝时,catch
调用将错误转换为响应对象(如果还不是),以便服务器可以发送错误响应,通知客户端处理请求失败。
响应描述的状态字段可以省略,在这种情况下,它默认为200
(OK)。类型属性中的内容类型也可以省略,在这种情况下,响应被认为是纯文本。
当body
的值是可读流时,它将具有一个pipe
方法,我们可以用它将所有内容从可读流转发到可写流。如果不是,则假定它是null
(没有主体)、字符串或缓冲区,并直接传递给响应的end
方法。
为了找出哪个文件路径对应于请求 URL,urlPath
函数使用内置的URL
类(在浏览器中也存在)来解析 URL。该构造函数期望一个完整的 URL,而不仅仅是以斜杠开头的部分(我们从request.url
中获得),因此我们提供一个虚拟域名来填充。它提取其路径名,类似于/file.txt
,对其进行解码以去除%20
样式的转义码,并相对于程序的工作目录解析它。
import {resolve, sep} from "node:path";
const baseDirectory = process.cwd();
function urlPath(url) {
let {pathname} = new URL(url, "http://d");
let path = resolve(decodeURIComponent(pathname).slice(1));
if (path != baseDirectory &&
!path.startsWith(baseDirectory + sep)) {
throw {status: 403, body: "Forbidden"};
}
return path;
}
一旦你设置了一个程序来接受网络请求,就必须开始担心安全性。在这种情况下,如果我们不小心,很可能会意外地将整个文件系统暴露给网络。
文件路径在 Node 中是字符串。要将这样的字符串映射到实际文件,需要进行相当复杂的解释。例如,路径可能包含../
来引用父目录。一个明显的问题来源是请求类似于/../secret_file
的路径。
为了避免此类问题,urlPath
使用来自node:path
模块的resolve
函数,该函数解析相对路径。它随后验证结果是否在工作目录之下
。process.cwd
函数(其中cwd
代表“当前工作目录”)可以用来找到这个工作目录。来自node:path
包的sep
绑定是系统的路径分隔符——在 Windows 上是反斜杠,在大多数其他系统上是正斜杠。当路径不以基本目录开头时,该函数会抛出一个错误响应对象,使用 HTTP 状态码指示访问该资源是被禁止的。
我们将设置GET
方法,以便在读取目录时返回文件列表,并在读取常规文件时返回文件的内容。
一个棘手的问题是我们在返回文件内容时应该设置什么样的Content-Type
头。由于这些文件可能是任何类型,我们的服务器不能简单地为它们全部返回相同的内容类型。NPM
在这里可以再次帮助我们。mime-types
包(像text/plain
这样的内容类型指示符也被称为MIME类型
)知道许多文件扩展名的正确类型。
在服务器脚本所在的目录中,以下npm
命令安装特定版本的mime
:
$ npm install mime-types@2.1.0
当请求的文件不存在时,返回的正确 HTTP 状态码是404
。我们将使用stat
函数,该函数查找有关文件的信息,以确定文件是否存在以及它是否是一个目录。
import {createReadStream} from "node:fs";
import {stat, readdir} from "node:fs/promises";
import {lookup} from "mime-types";
methods.GET = async function(request) {
let path = urlPath(request.url);
let stats;
try {
stats = await stat(path);
} catch (error) {
if (error.code != "ENOENT") throw error;
else return {status: 404, body: "File not found"};
}
if (stats.isDirectory()) {
return {body: (await readdir(path)).join("\n")};
} else {
return {body: createReadStream(path),
type: lookup(path)};
}
};
由于它必须接触磁盘,因此可能需要一些时间,stat
是异步的。因为我们使用的是Promise
而不是回调风格,所以它必须从node:fs/promises
导入,而不是直接从node:fs
导入。
当文件不存在时,stat
将抛出一个带有“ENOENT”代码属性的错误对象。这些有些晦涩的、受 Unix 启发的代码是识别 Node 中错误类型的方式。
stat
返回的stats
对象告诉我们有关文件的许多信息,例如其大小(size
属性)和修改日期(mtime
属性)。在这里,我们关注的问题是它是一个目录还是一个普通文件,这由isDirectory
方法告诉我们。
我们使用readdir
读取目录中的文件数组并将其返回给客户端。对于普通文件,我们使用createReadStream
创建一个可读流,并将其作为主体返回,同时附上mime
包为文件名提供的内容类型。
处理DELETE
请求的代码稍微简单一些。
import {rmdir, unlink} from "node:fs/promises";
methods.DELETE = async function(request) {
let path = urlPath(request.url);
let stats;
try {
stats = await stat(path);
} catch (error) {
if (error.code != "ENOENT") throw error;
else return {status: 204};
}
if (stats.isDirectory()) await rmdir(path);
else await unlink(path);
return {status: 204};
};
当HTTP
响应不包含任何数据时,可以使用状态码204
(“无内容”)来指示这一点。由于删除的响应不需要传输除操作是否成功之外的任何信息,因此在这里返回这个是合理的。
你可能会想,为什么尝试删除一个不存在的文件会返回成功状态码而不是错误。当要删除的文件不存在时,可以说请求的目标已经实现。HTTP
标准鼓励我们使请求幂等
,这意味着多次发出相同请求的结果与只发出一次相同。在某种意义上,如果你尝试删除已经不存在的东西,那么你试图创造的效果已经实现——那个东西不再在那里。
这是处理PUT
请求的处理程序:
import {createWriteStream} from "node:fs";
function pipeStream(from, to) {
return new Promise((resolve, reject) => {
from.on("error", reject);
to.on("error", reject);
to.on("finish", resolve);
from.pipe(to);
});
}
methods.PUT = async function(request) {
let path = urlPath(request.url);
await pipeStream(request, createWriteStream(path));
return {status: 204};
};
这次我们不需要检查文件是否存在——如果存在,我们只需覆盖它。我们再次使用管道将数据从可读流移动到可写流,在这种情况下是从请求到文件。但由于管道没有返回一个Promise
,我们需要编写一个包装器pipeStream
,它在调用管道的结果周围创建一个Promise
。
当打开文件时出现问题时,createWriteStream
仍会返回一个流,但该流会触发一个“错误”事件。请求的流也可能失败,例如,如果网络中断。因此,我们将两个流的“错误”事件连接起来,以拒绝该promise
。当管道操作完成时,它会关闭输出流,这会触发一个“完成”事件。这时我们可以成功解析该promise
(不返回任何内容)。
服务器的完整脚本可在[eloquentjavascript.net/code/file_server.mjs](https://eloquentjavascript.net/code/file_server.mjs)
上找到。你可以下载它,并在安装其依赖后,使用Node
运行它来启动自己的文件服务器。当然,你也可以修改和扩展它,以解决本章的练习或进行实验。
命令行工具curl
广泛用于类Unix
系统(如macOS
和Linux
),可用于发起HTTP
请求。以下会话简要测试我们的服务器。-X
选项用于设置请求的方法,-d
选项用于包含请求主体。
$ curl http://localhost:8000/file.txt
File not found
$ curl -X PUT -d CONTENT http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
CONTENT
$ curl -X DELETE http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
File not found
对file.txt
的第一次请求失败,因为文件尚不存在。PUT
请求创建了该文件,接着下一个请求成功检索了它。在通过DELETE
请求删除后,文件再次消失。
总结
Node
是一个精巧的小系统,使我们能够在非浏览器环境中运行JavaScript
。它最初设计用于网络任务,充当网络中的一个节点,但它适用于各种脚本任务。如果编写JavaScript
是你喜欢的事情,使用Node
自动化任务可能会对你很有帮助。
NPM
提供了你能想到的所有包(还有一些你可能从未想到的包),并允许你使用npm
程序获取和安装这些包。Node
内置了一些模块,包括用于处理文件系统的node:fs
模块和用于运行HTTP
服务器的node:http
模块。
在Node
中,所有的输入和输出都是异步进行的,除非你明确使用函数的同步变体,例如readFileSync
。Node
最初使用回调来实现异步功能,但node:fs/promises
包提供了基于promise
的文件系统接口。
练习
搜索工具
在Unix
系统上,有一个名为grep
的命令行工具,可以快速搜索文件中的正则表达式。
编写一个可以从命令行运行的Node
脚本,行为类似于grep
。它将第一个命令行参数视为正则表达式,后续参数视为要搜索的文件。它输出任何内容与正则表达式匹配的文件的名称。
当那样工作时,扩展它,使得当其中一个参数是目录时,它会搜索该目录及其子目录中的所有文件。
根据需要使用异步或同步文件系统函数。设置多个异步操作同时请求可能会加快速度,但效果有限,因为大多数文件系统一次只能读取一个文件。
目录创建
尽管我们文件服务器的DELETE
方法能够删除目录(使用rmdir
),但服务器目前不提供任何创建
目录的方法。
添加对MKCOL
方法(“创建集合”)的支持,该方法应通过调用node:fs
模块中的mkdir
来创建目录。MKCOL
不是一种广泛使用的HTTP
方法,但在WebDAV
标准中确实存在此目的,WebDAV
在HTTP
上指定了一组约定,使其适合于创建文档。
网络上的公共空间
由于文件服务器可以提供任何类型的文件,并且还包含正确的 Content-Type
头,因此您可以用它来提供一个网站。考虑到该服务器允许所有人删除和替换文件,这将会创建一种有趣的网站:一个可以被每个花时间进行正确 HTTP 请求的人修改、改进和破坏的网站。
编写一个基本的 HTML 页面,其中包含一个简单的 JavaScript 文件。将文件放在文件服务器提供的目录中,并在浏览器中打开它们。
接下来,作为一个高级练习或周末项目,结合从本书中获得的所有知识,构建一个更用户友好的界面来修改网站——从网站的内部
。
使用 HTML 表单编辑构成网站的文件内容,允许用户通过使用 HTTP 请求在服务器上更新它们,如第十八章所述。
首先只使单个文件可编辑。然后使用户可以选择要编辑的文件。利用我们的文件服务器在读取目录时返回文件列表的事实。
不要直接在文件服务器暴露的代码中工作,因为如果出错,您可能会损坏那里的文件。相反,将您的工作保留在公共可访问目录之外,并在测试时将其复制到那里。
如果你有知识,让他人借此点燃他们的蜡烛。
—玛格丽特·富勒
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0354-01.jpg
第二十二章:项目:技能分享网站
技能分享
会议是一个人们聚集在一起,围绕共同兴趣进行小型非正式演讲的活动。在一次园艺技能分享会议上,有人可能会讲解如何种植芹菜。或者在一次编程技能分享小组中,你可以随意来告诉大家关于Node.js
的信息。
在本项目的最后一章中,我们的目标是建立一个用于管理技能分享会议上讲座的网站。想象一下,一小群人定期在其中一位成员的办公室聚会,讨论独轮车。之前的会议组织者已搬到另一个城市,没有人主动承担这一任务。我们希望有一个系统,让参与者在没有积极组织者的情况下,自主提议和讨论讲座。
项目的完整代码可以从eloquentjavascript.net/code/skillsharing.zip
下载。
设计
该项目包含一个为Node.js
编写的服务器
部分和一个为浏览器编写的客户端
部分。服务器存储系统的数据并将其提供给客户端。同时,服务器还提供实现客户端系统的文件。
服务器保持下次会议提议的讲座列表,客户端显示此列表。每个讲座都有一个演讲者姓名、标题、摘要以及与之相关的评论数组。客户端允许用户提议新讲座(将其添加到列表中)、删除讲座并对现有讲座进行评论。每当用户进行这样的更改时,客户端会发送HTTP
请求以告知服务器。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0356-01.jpg
该应用程序将设置为显示当前提议的讲座及其评论的实时
视图。每当有人在任何地方提交新讲座或添加评论时,所有在浏览器中打开该页面的人应立即看到变化。这带来了一些挑战——因为没有办法让网络服务器打开与客户端的连接,也没有好的方法来知道当前哪些客户端正在查看特定网站。
解决此问题的一个常见方法称为长轮询
,这恰好是Node
设计的动机之一。
长轮询
为了能够立即通知客户某些内容发生了变化,我们需要与该客户建立连接。由于网络浏览器通常不接受连接,并且客户常常处于会阻止此类连接的路由器后面,因此让服务器发起此连接并不实用。
我们可以安排客户端打开连接并保持连接,以便服务器在需要时可以使用它发送信息。但HTTP
请求仅允许简单的信息流:客户端发送请求,服务器返回单个响应,便结束了。一个名为WebSockets
的技术使得可以为任意数据交换打开连接,但正确使用这些套接字有些棘手。
在本章中,我们使用一种更简单的技术,即长轮询
,客户端通过常规的HTTP
请求持续向服务器请求新信息,而服务器在没有新信息时会延迟响应。
只要客户端确保始终保持一个轮询请求开放,它将能够在信息可用后快速接收来自服务器的信息。例如,如果Fatma
在浏览器中打开了我们的技能共享应用程序,那么该浏览器将已经发出更新请求,并等待对此请求的响应。当Iman
提交关于极限单轮车的讨论时,服务器将注意到Fatma
在等待更新,并将包含新讨论的响应发送给她的挂起请求。Fatma
的浏览器将接收到数据并更新屏幕以显示讨论内容。
为了防止连接超时(因缺乏活动而被中止),长轮询技术通常会为每个请求设置最大时间,超过该时间后,即使服务器没有任何报告,仍会做出响应。然后客户端可以启动新的请求。定期重新启动请求也使得这种技术更加稳健,使客户端能够从临时的连接故障或服务器问题中恢复。
一个繁忙的服务器如果使用长轮询,可能会有数千个等待请求,因此会保持许多TCP
连接。Node.js
可以轻松管理多个连接,而无需为每个连接创建单独的控制线程,这使其非常适合这种系统。
HTTP
接口
在我们开始设计服务器或客户端之前,让我们先思考它们交互的点:用于通信的HTTP
接口。
我们将使用JSON
作为请求和响应主体的格式。就像在第二十章的文件服务器中,我们将尽量充分利用HTTP
方法和头部。接口围绕/talks
路径展开。以/talks
开头的路径将用于提供静态文件——客户端系统的HTML
和JavaScript
代码。
对/talks
的GET
请求将返回如下JSON
文档:
[{"title": "Unituning",
"presenter": "Jamal",
"summary": "Modifying your cycle for extra style",
"comments": []}]
创建新讨论可以通过向类似/talks/Unituning
的URL
发起PUT
请求来实现,其中第二个斜杠后的部分是讨论的标题。PUT
请求的主体应包含一个具有presenter
和summary
属性的JSON
对象。
由于讨论标题可能包含空格和其他在URL
中通常不会出现的字符,因此在构建此类URL
时,标题字符串必须使用encodeURIComponent
函数进行编码。
console.log("/talks/" + encodeURIComponent("How to Idle"));
// → /talks/How%20to%20Idle
创建关于闲置的讲座的请求可能看起来像这样:
PUT /talks/How%20to%20Idle HTTP/1.1
Content-Type: application/json
Content-Length: 92
{"presenter": "Maureen",
"summary": "Standing still on a unicycle"}
此类URL
也支持GET
请求以检索讲座的JSON
表示和DELETE
请求以删除讲座。
向讲座添加评论是通过向类似/talks/Unituning/comments
的URL发送POST
请求来完成的,JSON
正文中包含author
和message
属性。
POST /talks/Unituning/comments HTTP/1.1
Content-Type: application/json
Content-Length: 72
{"author": "Iman",
"message": "Will you talk about raising a cycle?"}
为了支持长轮询,对/talks
的GET
请求可以包含额外的头部,告知服务器在没有新信息可用时延迟响应。我们将使用一对通常用于管理缓存的头部:ETag
和If-None-Match
。
服务器可能在响应中包含ETag
(“实体标签”)头部。其值是一个字符串,用于标识资源的当前版本。当客户端稍后再次请求该资源时,可以通过包含If-None-Match
头部并将其值设置为该字符串来进行条件请求
。如果资源未发生变化,服务器将以状态码304
响应,这意味着“未修改”,告知客户端其缓存版本仍然是最新的。当标签不匹配时,服务器则正常响应。
我们需要类似这样的功能,客户端可以告知服务器它拥有的讲座列表的版本,服务器仅在该列表发生变化时作出响应。但服务器不应立即返回304
响应,而是应延迟响应,仅在有新信息可用或经过一定时间后才返回。为了将长轮询请求与普通条件请求区分开,我们为它们提供了另一个头部,Prefer: wait=90
,这告诉服务器客户端愿意等待最长90
秒的时间以获取响应。
服务器将保持一个版本号,每次讲座发生变化时更新该版本号,并将其用作ETag
值。客户端可以发出这样的请求,以便在讲座发生变化时得到通知:
GET /talks HTTP/1.1
If-None-Match: "4"
Prefer: wait=90
(time passes)
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "5"
Content-Length: 295
--snip--
此处描述的协议不进行任何访问控制。每个人都可以评论、修改讲座,甚至删除它们。(由于互联网上充满了流氓,将这样的系统在线放置而没有进一步的保护可能不会有好的结果。)
服务器
让我们开始构建程序的服务器端部分。本节中的代码在Node.js
上运行。
路由
我们的服务器将使用Node
的createServer
来启动一个HTTP
服务器。在处理新请求的函数中,我们必须区分我们支持的各种请求类型(由方法和路径决定)。这可以通过一长串if
语句来完成,但还有更好的方法。
路由器
是一个组件,帮助将请求分发到可以处理它的函数。你可以告诉路由器,例如,PUT
请求的路径匹配正则表达式/^\/talks\/([^\/]+)$/
(talks/
后跟讲座标题)可以由特定函数处理。此外,它还可以帮助提取路径中的有意义部分(在此情况下为讲座标题),这些部分用正则表达式中的括号包裹,并将它们传递给处理函数。
NPM
上有许多优秀的路由器包,但在这里我们将自己编写一个以说明原理。
这是router.mjs
,我们稍后将从服务器模块中导入它:
export class Router {
constructor() {
this.routes = [];
}
add(method, url, handler) {
this.routes.push({method, url, handler});
}
async resolve(request, context) {
let {pathname} = new URL(request.url, "http://d");
for (let {method, url, handler} of this.routes) {
let match = url.exec(pathname);
if (!match || request.method != method) continue;
let parts = match.slice(1).map(decodeURIComponent);
return handler(context, ...parts, request);
}
}
}
该模块导出了Router
类。路由器对象允许你使用其add
方法注册特定方法和URL
模式的处理程序。当使用resolve
方法解析请求时,路由器会调用与请求的方法和URL
匹配的处理程序,并返回其结果。
处理函数在给定的上下文值下调用resolve
。我们将利用这一点使它们能够访问我们的服务器状态。此外,它们接收其正则表达式中定义的任何组的匹配字符串,以及请求对象。这些字符串必须进行URL
解码,因为原始URL
可能包含%20
样式的编码。
服务文件
当请求与我们路由器中定义的请求类型都不匹配时,服务器必须将其解释为对public
目录中某个文件的请求。可以使用第二十章中定义的文件服务器来提供此类文件,但我们既不需要也不想在文件上支持PUT
和DELETE
请求,并且我们希望具备支持缓存等高级功能。让我们使用NPM
中一个稳健且经过充分测试的静态文件服务器。
我选择了serve-static
。这不是NPM
上唯一的此类服务器,但它工作良好,符合我们的目的。serve-static
包导出一个可以用根目录调用的函数,以生成请求处理函数。处理函数接受服务器从node:http
提供的请求和响应参数,以及第三个参数,如果没有文件与请求匹配,它将调用的一个函数。我们希望我们的服务器首先检查应该特别处理的请求,如路由器中定义的那样,因此我们将其包装在另一个函数中。
import {createServer} from "node:http";
import serveStatic from "serve-static";
function notFound(request, response) {
response.writeHead(404, "Not found");
response.end("<h1>Not found</h1>");
}
class SkillShareServer {
constructor(talks) {
this.talks = talks;
this.version = 0;
this.waiting = [];
let fileServer = serveStatic("./public");
this.server = createServer((request, response) => {
serveFromRouter(this, request, response, () => {
fileServer(request, response,
() => notFound(request, response));
});
});
}
start(port) {
this.server.listen(port);
}
stop() {
this.server.close();
}
}
serveFromRouter
函数具有与fileServer
相同的接口,接受(request, response, next)
参数。我们可以利用这一点“链接”多个请求处理程序,允许每个处理程序处理请求或将责任传递给下一个处理程序。最终处理程序notFound
仅仅响应一个“未找到”错误。
我们的serveFromRouter
函数使用与前一章文件服务器类似的约定来处理响应——路由器中的处理程序返回的承诺解析为描述响应的对象。
import {Router} from "./router.mjs";
const router = new Router();
const defaultHeaders = {"Content-Type": "text/plain"};
async function serveFromRouter(server, request,
response, next) {
let resolved = await router.resolve(request, server)
.catch(error => {
if (error.status != null) return error;
return {body: String(err), status: 500};
});
if (!resolved) return next();
let {body, status = 200, headers = defaultHeaders} =
await resolved;
response.writeHead(status, headers);
response.end(body);
}
讲座作为资源
已提议的演讲存储在服务器的talks
属性中,这是一个对象,其属性名称为演讲标题。我们将为我们的路由器添加一些处理程序,将其作为 HTTP 资源公开,路径为/talks/<title>
。
处理GET
单个演讲请求的处理程序必须查找该演讲,并以该演讲的 JSON 数据或 404 错误响应进行回应。
const talkPath = /^\/talks\/([^\/]+)$/;
router.add("GET", talkPath, async (server, title) => {
if (Object.hasOwn(server.talks, title)) {
return {body: JSON.stringify(server.talks[title]),
headers: {"Content-Type": "application/json"}};
} else {
return {status: 404, body: `No talk '${title}' found`};
}
});
删除演讲是通过将其从talks
对象中移除来完成的。
router.add("DELETE", talkPath, async (server, title) => {
if (Object.hasOwn(server.talks, title)) {
delete server.talks[title];
server.updated();
}
return {status: 204};
});
updated
方法(稍后我们将定义)会通知等待的长轮询请求有关更改的信息。
需要读取请求主体的一个处理程序是PUT
处理程序,它用于创建新的演讲。它必须检查提供的数据是否具有字符串类型的presenter
和summary
属性。来自系统外部的任何数据可能都是无意义的,我们不想损坏内部数据模型或在出现错误请求时崩溃。
如果数据看起来有效,处理程序将一个表示新演讲的对象存储在talks
对象中,可能会覆盖具有相同标题的现有演讲,并再次调用updated
。
为了从请求流中读取主体,我们将使用来自node:stream/consumers
的json
函数,该函数收集流中的数据并将其解析为 JSON。这个包中还有类似的导出,称为text
(用于将内容读取为字符串)和buffer
(用于将其读取为二进制数据)。由于json
是一个非常通用的名称,因此我们将其导入重命名为readJSON
,以避免混淆。
import {json as readJSON} from "node:stream/consumers";
router.add("PUT", talkPath,
async (server, title, request) => {
let talk = await readJSON(request);
if (!talk ||
typeof talk.presenter != "string" ||
typeof talk.summary != "string") {
return {status: 400, body: "Bad talk data"};
}
server.talks[title] = {
title,
presenter: talk.presenter,
summary: talk.summary,
comments: []
};
server.updated();
return {status: 204};
});
向演讲添加评论的过程类似。我们使用readJSON
获取请求的内容,验证结果数据,并在数据有效时将其存储为评论。
router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
async (server, title, request) => {
let comment = await readJSON(request);
if (!comment ||
typeof comment.author != "string" ||
typeof comment.message != "string") {
return {status: 400, body: "Bad comment data"};
} else if (Object.hasOwn(server.talks, title)) {
server.talks[title].comments.push(comment);
server.updated();
return {status: 204};
} else {
return {status: 404, body: `No talk '${title}' found`};
}
});
尝试向一个不存在的演讲添加评论将返回 404 错误。
长轮询支持
服务器最有趣的部分是处理长轮询的部分。当对/talks
发起GET
请求时,它可能是一个常规请求或一个长轮询请求。
我们将有多个地方需要向客户端发送一个演讲数组,因此我们首先定义一个帮助方法来构建这样的数组,并在响应中包含一个ETag
头。
SkillShareServer.prototype.talkResponse = function() {
let talks = Object.keys(this.talks)
.map(title => this.talks[title]);
return {
body: JSON.stringify(talks),
headers: {"Content-Type": "application/json",
"ETag": `"${this.version}"`,
"Cache-Control": "no-store"}
};
};
处理程序本身需要查看请求头,以确认是否存在If-None-Match
和Prefer
头。Node 将头信息以不区分大小写的名称存储为小写形式。
router.add("GET", /^\/talks$/, async (server, request) => {
let tag = /"(.*)"/.exec(request.headers["if-none-match"]);
let wait = /\bwait=(\d+)/.exec(request.headers["prefer"]);
if (!tag || tag[1] != server.version) {
return server.talkResponse();
} else if (!wait) {
return {status: 304};
} else {
return server.waitForChanges(Number(wait[1]));
}
});
如果没有提供标签,或者提供的标签与服务器当前版本不匹配,处理程序将响应演讲列表。如果请求是条件性的且演讲没有改变,我们会查看Prefer
头,以决定是否延迟响应或立即回应。
延迟请求的回调函数存储在服务器的等待数组中,以便在发生某些事情时能够通知它们。waitForChanges
方法还会立即设置一个定时器,在请求等待足够长的时间后以 304 状态进行响应。
SkillShareServer.prototype.waitForChanges = function(time) {
return new Promise(resolve => {
this.waiting.push(resolve);
setTimeout(() => {
if (!this.waiting.includes(resolve)) return;
this.waiting = this.waiting.filter(r => r != resolve);
resolve({status: 304});
}, time * 1000);
});
};
使用updated
注册更改会增加版本属性并唤醒所有等待的请求。
SkillShareServer.prototype.updated = function() {
this.version++;
let response = this.talkResponse();
this.waiting.forEach(resolve => resolve(response));
this.waiting = [];
};
这就是服务器代码的全部内容。如果我们创建SkillShare Server
的实例并在 8000 端口启动它,生成的 HTTP 服务器将从public
子目录提供文件,并在/talks
URL下提供讲座管理界面。
new SkillShareServer({}).start(8000);
客户端
技能共享网站的客户端部分由三个文件组成:一个小的 HTML 页面、一个样式表和一个 JavaScript 文件。
HTML
当直接请求与目录对应的路径时,Web 服务器通常会尝试提供名为index.xhtml
的文件。我们使用的文件服务器模块serve-static
支持这一约定。当对路径/
发出请求时,服务器会查找文件./public/index.xhtml
(./public
是我们指定的根目录),并在找到时返回该文件。
因此,如果我们希望在浏览器指向我们的服务器时显示一个页面,我们应该将其放在public/index.xhtml
中。这是我们的索引文件:
<!doctype html>
<meta charset="utf-8">
<title>Skill Sharing</title>
<link rel="stylesheet" href="skillsharing.css">
<h1>Skill Sharing</h1>
<script src="skillsharing_client.js"></script>
它定义了文档标题,并包含一个样式表,该样式表定义了一些样式,以确保讲座之间有一些空间。然后,它在页面顶部添加了一个标题,并加载包含客户端应用程序的脚本。
操作
应用程序状态包括讲座列表和用户的名字,我们将其存储在一个{talks, user}
对象中。我们不允许用户界面直接操作状态或发送 HTTP 请求。相反,它可以发出描述用户尝试执行的操作的动作
。
handleAction
函数接受这样的操作并使其生效。由于我们的状态更新非常简单,状态更改在同一函数中处理。
function handleAction(state, action) {
if (action.type == "setUser") {
localStorage.setItem("userName", action.user);
return {...state, user: action.user};
} else if (action.type == "setTalks") {
return {...state, talks: action.talks};
} else if (action.type == "newTalk") {
fetchOK(talkURL(action.title), {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
presenter: state.user,
summary: action.summary
})
}).catch(reportError);
} else if (action.type == "deleteTalk") {
fetchOK(talkURL(action.talk), {method: "DELETE"})
.catch(reportError);
} else if (action.type == "newComment") {
fetchOK(talkURL(action.talk) + "/comments", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
author: state.user,
message: action.message
})
}).catch(reportError);
}
return state;
}
我们将用户的名字存储在localStorage
中,以便在页面加载时能够恢复。
需要与服务器交互的操作会使用fetch
发起网络请求,访问前面描述的 HTTP 接口。我们使用一个包装函数fetchOK
,以确保当服务器返回错误代码时,返回的 Promise 被拒绝。
function fetchOK(url, options) {
return fetch(url, options).then(response => {
if (response.status < 400) return response;
else throw new Error(response.statusText);
});
}
这个辅助函数用于构建具有给定标题的讲座的 URL。
function talkURL(title) {
return "talks/" + encodeURIComponent(title);
}
当请求失败时,我们不希望页面就这样静止不动而没有解释。我们使用的名为reportError
的函数作为捕获处理程序,向用户显示一个简单的对话框,告诉他们出了点问题。
function reportError(error) {
alert(String(error));
}
渲染组件
我们将采用类似于在第十九章中看到的方法,将应用程序分为组件。然而,由于某些组件要么从不需要更新,要么在更新时总是完全重绘,因此我们将那些定义为函数,而不是类,直接返回一个DOM
节点。例如,下面是一个显示用户可以输入其姓名的字段的组件:
function renderUserField(name, dispatch) {
return elt("label", {}, "Your name: ", elt("input", {
type: "text",
value: name,
onchange(event) {
dispatch({type: "setUser", user: event.target.value});
}
}));
}
elt
函数用于构建DOM
元素,这是我们在第十九章中使用的函数。
一个类似的函数用于渲染讲座,其中包括评论列表和添加新评论的表单。
function renderTalk(talk, dispatch) {
return elt(
"section", {className: "talk"},
elt("h2", null, talk.title, " ", elt("button", {
type: "button",
onclick() {
dispatch({type: "deleteTalk", talk: talk.title});
}
}, "Delete")),
elt("div", null, "by ",
elt("strong", null, talk.presenter)),
elt("p", null, talk.summary),
...talk.comments.map(renderComment),
elt("form", {
onsubmit(event) {
event.preventDefault();
let form = event.target;
dispatch({type: "newComment",
talk: talk.title,
message: form.elements.comment.value});
form.reset();
}
}, elt("input", {type: "text", name: "comment"}), " ",
elt("button", {type: "submit"}, "Add comment")));
}
“提交”事件处理程序在创建newComment
操作后调用form.reset
以清除表单内容。
当创建中等复杂度的DOM
元素时,这种编程风格开始显得相当混乱。为避免这种情况,人们通常使用模板语言
,它允许你将界面作为一个HTML
文件编写,并使用一些特殊标记来指示动态元素的位置。或者他们使用JSX
,这是一种非标准的JavaScript
方言,允许你在程序中编写非常接近HTML
标签的内容,就好像它们是JavaScript
表达式。上述两种方法都使用额外的工具在运行之前预处理代码,这一章我们将避免使用这些工具。
评论的渲染非常简单。
function renderComment(comment) {
return elt("p", {className: "comment"},
elt("strong", null, comment.author),
": ", comment.message);
}
最后,用户可以用来创建新讲座的表单渲染如下:
function renderTalkForm(dispatch) {
let title = elt("input", {type: "text"});
let summary = elt("input", {type: "text"});
return elt("form", {
onsubmit(event) {
event.preventDefault();
dispatch({type: "newTalk",
title: title.value,
summary: summary.value});
event.target.reset();
}
}, elt("h3", null, "Submit a Talk"),
elt("label", null, "Title: ", title),
elt("label", null, "Summary: ", summary),
elt("button", {type: "submit"}, "Submit"));
}
轮询
要启动应用程序,我们需要当前的讲座列表。由于初始加载与长轮询过程密切相关——加载时的ETag
必须在轮询时使用——我们将编写一个函数,该函数持续向服务器轮询/talks
,并在有新的讲座集可用时调用回调函数。
async function pollTalks(update) {
let tag = undefined;
for (;;) {
let response;
try {
response = await fetchOK("/talks", {
headers: tag && {"If-None-Match": tag,
"Prefer": "wait=90"}
});
} catch (e) {
console.log("Request failed: " + e);
await new Promise(resolve => setTimeout(resolve, 500));
continue;
}
if (response.status == 304) continue;
tag = response.headers.get("ETag");
update(await response.json());
}
}
这是一个异步函数,以便于循环和等待请求。它运行一个无限循环,在每次迭代中检索讲座列表——要么正常检索,要么如果这不是第一次请求,则包含使其成为长轮询请求的头部信息。
当请求失败时,函数会等待片刻然后重试。这样,如果你的网络连接暂时中断后又恢复,应用程序可以恢复并继续更新。通过setTimeout
解决的promise是一种强制异步函数等待的方法。
当服务器返回304
响应时,意味着长轮询请求超时,因此函数应立即开始下一个请求。如果响应是正常的200
响应,则其主体将被读取为JSON
并传递给回调函数,其ETag
头部值将被存储以供下次迭代使用。
应用程序
以下组件将整个用户界面串联在一起:
class SkillShareApp {
constructor(state, dispatch) {
this.dispatch = dispatch;
this.talkDOM = elt("div", {className: "talks"});
this.dom = elt("div", null,
renderUserField(state.user, dispatch),
this.talkDOM,
renderTalkForm(dispatch));
this.syncState(state);
}
syncState(state) {
if (state.talks != this.talks) {
this.talkDOM.textContent = "";
for (let talk of state.talks) {
this.talkDOM.appendChild(
renderTalk(talk, this.dispatch));
}
this.talks = state.talks;
}
}
}
当讲座发生变化时,该组件会重新绘制所有讲座。这很简单,但也很浪费。我们将在练习中回到这个问题。
我们可以这样启动应用程序:
function runApp() {
let user = localStorage.getItem("userName") || "Anon";
let state, app;
function dispatch(action) {
state = handleAction(state, action);
app.syncState(state);
}
pollTalks(talks => {
if (!app) {
state = {user, talks};
app = new SkillShareApp(state, dispatch);
document.body.appendChild(app.dom);
} else {
dispatch({type: "setTalks", talks});
}
}).catch(reportError);
}
runApp();
如果你运行服务器并在两个浏览器窗口中打开http://localhost:8000
,你会看到你在一个窗口中执行的操作会立即在另一个窗口中显示。
练习
以下练习将涉及修改本章定义的系统。为了解决这些问题,请确保你已下载代码([eloquentjavascript.net/code/skillsharing.zip](https://eloquentjavascript.net/code/skillsharing.zip)
),安装了Node
([nodejs.org](https://nodejs.org)
),并使用npm install
安装项目依赖。
磁盘持久性
技能分享服务器将其数据完全保存在内存中。这意味着当它崩溃或因任何原因重新启动时,所有讲座和评论都将丢失。
扩展服务器,使其将谈话数据存储到磁盘,并在重启时自动重新加载数据。不要担心效率——只需做最简单有效的事情。
评论字段重置
对谈话进行全面重绘效果不错,因为通常你无法区分一个DOM
节点和它的相同替代品。但也有例外。如果你在一个浏览器窗口的评论字段中开始输入内容,然后在另一个窗口中给该谈话添加评论,第一个窗口中的字段将被重绘,移除其内容和焦点。
当多个人同时添加评论时,这会很烦人。你能想出解决办法吗?
重大优化来自于对高层设计的精炼,而不是单个例程。
—史蒂夫·麦康奈尔,《代码大全》
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0372-01.jpg
第二十三章:JAVASCRIPT
和性能
在机器上运行计算机程序需要弥合编程语言与机器自身指令格式之间的差距。这可以通过编写一个解释
其他程序的程序来实现,正如我们在第十一章中所做的,但通常是通过将程序编译
(翻译)为机器代码来完成。
一些语言,如C
和Rust
编程语言,旨在表达机器擅长的那些东西。这使得它们易于高效编译。JavaScript
的设计方式则截然不同,注重简单性和易用性。几乎没有它允许你表达的操作与机器的特性直接对应。这使得JavaScript
的编译变得更加困难。
然而,现代JavaScript
引擎
(编译和运行JavaScript
的程序)确实能够以令人印象深刻的速度执行程序。可以编写的JavaScript
程序,其速度仅比等效的C
或Rust
程序慢几倍。尽管这听起来差距很大,但较旧的JavaScript
引擎(以及类似设计的语言的当代实现,如Python
和Ruby
)往往比C
慢接近100
倍。与这些语言相比,现代JavaScript
的速度令人瞩目——如此之快,以至于你很少会因为性能问题而被迫切换到另一种语言。
不过,你可能需要调整代码以避免语言中较慢的方面。作为这一过程的例子,本章将通过一个对速度要求高的程序来使其更快。在这个过程中,我们将讨论JavaScript
引擎如何编译你的程序。
分阶段编译
首先,你必须理解JavaScript
编译器并不只是像经典编译器那样一次性编译一个程序。相反,代码在程序运行时根据需要编译和重新编译。
对于大多数语言,编译大型程序需要一段时间。这通常是可以接受的,因为程序是提前编译并以编译形式分发的。
对于JavaScript
,情况有所不同。一个网站可能包含大量以文本形式检索的代码,每次打开网站时都必须编译。如果这个过程花费五分钟,用户肯定不会满意。JavaScript
编译器必须能够几乎瞬间开始运行程序——即使是大型程序。
为此,这些编译器具有多种编译策略。当网站首次打开时,脚本首先以一种廉价、表面的方式编译。这并不会导致非常快的执行,但允许脚本快速启动。函数可能在第一次被调用之前根本不会被编译。
在一个典型的程序中,大多数代码只会运行少数几次(或者根本不运行)。对于程序的这些部分,廉价的编译策略就足够了——反正它们不会花费太多时间。但是,频繁调用的函数或包含大量工作循环的函数必须以不同的方式对待。在运行程序时,JavaScript
引擎会观察每段代码运行的频率。当某段代码似乎可能耗费大量时间时(这通常被称为热点代码
),它会用一个更高级但更慢的编译器重新编译。这种编译器会进行更多的优化
以生成更快的代码。甚至可能会有超过两种编译策略,对非常
热点的代码应用更昂贵的优化。
交替运行和编译代码意味着在聪明的编译器开始处理一段代码时,它已经被运行多次。这使得能够观察
运行中的代码并收集有关它的信息。在本章后面,我们将看到这如何使编译器能够生成更高效的代码。
图布局
本章的示例问题再次与图有关。图的图片可以用于描述道路系统、网络、控制在计算机程序中的流动等。下图显示了一个表示南美国家和领土的图,其中边表示共享陆地边界的国家:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0375-01.jpg
从图的定义推导出这样的图像被称为图布局
。它涉及为每个节点分配一个位置,使得相连的节点彼此接近,同时节点不会相互拥挤。相同图的随机布局则更难以解读。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0375-02.jpg
为给定图找到一个好看的布局是一个众所周知的困难问题。对于任意图,没有已知的解决方案能够可靠地做到这一点。大型、密集连接的图尤其具有挑战性。但对于某些特定类型的图,例如平面
图(可以画出而不让边相互交叉),有效的方法是存在的。
为了布局一个不太复杂的小图,我们可以应用一种叫做基于力的图布局
的方法。这在图的节点上运行一个简化的物理模拟,将边视为弹簧,并让节点之间相互排斥,就像带电一样。
在本章中,我们将实现一个基于力的图布局系统并观察其性能。我们可以通过反复计算作用在每个节点上的力并根据这些力移动节点来运行这样的模拟。这样的程序性能很重要,因为达到一个好看的布局可能需要很多次迭代,而每次迭代都会计算大量的力。
定义图
我们可以使用类似这样的类来表示图。每个节点都有一个编号,从0
开始,并存储一组连接的节点。
class Graph {
#nodes = [];
get size() {
return this.#nodes.length;
}
addNode() {
let id = this.#nodes.length;
this.#nodes.push(new Set());
return id;
}
addEdge(nodeA, nodeB) {
this.#nodes[nodeA].add(nodeB);
this.#nodes[nodeB].add(nodeA);
}
neighbors(node) {
return this.#nodes[node];
}
}
在构建图时,你调用addNode
来定义一个新节点,调用addEdge
将两个节点连接在一起。与图一起工作的代码可以使用neighbors
,返回一组连接的节点 ID,以读取有关边的信息。
为了表示图的布局,我们将使用之前章节中的熟悉的Vec
类。图的布局是一个长度为graph.size
的向量数组,为每个节点保存一个位置。
function randomLayout(graph) {
let layout = [];
for (let i = 0; i < graph.size; i++) {
layout.push(new Vec(Math.random() * 1000,
Math.random() * 1000));
}
return layout;
}
gridGraph
函数构建一个只是节点的方形网格的图,这是图布局程序的一个有用测试用例。它创建size * size
个节点,并将每个节点与其上方或左侧的节点连接(如果存在这样的节点)。
function gridGraph(size) {
let grid = new Graph();
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let id = grid.addNode();
if (x > 0) grid.addEdge(id, id - 1);
if (y > 0) grid.addEdge(id, id - size);
}
}
return grid;
}
这就是一个网格布局的样子:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0377-01.jpg
为了让我们检查代码生成的布局,我定义了一个drawGraph
函数,将图形绘制到画布上。这个函数在[eloquentjavascript.net/code/draw_layout.js](http://eloquentjavascript.net/code/draw_layout.js)
的代码中定义,并且在在线沙箱中可用。
力导向布局
我们将一次移动一个节点,计算作用于当前节点的力,并立即将该节点移动到这些力的总和方向。
(理想化的)弹簧施加的力可以用胡克定律来近似,该定律指出,这个力与弹簧的静止长度和当前长度之间的差值成正比。绑定弹簧长度定义了我们边缘弹簧的静止长度。弹簧的刚度由springStrength
定义,我们将通过长度差乘以该值来确定结果力。
为了模拟节点之间的排斥力,我们使用另一个物理公式,即库仑定律,它表明两个带电粒子之间的排斥力与它们之间距离的平方成反比。当两个节点几乎重叠时,平方距离很小,结果力是巨大的。随着节点远离,平方距离迅速增大,从而排斥力迅速减弱。
我们将乘以一个实验确定的常数,排斥力强度,该常数控制节点之间排斥的强度。
这个函数计算作用于节点的力的强度,因为在给定距离下存在另一个节点。它始终包括排斥力,并在节点连接时将其与弹簧的力相结合。
const springLength = 20;
const springStrength = 0.1;
const repulsionStrength = 1500;
function forceSize(distance, connected) {
let repulse = -repulsionStrength / (distance * distance);
let spring = 0;
if (connected) {
spring = (distance - springLength) * springStrength;
}
return spring + repulse;
}
节点移动的方式是通过结合所有其他节点施加在它上的力来决定的。对于每对节点,我们的函数需要知道它们之间的距离。我们可以通过减去节点的位置并计算结果向量的长度来计算这个距离。当距离小于一时,我们将其设为一,以防止除以零或非常小的数,因为这样会产生NaN
值或产生巨大的力,将节点抛入太空。
使用这个距离以及节点之间连接边的存在与否,我们可以计算作用于它们之间的力的大小。要从这个大小得到力向量,我们可以将大小乘以一个标准化的分离向量。标准化
一个向量意味着创建一个方向相同但长度为一的向量。我们可以通过将向量除以其自身的长度来实现。将这个值添加到节点的位置会使其朝着力的方向移动。
function forceDirected_simple(layout, graph) {
for (let a = 0; a < graph.size; a++) {
for (let b = 0; b < graph.size; b++) {
if (a == b) continue;
let apart = layout[b].minus(layout[a]);
let distance = Math.max(1, apart.length);
let connected = graph.neighbors(a).has(b);
let size = forceSize(distance, connected);
let force = apart.times(1 / distance).times(size);
layout[a] = layout[a].plus(force);
}
}
}
我们将使用以下函数来测试我们图形布局系统的给定实现。它从随机布局开始,并运行模型三秒钟。完成后,它记录每秒处理的迭代次数。为了让我们在代码运行时有所观察,它在每100
次迭代后绘制当前图形的布局。
function pause() {
return new Promise(done => setTimeout(done, 0))
}
async function runLayout(implementation, graph) {
let time = 0, iterations = 0;
let layout = randomLayout(graph);
while (time < 3000) {
let start = Date.now();
for (let i = 0; i < 100; i++) {
implementation(layout, graph);
iterations++;
}
time += Date.now() - start;
drawGraph(graph, layout);
await pause();
}
let perSecond = Math.round(iterations / (time / 1000));
console.log(`${perSecond} iterations per second`);
}
为了让浏览器有机会实际显示图形,该函数在每次绘制图形时将控制权短暂地返回给事件循环。该函数是异步的,以便能够等待超时。
我们可以运行这个第一个实现,看看需要多少时间。
runLayout(forceDirected_simple, gridGraph(12));
在我的机器上,这个版本每秒处理约1,600
次迭代。这已经相当多了。但让我们看看是否可以做得更好。
避免工作
完成某件事情最快的方法就是避免去做它——或者至少部分避免。通过思考代码的作用,你通常可以发现不必要的冗余或可以更快完成的操作。
在我们示例项目的情况下,存在减少工作量的机会。每对节点之间的力计算了两次,一次是在移动第一个节点时,另一次是在移动第二个节点时。由于节点X
施加于节点Y
的力恰好是节点Y
施加于节点X
的力的相反数,因此我们不需要计算这些力两次。
函数的下一个版本将内部循环更改为只遍历当前节点之后的节点,以便每对节点仅被查看一次。在计算一对节点之间的力之后,函数更新两个节点的位置。
function forceDirected_noRepeat(layout, graph) {
for (let a = 0; a < graph.size; a++) {
for (let b = a + 1; b < graph.size; b++) {
let apart = layout[b].minus(layout[a]);
let distance = Math.max(1, apart.length);
let connected = graph.neighbors(a).has(b);
let size = forceSize(distance, connected);
let force = apart.times(1 / distance).times(size);
layout[a] = layout[a].plus(force);
layout[b] = layout[b].minus(force);
}
}
}
除了循环结构以及调整两个节点的事实外,这个版本与之前的版本完全相同。测量这段代码显示出显著的速度提升——在Chrome
上快约45%
,在Firefox
上快约55%
。
不同的JavaScript
引擎工作方式不同,可能以不同的速度运行程序。因此,在一个引擎中使代码运行更快的更改,在另一个引擎中可能无效(甚至可能有害)——甚至在同一引擎的不同版本中也是如此。这很烦人,但考虑到这些系统的复杂性,这并不意外。
如果我们仔细看看程序的运行情况,比如通过调用console.log
输出大小,就会发现大多数节点对之间产生的力微乎其微,实际上并没有影响布局。具体来说,当节点未连接且相距较远时,它们之间的力几乎可以忽略不计。然而我们仍然为它们计算向量并稍微移动节点。如果我们不这样做会怎样呢?
下一个版本定义了一个距离,超过该距离的(未连接)节点将不再计算和应用力。设置该距离为175
时,忽略低于0.05
的力。
const skipDistance = 175;
function forceDirected_skip(layout, graph) {
for (let a = 0; a < graph.size; a++) {
for (let b = a + 1; b < graph.size; b++) {
let apart = layout[b].minus(layout[a]);
let distance = Math.max(1, apart.length);
let connected = graph.neighbors(a).has(b);
if (distance > skipDistance && !connected) continue;
let size = forceSize(distance, connected);
let force = apart.times(1 / distance).times(size);
layout[a] = layout[a].plus(force);
layout[b] = layout[b].minus(force);
}
}
}
这使得速度又提高了75%
,而布局没有明显的退化。我们省了一些麻烦,结果也不错。
性能分析
通过对程序的推理,我们能够相当大幅度地加快程序的运行速度。但在微优化
方面——即通过稍微不同的方式来提高速度——通常很难预测哪些更改会有帮助,哪些不会。在这种情况下,我们不能再依赖推理,我们必须观察
。
我们的runLayout
函数测量程序当前的运行时间。这是一个好的开始。要改善某件事,必须对其进行测量。不进行测量,你就无法知道你的更改是否达到了预期效果。
现代浏览器中的开发者工具提供了一种更好的方法来测量程序的速度。这个工具被称为性能分析器
。在程序运行时,它会收集程序各个部分所用时间的信息。
如果你的浏览器有性能分析器,它将在开发者工具界面中可用,可能在一个名为“性能”的标签上。当我让Chrome
记录3,000
次forceDirected_skip
的迭代时,性能分析器输出以下表格:
Activity Self Time Total Time
forceDirected_skip 74.0ms 82.4% 769.5ms 94.1%
Minor GC 48.2ms 5.9% 48.2ms 5.9%
Vec 44.8ms 5.5% 46.9ms 5.7%
plus 4.6ms 0.6% 5.5ms 0.7%
Optimize Code 0.1ms 0.0% 0.1ms 0.0%
这列出了耗时较长的函数(或其他任务)。对于每个函数,它报告执行该函数所花费的时间,包括毫秒和总时间的百分比。第一列只显示控制实际上在函数中的时间,而第二列包括在该函数调用的函数中花费的时间。
就性能分析而言,这个非常简单,因为程序没有
很多函数。对于更复杂的程序,列表将会更长。由于耗时最长的函数显示在顶部,因此通常仍然容易找到有趣的信息。
从这个表中,我们可以看出,大部分时间花费在物理仿真函数上。这并不意外。但在第二行,我们看到了“Minor GC”。GC
代表“垃圾收集”—释放程序不再使用的值所占用的内存空间的过程。第三行的时间类似,测量的是向量构造函数。这些表明程序在创建和清理Vec
对象上花费了相当多的时间。
再次想象内存是一排排长长的比特。当程序启动时,它可能会接收一块空的内存,并开始逐个放入它创建的对象。但在某个时刻,空间满了,其中的一些对象不再使用。JavaScript
引擎必须弄清楚哪些对象正在使用,哪些对象没有使用,以便能够重用未使用的内存块。
我们循环的每次迭代都会创建五个对象。引擎创建和回收所有这些对象的速度已经相当惊人。因为许多 JavaScript 程序创建大量对象,而管理这些对象的内存可能会导致程序变慢,因此在提高这方面的效率上花费了很多精力。
但尽管效率如此,它仍然是必须进行的工作。让我们尝试一个不创建新向量的代码版本。
function forceDirected_noVector(layout, graph) {
for (let a = 0; a < graph.size; a++) {
let posA = layout[a];
for (let b = a + 1; b < graph.size; b++) {
let posB = layout[b];
let apartX = posB.x - posA.x
let apartY = posB.y - posA.y;
let distance = Math.sqrt(apartX * apartX +
apartY * apartY);
let connected = graph.neighbors(a).has(b);
if (distance > skipDistance && !connected) continue;
let size = forceSize(distance, connected);
let forceX = (apartX / distance) * size;
let forceY = (apartY / distance) * size;
posA.x += forceX;
posA.y += forceY;
posB.x -= forceX;
posB.y -= forceY;
}
}
}
新代码更加冗长和重复,但如果我进行测量,这种改进足够大,值得在对性能敏感的代码中考虑进行这种手动对象扁平化。在Firefox
和Chrome
上,新版本的速度比之前的版本快了大约50%
。
综合这些步骤,我们使得程序比最初版本快了大约四倍。这是一个相当大的改进。但请记住,进行这项工作仅对那些实际消耗大量时间的代码是有用的。试图立即优化所有内容只会让你变得更加缓慢,并留下大量不必要的复杂代码。
函数内联
一些向量方法(例如times
)在我们看到的分析中没有出现,尽管它们被大量使用。这是因为编译器对它们进行了内联
。与其让内部函数中的代码调用实际方法来相乘向量,不如将向量乘法代码直接放入函数内部,编译后的代码中没有实际的方法调用。
内联帮助代码变快的方式有很多。函数和方法在机器级别上是通过一种协议调用的,这需要将参数和返回地址(当函数返回时执行需要继续的地方)放在函数可以找到的位置。当函数调用将控制权交给程序的其他部分时,通常还需要保存一些处理器的状态,以便被调用的函数可以使用处理器而不干扰调用者仍然需要的数据。当函数被内联时,所有这些都变得不必要。
此外,一个好的编译器会尽力寻找简化其生成代码的方法。如果将函数视为可能做任何事情的黑箱,编译器就没有太多可供利用的内容。另一方面,如果它能够在分析中看到并包含函数体,可能会找到更多优化代码的机会。
例如,JavaScript
引擎可以完全避免在我们的代码中创建一些向量对象。在像下面这样的表达式中,如果我们可以透视这些方法,很明显,结果向量的坐标是将force
的坐标与normalized
和forceSize
绑定的乘积相加的结果。因此,没有必要创建由times
方法生成的中间对象。
pos.plus(normalized.times(forceSize))
但是JavaScript
允许我们通过操纵原型对象在任何时候替换方法。编译器如何确定这个times
方法实际上是哪一个函数呢?如果之后有人更改了Vec.prototype.times
中存储的值会怎样?下次运行已内联该函数的代码时,它可能会继续使用旧的定义,从而违反程序员对程序行为的假设。
这里是执行和编译交错开始发挥作用的地方。当一个热点函数被编译时,它已经运行了多次。如果在这些运行中,它总是调用同一个函数,那么尝试内联这个函数是合理的。代码是乐观地编译的,假设将来会在这里调用同一个函数。
为了处理悲观的情况,即调用了另一个函数,编译器插入了一个测试,比较被调用的函数和内联的函数。如果两者不匹配,乐观编译的代码就是错误的,JavaScript引擎必须反优化
,这意味着它回退到一个不那么优化的代码版本。在之后的某个时刻,它可能会根据现在所知道的内容尝试以不同的方式再次优化。
动态类型
像graph.size
这样的JavaScript表达式,从对象中获取属性,并非易事。在许多语言中,绑定
是有类型的,因此当你对它们持有的值执行操作时,编译器已经知道你需要什么样的操作。在JavaScript中,只有值
有类型,而一个绑定可能会持有不同类型的值。
这意味着最初编译器对代码可能尝试访问的属性了解不多,必须生成处理所有可能类型的代码。如果graph
持有未定义的值,代码必须抛出一个错误。如果它持有一个字符串,则必须在String.prototype
中查找size
。如果它持有一个对象,则从中提取size
属性的方式取决于对象的形状。依此类推。
幸运的是,尽管JavaScript不要求如此,但大多数程序中的绑定确实
只有一种类型。如果编译器知道这个类型,它可以利用这些信息生成高效的代码。如果图形至今一直是Graph
的一个实例,那么优化编译器可以创建内联从该类获取大小的方法的代码。
再次强调,过去观察到的事件并不能保证未来将会发生的事件。某些尚未运行的代码仍可能向我们的函数传递另一种类型的值——例如,另一种图形对象,其大小属性的工作方式不同。
这意味着编译的代码仍然需要检查
其假设是否成立,并在不成立时采取适当的行动。引擎可以完全去优化,退回到未优化的函数版本。或者它可以编译一个新的函数版本,以处理新观察到的类型。
你可以通过故意破坏输入对象的一致性来观察因无法预测对象类型而导致的性能下降。例如,我们可以用一个版本替换randomLayout
,该版本为每个向量添加一个随机名称的属性。
function randomLayout(graph) {
let layout = [];
for (let i = 0; i < graph.size; i++) {
let vector = new Vec(Math.random() * 1000,
Math.random() * 1000);
vector[`p${Math.floor(Math.random() * 999)}`] = true;
layout.push(vector);
}
return layout;
}
runLayout(forceDirected_noVector, gridGraph(12));
如果我们在结果图上运行我们的快速模拟代码,它在Firefox上会变得慢大约三倍,而在Chrome上则变慢五倍。现在对象类型不再一致,与向量交互的代码必须在没有事先了解对象形状的情况下查找属性,这样做的成本要高得多。
有趣的是,在运行此代码后,即使在常规的、未损坏的布局向量上运行forceDirected_noVector
也变得缓慢。混乱的类型在某种程度上“毒害”了编译的代码——在某个时刻,浏览器往往会丢弃编译的代码并重新从头编译,消除这种影响。
类似的技术也用于其他非属性访问的情况。例如,+
运算符的意义取决于它应用于什么类型的值。聪明的JavaScript编译器不会每次都运行处理所有这些含义的完整代码,而是会利用先前的观察构建对运算符可能应用类型的某种预期。如果它仅应用于数字,则可以生成一个更简单的机器代码来处理它。但同样,这种假设必须在每次函数运行时进行检查。
这里的教训是,如果一段代码需要快速执行,你可以通过提供一致的类型来帮助它。JavaScript引擎能够相对较好地处理少量不同类型的情况——它们会生成处理所有这些类型的代码,并且仅在看到新类型时才会进行去优化。但即便如此,生成的代码仍然比单一类型时的代码慢。
摘要
多亏了大量资金投入到网络中,以及不同浏览器之间的竞争,JavaScript编译器在其工作中表现出色:使代码运行得更快。但有时你需要稍微帮助它们,重写你的内循环,以避免更昂贵的JavaScript特性。创建更少的对象(以及数组和字符串)通常会有所帮助。
在你开始修改代码以提高速度之前,先考虑如何减少代码的工作量。优化的最大机会通常在于这个方向。
JavaScript引擎会多次编译热点代码,并利用之前执行过程中收集的信息来编译更高效的代码。为你的绑定提供一致的类型有助于实现这种类型的优化。
练习
质数
编写一个生成器(见 第十一章)primes
,生成源源不断的质数
。质数是大于 1 的整数,不能被任何小于它们且大于 1 的整数整除。例如,前五个质数是 2、3、5、7 和 11。现在先不考虑速度。
设置一个函数measurePrimes
,使用Date.now()
来测量你的质数函数找到前一万个质数所需的时间。
更快的质数
现在你有了一个测量过的测试用例,想办法让你的质数函数更快。考虑减少你需要执行的余数检查的次数。