项目: 技能分享网站

如果你拥有知识,那就让别人点燃他们的蜡烛吧。

玛格丽特·富勒
Illustration showing two unicycles leaned against a mailbox

技能分享会议是一个人们聚集在一起,就他们所知的事物进行小型非正式演示的活动。在园艺技能分享会议上,有人可能会解释如何种植芹菜。或者在编程技能分享小组中,你可以来分享一些关于 Node.js 的信息。

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

就像在 上一章中一样,本章中的一些代码是为 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 上运行。

路由

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

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

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

这是 router.mjs,我们稍后将在服务器模块中从它 import

export class Router {
  constructor() {
    this.routes = [];
  }
  add(method, url, handler) {
    this.routes.push({method, url, handler});
  }
  async resolve(request, context) {
    let {pathname} = new URL(request.url, "http://d");
    for (let {method, url, handler} of this.routes) {
      let match = url.exec(pathname);
      if (!match || request.method != method) continue;
      let parts = match.slice(1).map(decodeURIComponent);
      return handler(context, ...parts, request);
    }
  }
}

模块导出 Router 类。路由器对象允许您使用其 add 方法为特定方法和 URL 模式注册处理程序。当请求使用 resolve 方法解析时,路由器会调用其方法和 URL 与请求匹配的处理程序,并返回其结果。

处理程序函数使用传递给 resolvecontext 值调用。我们将使用它来让他们访问我们的服务器状态。此外,他们还接收在其正则表达式中定义的任何组的匹配字符串,以及请求对象。字符串必须进行 URL 解码,因为原始 URL 可能包含 %20 样式的代码。

提供文件

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

我选择了 serve-static。这不是 NPM 上唯一的一个这样的服务器,但它运行良好,符合我们的目的。serve-static 包导出一个函数,可以调用它并使用根目录生成一个请求处理程序函数。处理程序函数接受来自 "node:http" 的服务器提供的 requestresponse 参数,以及第三个参数,一个函数,如果没有任何文件与请求匹配,它将调用该函数。我们希望我们的服务器首先检查我们应该特别处理的请求,如路由器中所定义,因此我们将其包装在另一个函数中。

import {createServer} from "node:http";
import serveStatic from "serve-static";

function notFound(request, response) {
  response.writeHead(404, "Not found");
  response.end("<h1>Not found</h1>");
}

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

    let fileServer = serveStatic("./public");
    this.server = createServer((request, response) => {
      serveFromRouter(this, request, response, () => {
        fileServer(request, response,
                   () => notFound(request, response));
      });
    });
  }
  start(port) {
    this.server.listen(port);
  }
  stop() {
    this.server.close();
  }
}

serveFromRouter 函数具有与 fileServer 相同的接口,接受 (request, response, next) 参数。我们可以使用它来“链接”多个请求处理程序,允许每个处理程序处理请求或将该请求的责任传递给下一个处理程序。最后一个处理程序 notFound 只需返回“未找到”错误。

我们的 serveFromRouter 函数使用与 上一章 中的文件服务器类似的约定来进行响应——路由器中的处理程序返回解析为描述响应的对象的 Promise。

import {Router} from "./router.mjs";

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

async function serveFromRouter(server, request,
                               response, next) {
  let resolved = await router.resolve(request, server)
    .catch(error => {
      if (error.status != null) return error;
      return {body: String(err), status: 500};
    });
  if (!resolved) return next();
  let {body, status = 200, headers = defaultHeaders} =
    await resolved;
  response.writeHead(status, headers);
  response.end(body);
}

讲座作为资源

已提出的讲座存储在服务器的 talks 属性中,该属性是一个对象的属性名称为讲座标题。我们将向路由器添加一些处理程序,以将这些讲座作为 HTTP 资源公开到 /talks/<title> 下。

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

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

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

updated 方法(我们将在 稍后 定义)通知等待的长时间轮询请求有关更改的信息。

一个需要读取请求正文的处理程序是 PUT 处理程序,它用于创建新的讲座。它必须检查它得到的数据是否具有 presentersummary 属性,它们是字符串。来自系统外部的任何数据都可能是无意义的,我们不希望在收到错误请求时破坏我们的内部数据模型或崩溃。

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

要从请求流中读取正文,我们将使用来自 "node:stream/consumers"json 函数,该函数收集流中的数据,然后将其解析为 JSON。此包中还有类似的导出,称为 text(将内容读为字符串)和 buffer(将内容读为二进制数据)。由于 json 是一个非常通用的名称,因此导入将其重命名为 readJSON 以避免混淆。

import {json as readJSON} from "node:stream/consumers";

router.add("PUT", talkPath,
           async (server, title, request) => {
  let talk = await readJSON(request);
  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};
});

向讲座添加评论的工作原理类似。我们使用 readJSON 获取请求的内容,验证结果数据,并在数据看起来有效时将其存储为评论。

router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
           async (server, title, request) => {
  let comment = await readJSON(request);
  if (!comment ||
      typeof comment.author != "string" ||
      typeof comment.message != "string") {
    return {status: 400, body: "Bad comment data"};
  } else if (Object.hasOwn(server.talks, title)) {
    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 = Object.keys(this.talks)
    .map(title => 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({}).start(8000);

客户端

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

HTML

这是一个广泛使用的约定,用于 Web 服务器在对直接对应于目录的路径发出请求时尝试提供名为 index.html 的文件。我们使用的文件服务器模块 serve-static 支持此约定。当对路径 / 发出请求时,服务器会查找文件 ./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 {...state, user: action.user};
  } else if (action.type == "setTalks") {
    return {...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,该函数确保在服务器返回错误代码时返回的 Promise 会被拒绝。

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

当请求失败时,我们不希望我们的页面只是坐在那里什么也不做,没有任何解释。我们用作 catch 处理程序的函数 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" 事件处理程序调用 form.reset 以在创建 "newComment" 操作后清除表单的内容。

在创建中等复杂度的 DOM 片段时,这种编程风格开始显得比较杂乱。为了避免这种情况,人们通常使用模板语言,它允许您将界面编写为一个 HTML 文件,其中包含一些特殊标记来指示动态元素的位置。或者他们使用JSX,这是一种非标准的 JavaScript 方言,允许您在程序中编写非常接近 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 读取该文件,如果读取成功,服务器可以使用该文件的内容作为其初始数据。

评论字段重置

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

当多个人同时添加评论时,这会很烦人。你能想出解决办法吗?

显示提示...

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

困难的部分是,当一个变化的话题列表进来时,我们必须将现有的 DOM 组件列表与新列表上的话题进行协调——删除其话题被删除的组件,并更新其话题发生变化的组件。

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