现已推出 第四版点击此处阅读

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

如果你拥有知识,就让其他人从你这里点燃他们的蜡烛。

玛格丽特·富勒
Picture of two unicycles

技能分享会议是一种活动,人们带着共同的兴趣聚在一起,就他们所知的事情进行简短的非正式演讲。在一个园艺技能分享会议上,有人可能会解释如何种植芹菜。或者在编程技能分享小组中,你可以来分享关于 Node.js 的知识。

这种聚会——当与计算机有关时,也经常被称为用户组——是拓宽视野、了解新发展或仅仅结识志同道合之人的绝佳方式。许多大城市都有 JavaScript 聚会。它们通常是免费参加的,我发现我去过的聚会都很友好且受欢迎。

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

就像在 上一章中一样,本章中的一些代码是为 Node.js 编写的,直接在您正在查看的 HTML 页面中运行它可能不会起作用。该项目的完整代码可以从 https://eloquent.javascript.ac.cn/code/skillsharing.zip 下载。

设计

该项目有一个为 Node.js 编写的服务器部分,以及一个为浏览器编写的客户端部分。服务器存储系统的數據并将其提供给客户端。它还提供实现客户端系统的文件。

服务器保存着下次会议提议的演讲列表,客户端显示这个列表。每个演讲都有一个演讲者姓名、一个标题、一个摘要和一个与其相关的评论数组。客户端允许用户提议新的演讲(将它们添加到列表中)、删除演讲以及评论现有演讲。每当用户进行此类更改时,客户端都会发出 HTTP 请求来告诉服务器关于更改的信息。

Screenshot of the skill-sharing website

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

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

长轮询

为了能够立即通知客户端发生了更改,我们需要与该客户端的连接。由于 Web 浏览器传统上不接受连接,而且客户端通常位于路由器后面,这些路由器可能会阻止此类连接,因此让服务器发起此连接并不实际。

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

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

在本章中,我们使用一种更简单的技术——长轮询——其中客户端不断地使用常规 HTTP 请求向服务器请求新信息,而服务器在没有新信息报告时会延迟其响应。

只要客户端确保始终打开一个轮询请求,它将在新信息可用后立即从服务器接收信息。例如,如果 Fatma 在她的浏览器中打开了我们的技能分享应用程序,那么该浏览器将已发出更新请求,并将等待对该请求的响应。当 Iman 提交关于极限山地独轮车的演讲时,服务器会注意到 Fatma 正在等待更新,并向她待处理的请求发送包含新演讲的响应。Fatma 的浏览器将接收数据并更新屏幕以显示演讲。

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

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

HTTP 接口

在我们开始设计服务器或客户端之前,让我们考虑一下它们接触的点:它们之间进行通信的 HTTP 接口。

我们将使用 JSON 作为我们的请求和响应主体格式。像 第 20 章中的文件服务器一样,我们将尝试充分利用 HTTP 方法和标头。该接口以 /talks 路径为中心。不以 /talks 开头的路径将用于提供静态文件——客户端系统的 HTML 和 JavaScript 代码。

/talksGET 请求返回一个类似于此的 JSON 文档

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

创建新的演讲是通过对类似于 /talks/Unituning 的 URL 发出 PUT 请求完成的,其中第二个斜杠后的部分是演讲的标题。PUT 请求的主体应该包含一个具有 presentersummary 属性的 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 主体具有 authormessage 属性。

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

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

为了支持长轮询,对 /talksGET 请求可能包含额外的标头,这些标头会通知服务器,如果没有任何新信息可用,则延迟响应。我们将使用一对通常用于管理缓存的标头: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

[....]

此处描述的协议不执行任何访问控制。每个人都可以发表评论、修改演讲,甚至删除演讲。(由于互联网上到处都是流氓,在没有进一步保护的情况下将这种系统上线可能不会有好结果。)

服务器

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

路由

我们的服务器将使用 createServer 来启动 HTTP 服务器。在处理新请求的函数中,我们必须区分我们支持的各种请求(由方法和路径决定)。这可以通过一系列 if 语句完成,但有一种更简洁的方法。

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

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

后者将在找到处理程序时返回响应,否则返回 null。它会依次尝试路由(按定义顺序),直到找到匹配的路由。

处理程序函数使用 context 值(在本例中将是服务器实例)、与它们在正则表达式中定义的任何组匹配的字符串和请求对象调用。这些字符串必须进行 URL 解码,因为原始 URL 可能包含 %20 样式的代码。

提供文件

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

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

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

这使用与 上一章 中文件服务器类似的约定来进行响应 - 处理程序返回解析为描述响应的对象的承诺。它将服务器包装在一个也保存其状态的对象中。

对话作为资源

已提出的对话存储在服务器的 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 的函数,该函数从可读流中读取所有内容并返回解析为字符串的承诺。

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

如果数据看起来有效,处理程序将在 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 错误。

长时间轮询支持

服务器最有趣的部分是处理长时间轮询的部分。当 GET 请求进入 /talks 时,它可能是常规请求或长时间轮询请求。

我们将有多个地方需要向客户端发送对话数组,因此我们首先定义一个辅助方法,该方法构建这样的数组并在响应中包含 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}"`,
              "Cache-Control": "no-store"}
  };
};

处理程序本身需要查看请求标头,以查看是否存在 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 属性并唤醒所有等待的请求。

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

这结束了服务器代码。如果我们创建一个 SkillShareServer 实例并在 8000 端口启动它,则生成的 HTTP 服务器将提供 public 子目录中的文件以及 /talks URL 下的对话管理界面。

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

客户端

技能共享网站的客户端部分包含三个文件:一个微小的 HTML 页面、一个样式表和一个 JavaScript 文件。

HTML

这是一个广泛使用的约定,用于当对直接对应于目录的路径发出请求时,Web 服务器尝试提供名为 index.html 的文件。我们使用的文件服务器模块 ecstatic 支持此约定。当对路径 / 发出请求时,服务器会查找文件 ./public/index.html./public 是我们提供的根目录)并在找到时返回该文件。

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

<!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 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);
  }
  return state;
}

我们将用户的姓名存储在 localStorage 中,以便在页面加载时可以恢复它。

需要涉及服务器的动作使用 fetch 向前面描述的 HTTP 界面发出网络请求。我们使用一个包装函数 fetchOK,它确保当服务器返回错误代码时返回的承诺被拒绝。

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

渲染组件

我们将使用与我们在 第 19 章 中看到的方法类似的方法,将应用程序拆分为组件。但是,由于某些组件要么永远不需要更新,要么在更新时总是完全重新绘制,因此我们将它们不定义为类,而是定义为直接返回 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});
    }
  }));
}

用于构建 DOM 元素的 elt 函数是我们在 第 19 章 中使用的函数。

一个类似的函数用于渲染对话,其中包括评论列表和用于添加新评论的表单。

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" 事件处理程序在创建 "newComment" 操作后调用 form.reset 来清除表单的内容。

在创建中等复杂的 DOM 片段时,这种编程风格开始看起来很混乱。有一个广泛使用的(非标准)JavaScript 扩展叫做JSX,它允许你直接在你的脚本中编写 HTML,这可以使这种代码更漂亮(取决于你认为什么是漂亮)。在实际运行这种代码之前,你必须在你脚本上运行一个程序,将伪 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());
  }
}

这是一个 async 函数,以便循环和等待请求更容易。它运行一个无限循环,在每次迭代中,都会检索话题列表——无论是正常检索,还是如果这不是第一个请求,则包含使它成为长轮询请求的标题。

当请求失败时,函数会等待片刻然后重试。这样,如果您的网络连接断开一段时间然后恢复,应用程序可以恢复并继续更新。通过 setTimeout 解析的承诺是强制 async 函数等待的一种方式。

当服务器返回 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();

如果您运行服务器并打开两个浏览器窗口,它们并排显示 https://127.0.0.1:8000,您会看到在一个窗口中执行的操作会立即在另一个窗口中显示出来。

练习

以下练习将涉及修改本章定义的系统。要进行练习,请确保您首先下载代码 (https://eloquent.javascript.ac.cn/code/skillsharing.zip),安装 Node https://node.org.cn,并使用 npm install 安装项目的依赖项。

磁盘持久性

技能分享服务器将其数据完全保存在内存中。这意味着当它崩溃或因任何原因重新启动时,所有话题和评论都会丢失。

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

我能想到的最简单的解决方案是将整个 talks 对象编码为 JSON,并使用 writeFile 将其转储到文件中。已经有一个方法 (updated) 在每次服务器数据更改时被调用。可以将其扩展为将新数据写入磁盘。

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

但是,要注意。talks 对象最初是一个没有原型的对象,以便可以可靠地使用 in 运算符。JSON.parse 将返回具有 Object.prototype 作为其原型的普通对象。如果您使用 JSON 作为您的文件格式,您将不得不将 JSON.parse 返回的对象的属性复制到一个新的、没有原型的对象中。

评论字段重置

话题的整体重新绘制工作得很好,因为您通常无法区分 DOM 节点与其完全相同的替换。但也有例外。如果您在一个浏览器窗口中开始在某个话题的评论字段中键入内容,然后在另一个窗口中为该话题添加评论,第一个窗口中的字段将被重新绘制,从而删除其内容和焦点。

在热烈的讨论中,当多个人同时添加评论时,这会很烦人。你能想出解决方法吗?

最好的方法可能是制作话题组件对象,带有一个 syncState 方法,以便它们可以更新以显示修改后的话题版本。在正常操作期间,更改话题的唯一方法是添加更多评论,因此 syncState 方法可以相对简单。

困难的部分是,当一个更改后的话题列表进来时,我们必须将现有的 DOM 组件列表与新列表上的话题进行协调——删除已删除话题的组件,更新已更改话题的组件。

要做到这一点,可能需要保持一个数据结构,该数据结构在话题标题下存储话题组件,以便您可以轻松地确定是否为给定话题存在组件。然后,您可以遍历新的话题数组,对于每个话题,要么同步现有的组件,要么创建一个新的组件。要删除已删除话题的组件,您还必须遍历组件,并检查相应的话题是否仍然存在。