摘要:來源編程精解中文第三版翻譯項目原文譯者飛龍協議自豪地采用谷歌翻譯部分參考了編程精解第版技能分享會是一個活動,其中興趣相同的人聚在一起,針對他們所知的事情進行小型非正式的展示。所有接口均以路徑為中心。
來源:ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目原文:Project: Skill-Sharing Website
譯者:飛龍
協議:CC BY-NC-SA 4.0
自豪地采用谷歌翻譯
部分參考了《JavaScript 編程精解(第 2 版)》
If you have knowledge, let others light their candles at it.
Margaret Fuller
技能分享會是一個活動,其中興趣相同的人聚在一起,針對他們所知的事情進行小型非正式的展示。在園藝技能分享會上,可以解釋如何耕作芹菜。如果在編程技能分享小組中,你可以順便給每個人講講 Node.js。
在計算機領域中,這類聚會往往名為用戶小組,是開闊眼界、了解行業新動態或僅僅接觸興趣相同的人的好方法。許多大城市都會有 JavaScript 聚會。這類聚會往往是可以免費參加的,而且我發現我參加過的那些聚會都非常友好熱情。
在最后的項目章節中,我們的目標是建立網站,管理特定技能分享會的討論內容。假設一個小組的人會在成員辦公室中定期舉辦關于獨輪車的聚會。上一個組織者搬到了另一個城市,并且沒人可以站出來接下來他的任務。我們需要一個系統,讓參與者可以在系統中發言并相互討論,這樣就不需要一個中心組織人員了。
就像上一章一樣,本章中的一些代碼是為 Node.js 編寫的,并且直接在你正在查看的 HTML頁面中運行它不太可行。 該項目的完整代碼可以從eloquentjavascript.net/code/skillsharing.zip下載。
設計本項目的服務器部分為 Node.js 編寫,客戶端部分則為瀏覽器編寫。服務器存儲系統數據并將其提供給客戶端。它也提供實現客戶端系統的文件。
服務器保存了為下次聚會提出的對話列表。每個對話包括參與人員姓名、標題和該對話的相關評論。客戶端允許用戶提出新的對話(將對話添加到列表中)、刪除對話和評論已存在的對話。每當用戶做了修改時,客戶端會向服務器發送關于更改的 HTTP 請求。
我們創建應用來展示一個實時視圖,來展示目前已經提出的對話和評論。每當某些人在某些地點提交了新的對話或添加新評論時,所有在瀏覽器中打開頁面的人都應該立即看到變化。這個特性略有挑戰,網絡服務器無法建立到客戶端的連接,也沒有好方法來知道有哪些客戶端現在在查看特定網站。
該問題的一個解決方案叫作長時間輪詢,這恰巧是 Node 的設計動機之一。
長輪詢為了能夠立即提示客戶端某些信息發生了改變,我們需要建立到客戶端的連接。由于通常瀏覽器無法接受連接,而且客戶端通常在路由后面,它無論如何都會拒絕這類連接,因此由服務器初始化連接是不切實際的。
我們可以安排客戶端來打開連接并保持該連接,因此服務器可以使用該連接在必要時傳送信息。
但 HTTP 請求只是簡單的信息流:客戶端發送請求,服務器返回一條響應,就是這樣。有一種名為 WebSocket 的技術,受到現代瀏覽器的支持,是的我們可以建立連接并進行任意的數據交換。但如何正確運用這項技術是較為復雜的。
本章我們將會使用一種相對簡單的技術:長輪詢(Long Polling)。客戶端會連續使用定時的 HTTP 請求向服務器詢問新信息,而當沒有新信息需要報告時服務器會簡單地推遲響應。
只要客戶端確保其可以持續不斷地建立輪詢請求,就可以在信息可用之后,從服務器快速地接收到信息。例如,若 Fatma 在瀏覽器中打開了技能分享程序,瀏覽器會發送請求詢問是否有更新,且等待請求的響應。當 Iman 在自己的瀏覽器中提交了關于“極限降滑獨輪車”的對話之后。服務器發現 Fatma 在等待更新請求,并將新的對話作為響應發送給待處理的請求。Fatma 的瀏覽器將會接收到數據并更新屏幕展示對話內容。
為了防止連接超時(因為連接一定時間不活躍后會被中斷),長輪詢技術常常為每個請求設置一個最大等待時間,只要超過了這個時間,即使沒人有任何需要報告的信息也會返回響應,在此之后,客戶端會建立一個新的請求。定期重新發送請求也使得這種技術更具魯棒性,允許客戶端從臨時的連接失敗或服務器問題中恢復。
使用了長輪詢技術的繁忙的服務器,可以有成百上千個等待的請求,因此也就有這么多個 TCP 連接處于打開狀態。Node簡化了多連接的管理工作,而不是建立多帶帶線程來控制每個連接,這對這樣的系統是非常合適的。
HTTP 接口在我們設計服務器或客戶端的代碼之前,讓我們先來思考一下兩者均會涉及的一點:雙方通信的 HTTP 接口。
我們會使用 JSON 作為請求和響應正文的格式,就像第二十章中的文件服務器一樣,我們嘗試充分利用 HTTP 方法。所有接口均以/talks路徑為中心。不以/talks開頭的路徑則用于提供靜態文件服務,即用于實現客戶端系統的 HTML 和 JavaScript 代碼。
訪問/talks的GET請求會返回如下所示的 JSON 文檔。
[{"title": "Unituning", "presenter": "Jamal", "summary": "Modifying your cycle for extra style", "comment": []}]
我們可以發送PUT請求到類似于/talks/Unituning之類的 URL 上來創建新對話,在第二個斜杠后的那部分是對話的名稱。PUT請求正文應當包含一個 JSON 對象,其中有一個presenter屬性和一個summary屬性。
因為對話標題可以包含空格和其他無法正常出現在 URL 中的字符,因此我們必須使用encodeURIComponent函數來編碼標題字符串,并構建 URL。
console.log("/talks/" + encodeURIComponent("How to Idle")); // → /talks/How%20to%20Idle
下面這個請求用于創建關于“空轉”的對話。
PUT /talks/How%20to%20Idle HTTP/1.1 Content-Type: application/json Content-Length: 92 {"presenter": "Maureen", "summary": "Standing still on a unicycle"}
我們也可以使用GET請求通過這些 URL 獲取對話的 JSON 數據,或使用DELETE請求通過這些 URL 刪除對話。
為了在對話中添加一條評論,可以向諸如/talks/Unituning/comments的 URL 發送POST請求,JSON 正文包含author屬性和message屬性。
POST /talks/Unituning/comments HTTP/1.1 Content-Type: application/json Content-Length: 72 {"author": "Iman", "message": "Will you talk about raising a cycle?"}
為了支持長輪詢,如果沒有新的信息可用,發送到/talks的GET請求可能會包含額外的標題,通知服務器延遲響應。 我們將使用通常用于管理緩存的一對協議頭:ETag和If-None-Match。
服務器可能在響應中包含ETag(“實體標簽”)協議頭。 它的值是標識資源當前版本的字符串。 當客戶稍后再次請求該資源時,可以通過包含一個If-None-Match頭來進行條件請求,該頭的值保存相同的字符串。 如果資源沒有改變,服務器將響應狀態碼 304,這意味著“未修改”,告訴客戶端它的緩存版本仍然是最新的。 當標簽與服務器不匹配時,服務器正常響應。
我們需要這樣的東西,通過它客戶端可以告訴服務器它有哪個版本的對話列表,僅當列表發生變化時,服務器才會響應。 但服務器不是立即返回 304 響應,它應該停止響應,并且僅當有新東西的可用,或已經過去了給定的時間時才返回。 為了將長輪詢請求與常規條件請求區分開來,我們給他們另一個標頭Prefer: wait=90,告訴服務器客戶端最多等待 90 秒的響應。
服務器將保留版本號,每次對話更改時更新,并將其用作ETag值。 客戶端可以在對話變更時通知此類要求:
GET /talks HTTP/1.1 If-None-Match: "4" Prefer: wait=90 (time passes) HTTP/1.1 200 OK Content-Type: application/json ETag: "5" Content-Length: 295 [....]
這里描述的協議并沒有任何訪問控制。每個人都可以評論、修改對話或刪除對話。因為因特網中充滿了流氓,因此將這類沒有進一步保護的系統放在網絡上最后可能并不是很好。
服務器讓我們開始構建程序的服務器部分。本節的代碼可以在 Node.js 中執行。
路由我們的服務器會使用createServer來啟動 HTTP 服務器。在處理新請求的函數中,我們必須區分我們支持的請求的類型(根據方法和路徑確定)。我們可以使用一長串的if語句完成該任務,但還存在一種更優雅的方式。
路由可以作為幫助把請求調度傳給能處理該請求的函數。路徑匹配正則表達式/^/talks/([^/]+)$/(/talks/帶著對話名稱)的PUT請求,應當由指定函數處理。此外,路由可以幫助我們提取路徑中有意義的部分,在本例中會將對話的標題(包裹在正則表達式的括號之中)傳遞給處理器函數。
在 NPM 中有許多優秀的路由包,但這里我們自己編寫一個路由來展示其原理。
這里給出router.js,我們隨后將在服務器模塊中使用require獲取該模塊。
const {parse} = require("url"); module.exports = class Router { constructor() { this.routes = []; } add(method, url, handler) { this.routes.push({method, url, handler}); } resolve(context, request) { let path = parse(request.url).pathname; for (let {method, url, handler} of this.routes) { let match = url.exec(path); if (!match || request.method != method) continue; let urlParts = match.slice(1).map(decodeURIComponent); return handler(context, ...urlParts, request); } return null; } };
該模塊導出Router類。我們可以使用路由對象的add方法來注冊一個新的處理器,并使用resolve方法解析請求。
找到處理器之后,后者會返回一個響應,否則為null。它會逐個嘗試路由(根據定義順序排序),當找到一個匹配的路由時返回true。
路由會使用context值調用處理器函數(這里是服務器實例),將請求對象中的字符串,與已定義分組中的正則表達式匹配。傳遞給處理器的字符串必須進行 URL 解碼,因為原始 URL 中可能包含%20風格的代碼。
文件服務當請求無法匹配路由中定義的任何請求類型時,服務器必須將其解釋為請求位于public目錄下的某個文件。服務器可以使用第二十章中定義的文件服務器來提供文件服務,但我們并不需要也不想對文件支持 PUT 和 DELETE 請求,且我們想支持類似于緩存等高級特性。因此讓我們使用 NPM 中更為可靠且經過充分測試的靜態文件服務器。
我選擇了ecstatic。它并不是 NPM 中唯一的此類服務,但它能夠完美工作且符合我們的意圖。ecstatic模塊導出了一個函數,我們可以調用該函數,并傳遞一個配置對象來生成一個請求處理函數。我們使用root選項告知服務器文件搜索位置。
const {createServer} = require("http"); const Router = require("./router"); const ecstatic = require("ecstatic"); const router = new Router(); const defaultHeaders = {"Content-Type": "text/plain"}; class SkillShareServer { constructor(talks) { this.talks = talks; this.version = 0; this.waiting = []; let fileServer = ecstatic({root: "./public"}); this.server = createServer((request, response) => { let resolved = router.resolve(this, request); if (resolved) { resolved.catch(error => { if (error.status != null) return error; return {body: String(error), status: 500}; }).then(({body, status = 200, headers = defaultHeaders}) => { response.writeHead(status, headers); response.end(body); }); } else { fileServer(request, response); } }); } start(port) { this.server.listen(port); } stop() { this.server.close(); } }
它使用上一章中的文件服務器的類似約定來處理響應 - 處理器返回Promise,可解析為描述響應的對象。 它將服務器包裝在一個對象中,它也維護它的狀態。
作為資源的對話已提出的對話存儲在服務器的talks屬性中,這是一個對象,屬性名稱是對話標題。這些對話會展現為/talks/[title]下的 HTTP 資源,因此我們需要將處理器添加我們的路由中供客戶端選擇,來實現不同的方法。
獲取(GET)單個對話的請求處理器,必須查找對話并使用對話的 JSON 數據作為響應,若不存在則返回 404 錯誤響應碼。
const talkPath = /^/talks/([^/]+)$/; router.add("GET", talkPath, async (server, title) => { if (title in server.talks) { return {body: JSON.stringify(server.talks[title]), headers: {"Content-Type": "application/json"}}; } else { return {status: 404, body: `No talk "${title}" found`}; } });
刪除對話時,將其從talks對象中刪除即可。
router.add("DELETE", talkPath, async (server, title) => { if (title in server.talks) { delete server.talks[title]; server.updated(); } return {status: 204}; });
我們將在稍后定義updated方法,它通知等待有關更改的長輪詢請求。
為了獲取請求正文的內容,我們定義一個名為readStream的函數,從可讀流中讀取所有內容,并返回解析為字符串的Promise。
function readStream(stream) { return new Promise((resolve, reject) => { let data = ""; stream.on("error", reject); stream.on("data", chunk => data += chunk.toString()); stream.on("end", () => resolve(data)); }); }
需要讀取響應正文的函數是PUT的處理器,用戶使用它創建新對話。該函數需要檢查數據中是否有presenter和summary屬性,這些屬性都是字符串。任何來自外部的數據都可能是無意義的,我們不希望錯誤請求到達時會破壞我們的內部數據模型,或者導致服務崩潰。
若數據看起來合法,處理器會將對話轉化為對象,存儲在talks對象中,如果有標題相同的對話存在則覆蓋,并再次調用updated。
router.add("PUT", talkPath, async (server, title, request) => { let requestBody = await readStream(request); let talk; try { talk = JSON.parse(requestBody); } catch (_) { return {status: 400, body: "Invalid JSON"}; } if (!talk || typeof talk.presenter != "string" || typeof talk.summary != "string") { return {status: 400, body: "Bad talk data"}; } server.talks[title] = {title, presenter: talk.presenter, summary: talk.summary, comments: []}; server.updated(); return {status: 204}; });
在對話中添加評論也是類似的。我們使用readStream來獲取請求內容,驗證請求數據,若看上去合法,則將其存儲為評論。
router.add("POST", /^/talks/([^/]+)/comments$/, async (server, title, request) => { let requestBody = await readStream(request); let comment; try { comment = JSON.parse(requestBody); } catch (_) { return {status: 400, body: "Invalid JSON"}; } if (!comment || typeof comment.author != "string" || typeof comment.message != "string") { return {status: 400, body: "Bad comment data"}; } else if (title in server.talks) { server.talks[title].comments.push(comment); server.updated(); return {status: 204}; } else { return {status: 404, body: `No talk "${title}" found`}; } });
嘗試向不存在的對話中添加評論會返回 404 錯誤。
長輪詢支持服務器中最值得探討的方面是處理長輪詢的部分代碼。當 URL 為/talks的GET請求到來時,它可能是一個常規請求或一個長輪詢請求。
我們可能在很多地方,將對話列表發送給客戶端,因此我們首先定義一個簡單的輔助函數,它構建這樣一個數組,并在響應中包含ETag協議頭。
SkillShareServer.prototype.talkResponse = function() { let talks = []; for (let title of Object.keys(this.talks)) { talks.push(this.talks[title]); } return { body: JSON.stringify(talks), headers: {"Content-Type": "application/json", "ETag": `"${this.version}"`} }; };
處理器本身需要查看請求頭,來查看是否存在If-None-Match和Prefer標頭。 Node 在其小寫名稱下存儲協議頭,根據規定其名稱是不區分大小寫的。
router.add("GET", /^/talks$/, async (server, request) => { let tag = /"(.*)"/.exec(request.headers["if-none-match"]); let wait = /wait=(d+)/.exec(request.headers["prefer"]); if (!tag || tag[1] != server.version) { return server.talkResponse(); } else if (!wait) { return {status: 304}; } else { return server.waitForChanges(Number(wait[1])); } });
如果沒有給出標簽,或者給出的標簽與服務器的當前版本不匹配,則處理器使用對話列表來響應。 如果請求是有條件的,并且對話沒有變化,我們查閱Prefer標題來查看,是否應該延遲響應或立即響應。
用于延遲請求的回調函數存儲在服務器的waiting數組中,以便在發生事件時通知它們。 waitForChanges方法也會立即設置一個定時器,當請求等待了足夠長時,以 304 狀態來響應。
SkillShareServer.prototype.waitForChanges = function(time) { return new Promise(resolve => { this.waiting.push(resolve); setTimeout(() => { if (!this.waiting.includes(resolve)) return; this.waiting = this.waiting.filter(r => r != resolve); resolve({status: 304}); }, time * 1000); }); };
使用updated注冊一個更改,會增加version屬性并喚醒所有等待的請求。
var changes = []; SkillShareServer.prototype.updated = function() { this.version++; let response = this.talkResponse(); this.waiting.forEach(resolve => resolve(response)); this.waiting = []; };
服務器代碼這樣就完成了。 如果我們創建一個SkillShareServer的實例,并在端口 8000 上啟動它,那么生成的 HTTP 服務器,將服務于public子目錄中的文件,以及/ talksURL 下的一個對話管理界面。
new SkillShareServer(Object.create(null)).start(8000);客戶端
技能分享網站的客戶端部分由三個文件組成:微型 HTML 頁面、樣式表以及 JavaScript 文件。
HTML在網絡服務器提供文件服務時,有一種廣為使用的約定是:當請求直接訪問與目錄對應的路徑時,返回名為index.html的文件。我們使用的文件服務模塊ecstatic就支持這種約定。當請求路徑為/時,服務器會搜索文件./public/index.html(./public是我們賦予的根目錄),若文件存在則返回文件。
因此,若我們希望瀏覽器指向我們服務器時展示某個特定頁面,我們將其放在public/index.html中。這就是我們的index文件。
Skill Sharing Skill Sharing
它定義了文檔標題并包含一個樣式表,除了其它東西,它定義了幾種樣式,確保對話之間有一定的空間。
最后,它在頁面頂部添加標題,并加載包含客戶端應用的腳本。
動作應用狀態由對話列表和用戶名稱組成,我們將它存儲在一個{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/打開兩個瀏覽器窗口,你可以看到在一個窗口中執行動作時,另一個窗口中會立即做出反應。
習題下面的習題涉及修改本章中定義的系統。為了使用該系統進行工作,請確保首先下載代碼,安裝了 Node,并使用npm install安裝了項目的所有依賴。
磁盤持久化技能分享服務只將數據存儲在內存中。這就意味著當服務崩潰或以為任何原因重啟時,所有的對話和評論都會丟失。
擴展服務使得其將對話數據存儲到磁盤上,并在程序重啟時自動重新加載數據。不要擔心效率,只要用最簡單的代碼讓其可以工作即可。
重置評論字段由于我們常常無法在 DOM 節點中找到唯一替換的位置,因此整批地重繪對話是個很好的工作機制。但這里有個例外,若你開始在對話的評論字段中輸入一些文字,而在另一個窗口向同一條對話添加了一條評論,那么第一個窗口中的字段就會被重繪,會移除掉其內容和焦點。
在激烈的討論中,多人同時添加評論,這將是非常煩人的。 你能想出辦法解決它嗎?
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/105071.html
摘要:在這樣的程序中,異步編程通常是有幫助的。最初是為了使異步編程簡單方便而設計的。在年設計時,人們已經在瀏覽器中進行基于回調的編程,所以該語言的社區用于異步編程風格。 來源:ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目原文:Node.js 譯者:飛龍 協議:CC BY-NC-SA 4.0 自豪地采用谷歌翻譯 部分參考了《JavaScript 編程精解(第 2 版)...
摘要:在本例中,使用屬性指定鏈接的目標,其中表示超文本鏈接。您應該認為和元數據隱式出現在示例中,即使它們沒有實際顯示在文本中。 來源:ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目原文:JavaScript and the Browser 譯者:飛龍 協議:CC BY-NC-SA 4.0 自豪地采用谷歌翻譯 部分參考了《JavaScript 編程精解(第 2 版)》 ...
摘要:相反,當響應指針事件時,它會調用創建它的代碼提供的回調函數,該函數將處理應用的特定部分。回調函數可能會返回另一個回調函數,以便在按下按鈕并且將指針移動到另一個像素時得到通知。它們為組件構造器的數組而提供。 來源:ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目原文:Project: A Pixel Art Editor 譯者:飛龍 協議:CC BY-NC-SA 4...
摘要:來源編程精解中文第三版翻譯項目原文譯者飛龍協議自豪地采用谷歌翻譯部分參考了編程精解第版,這是一本關于指導電腦的書。在可控的范圍內編寫程序是編程過程中首要解決的問題。我們可以用中文來描述這些指令將數字存儲在內存地址中的位置。 來源:ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目原文:Introduction 譯者:飛龍 協議:CC BY-NC-SA 4.0 自豪地...
摘要:為了運行包裹的程序,可以將這些值應用于它們。在瀏覽器中,輸出出現在控制臺中。在英文版頁面上運行示例或自己的代碼時,會在示例之后顯示輸出,而不是在瀏覽器的控制臺中顯示。這被稱為條件執行。 來源:ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目原文:Program Structure 譯者:飛龍 協議:CC BY-NC-SA 4.0 自豪地采用谷歌翻譯 部分參考了《J...
閱讀 516·2021-10-09 09:44
閱讀 2091·2021-09-02 15:41
閱讀 3555·2019-08-30 15:53
閱讀 1834·2019-08-30 15:44
閱讀 1290·2019-08-30 13:10
閱讀 1196·2019-08-30 11:25
閱讀 1462·2019-08-30 10:51
閱讀 3367·2019-08-30 10:49