diff --git a/21.md b/21.md new file mode 100644 index 0000000..6bee399 --- /dev/null +++ b/21.md @@ -0,0 +1,760 @@ +# 二十一、项目:技能分享网站 + +技能分享会议将拥有相同兴趣的聚集到一起,并针对了解的知识进行简短且非正式的展示。在园艺的技能分享会议上,可以解释如何耕作芹菜。如果在面向编程的技能分享小组中,你可以顺便给每个人讲讲Node.js。 + +在计算机专业中,这类聚会往往名为用户小组,是开阔眼界、了解行业新动态或仅仅接触兴趣相同的人的好方法。许多大城市都会有JavaScript聚会。这类聚会往往是可以免费参加的,而且我发现我参加过的那些聚会都非常友好热情。 + +在最后的项目章节中,我们的目标是建立网站,管理特定技能分享会议的讨论内容。假设一个小组的人会在成员办公室中定期举办关于独轮车的会议。问题是当上一个组织者搬到了另一个城市后,没人可以站出来接下来他的任务。我们需要一个系统,让参与者可以在系统中发言并相互讨论,这样就不需要一个中心组织人员了。 + +![]() + +项目的完整代码可以从[http://eloquentjavascript.net/code/skillsharing.zip](http://eloquentjavascript.net/code/skillsharing.zip)下载。 + + + +### 21.1 设计 + +本项目的服务器部分为Node.js编写,客户端部分则为浏览器编写。服务器存储系统数据并将其提供给客户端,同时也提供获取HTML和JavaScript文件的服务,这些文件用来实现前端系统。 + +服务器保存了为下次会议提出的对话列表。每个对话包括参与人员姓名、标题和该对话的相关评论。客户端允许用户提出新的对话(将对话添加到列表中)、删除对话和评论已存在的对话。每当用户做了修改时,客户端会向服务器发送关于更改的HTTP请求。 + + + +我们创建应用程序来展示一个实时视图,来展示目前已经提出的对话和评论。每当某些人在某些地点提交了新的对话或添加新评论时,所有在浏览器中打开页面的人都应该立即看到变化。这个特性略有挑战,因为网络服务器无法建立到客户端的连接,也没有好方法来知道有哪些客户端现在在查看特定网站。 + +该问题的一个解决方案叫作长时间轮询,这恰巧是Node的设计动机之一。 + +### 21.2 长轮询 + +为了能够立即提示客户端某些信息发生了改变,我们需要建立到客户端的连接。由于通常浏览器无法接受连接,而且客户端运行的设备无论如何都会拒绝这类连接,因此由服务器初始化连接是不切实际的。 + +我们可以安排客户端来打开连接并保持该连接,因此服务器可以使用该连接在必要时传送信息。 + +但HTTP请求只是简单的信息流,客户端发送请求,服务器返回一条响应,就是这样。有一种名为WebSocket的技术,受到现代浏览器的支持,是的我们可以建立连接并进行任意的数据交换。但如何正确运用这项技术是较为复杂的。 + +本章我们将会使用一种相对简单的技术——长轮询(Long Polling),客户端会连续不断使用定时的HTTP请求向服务器询问新信息,而当没有新信息需要报告时服务器会简单地推迟响应。 + +只要客户端确保其可以持续不断地建立轮询请求,就可以从服务器立即接收到信息。例如,若Alice在浏览器中打开了技能分享程序,浏览器会发送请求询问是否有更新,且等待请求的响应。当Bob在自己的浏览器中提交了关于“极限降滑独轮车”的对话之后。服务器发现Alice在等待更新请求,并将新的对话作为响应发送给待处理的请求。Alice的浏览器将会接收到数据并更新屏幕展示对话内容。 + +为了防止连接超时(因为连接一定时间不活跃后会被中断),长轮询技术常常为每个请求设置一个最大等待时间,只要超过了这个时间,即使没人有任何需要报告的信息也会返回响应,而客户端会建立一个新的请求。定期重新发送请求也使得这种技术更具鲁棒性,允许客户端从临时的连接失败或服务器问题中恢复。 + +使用了长轮询技术的繁忙的服务器,可以有成百上千个等待的请求,因此也就有这么多个TCP连接处于打开状态。Node简化了多连接的管理工作,而不是建立单独线程来控制每个连接,这对这样的系统是非常合适的。 + +### 21.3 HTTP接口 + +在我们充实服务器或客户端的代码之前,让我们先来思考一下两者均会涉及的一点:双方通信的HTTP接口。 + +我们将会在JSON的基础上建立接口,就像第20章中描述的文件服务器一样,我们尝试充分利用HTTP方法。所有接口均以/talks路径为中心。不以/talks开头的路径则用于提供静态文件服务,即用于实现客户端系统的HTML和JavaScript代码。 + +访问/talks的GET请求会返回如下所示的JSON文档。 + +```json +{"serverTime": 1405438911833, + "talks": [{"title": "Unituning", + "presenter": "Carlos", + "summary": "Modifying your cycle for extra style", + "comment": []}]} +``` + +其中serverTime字段用于产生可靠的长轮询。我们随后将会回来介绍。 + +我们可以发送PUT请求到类似于/talks/Unituning之类的URL上来创建新对话,在第二个斜杠后的那部分是对话的名称。PUT请求体应当包含一个JSON对象,其中有一个presenter属性和一个summary属性。 + +因为对话标题可以包含空格和其他无法正常出现在URL中的字符,因此我们必须使用encodeURIComponent函数来编码标题字符串,并构建URL。 + +```js +console.log("/talks/" + encodeURIComponent("How to Idle")); +// → /talks/How%20to%20Idle +``` + +下面这个请求用于创建关于“空转”的对话。 + + + +```http +PUT /talks/How%20to%20Idle HTTP/1.1 +Content-Type: application/json +Content-Length: 92 + +{"presenter": "Dana", + "summary": "Standing still on a unicycle"} +``` + +我们也可以使用GET请求通过这些URL获取对话的JSON数据,或使用DELETE请求通过这些URL删除对话。 + +若想在对话中添加一条评论,可以向诸如/talks/Unituning/comments的URL发送POST请求,请求体是一个JSON对象,包含一个author属性和message属性。 + +```http +POST /talks/Unituning/comments HTTP/1.1 +Content-Type: application/json +Content-Length: 72 + +{"author": "Alice", + "message": "Will you talk about raising a cycle?"} +``` + +为了支持长轮询发送到/talks的GET请求会包含一个名为changeSince的查询参数,该参数表示客户端希望获取的是从某个时间点开始的更新信息。当发生修改时,请求将会立即返回。若没有修改,则会延迟到某些更新发生,或等到指定的时间周期流逝之后再产生响应。 + +时间必须是从1970年开始计算的毫秒数,正和Date.now()返回的数字一样。为了确保客户端可以接收到所有更新,且不会重复接收到同一条更新信息,客户端必须将最后接收信息的时间传递给服务器。服务器时钟与客户端时钟可能并不同步,但即使同步,客户端也无法知道服务器发送响应的精确时间,因为在网络中传输数据需要耗费时间。 + +因此处理URL为/talks的请求时,需要在返回的响应中添加serverTime属性。该属性告知客户端其接收到的数据创建时服务器方的精确时间。客户端可以简单地存储这个时间,并在下次长轮询请求时确保其能够接收到精确的,从未接收过的更新信息。 + +```http +GET /talks?changesSince=1405438911833 HTTP/1.1 + +(time passes) + +HTTP/1.1 200 OK +Content-Type: application/json +Content-Length: 95 + +{"serverTime": 1405438913401, + "talks": [{"title": "Unituning", + "deleted": true}]} +``` + +当改变对话、创建新的对话或添加新评论后,对话的完整信息都会包含在客户端下一次长轮询的响应中。当删除对话后,响应中只包含其标题和一个名为deleted的属性。客户端随后可以使用标题添加之前并未显示的对话,更新已经显示的对话,并移除已经删除的对话。 + +本章描述的协议并没有任何访问控制。每个人都可以评论、修改对话或删除对话。因为因特网中充满了流氓,因此将这类没有进一步保护的系统放在网络上最后可能会招致灾难。 + +一个简单方案是将系统置于反向代理之后。所谓反向代理,其实是一个HTTP服务器,会接收系统外部请求,并将其转发给本地运行的HTTP服务器。我们可以配置这种代理,向请求要求用户名和密码,且你可以确保只有技能分享小组的参与者才持有该密码。 + +### 21.4 服务器 + +让我们开始编写程序的服务器部分。本节的代码可以在Node.js中执行。 + +#### 21.4.1 路由 + +我们的服务器会使用http.createServer来启动HTTP服务器。在处理新请求的函数中,我们必须区分我们支持的请求的类型(根据方法和路径确定)。我们可以使用一长串的if语句完成该任务,但还存在一种更优雅的方式。 + +路由可以作为帮助把请求调度传给能处理该请求的函数路径可以和正则表达式/^\/talks\/([^\/]+)$/匹配(匹配/talks/后紧跟对话名称)的PUT请求应当由指定函数处理。此外,路由器可以帮助我们提取路径中有意义的部分,在本例中会将对话的标题(包裹在正则表达式的括号之中)传递给处理器函数。 + +在NPM中有许多优秀的路由器包,但这里我们自己编写一个路由器来展示其原理。 + +这里给出router.js,我们随后将在服务器模块中使用require获取该模块。 + +```js +var Router = module.exports = function() { + this.routes = []; +}; + +Router.prototype.add = function(method, url, handler) { + this.routes.push({method: method, + url: url, + handler: handler}); +}; + +Router.prototype.resolve = function(request, response) { + var path = require("url").parse(request.url).pathname; + + return this.routes.some(function(route) { + var match = route.url.exec(path); + if (!match || route.method != request.method) + return false; + + var urlParts = match.slice(1).map(decodeURIComponent); + route.handler.apply(null, [request, response] + .concat(urlParts)); + return true; + }); +}; +``` + +该模块导出Router构造函数。我们可以使用路由器对象的ad方法来注册一个新的处理器,并使用resolve方法解析请求。 + +后者会返回一个布尔值表示是否找到处理器。路由器中数组的some方法会逐个尝试路由(根据定义顺序排序),当找到一个匹配的路由时返回true。 + +路由器会使用request和response对象调动处理器函数。当匹配URL的正则表达式中包含任何元组时,路由器会将元组匹配的字符串作为额外参数传递给处理器。传递给处理的字符串必须进行URL解码,因为原始URL中可能包含%20–style风格的代码。 + +#### 21.4.2 文件服务 + +当请求无法匹配路由器中定义的任何请求类型时,服务器必须将其解释为请求位于public目录下的某个文件。服务器可以使用第20章中定义的文件服务器来提供文件服务,但我们并不需要也不想对文件支持PUT和DELETE请求,且我们想支持类似于缓存等高级特性。因此让我们使用NPM中更为可靠且经过充分测试的静态文件服务器。 + +我选择了ecstatic。它并不是NPM中唯一的此类服务,但它能够完美工作且符合我们的意图。ecstatic模块导出了一个函数,我们可以调用该函数,并传递一个配置对象来生成一个请求处理函数。我们使用root选项告知服务器文件搜索位置。 + +```js +var http = require("http"); +var Router = require("./router"); +var ecstatic = require("ecstatic"); + +var fileServer = ecstatic({root: "./public"}); +var router = new Router(); + +http.createServer(function(request, response) { + if (!router.resolve(request, response)) + fileServer(request, response); +}).listen(8000); +``` + +服务器代码中会大量使用辅助函数respond和respondJSON,这样可以使用一条函数调用来发送请求。 + +```js +function respond(response, status, data, type) { + response.writeHead(status, { + "Content-Type": type || "text/plain" + }); + response.end(data); +} + +function respondJSON(response, status, data) { + respond(response, status, JSON.stringify(data), + "application/json"); +} +``` + +#### 21.4.3 将对话作为资源 + +服务器会将已发布的对话保存在名为talks的对象中,其属性名是对话标题。这些对话会展现为/talks/[title]下的HTTP资源,因此我们需要将处理器添加我们的路由器中供客户端选择,来实现不同的方法。 + +处理获取(GET)对话请求的函数必须查找对话并使用对话的JSON数据作为响应,若不存在则返回404错误响应码。 + +```js +var talks = Object.create(null); + +router.add("GET", /^\/talks\/([^\/]+)$/, + function(request, response, title) { + if (title in talks) + respondJSON(response, 200, talks[title]); + else + respond(response, 404, "No talk '" + title + "' found"); +}); +``` + +删除对话时,将其从talks对象中删除即可。 + +```js +router.add("DELETE", /^\/talks\/([^\/]+)$/, + function(request, response, title) { + if (title in talks) { + delete talks[title]; + registerChange(title); + } + respond(response, 204, null); +}); +``` + +我们随后定义registerChange函数,用于提醒等待中的长轮询请求,告知数据发生改变。 + +为了获取请求体中JSON编码的数据内容,我们定义一个名为readStreamAsJSON的函数,从流中读取所有内容,并将其解析成JSON对象,随后调用回调函数。 + +```js +function readStreamAsJSON(stream, callback) { + var data = ""; + stream.on("data", function(chunk) { + data += chunk; + }); + stream.on("end", function() { + var result, error; + try { result = JSON.parse(data); } + catch (e) { error = e; } + callback(error, result); + }); + stream.on("error", function(error) { + callback(error); + }); +} +``` + +需要读取JSON响应的函数是PUT的处理器,用户使用它创建新对话。该函数需要检查数据中是否有presenter和summary属性,这些属性都是字符串。任何来自外部的数据都可能是无意义的,我们不希望错误请求到达时会破坏我们的内部数据模型,甚至导致服务崩溃。 + +若数据看起来合法,处理器会将对话转化为对象,存储在talks对象中,如果有标题相同的对话存在则覆盖,并再次调用registerChange。 + +```js +router.add("PUT", /^\/talks\/([^\/]+)$/, + function(request, response, title) { + readStreamAsJSON(request, function(error, talk) { + if (error) { + respond(response, 400, error.toString()); + } else if (!talk || + typeof talk.presenter != "string" || + typeof talk.summary != "string") { + respond(response, 400, "Bad talk data"); + } else { + talks[title] = {title: title, + presenter: talk.presenter, + summary: talk.summary, + comments: []}; + registerChange(title); + respond(response, 204, null); + } + }); +}); +``` + +在对话中添加评论也是类似的。我们使用readStreamAsJSON来获取请求内容,验证请求数据,若看上去合法,则将其存储为评论。 + +```js +router.add("POST", /^\/talks\/([^\/]+)\/comments$/, + function(request, response, title) { + readStreamAsJSON(request, function(error, comment) { + if (error) { + respond(response, 400, error.toString()); + } else if (!comment || + typeof comment.author != "string" || + typeof comment.message != "string") { + respond(response, 400, "Bad comment data"); + } else if (title in talks) { + talks[title].comments.push(comment); + registerChange(title); + respond(response, 204, null); + } else { + respond(response, 404, "No talk '" + title + "' found"); + } + }); +}); +``` + +尝试向不存在的对话中添加评论当然会返回404错误。 + +#### 21.4.4 长轮询支持 + +服务器中最值得探讨的方面是处理长轮询的部分代码。当URL为/talks的GET请求到来时,请求可能希望获取所有对话或通过changesSince参数获取更新信息。 + +在我们将对话列表发送给客户端时可能会有许多情况,因此我们首先定义一个简单的辅助函数,在响应中附上serverTime字段。 + +```js +function sendTalks(talks, response) { + respondJSON(response, 200, { + serverTime: Date.now(), + talks: talks + }); +} +``` + +处理器自身需要查看请求URL中的查询参数,来查看请求是否指定了changesSince参数。若向url模块的parse函数传递第二个参数,值为true,则该函数会解析出URL中查询参数部分。函数返回对象中会包含一个query属性,该属性是一个对象,可以将参数名称映射成参数值。 + +```js +router.add("GET", /^\/talks$/, function(request, response) { + var query = require("url").parse(request.url, true).query; + if (query.changesSince == null) { + var list = []; + for (var title in talks) + list.push(talks[title]); + sendTalks(list, response); + } else { + var since = Number(query.changesSince); + if (isNaN(since)) { + respond(response, 400, "Invalid parameter"); + } else { + var changed = getChangedTalks(since); + if (changed.length > 0) + sendTalks(changed, response); + else + waitForChanges(since, response); + } + } +}); +``` + +若没有changesSince参数,处理器会简单地构建所有对话的列表并返回。 + +否则处理函数会首先检查changesSince参数,确保该参数是一个合法数值。然后定义短小的getChangedTalks函数,返回指定时间之内修改的对话组成的数组。若该函数返回一个空数组,表示服务器没有任何信息需要发回到客户端,因此服务器会存储response对象(使用waitForChanges)以作为随后的响应。 + +```js +var waiting = []; + +function waitForChanges(since, response) { + var waiter = {since: since, response: response}; + waiting.push(waiter); + setTimeout(function() { + var found = waiting.indexOf(waiter); + if (found > -1) { + waiting.splice(found, 1); + sendTalks([], response); + } + }, 90 * 1000); +} +``` + +方法splice用于删除数组元素。给定一个索引和元素数量,该函数会修改数组,移除给定所索引之后的元素。在本例中,我们从跟踪等待的响应对象列表中删除一个元素,其索引可以使用indexOf获取。若你传递一个额外参数给splice,splice会将该值插入到数组的给定位置,替换删除掉的元素。 + +当响应对象存储在waiting数组中时,会设置一个超时参数。每过90秒,timeout会检查请求是否仍然在等待,如果在等待则发送一个空响应,并将其从waiting数组中删除。 + +为了精确找出给定时间点之后发生修改的对话,我们需要跟踪修改历史。使用registerChange会将修改记录连带当前时间保存到名为changes的数组中。当某个修改发生时,说明有新数据,因此所有等待的请求都会立即响应。 + +```js +var changes = []; + +function registerChange(title) { + changes.push({title: title, time: Date.now()}); + waiting.forEach(function(waiter) { + sendTalks(getChangedTalks(waiter.since), waiter.response); + }); + waiting = []; +} +``` + +最后,getChangedTalks使用changes数组来构建经过修改的会话列表,包括删除掉的对话对象,这类对象中包含了delete属性。在构建数组时,getChangedTalks必须确保数组中不会重复包含同一个对话,因为在指定时间后同一个对话可能发生多次修改。 + +```js +function getChangedTalks(since) { + var found = []; + function alreadySeen(title) { + return found.some(function(f) {return f.title == title;}); + } + for (var i = changes.length - 1; i >= 0; i--) { + var change = changes[i]; + if (change.time <= since) + break; + else if (alreadySeen(change.title)) + continue; + else if (change.title in talks) + found.push(talks[change.title]); + else + found.push({title: change.title, deleted: true}); + } + return found; +} +``` + +现在服务器代码已经完成。执行目前为止完成的程序,服务器将会在端口8000上运行,一方面提供文件服务,获取public子目录下的文件;一方面对以/talks开头的URL提供对话管理接口。 + +### 21.5 客户端 + +对话管理网站的客户端部分由三个文件组成:HTML页面、样式表以及JavaScript文件。 + +#### 21.5.1 HTML + +在网络服务器提供文件服务时,有一种广为使用的约定是:当请求直接访问与目录对应的路径时,返回名为index.html的文件。我们使用的文件服务模块ecstatic就支持这种约定。当请求路径为/时,服务器会搜索文件./public/index.html(./public是我们赋予的根目录),若文件存在则返回文件。 + +因此,若我们希望浏览器指向我们服务器时展示某个特定页面,我们将其放在public/index.html中。这就是我们最初的index文件。 + +```html + + +
Your name:
+ + +``` + +该文件中定义了文档标题,并包含了样式表,样式表中定义了一些样式,除了别的元素之外,还给每条对话添加了边框。随后我们添加标题和表示姓名的字段。我们希望用户将名字填写到该字段中,这样我们可以将姓名附加到用户提交的对话和评论中。 + +其中ID为”talks”的<div>元素包含了当前的对话列表。当脚本从服务器接收到任务列表后会填充该列表。 + +接下来我们编写创建新对话的表单。 + +```html + +``` + +脚本文件将会在表单的submit按钮上添加事件处理器,通过这个事件向服务器发送创建会话的HTTP请求。 + +接下来看一个更为神秘的模块,我们将其显示样式设置为none,防止该模块演示在页面上。你能猜到这是为什么设计的吗? + +```html + +``` + +使用JavaScript代码创建DOM结构会产生丑陋的代码。你可以通过引入辅助函数,例如第13章中的elt函数来使得代码稍微优雅一点,但其结果依然不如HTML,可以把HTML看成一种用于表达DOM结构的领域特定语言。 + +为了创建会话的DOM结构,我们的程序会定义一个简单的模板系统,使用文档中隐藏的DOM结构来初始化新的DOM结构,并使用特定会话中的值替换占位符。占位符包裹在两个大括号之间。 + +最后,HTML文档包含脚本文件,脚本文件中包含客户端代码。 + +```html + +``` + +#### 21.5.2 启动 + +页面加载后客户端首先向服务器请求当前的对话集。由于我们打算建立许多HTTP请求,我们再次定义了XMLHttpRequest的简单包装器,该函数接受一个对象,用于配置请求,并在请求结束时调用回调函数。 + +```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(); + } +}); +``` + +变量lastServerTime用于跟踪最后从服务器接收更新信息的时间。在第一个请求之后,客户端的对话视图需要在请求响应到来时与服务器的视图同步,因此响应中的serverTime属性给lastServerTime提供了合适初值。 + +当请求失败时,我们不希望我们的页面丝毫不变,不给予任何提示。因此我们定义一个函数,名为reportError,至少在发生错误时向用户展示一个对话框。 + +```js +function reportError(error) { + if (error) + alert(error.toString()); +} +``` + +该函数检查是否有实际的错误,只有当确实发生错误时才弹出警告框。这样我们可以忽略掉响应直接将该函数传递给request。这确保请求失败时会向用户报告错误信息。 + +#### 21.5.3 显示会话 + +为了能够在数据修改时更新对话视图,客户端必须持续跟踪当前显示的对话。这样,当屏幕上已存在的对话的新版本到来时,客户端可以使用更新后的对话替换原来的版本。类似地,当对话删除的信息到来时,必须从文档中删除当时的DOM元素。 + +函数displayTalks用于显示最初的对话,且在某些信息发生改动时更新对话。该函数使用shownTalks对象记录当前在屏幕上的对话,该对象保存了对话和DOM节点的关系。 + +```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; + } + }); +} +``` + +我们使用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辅助函数可以完成该任务。 + +```js +function talkURL(title) { + return "talks/" + encodeURIComponent(title); +} +``` + +deleteTalk函数会发送DELETE请求,并在失败时报告错误信息。 + +```js +function deleteTalk(title) { + request({pathname: talkURL(title), method: "DELETE"}, + reportError); +} +``` + +添加评论需要构建用于表示评论的JSON数据,并使用POST请求提交该数据。 + +```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属性,该属性对应于页面顶端的<input>字段,允许用户指定他们的姓名。我们也可以将该字段填写到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(); + } + }); +} +``` + +该函数在程序启动时调用一次,然后不断调用该函数确保长连接请求一直处于活跃状态。当请求失败时,我们不需要调用reportError,因为若每次请求无法到达服务器,都弹出一个对话框,会让用户感到厌烦。相反,我们将错误信息写到控制台中(易于调试),并在2.5秒之后再次进行尝试。 + +每当请求成功后,客户端会将新数据展示在屏幕中并根据接收到的数据更新lastServerTime,确保该属性与我们接收到数据的新时间点相一致。接着立即重新发起请求来等待下一轮更新。 + +若你执行服务器并同时打开两个浏览器窗口,都输入localhost:8000/,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。 + +### 21.6 习题 + +下面的习题涉及修改本章中定义的系统。为了使用该系统进行工作,请确保首先下载代码([http://eloquentjavascript.net/code/skillshare.zip](http://eloquentjavascript.net/code/skillshare.zip))并安装了Node([http://nodejs.org/](http://nodejs.org/))。 + +#### 21.6.1 磁盘持久化 + +技能分享服务只将数据存储在内存中。这就意味着当服务崩溃或以为任何原因重启时,所有的对话和评论都会丢失。 + +扩展服务使得其将对话数据存储到磁盘上,并在程序重启时自动重新加载数据。不要担心效率,只要用最简单的代码让其可以工作即可。 + +#### 21.6.2 重置评论字段 + +由于我们常常无法在DOM节点中找到唯一替换的位置,因此整批地重绘对话是个很好的工作机制。但这里有个例外,若你开始在对话的评论字段中输入一些文字,而在另一个窗口向同一条对话添加了一条评论,那么第一个窗口中的字段就会被重绘,会移除掉其内容和焦点。 + +在热烈的讨论中,当多个人向同一条对话中添加评论时,这种情况会非常烦人。你能想出一种方法来避免这个问题吗? + +#### 21.6.3 更好的模板 + +许多模板系统除了填充一些字符串外还能完成更多工作。至少这些模板系统支持条件包含某部分模板,类似于if语句;或者可以重复一部分模板,类似于循环。 + +若我们可以为数组中的每个元素重复一段模板,我们就不需要第二个模板(“comment”)了。而可以指定talk模板循环扫描talk持有的comments属性,并逐个渲染节点,这些节点组成了数组中的每个元素所对应的评论。 + +模板如下所示。 + +```html +