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