• 客户端
    • HTML
    • 动作
    • 渲染组件
    • 轮询
    • 应用

    客户端

    技能分享网站的客户端部分由三个文件组成:微型 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/打开两个浏览器窗口,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。