1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-27 22:52:20 +00:00
This commit is contained in:
wizardforcel 2018-06-02 14:52:06 +08:00
parent 52538421aa
commit 97fba152c2

498
21.md
View File

@ -381,220 +381,80 @@ new SkillShareServer(Object.create(null)).start(8000);
### 21.5 客户端
对话管理网站的客户端部分由三个文件组成HTML页面、样式表以及JavaScript文件。
技能分享网站的客户端部分由三个文件组成:微型 HTML 页面、样式表以及 JavaScript 文件。
#### 21.5.1 HTML
在网络服务器提供文件服务时有一种广为使用的约定是当请求直接访问与目录对应的路径时返回名为index.html的文件。我们使用的文件服务模块ecstatic就支持这种约定。当请求路径为/时,服务器会搜索文件./public/index.html./public是我们赋予的根目录若文件存在则返回文件。
因此若我们希望浏览器指向我们服务器时展示某个特定页面我们将其放在public/index.html中。这就是我们最初的index文件。
因此,若我们希望浏览器指向我们服务器时展示某个特定页面,我们将其放在`public/index.html`中。这就是我们的`index`文件。
```html
<!doctype html>
<meta charset="utf-8">
<title>Skill Sharing</title>
<link rel="stylesheet" href="skillsharing.css">
<h1>Skill sharing</h1>
<h1>Skill Sharing</h1>
<p>Your name: <input type="text" id="name"></p>
<div id="talks"></div>
```
该文件中定义了文档标题,并包含了样式表,样式表中定义了一些样式,除了别的元素之外,还给每条对话添加了边框。随后我们添加标题和表示姓名的字段。我们希望用户将名字填写到该字段中,这样我们可以将姓名附加到用户提交的对话和评论中。
其中ID为”talks”的&lt;div&gt;元素包含了当前的对话列表。当脚本从服务器接收到任务列表后会填充该列表。
接下来我们编写创建新对话的表单。
```html
<form id="newtalk">
<h3>Submit a talk</h3>
Title: <input type="text" style="width: 40em" name="title">
<br>
Summary: <input type="text" style="width: 40em" name="summary">
<button type="submit">Send</button>
</form>
```
脚本文件将会在表单的submit按钮上添加事件处理器通过这个事件向服务器发送创建会话的HTTP请求。
接下来看一个更为神秘的模块我们将其显示样式设置为none防止该模块演示在页面上。你能猜到这是为什么设计的吗
```html
<div id="template" style="display: none">
<div class="talk">
<h2>{{title}}</h2>
<div>by <span class="name">{{presenter}}</span></div>
<p>{{summary}}</p>
<div class="comments"></div>
<form>
<input type="text" name="comment">
<button type="submit">Add comment</button>
<button type="button" class="del">Delete talk</button>
</form>
</div>
<div class="comment">
<span class="name">{{author}}</span>: {{message}}
</div>
</div>
```
使用JavaScript代码创建DOM结构会产生丑陋的代码。你可以通过引入辅助函数例如第13章中的elt函数来使得代码稍微优雅一点但其结果依然不如HTML可以把HTML看成一种用于表达DOM结构的领域特定语言。
为了创建会话的DOM结构我们的程序会定义一个简单的模板系统使用文档中隐藏的DOM结构来初始化新的DOM结构并使用特定会话中的值替换占位符。占位符包裹在两个大括号之间。
最后HTML文档包含脚本文件脚本文件中包含客户端代码。
```html
<script src="skillsharing_client.js"></script>
```
#### 21.5.2 启动
它定义了文档标题并包含一个样式表,除了其它东西,它定义了几种样式,确保对话之间有一定的空间。
页面加载后客户端首先向服务器请求当前的对话集。由于我们打算建立许多HTTP请求我们再次定义了XMLHttpRequest的简单包装器该函数接受一个对象用于配置请求并在请求结束时调用回调函数。
最后,它在页面顶部添加标题,并加载包含客户端应用的脚本。
### 动作
应用程序状态由对话列表和用户名称组成,我们将它存储在一个`{talks, user}`对象中。 我们不允许用户界面直接操作状态或发送 HTTP 请求。 反之,它可能会触发动作,它描述用户正在尝试做什么。
```js
function request(options, callback) {
var req = new XMLHttpRequest();
req.open(options.method || "GET", options.pathname, true);
req.addEventListener("load", function() {
if (req.status < 400)
callback(null, req.responseText);
else
callback(new Error("Request failed: " + req.statusText));
});
req.addEventListener("error", function() {
callback(new Error("Network error"));
});
req.send(options.body || null);
}
```
最初的请求将接收到的对话显示在屏幕上并调用waitForChanges来启动长轮询过程。
```js
var lastServerTime = 0;
request({pathname: "talks"}, function(error, response) {
if (error) {
reportError(error);
} else {
response = JSON.parse(response);
displayTalks(response.talks);
lastServerTime = response.serverTime;
waitForChanges();
function handleAction(state, action) {
if (action.type == "setUser") {
localStorage.setItem("userName", action.user);
return Object.assign({}, state, {user: action.user});
} else if (action.type == "setTalks") {
return Object.assign({}, 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);
}
});
```
变量lastServerTime用于跟踪最后从服务器接收更新信息的时间。在第一个请求之后客户端的对话视图需要在请求响应到来时与服务器的视图同步因此响应中的serverTime属性给lastServerTime提供了合适初值。
当请求失败时我们不希望我们的页面丝毫不变不给予任何提示。因此我们定义一个函数名为reportError至少在发生错误时向用户展示一个对话框。
```js
function reportError(error) {
if (error)
alert(error.toString());
return state;
}
```
该函数检查是否有实际的错误只有当确实发生错误时才弹出警告框。这样我们可以忽略掉响应直接将该函数传递给request。这确保请求失败时会向用户报告错误信息
我们将用户的名字存储在`localStorage`中,以便在页面加载时恢复。
#### 21.5.3 显示会话
为了能够在数据修改时更新对话视图客户端必须持续跟踪当前显示的对话。这样当屏幕上已存在的对话的新版本到来时客户端可以使用更新后的对话替换原来的版本。类似地当对话删除的信息到来时必须从文档中删除当时的DOM元素。
函数displayTalks用于显示最初的对话且在某些信息发生改动时更新对话。该函数使用shownTalks对象记录当前在屏幕上的对话该对象保存了对话和DOM节点的关系。
需要涉及服务器的操作使用`fetch`,将网络请求发送到前面描述的 HTTP 接口。 我们使用包装函数`fetchOK`,它确保当服务器返回错误代码时,拒绝返回的`Promise`
```js
var talkDiv = document.querySelector("#talks");
var shownTalks = Object.create(null);
function displayTalks(talks) {
talks.forEach(function(talk) {
var shown = shownTalks[talk.title];
if (talk.deleted) {
if (shown) {
talkDiv.removeChild(shown);
delete shownTalks[talk.title];
}
} else {
var node = drawTalk(talk);
if (shown)
talkDiv.replaceChild(node, shown);
else
talkDiv.appendChild(node);
shownTalks[talk.title] = node;
}
function fetchOK(url, options) {
return fetch(url, options).then(response => {
if (response.status < 400) return response;
else throw new Error(response.statusText);
});
}
```
我们使用HTML文档中的模板来构建对话的DOM结构。首先我们必须定义instantiateTemplate用于查找并填充模板。
模板中的name参数是模板名称。为了查找模板元素我们搜索一个类名与模板名称匹配的元素该元素是ID为template的元素的孩子节点。使用querySelector方法可以轻松完成该任务。在HTML页面中有名为talk和comment的模板。
```js
function instantiateTemplate(name, values) {
function instantiateText(text) {
return text.replace(/\{\{(\w+)\}\}/g, function(_, name) {
return values[name];
});
}
function instantiate(node) {
if (node.nodeType == document.ELEMENT_NODE) {
var copy = node.cloneNode();
for (var i = 0; i < node.childNodes.length; i++)
copy.appendChild(instantiate(node.childNodes[i]));
return copy;
} else if (node.nodeType == document.TEXT_NODE) {
return document.createTextNode(
instantiateText(node.nodeValue));
} else {
return node;
}
}
var template = document.querySelector("#template ." + name);
return instantiate(template);
}
```
每个DOM节点都有cloneNode方法用于创建节点的拷贝。除非该函数的第一个参数为true否则该函数不会拷贝节点的子节点。instantiate函数则用于递归构建模板的拷贝并根据值填充模板。
instantiateTemplate的第二个参数是一个对象其属性是想要填充到模板中的字符串。模板中像{{title}}这样的占位符会被替换为value的title属性的值。
这是非常原始的模板方法但这对于实验drawTalk来说已经足够了。
```js
function drawTalk(talk) {
var node = instantiateTemplate("talk", talk);
var comments = node.querySelector(".comments");
talk.comments.forEach(function(comment) {
comments.appendChild(
instantiateTemplate("comment", comment));
});
node.querySelector("button.del").addEventListener(
"click", deleteTalk.bind(null, talk.title));
var form = node.querySelector("form");
form.addEventListener("submit", function(event) {
event.preventDefault();
addComment(talk.title, form.elements.comment.value);
form.reset();
});
return node;
}
```
在实例化talk模板后还需要修补一些数据。首先我们需要反复实例化comment模板并将实例化结果添加到类为comments的节点之中。接下来必须要在删除对话的按钮上附加对应的事件处理器并在表单上附加创建新评论的事件处理器。
#### 21.5.4 更新服务器
通过drawTalk函数调用注册的事件处理器会调用deleteTalk和addComment来执行实际的动作以删除对话或添加评论。这些处理器需要根据指定的标题构建指向对话的URL我们之前定义的talkURL辅助函数可以完成该任务。
这个辅助函数用于为某个对话,使用给定标题建立 URL。
```js
function talkURL(title) {
@ -602,88 +462,190 @@ function talkURL(title) {
}
```
deleteTalk函数会发送DELETE请求并在失败时报告错误信息
当请求失败时我们不希望我们的页面丝毫不变不给予任何提示。因此我们定义一个函数名为reportError至少在发生错误时向用户展示一个对话框
```js
function deleteTalk(title) {
request({pathname: talkURL(title), method: "DELETE"},
reportError);
function reportError(error) {
alert(String(error));
}
```
添加评论需要构建用于表示评论的JSON数据并使用POST请求提交该数据。
#### 渲染组件
我们将使用一个方法,类似于我们在第十九章中所见,将应用程序拆分为组件。 但由于某些组件不需要更新,或者在更新时总是完全重新绘制,所以我们不将它们定义为类,而是直接返回 DOM 节点的函数。 例如,下面是一个组件,显示用户可以向它输入名称的字段的:
```js
function addComment(title, comment) {
var comment = {author: nameField.value, message: comment};
request({pathname: talkURL(title) + "/comments",
body: JSON.stringify(comment),
method: "POST"},
reportError);
}
```
变量nameField用于设置评论的author属性该属性对应于页面顶端的&lt;input&gt;字段允许用户指定他们的姓名。我们也可以将该字段填写到localStorage中这样每次重新载入页面时用户不必再次填写姓名。
```js
var nameField = document.querySelector("#name");
nameField.value = localStorage.getItem("name") || "";
nameField.addEventListener("change", function() {
localStorage.setItem("name", nameField.value);
});
```
页面底端的表单通过submit的事件处理器发表新对话。事件处理器阻止了时间的默认效果会导致重新加载页面清空表单并发送创建对话的PUT请求。
```js
var talkForm = document.querySelector("#newtalk");
talkForm.addEventListener("submit", function(event) {
event.preventDefault();
request({pathname: talkURL(talkForm.elements.title.value),
method: "PUT",
body: JSON.stringify({
presenter: nameField.value,
summary: talkForm.elements.summary.value
})}, reportError);
talkForm.reset();
});
```
#### 21.5.5 提示更改
这里需要指出的是不同修改应用程序状态的函数(创建删除任务以及添加评论)都不必确保他们的修改对屏幕可见。这些函数只需要告知服务器,并依赖长轮询机制来触发页面的适当更新。
考虑到我们在服务器中实现的机制以及我们定义displayTalks来处理会话更新的方式实际的长轮询代码简单得令人惊讶。
```js
function waitForChanges() {
request({pathname: "talks?changesSince=" + lastServerTime},
function(error, response) {
if (error) {
setTimeout(waitForChanges, 2500);
console.error(error.stack);
} else {
response = JSON.parse(response);
displayTalks(response.talks);
lastServerTime = response.serverTime;
waitForChanges();
function renderUserField(name, dispatch) {
return elt("label", {}, "Your name: ", elt("input", {
type: "text",
value: name,
onchange(event) {
dispatch({type: "setUser", user: event.target.value});
}
});
}));
}
```
该函数在程序启动时调用一次然后不断调用该函数确保长连接请求一直处于活跃状态。当请求失败时我们不需要调用reportError因为若每次请求无法到达服务器都弹出一个对话框会让用户感到厌烦。相反我们将错误信息写到控制台中易于调试并在2.5秒之后再次进行尝试
用于构建DOM元素的`elt`函数是我们在第十九章中使用的函数。
每当请求成功后客户端会将新数据展示在屏幕中并根据接收到的数据更新lastServerTime确保该属性与我们接收到数据的新时间点相一致。接着立即重新发起请求来等待下一轮更新
类似的函数用于渲染对话,包括评论列表和添加新评论的表单。
若你执行服务器并同时打开两个浏览器窗口都输入localhost8000/,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。
```js
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")));
}
```
`submit`事件处理器调用`form.reset`,在创建`"newComment"`动作后清除表单的内容。
在创建适度复杂的 DOM 片段时,这种编程风格开始显得相当混乱。 有一个广泛使用的非标准的JavaScript 扩展叫做 JSX它允许你直接在你的脚本中编写 HTML这可以使这样的代码更漂亮取决于你认为漂亮是什么。 在实际运行这种代码之前,必须在脚本上运行一个程序,将伪 HTML 转换为 JavaScript 函数调用,就像我们在这里用的东西。
评论更容易渲染。
```js
function renderComment(comment) {
return elt("p", {className: "comment"},
elt("strong", null, comment.author),
": ", comment.message);
}
```
最后,用户可以用于创建新对话的表现,渲染为这样。
```js
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`,并且在新的对话集可用时,调用回调函数。
```js
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());
}
}
```
这是一个`async`函数,因此循环和等待请求更容易。 它运行一个无限循环,每次迭代中,通常检索对话列表。或者,如果这不是第一个请求,则带有使其成为长轮询请求的协议头。
当请求失败时,函数会等待一会儿,然后再次尝试。 这样,如果您的网络连接断了一段时间然后又恢复,应用程序可以恢复并继续更新。 通过`setTimeout`解析的`Promise`,是强制`async`函数等待的方法。
当服务器回复 304 响应时,这意味着长轮询请求超时,所以函数应该立即启动下一个请求。 如果响应是普通的 200 响应,它的正文将当做 JSON 而读取并传递给回调函数,并且它的`ETag`协议头的值为下一次迭代而存储。
### 应用
以下组件将整个用户界面结合在一起。
```js
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.setState(state);
}
setState(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;
}
}
}
```
当对话改变时,这个组件重新绘制所有这些组件。 这很简单,但也是浪费。 我们将在练习中回顾一下。
我们可以像这样启动应用:
```js
function runApp() {
let user = localStorage.getItem("userName") || "Anon";
let state, app;
function dispatch(action) {
state = handleAction(state, action);
app.setState(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();
```
若你执行服务器并同时为`localhost:8000/`打开两个浏览器窗口,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。
### 21.6 习题
下面的习题涉及修改本章中定义的系统。为了使用该系统进行工作,请确保首先下载代码([http://eloquentjavascript.net/code/skillshare.zip](http://eloquentjavascript.net/code/skillshare.zip)并安装了Node[http://nodejs.org/](http://nodejs.org/))。
下面的习题涉及修改本章中定义的系统。为了使用该系统进行工作,请确保首先下载[代码](http://eloquentjavascript.net/code/skillshare.zip),安装了 [Node](http://nodejs.org/),并使用`npm install`安装了项目的所有依赖
#### 21.6.1 磁盘持久化
@ -695,36 +657,4 @@ function waitForChanges() {
由于我们常常无法在DOM节点中找到唯一替换的位置因此整批地重绘对话是个很好的工作机制。但这里有个例外若你开始在对话的评论字段中输入一些文字而在另一个窗口向同一条对话添加了一条评论那么第一个窗口中的字段就会被重绘会移除掉其内容和焦点。
在热烈的讨论中,当多个人向同一条对话中添加评论时,这种情况会非常烦人。你能想出一种方法来避免这个问题吗?
#### 21.6.3 更好的模板
许多模板系统除了填充一些字符串外还能完成更多工作。至少这些模板系统支持条件包含某部分模板类似于if语句或者可以重复一部分模板类似于循环。
若我们可以为数组中的每个元素重复一段模板我们就不需要第二个模板“comment”了。而可以指定talk模板循环扫描talk持有的comments属性并逐个渲染节点这些节点组成了数组中的每个元素所对应的评论。
模板如下所示。
```html
<div class="comments">
<div class="comment" template-repeat="comments">
<span class="name">{{author}}</span>: {{message}}
</div>
</div>
```
其思想是在模板实例化时每当发现某个节点有template-repeat属性实例化代码就会认为标签中属性名在对象中对应的属性是数组并循环遍历该属性。对于数组中的每个元素都会添加一个节点的实例。模板的上下文instantiateTemplate中的values变量在循环中会指向数组中的每个元素因此{{author}}将会查找comment对象而非原来的上下文talk对象
重写instantiateTemplate来实现该特性并修改模板使用该特性来移除掉drawTalk中显示渲染评论的代码。
为了能在特定值为true或者false时可以省略模板中的一部分你应该如何在节点中添加条件实例化呢
#### 21.6.4 无脚本化
当某些人使用浏览器访问我们的网站时若浏览器禁用了JavaScript或只是无法显示JavaScript他们将会看到一个完全损坏无法使用的页面。这并不是一件好事。
某些类型的应用程序不使用JavaScript是无法完成的。对于其他应用你不需要关心客户端是否支持运行脚本。但对于有大量用户的网站支持无脚本用户是必要的。
尝试思考一下如何在不使用JavaScript下建立一个技能分享网站保留其基本功能。这个版本不需要加入自动更新人们可以使用传统的方式来刷新页面。但查看存在的对话创建新节点并提交评论等功能需要完好。
本书并不要求读者实际实现该网站,画出大致解决方案就足够了。是不是修订过的方法比最初的方法更吸引你呢?
在激烈的讨论中,多人同时添加评论,这将是非常烦人的。 你能想出办法解决它吗?