1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-30 00:32:22 +00:00
wizardforcel 52538421aa 21.
2018-06-02 11:56:31 +08:00

34 KiB
Raw Blame History

二十一、项目:技能分享网站

If you have knowledge, let others light their candles at it.

Margaret Fuller

技能分享会议将拥有相同兴趣的聚集到一起并针对了解的知识进行简短且非正式的展示。在园艺的技能分享会议上可以解释如何耕作芹菜。如果在编程的技能分享小组中你可以顺便给每个人讲讲Node.js。

在计算机专业中这类聚会往往名为用户小组是开阔眼界、了解行业新动态或仅仅接触兴趣相同的人的好方法。许多大城市都会有JavaScript聚会。这类聚会往往是可以免费参加的而且我发现我参加过的那些聚会都非常友好热情。

在最后的项目章节中,我们的目标是建立网站,管理特定技能分享会议的讨论内容。假设一个小组的人会在成员办公室中定期举办关于独轮车的会议。上一个组织者搬到了另一个城市,并且没人可以站出来接下来他的任务。我们需要一个系统,让参与者可以在系统中发言并相互讨论,这样就不需要一个中心组织人员了。

就像上一章一样,本章中的一些代码是为 Node.js 编写的,并且直接在您正在查看的 HTML页面中运行它不太可行。 该项目的完整代码可以从eloquentjavascript.net/code/skillsharing.zip下载。

21.1 设计

本项目的服务器部分为Node.js编写客户端部分则为浏览器编写。服务器存储系统数据并将其提供给客户端。它也提供实现客户端系统的文件。

服务器保存了为下次会议提出的对话列表。每个对话包括参与人员姓名、标题和该对话的相关评论。客户端允许用户提出新的对话将对话添加到列表中、删除对话和评论已存在的对话。每当用户做了修改时客户端会向服务器发送关于更改的HTTP请求。

我们创建应用程序来展示一个实时视图,来展示目前已经提出的对话和评论。每当某些人在某些地点提交了新的对话或添加新评论时,所有在浏览器中打开页面的人都应该立即看到变化。这个特性略有挑战,网络服务器无法建立到客户端的连接,也没有好方法来知道有哪些客户端现在在查看特定网站。

该问题的一个解决方案叫作长时间轮询这恰巧是Node的设计动机之一。

21.2 长轮询

为了能够立即提示客户端某些信息发生了改变,我们需要建立到客户端的连接。由于通常浏览器无法接受连接,而且客户端通常在路由后面,它无论如何都会拒绝这类连接,因此由服务器初始化连接是不切实际的。

我们可以安排客户端来打开连接并保持该连接,因此服务器可以使用该连接在必要时传送信息。

但HTTP请求只是简单的信息流客户端发送请求服务器返回一条响应就是这样。有一种名为WebSocket的技术受到现代浏览器的支持是的我们可以建立连接并进行任意的数据交换。但如何正确运用这项技术是较为复杂的。

本章我们将会使用一种相对简单的技术长轮询Long Polling。客户端会连续使用定时的HTTP请求向服务器询问新信息而当没有新信息需要报告时服务器会简单地推迟响应。

只要客户端确保其可以持续不断地建立轮询请求就可以在信息可用之后从服务器快速地接收到信息。例如若Fatma在浏览器中打开了技能分享程序浏览器会发送请求询问是否有更新且等待请求的响应。当Iman在自己的浏览器中提交了关于“极限降滑独轮车”的对话之后。服务器发现Fatma在等待更新请求并将新的对话作为响应发送给待处理的请求。Fatma的浏览器将会接收到数据并更新屏幕展示对话内容。

为了防止连接超时(因为连接一定时间不活跃后会被中断),长轮询技术常常为每个请求设置一个最大等待时间,只要超过了这个时间,即使没人有任何需要报告的信息也会返回响应,在此之后,客户端会建立一个新的请求。定期重新发送请求也使得这种技术更具鲁棒性,允许客户端从临时的连接失败或服务器问题中恢复。

使用了长轮询技术的繁忙的服务器可以有成百上千个等待的请求因此也就有这么多个TCP连接处于打开状态。Node简化了多连接的管理工作而不是建立单独线程来控制每个连接这对这样的系统是非常合适的。

21.3 HTTP接口

在我们设计服务器或客户端的代码之前让我们先来思考一下两者均会涉及的一点双方通信的HTTP接口。

我们会使用 JSON 作为请求和响应正文的格式就像第二十章中的文件服务器一样我们尝试充分利用HTTP方法。所有接口均以/talks路径为中心。不以/talks开头的路径则用于提供静态文件服务即用于实现客户端系统的HTML和JavaScript代码。

访问/talks的GET请求会返回如下所示的JSON文档。

[{"title": "Unituning",
  "presenter": "Jamal",
  "summary": "Modifying your cycle for extra style",
  "comment": []}]

我们可以发送PUT请求到类似于/talks/Unituning之类的URL上来创建新对话在第二个斜杠后的那部分是对话的名称。PUT请求正文应当包含一个JSON对象其中有一个presenter属性和一个summary属性。

因为对话标题可以包含空格和其他无法正常出现在URL中的字符因此我们必须使用encodeURIComponent函数来编码标题字符串并构建URL。

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

我们也可以使用GET请求通过这些URL获取对话的JSON数据或使用DELETE请求通过这些URL删除对话。

若想在对话中添加一条评论,可以向诸如/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请求可能会包含额外的标题通知服务器延迟响应。 我们将使用通常用于管理缓存的一对协议头:ETagIf-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

[....]

这里描述的协议并没有任何访问控制。每个人都可以评论、修改对话或删除对话。因为因特网中充满了流氓,因此将这类没有进一步保护的系统放在网络上最后可能并不是很好。

21.4 服务器

让我们开始构建程序的服务器部分。本节的代码可以在Node.js中执行。

21.4.1 路由

我们的服务器会使用createServer来启动HTTP服务器。在处理新请求的函数中我们必须区分我们支持的请求的类型根据方法和路径确定。我们可以使用一长串的if语句完成该任务但还存在一种更优雅的方式。

路由可以作为帮助把请求调度传给能处理该请求的函数路径可以和正则表达式/^/talks/[^/]+$/匹配(后面有对话名称的/talks/的PUT请求应当由指定函数处理。此外路由器可以帮助我们提取路径中有意义的部分在本例中会将对话的标题包裹在正则表达式的括号之中传递给处理器函数。

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

这里给出router.js我们随后将在服务器模块中使用require获取该模块。

const {parse} = require("url");

module.exports = class Router {
  constructor() {
    this.routes = [];
  }
  add(method, url, handler) {
    this.routes.push({method, url, handler});
  }
  resolve(context, request) {
    let path = parse(request.url).pathname;

    for (let {method, url, handler} of this.routes) {
      let match = url.exec(path);
      if (!match || request.method != method) continue;
      let urlParts = match.slice(1).map(decodeURIComponent);
      return handler(context, ...urlParts, request);
    }
    return null;
  }
};

该模块导出Router类。我们可以使用路由器对象的ad方法来注册一个新的处理器并使用resolve方法解析请求。

找到处理器之后,后者会返回一个响应,否则为null。它会逐个尝试路由根据定义顺序排序当找到一个匹配的路由时返回true。

路由器会使用context值调用处理器函数这里是服务器实例将请求对象中的字符串与已定义分组中的正则表达式匹配。传递给处理的字符串必须进行URL解码因为原始URL中可能包含%20style风格的代码。

21.4.2 文件服务

当请求无法匹配路由器中定义的任何请求类型时服务器必须将其解释为请求位于public目录下的某个文件。服务器可以使用第20章中定义的文件服务器来提供文件服务但我们并不需要也不想对文件支持PUT和DELETE请求且我们想支持类似于缓存等高级特性。因此让我们使用NPM中更为可靠且经过充分测试的静态文件服务器。

我选择了ecstatic。它并不是NPM中唯一的此类服务但它能够完美工作且符合我们的意图。ecstatic模块导出了一个函数我们可以调用该函数并传递一个配置对象来生成一个请求处理函数。我们使用root选项告知服务器文件搜索位置。

const {createServer} = require("http");
const Router = require("./router");
const ecstatic = require("ecstatic");

const router = new Router();
const defaultHeaders = {"Content-Type": "text/plain"};

class SkillShareServer {
  constructor(talks) {
    this.talks = talks;
    this.version = 0;
    this.waiting = [];

    let fileServer = ecstatic({root: "./public"});
    this.server = createServer((request, response) => {
      let resolved = router.resolve(this, request);
      if (resolved) {
        resolved.catch(error => {
          if (error.status != null) return error;
          return {body: String(error), status: 500};
        }).then(({body,
                  status = 200,
                  headers = defaultHeaders}) => {
          response.writeHead(status, headers);
          response.end(body);
        });
      } else {
        fileServer(request, response);
      }
    });
  }
  start(port) {
    this.server.listen(port);
  }
  stop() {
    this.server.close();
  }
}

它使用上一章中的文件服务器的类似约定来处理响应 - 处理器返回Promise,可解析为描述响应的对象。 它将服务器包装在一个对象中,它也维护它的状态。

21.4.3 将对话作为资源

已提出的对话存储在服务器的talks属性中,这是一个对象,属性名称是对话标题。这些对话会展现为/talks/[title]下的HTTP资源因此我们需要将处理器添加我们的路由器中供客户端选择来实现不同的方法。

处理获取GET对话请求的函数必须查找对话并使用对话的JSON数据作为响应若不存在则返回404错误响应码。

const talkPath = /^\/talks\/([^\/]+)$/;

router.add("GET", talkPath, async (server, title) => {
  if (title in server.talks) {
    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 (title in server.talks) {
    delete server.talks[title];
    server.updated();
  }
  return {status: 204};
});

我们将在稍后定义updated方法,它通知等待有关更改的长轮询请求。

为了获取请求正文的内容我们定义一个名为readStream的函数从可读流中读取所有内容并返回解析为字符串的Promise

function readStream(stream) {
  return new Promise((resolve, reject) => {
    let data = "";
    stream.on("error", reject);
    stream.on("data", chunk => data += chunk.toString());
    stream.on("end", () => resolve(data));
  });
}

需要读取响应正文的函数是PUT的处理器用户使用它创建新对话。该函数需要检查数据中是否有presenter和summary属性这些属性都是字符串。任何来自外部的数据都可能是无意义的我们不希望错误请求到达时会破坏我们的内部数据模型或者导致服务崩溃。

若数据看起来合法处理器会将对话转化为对象存储在talks对象中如果有标题相同的对话存在则覆盖并再次调用updated。

router.add("PUT", talkPath,
           async (server, title, request) => {
  let requestBody = await readStream(request);
  let talk;
  try { talk = JSON.parse(requestBody); }
  catch (_) { return {status: 400, body: "Invalid JSON"}; }

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

在对话中添加评论也是类似的。我们使用readStream来获取请求内容验证请求数据若看上去合法则将其存储为评论。

router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
           async (server, title, request) => {
  let requestBody = await readStream(request);
  let comment;
  try { comment = JSON.parse(requestBody); }
  catch (_) { return {status: 400, body: "Invalid JSON"}; }

  if (!comment ||
      typeof comment.author != "string" ||
      typeof comment.message != "string") {
    return {status: 400, body: "Bad comment data"};
  } else if (title in server.talks) {
    server.talks[title].comments.push(comment);
    server.updated();
    return {status: 204};
  } else {
    return {status: 404, body: `No talk '${title}' found`};
  }
});

尝试向不存在的对话中添加评论会返回404错误。

21.4.4 长轮询支持

服务器中最值得探讨的方面是处理长轮询的部分代码。当URL为/talks的GET请求到来时它可能是一个常规请求或一个长轮询请求。

我们可能在很多地方,将对话列表发送给客户端,因此我们首先定义一个简单的辅助函数,它构建这样一个数组,并在响应中包含ETag协议头。

SkillShareServer.prototype.talkResponse = function() {
  let talks = [];
  for (let title of Object.keys(this.talks)) {
    talks.push(this.talks[title]);
  }
  return {
    body: JSON.stringify(talks),
    headers: {"Content-Type": "application/json",
              "ETag": `"${this.version}"`}
  };
};

处理程序本身需要查看请求头,来查看是否存在If-None-MatchPrefer标头。 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标题来查看,是否应该延迟响应或立即响应。

用于延迟请求的回调函数存储在服务器的waiting数组中,以便在发生事件时通知它们。 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注册一个更改,会增加version属性并唤醒所有等待的请求。

var changes = [];

SkillShareServer.prototype.updated = function() {
  this.version++;
  let response = this.talkResponse();
  this.waiting.forEach(resolve => resolve(response));
  this.waiting = [];
};

服务器代码这样就完成了。 如果我们创建一个SkillShareServer的实例,并在端口 8000 上启动它,那么生成的 HTTP 服务器,将服务于public子目录中的文件,以及/ talksURL 下的一个对话管理界面。

new SkillShareServer(Object.create(null)).start(8000);

21.5 客户端

对话管理网站的客户端部分由三个文件组成HTML页面、样式表以及JavaScript文件。

21.5.1 HTML

在网络服务器提供文件服务时有一种广为使用的约定是当请求直接访问与目录对应的路径时返回名为index.html的文件。我们使用的文件服务模块ecstatic就支持这种约定。当请求路径为/时,服务器会搜索文件./public/index.html./public是我们赋予的根目录若文件存在则返回文件。

因此若我们希望浏览器指向我们服务器时展示某个特定页面我们将其放在public/index.html中。这就是我们最初的index文件。

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

接下来看一个更为神秘的模块我们将其显示样式设置为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>

21.5.2 启动

页面加载后客户端首先向服务器请求当前的对话集。由于我们打算建立许多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。这确保请求失败时会向用户报告错误信息。

21.5.3 显示会话

为了能够在数据修改时更新对话视图客户端必须持续跟踪当前显示的对话。这样当屏幕上已存在的对话的新版本到来时客户端可以使用更新后的对话替换原来的版本。类似地当对话删除的信息到来时必须从文档中删除当时的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;
    }
  });
}

我们使用HTML文档中的模板来构建对话的DOM结构。首先我们必须定义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);
}

每个DOM节点都有cloneNode方法用于创建节点的拷贝。除非该函数的第一个参数为true否则该函数不会拷贝节点的子节点。instantiate函数则用于递归构建模板的拷贝并根据值填充模板。

instantiateTemplate的第二个参数是一个对象其属性是想要填充到模板中的字符串。模板中像{{title}}这样的占位符会被替换为value的title属性的值。

这是非常原始的模板方法但这对于实验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的节点之中。接下来必须要在删除对话的按钮上附加对应的事件处理器并在表单上附加创建新评论的事件处理器。

21.5.4 更新服务器

通过drawTalk函数调用注册的事件处理器会调用deleteTalk和addComment来执行实际的动作以删除对话或添加评论。这些处理器需要根据指定的标题构建指向对话的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);
}

变量nameField用于设置评论的author属性该属性对应于页面顶端的<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();
});

21.5.5 提示更改

这里需要指出的是不同修改应用程序状态的函数(创建删除任务以及添加评论)都不必确保他们的修改对屏幕可见。这些函数只需要告知服务器,并依赖长轮询机制来触发页面的适当更新。

考虑到我们在服务器中实现的机制以及我们定义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确保该属性与我们接收到数据的新时间点相一致。接着立即重新发起请求来等待下一轮更新。

若你执行服务器并同时打开两个浏览器窗口都输入localhost8000/,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。

21.6 习题

下面的习题涉及修改本章中定义的系统。为了使用该系统进行工作,请确保首先下载代码(http://eloquentjavascript.net/code/skillshare.zip并安装了Nodehttp://nodejs.org/)。

21.6.1 磁盘持久化

技能分享服务只将数据存储在内存中。这就意味着当服务崩溃或以为任何原因重启时,所有的对话和评论都会丢失。

扩展服务使得其将对话数据存储到磁盘上,并在程序重启时自动重新加载数据。不要担心效率,只要用最简单的代码让其可以工作即可。

21.6.2 重置评论字段

由于我们常常无法在DOM节点中找到唯一替换的位置因此整批地重绘对话是个很好的工作机制。但这里有个例外若你开始在对话的评论字段中输入一些文字而在另一个窗口向同一条对话添加了一条评论那么第一个窗口中的字段就会被重绘会移除掉其内容和焦点。

在热烈的讨论中,当多个人向同一条对话中添加评论时,这种情况会非常烦人。你能想出一种方法来避免这个问题吗?

21.6.3 更好的模板

许多模板系统除了填充一些字符串外还能完成更多工作。至少这些模板系统支持条件包含某部分模板类似于if语句或者可以重复一部分模板类似于循环。

若我们可以为数组中的每个元素重复一段模板我们就不需要第二个模板“comment”了。而可以指定talk模板循环扫描talk持有的comments属性并逐个渲染节点这些节点组成了数组中的每个元素所对应的评论。

模板如下所示。

<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下建立一个技能分享网站保留其基本功能。这个版本不需要加入自动更新人们可以使用传统的方式来刷新页面。但查看存在的对话创建新节点并提交评论等功能需要完好。

本书并不要求读者实际实现该网站,画出大致解决方案就足够了。是不是修订过的方法比最初的方法更吸引你呢?