第三版 现已发布。点击此处阅读

第 21 章
项目:技能分享网站

技能分享 会议是一个活动,一群志同道合的人聚在一起,就他们所知道的主题进行简短、非正式的演讲。在园艺技能分享会议上,有人可能会解释如何种植芹菜。或者在以编程为导向的技能分享小组中,你可以顺便去告诉大家关于 Node.js 的信息。

这类聚会通常也称为用户组,尤其是当他们与计算机有关时,是开阔眼界、了解新发展或仅仅结识志趣相投的人的绝佳方式。许多大城市都有 JavaScript 聚会。这些聚会通常是免费参加的,我发现我去过的那些都非常友好和欢迎。

在本项目最后一章中,我们的目标是建立一个网站来管理技能分享会议上的演讲。想象一下,一群人定期在一个会员办公室聚会,讨论独轮车骑行。问题是,当之前的会议组织者搬到另一个城镇时,没有人站出来接手这项任务。我们想要一个系统,让参与者可以互相提出和讨论演讲,而无需中央组织者。

The unicycling meetup

就像在上一章一样,本章中的代码是为 Node.js 编写的,直接在您正在查看的 HTML 页面中运行它可能无法正常工作。本项目的完整代码可以从 eloquentjavascript.net/2nd_edition/code/skillsharing.zip 下载。

设计

这个项目有一个服务器部分,用 Node.js 编写,还有一个客户端部分,用浏览器编写。服务器存储系统的数据并将其提供给客户端。它还提供 HTML 和 JavaScript 文件,这些文件实现了客户端系统。

服务器保存下一个会议的演讲列表,客户端显示该列表。每个演讲都有一个演讲者姓名、标题、摘要和与之相关的评论列表。客户端允许用户提出新演讲(添加到列表中)、删除演讲和对现有演讲进行评论。每当用户进行此类更改时,客户端都会发出 HTTP 请求,将更改通知服务器。

Screenshot of the skill-sharing website

该应用程序将被设置为显示当前建议演讲及其评论的实时视图。每当有人在任何地方提交一个新的演讲或添加一个评论时,所有在浏览器中打开该页面的人应该立即看到更改。这带来了一些挑战,因为没有办法让 Web 服务器打开与客户端的连接,也没有什么好的方法来知道哪些客户端目前正在查看某个特定网站。

解决这个问题的常用方法称为长轮询,而这恰好是 Node 设计的动机之一。

长轮询

为了能够立即通知客户端发生了变化,我们需要与该客户端的连接。由于 Web 浏览器传统上不接受连接,并且客户端通常位于会阻止此类连接的设备后面,因此让服务器启动此连接是不切实际的。

我们可以安排客户端打开连接并保持连接,以便服务器可以使用它在需要时发送信息。

但是 HTTP 请求只允许简单的信息流,客户端发送请求,服务器返回一个响应,就这样。有一种名为WebSockets 的技术,现代浏览器支持它,这使得打开连接以进行任意数据交换成为可能。但是,正确使用它们有些棘手。

在本章中,我们将使用一种相对简单的技术,即长轮询,客户端使用常规 HTTP 请求不断地向服务器请求新信息,服务器在没有新信息要报告时只是暂停其回答。

只要客户端确保它始终保持一个轮询请求处于打开状态,它就会立即从服务器接收信息。例如,如果 Alice 在她的浏览器中打开了我们的技能分享应用程序,那么该浏览器就会发出一个更新请求,并等待对该请求的响应。当 Bob 提交关于极限下山独轮车骑行的演讲时,服务器会注意到 Alice 正在等待更新,并将有关新演讲的信息作为对她的挂起请求的响应发送出去。Alice 的浏览器会收到数据并更新屏幕以显示该演讲。

为了防止连接超时(由于缺乏活动而被中止),长轮询技术通常为每个请求设置一个最长时间,在此之后,服务器无论如何都会进行响应,即使它没有要报告的内容,客户端也会启动一个新的请求。定期重新启动请求也使该技术更加健壮,允许客户端从暂时的连接故障或服务器问题中恢复。

一个使用长轮询的繁忙服务器可能拥有数千个正在等待的请求,因此有数千个 TCP 连接处于打开状态。Node 使得管理许多连接变得容易,无需为每个连接创建单独的控制线程,因此非常适合这种系统。

HTTP 接口

在我们开始详细说明服务器或客户端之前,让我们先考虑一下它们接触的地方:它们之间的通信 HTTP 接口。

我们将以 JSON 为基础构建我们的接口,就像在 第 20 章 中的文件服务器一样,我们将尝试充分利用 HTTP 方法。该接口以 /talks 路径为中心。不以 /talks 开头的路径将用于提供静态文件 - 实现客户端系统的 HTML 和 JavaScript 代码。

/talksGET 请求将返回一个类似这样的 JSON 文档

{"serverTime": 1405438911833,
 "talks": [{"title": "Unituning",
            "presenter": "Carlos",
            "summary": "Modifying your cycle for extra style",
            "comment": []}]}

serverTime 字段将用于实现可靠的长轮询。我将在后面讨论它。

创建新的演讲可以通过向类似 /talks/Unituning 的 URL 发出 PUT 请求来完成,其中第二个斜杠后的部分是演讲的标题。PUT 请求的主体应该包含一个 JSON 对象,该对象具有 presentersummary 属性。

由于演讲标题可能包含空格和其他可能不会在 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": "Dana",
 "summary": "Standing still on a unicycle"}

此类 URL 还支持 GET 请求来检索演讲的 JSON 表示形式,以及 DELETE 请求来删除演讲。

向演讲添加评论可以通过向类似 /talks/Unituning/comments 的 URL 发出 POST 请求来完成,请求主体包含一个 JSON 对象,该对象具有 authormessage 属性。

POST /talks/Unituning/comments HTTP/1.1
Content-Type: application/json
Content-Length: 72

{"author": "Alice",
 "message": "Will you talk about raising a cycle?"}

为了支持长轮询,对 /talksGET 请求可能包含一个名为 changesSince 的查询参数,用于指示客户端对自给定时间点以来发生的更新感兴趣。当有此类更改时,它们将立即返回。如果没有,则会延迟响应,直到发生某些事情或给定时间段(我们将使用 90 秒)过去为止。

时间必须表示为自 1970 年开始以来的毫秒数,与 Date.now() 返回的数字类型相同。为了确保它接收所有更新并且不会多次接收相同的更新,客户端必须传递它上次从服务器接收信息的时 间。服务器的时钟可能与客户端的时钟不同步,即使同步了,客户端也不可能知道服务器发送响应的确切时间,因为通过网络传输数据需要时间。

这就是 serverTime 属性存在于发送到 /talksGET 请求的响应中的原因。该属性告诉客户端它收到的数据的创建时间,从服务器的角度来看。然后,客户端可以简单地存储此时间,并在其下一个轮询请求中传递它,以确保它只接收之前未见过的更新。

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 服务器。此类代理可以配置为需要用户名和密码,并且可以确保只有技能分享小组的参与者拥有此密码。

服务器

让我们从编写程序的服务器端部分开始。本节中的代码在 Node.js 上运行。

路由

我们的服务器将使用 http.createServer 来启动一个 HTTP 服务器。在处理新请求的函数中,我们必须区分我们支持的各种请求类型(由方法和路径确定)。这可以通过一长串 if 语句来完成,但还有更好的方法。

路由器是一个组件,它可以帮助将请求分派给可以处理它的函数。例如,您可以告诉路由器,PUT 请求,其路径与正则表达式 /^\/talks\/([^\/]+)$/(与 /talks/ 后跟演讲标题匹配)匹配,可以由给定函数处理。此外,它可以帮助提取路径中有意义的部分,在本例中是演讲标题,用括号括在正则表达式中,并将这些部分传递给处理程序函数。

NPM 上有许多优秀的路由器包,但在这里我们将自己编写一个来演示其原理。

这是 router.js,我们稍后将从服务器模块中 require

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 构造函数。路由器对象允许使用 add 方法注册新的处理程序,并可以使用 resolve 方法解析请求。

后者将返回一个布尔值,指示是否找到了处理程序。路由数组上的 some 方法将依次尝试路由(按照定义顺序),并在找到匹配的路由时停止,返回 true

处理程序函数使用 requestresponse 对象调用。当匹配 URL 的正则表达式包含任何组时,它们匹配的字符串将作为额外参数传递给处理程序。这些字符串必须进行 URL 解码,因为原始 URL 包含 %20 样式的代码。

提供文件

当请求与我们的路由器中定义的任何请求类型都不匹配时,服务器必须将其解释为对 public 目录中文件的请求。可以使用 第 20 章 中定义的文件服务器来提供此类文件,但我们既不需要也不希望支持对文件的 PUTDELETE 请求,并且我们希望拥有诸如支持缓存之类的高级功能。因此,让我们从 NPM 使用一个稳定且经过良好测试的静态文件服务器。

我选择了 ecstatic。这并不是 NPM 上唯一一个这样的服务器,但它运行良好,适合我们的目的。ecstatic 模块导出一个函数,该函数可以使用配置对象调用以生成请求处理程序函数。我们使用 root 选项来告诉服务器它应该在哪里查找文件。处理程序函数接受 requestresponse 参数,可以直接传递给 createServer 来创建一个仅提供文件的服务器。但是,我们首先要检查我们专门处理的请求,因此我们将它包装在另一个函数中。

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);

respondrespondJSON 辅助函数在整个服务器代码中使用,用于通过单个函数调用发送响应。

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");
}

演讲作为资源

服务器将已提议的演讲保存在名为 talks 的对象中,其属性名称为演讲标题。这些将在 /talks/[title] 下作为 HTTP 资源公开,因此我们需要向我们的路由器添加处理程序来实现客户端可以使用来处理它们的各种方法。

GET 单个演讲的请求的处理程序必须查找演讲并使用演讲的 JSON 数据或 404 错误响应进行响应。

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 对象中删除来完成的。

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,然后调用回调函数。

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 处理程序,它用于创建新的演讲。它必须检查它获得的数据是否具有 presentersummary 属性,它们是字符串。来自系统外部的任何数据都可能是无意义的,我们不希望在收到错误请求时破坏我们的内部数据模型,甚至崩溃。

如果数据看起来有效,处理程序将在 talks 对象中存储一个表示新演讲的对象,可能用此标题覆盖现有的演讲,并再次调用 registerChange

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 获取请求的内容,验证结果数据,并在数据看起来有效时将其存储为评论。

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 错误。

长期轮询支持

服务器最有趣的方面是处理长期轮询的部分。当 GET 请求到达 /talks 时,它可以是所有演讲的简单请求,也可以是具有 changesSince 参数的更新请求。

在许多情况下,我们必须将演讲列表发送给客户端,因此我们首先定义一个小的辅助函数,该函数将 serverTime 字段附加到此类响应。

function sendTalks(talks, response) {
  respondJSON(response, 200, {
    serverTime: Date.now(),
    talks: talks
  });
}

处理程序本身需要查看请求 URL 中的查询参数,以查看是否提供了 changesSince 参数。如果将 "url" 模块的 parse 函数的第二个参数设置为 true,它还会解析 URL 的查询部分。它返回的对象将具有一个 query 属性,该属性保存另一个将参数名称映射到值的属性。

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 函数(稍后将定义)返回自给定时间点以来的已更改演讲数组。如果它返回一个空数组,则服务器还没有任何要发送回客户端的内容,因此它使用 waitForChanges 存储响应对象,以便稍后进行响应。

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 传递其他参数,它们的的值将被插入到数组中,替换删除的元素。

当响应对象存储在 waiting 数组中时,会立即设置超时。90 秒后,此超时会查看请求是否仍在等待,如果仍在等待,则发送一个空响应并将其从 waiting 数组中删除。

为了能够准确地找到自给定时间点以来的已更改演讲,我们需要跟踪更改的历史记录。使用 registerChange 注册更改将与当前时间一起记住该更改,存储在名为 changes 的数组中。当发生更改时,这意味着有新数据,因此所有等待的请求都可以立即得到响应。

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 数组来构建一个已更改演讲的数组,包括具有 deleted 属性的对象(用于不再存在的演讲)。构建该数组时,getChangedTalks 必须确保它不包含相同的演讲两次,因为自给定时间点以来可能对演讲进行了多次更改。

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 下的演讲管理界面。

客户端

演讲管理网站的客户端部分由三个文件组成:一个 HTML 页面、一个样式表和一个 JavaScript 文件。

HTML

这是一个普遍的约定,即当对直接对应于目录的路径进行请求时,Web 服务器尝试提供名为 index.html 的文件。我们使用的文件服务器模块 ecstatic 支持此约定。当对路径 / 进行请求时,服务器会查找文件 ./public/index.html./public 是我们给它的根目录),如果找到则返回该文件。

因此,如果我们希望在将浏览器指向我们的服务器时显示一个页面,我们应该将其放在 public/index.html 中。这是我们的索引文件开头的代码

<!doctype html>

<title>Skill Sharing</title>
<link rel="stylesheet" href="skillsharing.css">

<h1>Skill sharing</h1>

<p>Your name: <input type="text" id="name"></p>

<div id="talks"></div>

它定义了文档标题并包含一个样式表,该样式表定义了一些样式,这些样式除其他事项外,还在演讲周围添加了一个边框。然后它添加了一个标题和一个名称字段。用户应在后者中输入他们的姓名,以便将其附加到他们提交的演讲和评论。

ID 为 "talks"<div> 元素将包含当前的演讲列表。脚本在从服务器接收演讲时填充该列表。

接下来是用于创建新演讲的表单。

<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 请求。

接下来是一个相当神秘的块,它的 display 样式设置为 none,阻止它在页面上实际显示。你能猜出它是用来做什么的吗?

<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 文档包含包含客户端代码的脚本文件。

<script src="skillsharing_client.js"></script>

启动

客户端在页面加载时要做的第一件事是向服务器请求当前的演讲集。由于我们将发出很多 HTTP 请求,因此我们将再次围绕 XMLHttpRequest 定义一个小的包装器,它接受一个对象来配置请求,以及一个在请求完成时调用的回调函数。

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 启动长期轮询过程。

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 的简单函数,它至少会向用户显示一个对话框,告诉他们出了问题。

function reportError(error) {
  if (error)
    alert(error.toString());
}

该函数检查是否确实存在错误,并且只有在存在错误时才会发出警告。这样,我们也可以将此函数直接传递给 request,用于我们可以忽略响应的请求。这确保了如果请求失败,错误将报告给用户。

显示谈话

为了能够在更改发生时更新谈话的视图,客户端必须跟踪它当前显示的谈话。这样,当已经出现在屏幕上的谈话的新版本进来时,就可以用其更新后的形式替换谈话(就地)。类似地,当信息表明谈话正在被删除时,正确的 DOM 元素可以从文档中删除。

displayTalks 函数用于建立初始显示,并在发生更改时更新显示。它将使用 shownTalks 对象(将谈话标题与 DOM 节点关联)来记住它当前在屏幕上显示的谈话。

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;
    }
  });
}

谈话的 DOM 结构的建立使用的是 HTML 文档中包含的模板。首先,我们必须定义 instantiateTemplate,它查找并填充模板。

name 参数是模板的名称。要查找模板元素,我们搜索一个类名称与模板名称匹配的元素,它是 ID 为 "template" 的元素的子元素。使用 querySelector 方法使这变得容易。HTML 页面中有名为 "talk""comment" 的模板。

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);
}

cloneNode 方法是所有 DOM 节点都具有的方法,它创建节点的副本。除非 true 作为第一个参数给出,否则它不会复制节点的子节点。instantiate 函数递归地构建模板的副本,在构建时填充模板。

instantiateTemplate 的第二个参数应该是对象,其属性保存要填充到模板中的字符串。{{title}} 之类的占位符将替换为 valuestitle 属性的值。

这是一种粗略的模板方法,但足以实现 drawTalk

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" 的节点来填充注释。接下来,必须将事件处理程序附加到删除任务的按钮和添加新注释的表单。

更新服务器

drawTalk 注册的事件处理程序调用函数 deleteTalkaddComment 来执行删除谈话或添加注释所需的实际操作。这些将需要构建引用具有给定标题的谈话的 URL,为此,我们定义了 talkURL 辅助函数。

function talkURL(title) {
  return "talks/" + encodeURIComponent(title);
}

deleteTalk 函数触发 DELETE 请求并在请求失败时报告错误。

function deleteTalk(title) {
  request({pathname: talkURL(title), method: "DELETE"},
          reportError);
}

添加注释需要构建注释的 JSON 表示形式,并将其作为 POST 请求的一部分提交。

function addComment(title, comment) {
  var comment = {author: nameField.value, message: comment};
  request({pathname: talkURL(title) + "/comments",
           body: JSON.stringify(comment),
           method: "POST"},
          reportError);
}

用于设置注释的 author 属性的 nameField 变量是对页面顶部 <input> 字段的引用,该字段允许用户指定其姓名。我们还将该字段连接到 localStorage,以便每次重新加载页面时不必再次填写它。

var nameField = document.querySelector("#name");

nameField.value = localStorage.getItem("name") || "";

nameField.addEventListener("change", function() {
  localStorage.setItem("name", nameField.value);
});

页面底部的表单用于提出新的谈话,它获得了 "submit" 事件处理程序。此处理程序阻止事件的默认效果(这会导致页面重新加载),清除表单,并触发 PUT 请求以创建谈话。

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();
});

注意到变化

我应该指出,通过创建或删除谈话或添加注释来更改应用程序状态的各种函数绝对不做任何事情来确保它们所做的更改在屏幕上可见。它们只是告诉服务器,并依赖于长轮询机制来触发对页面的适当更新。

鉴于我们在服务器中实现的机制以及我们定义 displayTalks 来处理对已经在页面上的谈话的更新的方式,实际的长轮询令人惊讶地简单。

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/,你会发现你在一个窗口中执行的操作会立即在另一个窗口中显示。

练习

以下练习将涉及修改本章中定义的系统。要进行练习,请确保先下载代码(eloquentjavascript.net/2nd_edition/code/skillsharing.zip)并安装了 Node(nodejs.org)。

磁盘持久性

技能共享服务器纯粹在内存中保存其数据。这意味着当它崩溃或因任何原因重启时,所有谈话和评论都将丢失。

扩展服务器,使其将谈话数据存储到磁盘,并在重启时自动重新加载数据。不要担心效率——做最简单有效的事情。

我能想到的最简单的解决方案是将整个 talks 对象编码为 JSON 并将其使用 fs.writeFile 导出到文件。已经有一个函数(registerChange)在每次服务器数据发生更改时被调用。可以扩展它来将新数据写入磁盘。

选择一个文件名,例如 ./talks.json。当服务器启动时,它可以尝试使用 fs.readFile 读取该文件,如果成功,服务器可以使用文件的內容作为其起始数据。

但是,要注意。talks 对象最初是一个没有原型的对象,这样才能理智地使用 in 运算符。JSON.parse 将返回具有 Object.prototype 作为其原型的普通对象。如果你使用 JSON 作为你的文件格式,你将不得不将 JSON.parse 返回的对象的属性复制到一个新的无原型对象中。

评论字段重置

谈话的整体重绘效果很好,因为你通常无法区分 DOM 节点及其相同的替换。但也有例外。如果你在一个浏览器窗口中开始在谈话的评论字段中输入内容,然后在另一个窗口中向该谈话添加注释,第一个窗口中的字段将被重绘,从而删除其内容和焦点。

在热烈的讨论中,当多个人向同一个谈话添加注释时,这会非常令人讨厌。你能想出一种避免这种情况的方法吗?

临时解决方案是简单地存储谈话评论字段的状态(其内容以及它是否处于焦点状态),然后再重绘谈话,然后将字段重置为其旧状态。

另一个解决方案是不仅仅用新的 DOM 结构替换旧的 DOM 结构,而是递归地逐个节点地比较它们,只更新实际发生更改的部分。这实现起来要困难得多,但它更通用,即使我们添加另一个文本字段,它仍然可以继续工作。

更好的模板

大多数模板系统不仅填充一些字符串。至少,它们还允许有条件地包含模板的部分,类似于 if 语句,以及重复模板的部分,类似于循环。

如果我们能够为数组中的每个元素重复一部分模板,我们就不需要第二个模板("comment")。相反,我们可以指定 "talk" 模板来循环遍历谈话的 comments 属性中保存的数组,并为数组中的每个元素渲染构成注释的节点。

它可能看起来像这样

<div class="comments">
  <div class="comment" template-repeat="comments">
    <span class="name">{{author}}</span>: {{message}}
  </div>
</div>

想法是,只要在模板实例化过程中找到具有 template-repeat 属性的节点,实例化代码就会循环遍历该属性命名的属性中保存的数组。对于数组中的每个元素,它都会添加该节点的一个实例。在该循环期间,模板的上下文(instantiateTemplate 中的 values 变量)将指向数组的当前元素,因此 {{author}} 将在注释对象中而不是在原始上下文(谈话)中查找。

重写 instantiateTemplate 来实现这一点,然后更改模板以使用此功能,并从 drawTalk 函数中删除注释的显式渲染。

如何添加节点的条件实例化,使其能够在给定值为真或假时省略模板的某些部分?

你可以修改instantiateTemplate,使其内部函数不仅接收一个节点,还接收一个当前上下文作为参数。然后,在循环遍历节点的子节点时,你可以检查子节点是否具有template-repeat属性。如果有,不要实例化它一次,而是遍历该属性值指示的数组,并为数组中的每个元素实例化它一次,并将当前数组元素作为上下文传递。

条件可以用类似的方式实现,使用诸如template-whentemplate-unless之类的属性,这些属性会导致仅在给定属性为真(或假)时才实例化节点。

不可脚本化

当有人使用禁用了 JavaScript 的浏览器或根本无法显示 JavaScript 的浏览器访问我们的网站时,他们将看到一个完全损坏、无法操作的页面。这并不好。

某些类型的 Web 应用程序确实无法在没有 JavaScript 的情况下运行。对于其他应用程序,你可能没有预算或耐心去处理无法运行脚本的客户端。但对于面向广泛受众的页面,支持无脚本用户是一种礼貌。

尝试想出一个方法,使技能共享网站能够在没有 JavaScript 的情况下运行时保留基本功能。自动更新将不得不取消,用户将不得不以老式的方式刷新页面。但能够查看现有演讲、创建新演讲和提交评论将是一件好事。

不要感到有义务实际实现这一点。概述一个解决方案就足够了。你认为修改后的方法比我们最初的方法更优雅还是更不优雅?

本章采用的方法的两个核心方面——干净的 HTTP 接口和客户端模板渲染——在没有 JavaScript 的情况下无法工作。普通的 HTML 表单可以发送GETPOST请求,但不能发送PUTDELETE请求,并且只能将其数据发送到固定 URL。

因此,服务器将不得不修改以通过POST请求接受评论、新演讲和已删除的演讲,其主体不是 JSON,而是使用 HTML 表单使用的 URL 编码格式(参见第 17 章)。这些请求必须返回完整的新的页面,以便用户在进行更改后看到网站的新状态。这并不难设计,并且可以在“干净”的 HTTP 接口旁边实现。

渲染演讲的代码必须在服务器上复制。index.html文件不再是静态文件,而是必须通过向路由器添加对它的处理程序来动态生成。这样,它在被提供服务时就已经包含了当前的演讲和评论。