From 97fba152c21d67d52c1c82c40f1d1a2fdc07ae58 Mon Sep 17 00:00:00 2001 From: wizardforcel <562826179@qq.com> Date: Sat, 2 Jun 2018 14:52:06 +0800 Subject: [PATCH] 21. --- 21.md | 498 +++++++++++++++++++++++++--------------------------------- 1 file changed, 214 insertions(+), 284 deletions(-) diff --git a/21.md b/21.md index c61726a..1437297 100644 --- a/21.md +++ b/21.md @@ -381,220 +381,80 @@ new SkillShareServer(Object.create(null)).start(8000); ### 21.5 客户端 -对话管理网站的客户端部分由三个文件组成:HTML页面、样式表以及JavaScript文件。 +技能分享网站的客户端部分由三个文件组成:微型 HTML 页面、样式表以及 JavaScript 文件。 #### 21.5.1 HTML 在网络服务器提供文件服务时,有一种广为使用的约定是:当请求直接访问与目录对应的路径时,返回名为index.html的文件。我们使用的文件服务模块ecstatic就支持这种约定。当请求路径为/时,服务器会搜索文件./public/index.html(./public是我们赋予的根目录),若文件存在则返回文件。 -因此,若我们希望浏览器指向我们服务器时展示某个特定页面,我们将其放在public/index.html中。这就是我们最初的index文件。 +因此,若我们希望浏览器指向我们服务器时展示某个特定页面,我们将其放在`public/index.html`中。这就是我们的`index`文件。 ```html - + Skill Sharing -

Skill sharing

+

Skill Sharing

-

Your name:

- -
-``` - -该文件中定义了文档标题,并包含了样式表,样式表中定义了一些样式,除了别的元素之外,还给每条对话添加了边框。随后我们添加标题和表示姓名的字段。我们希望用户将名字填写到该字段中,这样我们可以将姓名附加到用户提交的对话和评论中。 - -其中ID为”talks”的<div>元素包含了当前的对话列表。当脚本从服务器接收到任务列表后会填充该列表。 - -接下来我们编写创建新对话的表单。 - -```html -
-

Submit a talk

- Title: -
- Summary: - -
-``` - -脚本文件将会在表单的submit按钮上添加事件处理器,通过这个事件向服务器发送创建会话的HTTP请求。 - -接下来看一个更为神秘的模块,我们将其显示样式设置为none,防止该模块演示在页面上。你能猜到这是为什么设计的吗? - -```html - -``` - -使用JavaScript代码创建DOM结构会产生丑陋的代码。你可以通过引入辅助函数,例如第13章中的elt函数来使得代码稍微优雅一点,但其结果依然不如HTML,可以把HTML看成一种用于表达DOM结构的领域特定语言。 - -为了创建会话的DOM结构,我们的程序会定义一个简单的模板系统,使用文档中隐藏的DOM结构来初始化新的DOM结构,并使用特定会话中的值替换占位符。占位符包裹在两个大括号之间。 - -最后,HTML文档包含脚本文件,脚本文件中包含客户端代码。 - -```html ``` -#### 21.5.2 启动 +它定义了文档标题并包含一个样式表,除了其它东西,它定义了几种样式,确保对话之间有一定的空间。 -页面加载后客户端首先向服务器请求当前的对话集。由于我们打算建立许多HTTP请求,我们再次定义了XMLHttpRequest的简单包装器,该函数接受一个对象,用于配置请求,并在请求结束时调用回调函数。 +最后,它在页面顶部添加标题,并加载包含客户端应用的脚本。 + +### 动作 + +应用程序状态由对话列表和用户名称组成,我们将它存储在一个`{talks, user}`对象中。 我们不允许用户界面直接操作状态或发送 HTTP 请求。 反之,它可能会触发动作,它描述用户正在尝试做什么。 ```js -function request(options, callback) { - var req = new XMLHttpRequest(); - req.open(options.method || "GET", options.pathname, true); - req.addEventListener("load", function() { - if (req.status < 400) - callback(null, req.responseText); - else - callback(new Error("Request failed: " + req.statusText)); - }); - req.addEventListener("error", function() { - callback(new Error("Network error")); - }); - req.send(options.body || null); -} -``` - -最初的请求将接收到的对话显示在屏幕上,并调用waitForChanges来启动长轮询过程。 - -```js -var lastServerTime = 0; - -request({pathname: "talks"}, function(error, response) { - if (error) { - reportError(error); - } else { - response = JSON.parse(response); - displayTalks(response.talks); - lastServerTime = response.serverTime; - waitForChanges(); +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); } -}); -``` - -变量lastServerTime用于跟踪最后从服务器接收更新信息的时间。在第一个请求之后,客户端的对话视图需要在请求响应到来时与服务器的视图同步,因此响应中的serverTime属性给lastServerTime提供了合适初值。 - -当请求失败时,我们不希望我们的页面丝毫不变,不给予任何提示。因此我们定义一个函数,名为reportError,至少在发生错误时向用户展示一个对话框。 - -```js -function reportError(error) { - if (error) - alert(error.toString()); + return state; } ``` -该函数检查是否有实际的错误,只有当确实发生错误时才弹出警告框。这样我们可以忽略掉响应直接将该函数传递给request。这确保请求失败时会向用户报告错误信息。 +我们将用户的名字存储在`localStorage`中,以便在页面加载时恢复。 -#### 21.5.3 显示会话 - -为了能够在数据修改时更新对话视图,客户端必须持续跟踪当前显示的对话。这样,当屏幕上已存在的对话的新版本到来时,客户端可以使用更新后的对话替换原来的版本。类似地,当对话删除的信息到来时,必须从文档中删除当时的DOM元素。 - -函数displayTalks用于显示最初的对话,且在某些信息发生改动时更新对话。该函数使用shownTalks对象记录当前在屏幕上的对话,该对象保存了对话和DOM节点的关系。 +需要涉及服务器的操作使用`fetch`,将网络请求发送到前面描述的 HTTP 接口。 我们使用包装函数`fetchOK`,它确保当服务器返回错误代码时,拒绝返回的`Promise`。 ```js -var talkDiv = document.querySelector("#talks"); -var shownTalks = Object.create(null); - -function displayTalks(talks) { - talks.forEach(function(talk) { - var shown = shownTalks[talk.title]; - if (talk.deleted) { - if (shown) { - talkDiv.removeChild(shown); - delete shownTalks[talk.title]; - } - } else { - var node = drawTalk(talk); - if (shown) - talkDiv.replaceChild(node, shown); - else - talkDiv.appendChild(node); - shownTalks[talk.title] = node; - } +function fetchOK(url, options) { + return fetch(url, options).then(response => { + if (response.status < 400) return response; + else throw new Error(response.statusText); }); } ``` -我们使用HTML文档中的模板来构建对话的DOM结构。首先我们必须定义instantiateTemplate,用于查找并填充模板。 - -模板中的name参数是模板名称。为了查找模板元素,我们搜索一个类名与模板名称匹配的元素,该元素是ID为template的元素的孩子节点。使用querySelector方法可以轻松完成该任务。在HTML页面中有名为talk和comment的模板。 - -```js -function instantiateTemplate(name, values) { - function instantiateText(text) { - return text.replace(/\{\{(\w+)\}\}/g, function(_, name) { - return values[name]; - }); - } - function instantiate(node) { - if (node.nodeType == document.ELEMENT_NODE) { - var copy = node.cloneNode(); - for (var i = 0; i < node.childNodes.length; i++) - copy.appendChild(instantiate(node.childNodes[i])); - return copy; - } else if (node.nodeType == document.TEXT_NODE) { - return document.createTextNode( - instantiateText(node.nodeValue)); - } else { - return node; - } - } - - var template = document.querySelector("#template ." + name); - return instantiate(template); -} -``` - -每个DOM节点都有cloneNode方法,用于创建节点的拷贝。除非该函数的第一个参数为true,否则该函数不会拷贝节点的子节点。instantiate函数则用于递归构建模板的拷贝,并根据值填充模板。 - -instantiateTemplate的第二个参数是一个对象,其属性是想要填充到模板中的字符串。模板中像{{title}}这样的占位符会被替换为value的title属性的值。 - -这是非常原始的模板方法,但这对于实验drawTalk来说已经足够了。 - -```js -function drawTalk(talk) { - var node = instantiateTemplate("talk", talk); - var comments = node.querySelector(".comments"); - talk.comments.forEach(function(comment) { - comments.appendChild( - instantiateTemplate("comment", comment)); - }); - - node.querySelector("button.del").addEventListener( - "click", deleteTalk.bind(null, talk.title)); - - var form = node.querySelector("form"); - form.addEventListener("submit", function(event) { - event.preventDefault(); - addComment(talk.title, form.elements.comment.value); - form.reset(); - }); - return node; -} -``` - -在实例化talk模板后,还需要修补一些数据。首先,我们需要反复实例化comment模板,并将实例化结果添加到类为comments的节点之中。接下来,必须要在删除对话的按钮上附加对应的事件处理器,并在表单上附加创建新评论的事件处理器。 - -#### 21.5.4 更新服务器 - -通过drawTalk函数调用注册的事件处理器会调用deleteTalk和addComment来执行实际的动作,以删除对话或添加评论。这些处理器需要根据指定的标题构建指向对话的URL,我们之前定义的talkURL辅助函数可以完成该任务。 +这个辅助函数用于为某个对话,使用给定标题建立 URL。 ```js function talkURL(title) { @@ -602,88 +462,190 @@ function talkURL(title) { } ``` -deleteTalk函数会发送DELETE请求,并在失败时报告错误信息。 +当请求失败时,我们不希望我们的页面丝毫不变,不给予任何提示。因此我们定义一个函数,名为reportError,至少在发生错误时向用户展示一个对话框。 ```js -function deleteTalk(title) { - request({pathname: talkURL(title), method: "DELETE"}, - reportError); +function reportError(error) { + alert(String(error)); } ``` -添加评论需要构建用于表示评论的JSON数据,并使用POST请求提交该数据。 +#### 渲染组件 + +我们将使用一个方法,类似于我们在第十九章中所见,将应用程序拆分为组件。 但由于某些组件不需要更新,或者在更新时总是完全重新绘制,所以我们不将它们定义为类,而是直接返回 DOM 节点的函数。 例如,下面是一个组件,显示用户可以向它输入名称的字段的: ```js -function addComment(title, comment) { - var comment = {author: nameField.value, message: comment}; - request({pathname: talkURL(title) + "/comments", - body: JSON.stringify(comment), - method: "POST"}, - reportError); -} -``` - -变量nameField用于设置评论的author属性,该属性对应于页面顶端的<input>字段,允许用户指定他们的姓名。我们也可以将该字段填写到localStorage中,这样每次重新载入页面时用户不必再次填写姓名。 - -```js -var nameField = document.querySelector("#name"); - -nameField.value = localStorage.getItem("name") || ""; - -nameField.addEventListener("change", function() { - localStorage.setItem("name", nameField.value); -}); -``` - -页面底端的表单通过submit的事件处理器发表新对话。事件处理器阻止了时间的默认效果(会导致重新加载页面),清空表单,并发送创建对话的PUT请求。 - -```js -var talkForm = document.querySelector("#newtalk"); - -talkForm.addEventListener("submit", function(event) { - event.preventDefault(); - request({pathname: talkURL(talkForm.elements.title.value), - method: "PUT", - body: JSON.stringify({ - presenter: nameField.value, - summary: talkForm.elements.summary.value - })}, reportError); - talkForm.reset(); -}); -``` - -#### 21.5.5 提示更改 - -这里需要指出的是不同修改应用程序状态的函数(创建删除任务以及添加评论)都不必确保他们的修改对屏幕可见。这些函数只需要告知服务器,并依赖长轮询机制来触发页面的适当更新。 - -考虑到我们在服务器中实现的机制,以及我们定义displayTalks来处理会话更新的方式,实际的长轮询代码简单得令人惊讶。 - -```js -function waitForChanges() { - request({pathname: "talks?changesSince=" + lastServerTime}, - function(error, response) { - if (error) { - setTimeout(waitForChanges, 2500); - console.error(error.stack); - } else { - response = JSON.parse(response); - displayTalks(response.talks); - lastServerTime = response.serverTime; - waitForChanges(); +function renderUserField(name, dispatch) { + return elt("label", {}, "Your name: ", elt("input", { + type: "text", + value: name, + onchange(event) { + dispatch({type: "setUser", user: event.target.value}); } - }); + })); } ``` -该函数在程序启动时调用一次,然后不断调用该函数确保长连接请求一直处于活跃状态。当请求失败时,我们不需要调用reportError,因为若每次请求无法到达服务器,都弹出一个对话框,会让用户感到厌烦。相反,我们将错误信息写到控制台中(易于调试),并在2.5秒之后再次进行尝试。 +用于构建DOM元素的`elt`函数是我们在第十九章中使用的函数。 -每当请求成功后,客户端会将新数据展示在屏幕中并根据接收到的数据更新lastServerTime,确保该属性与我们接收到数据的新时间点相一致。接着立即重新发起请求来等待下一轮更新。 +类似的函数用于渲染对话,包括评论列表和添加新评论的表单。 -若你执行服务器并同时打开两个浏览器窗口,都输入localhost:8000/,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。 +```js +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 函数调用,就像我们在这里用的东西。 + +评论更容易渲染。 + +```js +function renderComment(comment) { + return elt("p", {className: "comment"}, + elt("strong", null, comment.author), + ": ", comment.message); +} +``` + +最后,用户可以用于创建新对话的表现,渲染为这样。 + +```js +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`,并且在新的对话集可用时,调用回调函数。 + +```js +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`协议头的值为下一次迭代而存储。 + +### 应用 + +以下组件将整个用户界面结合在一起。 + +```js +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; + } + } +} +``` + +当对话改变时,这个组件重新绘制所有这些组件。 这很简单,但也是浪费。 我们将在练习中回顾一下。 + +我们可以像这样启动应用: + +```js +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/`打开两个浏览器窗口,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。 ### 21.6 习题 -下面的习题涉及修改本章中定义的系统。为了使用该系统进行工作,请确保首先下载代码([http://eloquentjavascript.net/code/skillshare.zip](http://eloquentjavascript.net/code/skillshare.zip))并安装了Node([http://nodejs.org/](http://nodejs.org/))。 +下面的习题涉及修改本章中定义的系统。为了使用该系统进行工作,请确保首先下载[代码](http://eloquentjavascript.net/code/skillshare.zip),安装了 [Node](http://nodejs.org/),并使用`npm install`安装了项目的所有依赖。 #### 21.6.1 磁盘持久化 @@ -695,36 +657,4 @@ function waitForChanges() { 由于我们常常无法在DOM节点中找到唯一替换的位置,因此整批地重绘对话是个很好的工作机制。但这里有个例外,若你开始在对话的评论字段中输入一些文字,而在另一个窗口向同一条对话添加了一条评论,那么第一个窗口中的字段就会被重绘,会移除掉其内容和焦点。 -在热烈的讨论中,当多个人向同一条对话中添加评论时,这种情况会非常烦人。你能想出一种方法来避免这个问题吗? - -#### 21.6.3 更好的模板 - -许多模板系统除了填充一些字符串外还能完成更多工作。至少这些模板系统支持条件包含某部分模板,类似于if语句;或者可以重复一部分模板,类似于循环。 - -若我们可以为数组中的每个元素重复一段模板,我们就不需要第二个模板(“comment”)了。而可以指定talk模板循环扫描talk持有的comments属性,并逐个渲染节点,这些节点组成了数组中的每个元素所对应的评论。 - -模板如下所示。 - -```html -
-
- {{author}}: {{message}} -
-
-``` - -其思想是:在模板实例化时,每当发现某个节点有template-repeat属性,实例化代码就会认为标签中属性名在对象中对应的属性是数组,并循环遍历该属性。对于数组中的每个元素都会添加一个节点的实例。模板的上下文(instantiateTemplate中的values变量)在循环中会指向数组中的每个元素,因此{{author}}将会查找comment对象而非原来的上下文(talk对象)。 - -重写instantiateTemplate来实现该特性并修改模板,使用该特性来移除掉drawTalk中显示渲染评论的代码。 - -为了能在特定值为true或者false时可以省略模板中的一部分,你应该如何在节点中添加条件实例化呢? - -#### 21.6.4 无脚本化 - -当某些人使用浏览器访问我们的网站时,若浏览器禁用了JavaScript或只是无法显示JavaScript,他们将会看到一个完全损坏,无法使用的页面。这并不是一件好事。 - -某些类型的应用程序不使用JavaScript是无法完成的。对于其他应用,你不需要关心客户端是否支持运行脚本。但对于有大量用户的网站,支持无脚本用户是必要的。 - -尝试思考一下,如何在不使用JavaScript下建立一个技能分享网站,保留其基本功能。这个版本不需要加入自动更新,人们可以使用传统的方式来刷新页面。但查看存在的对话,创建新节点,并提交评论等功能需要完好。 - -本书并不要求读者实际实现该网站,画出大致解决方案就足够了。是不是修订过的方法比最初的方法更吸引你呢? \ No newline at end of file +在激烈的讨论中,多人同时添加评论,这将是非常烦人的。 你能想出办法解决它吗?