客户端

技能分享网站的客户端部分由三个文件组成:微型 HTML 页面、样式表以及 JavaScript 文件。

HTML

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

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

  1. <!doctype html>
  2. <meta charset="utf-8">
  3. <title>Skill Sharing</title>
  4. <link rel="stylesheet" href="skillsharing.css">
  5. <h1>Skill Sharing</h1>
  6. <script src="skillsharing_client.js"></script>

它定义了文档标题并包含一个样式表,除了其它东西,它定义了几种样式,确保对话之间有一定的空间。

最后,它在页面顶部添加标题,并加载包含客户端应用的脚本。

动作

应用状态由对话列表和用户名称组成,我们将它存储在一个{talks, user}对象中。 我们不允许用户界面直接操作状态或发送 HTTP 请求。 反之,它可能会触发动作,它描述用户正在尝试做什么。

  1. function handleAction(state, action) {
  2. if (action.type == "setUser") {
  3. localStorage.setItem("userName", action.user);
  4. return Object.assign({}, state, {user: action.user});
  5. } else if (action.type == "setTalks") {
  6. return Object.assign({}, state, {talks: action.talks});
  7. } else if (action.type == "newTalk") {
  8. fetchOK(talkURL(action.title), {
  9. method: "PUT",
  10. headers: {"Content-Type": "application/json"},
  11. body: JSON.stringify({
  12. presenter: state.user,
  13. summary: action.summary
  14. })
  15. }).catch(reportError);
  16. } else if (action.type == "deleteTalk") {
  17. fetchOK(talkURL(action.talk), {method: "DELETE"})
  18. .catch(reportError);
  19. } else if (action.type == "newComment") {
  20. fetchOK(talkURL(action.talk) + "/comments", {
  21. method: "POST",
  22. headers: {"Content-Type": "application/json"},
  23. body: JSON.stringify({
  24. author: state.user,
  25. message: action.message
  26. })
  27. }).catch(reportError);
  28. }
  29. return state;
  30. }

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

需要涉及服务器的操作使用fetch,将网络请求发送到前面描述的 HTTP 接口。 我们使用包装函数fetchOK,它确保当服务器返回错误代码时,拒绝返回的Promise

  1. function fetchOK(url, options) {
  2. return fetch(url, options).then(response => {
  3. if (response.status < 400) return response;
  4. else throw new Error(response.statusText);
  5. });
  6. }

这个辅助函数用于为某个对话,使用给定标题建立 URL。

  1. function talkURL(title) {
  2. return "talks/" + encodeURIComponent(title);
  3. }

当请求失败时,我们不希望我们的页面丝毫不变,不给予任何提示。因此我们定义一个函数,名为reportError,至少在发生错误时向用户展示一个对话框。

  1. function reportError(error) {
  2. alert(String(error));
  3. }

渲染组件

我们将使用一个方法,类似于我们在第十九章中所见,将应用拆分为组件。 但由于某些组件不需要更新,或者在更新时总是完全重新绘制,所以我们不将它们定义为类,而是直接返回 DOM 节点的函数。 例如,下面是一个组件,显示用户可以向它输入名称的字段的:

  1. function renderUserField(name, dispatch) {
  2. return elt("label", {}, "Your name: ", elt("input", {
  3. type: "text",
  4. value: name,
  5. onchange(event) {
  6. dispatch({type: "setUser", user: event.target.value});
  7. }
  8. }));
  9. }

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

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

  1. function renderTalk(talk, dispatch) {
  2. return elt(
  3. "section", {className: "talk"},
  4. elt("h2", null, talk.title, " ", elt("button", {
  5. type: "button",
  6. onclick() {
  7. dispatch({type: "deleteTalk", talk: talk.title});
  8. }
  9. }, "Delete")),
  10. elt("div", null, "by ",
  11. elt("strong", null, talk.presenter)),
  12. elt("p", null, talk.summary),
  13. ...talk.comments.map(renderComment),
  14. elt("form", {
  15. onsubmit(event) {
  16. event.preventDefault();
  17. let form = event.target;
  18. dispatch({type: "newComment",
  19. talk: talk.title,
  20. message: form.elements.comment.value});
  21. form.reset();
  22. }
  23. }, elt("input", {type: "text", name: "comment"}), " ",
  24. elt("button", {type: "submit"}, "Add comment")));
  25. }

submit事件处理器调用form.reset,在创建"newComment"动作后清除表单的内容。

在创建适度复杂的 DOM 片段时,这种编程风格开始显得相当混乱。 有一个广泛使用的(非标准的)JavaScript 扩展叫做 JSX,它允许你直接在你的脚本中编写 HTML,这可以使这样的代码更漂亮(取决于你认为漂亮是什么)。 在实际运行这种代码之前,必须在脚本上运行一个程序,将伪 HTML 转换为 JavaScript 函数调用,就像我们在这里用的东西。

评论更容易渲染。

  1. function renderComment(comment) {
  2. return elt("p", {className: "comment"},
  3. elt("strong", null, comment.author),
  4. ": ", comment.message);
  5. }

最后,用户可以使用表单创建新对话,它渲染为这样。

  1. function renderTalkForm(dispatch) {
  2. let title = elt("input", {type: "text"});
  3. let summary = elt("input", {type: "text"});
  4. return elt("form", {
  5. onsubmit(event) {
  6. event.preventDefault();
  7. dispatch({type: "newTalk",
  8. title: title.value,
  9. summary: summary.value});
  10. event.target.reset();
  11. }
  12. }, elt("h3", null, "Submit a Talk"),
  13. elt("label", null, "Title: ", title),
  14. elt("label", null, "Summary: ", summary),
  15. elt("button", {type: "submit"}, "Submit"));
  16. }

轮询

为了启动应用,我们需要对话的当前列表。 由于初始加载与长轮询过程密切相关 — 轮询时必须使用来自加载的ETag — 我们将编写一个函数来不断轮询服务器的/ talks,并且在新的对话集可用时,调用回调函数。

  1. async function pollTalks(update) {
  2. let tag = undefined;
  3. for (;;) {
  4. let response;
  5. try {
  6. response = await fetchOK("/talks", {
  7. headers: tag && {"If-None-Match": tag,
  8. "Prefer": "wait=90"}
  9. });
  10. } catch (e) {
  11. console.log("Request failed: " + e);
  12. await new Promise(resolve => setTimeout(resolve, 500));
  13. continue;
  14. }
  15. if (response.status == 304) continue;
  16. tag = response.headers.get("ETag");
  17. update(await response.json());
  18. }
  19. }

这是一个async函数,因此循环和等待请求更容易。 它运行一个无限循环,每次迭代中,通常检索对话列表。或者,如果这不是第一个请求,则带有使其成为长轮询请求的协议头。

当请求失败时,函数会等待一会儿,然后再次尝试。 这样,如果你的网络连接断了一段时间然后又恢复,应用可以恢复并继续更新。 通过setTimeout解析的Promise,是强制async函数等待的方法。

当服务器回复 304 响应时,这意味着长轮询请求超时,所以函数应该立即启动下一个请求。 如果响应是普通的 200 响应,它的正文将当做 JSON 而读取并传递给回调函数,并且它的ETag协议头的值为下一次迭代而存储。

应用

以下组件将整个用户界面结合在一起。

  1. class SkillShareApp {
  2. constructor(state, dispatch) {
  3. this.dispatch = dispatch;
  4. this.talkDOM = elt("div", {className: "talks"});
  5. this.dom = elt("div", null,
  6. renderUserField(state.user, dispatch),
  7. this.talkDOM,
  8. renderTalkForm(dispatch));
  9. this.setState(state);
  10. }
  11. setState(state) {
  12. if (state.talks != this.talks) {
  13. this.talkDOM.textContent = "";
  14. for (let talk of state.talks) {
  15. this.talkDOM.appendChild(
  16. renderTalk(talk, this.dispatch));
  17. }
  18. this.talks = state.talks;
  19. }
  20. }
  21. }

当对话改变时,这个组件重新绘制所有这些组件。 这很简单,但也是浪费。 我们将在练习中回顾一下。

我们可以像这样启动应用:

  1. function runApp() {
  2. let user = localStorage.getItem("userName") || "Anon";
  3. let state, app;
  4. function dispatch(action) {
  5. state = handleAction(state, action);
  6. app.setState(state);
  7. }
  8. pollTalks(talks => {
  9. if (!app) {
  10. state = {user, talks};
  11. app = new SkillShareApp(state, dispatch);
  12. document.body.appendChild(app.dom);
  13. } else {
  14. dispatch({type: "setTalks", talks});
  15. }
  16. }).catch(reportError);
  17. }
  18. runApp();

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