- 客户端
- HTML
- 动作
- 渲染组件
- 轮询
- 应用
客户端
技能分享网站的客户端部分由三个文件组成:微型 HTML 页面、样式表以及 JavaScript 文件。
HTML
在网络服务器提供文件服务时,有一种广为使用的约定是:当请求直接访问与目录对应的路径时,返回名为index.html的文件。我们使用的文件服务模块ecstatic就支持这种约定。当请求路径为/时,服务器会搜索文件./public/index.html(./public是我们赋予的根目录),若文件存在则返回文件。
因此,若我们希望浏览器指向我们服务器时展示某个特定页面,我们将其放在public/index.html中。这就是我们的index文件。
<!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 请求。 反之,它可能会触发动作,它描述用户正在尝试做什么。
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,它确保当服务器返回错误代码时,拒绝返回的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);}
当请求失败时,我们不希望我们的页面丝毫不变,不给予任何提示。因此我们定义一个函数,名为reportError,至少在发生错误时向用户展示一个对话框。
function reportError(error) {alert(String(error));}
渲染组件
我们将使用一个方法,类似于我们在第十九章中所见,将应用拆分为组件。 但由于某些组件不需要更新,或者在更新时总是完全重新绘制,所以我们不将它们定义为类,而是直接返回 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函数是我们在第十九章中使用的函数。
类似的函数用于渲染对话,包括评论列表和添加新评论的表单。
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 片段时,这种编程风格开始显得相当混乱。 有一个广泛使用的(非标准的)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解析的Promise,是强制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.setState(state);}setState(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.setState(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();
若你执行服务器并同时为localhost:8000/打开两个浏览器窗口,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。
