JavaScript 编程精解 中文第三版 二十一、项目:技能分享网站

news/2024/5/13 13:33:10/文章来源:https://blog.csdn.net/weixin_34381666/article/details/89590603

二十一、项目:技能分享网站

原文: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

image

技能分享会是一个活动,其中兴趣相同的人聚在一起,针对他们所知的事情进行小型非正式的展示。在园艺技能分享会上,可以解释如何耕作芹菜。如果在编程技能分享小组中,你可以顺便给每个人讲讲 Node.js。

在计算机领域中,这类聚会往往名为用户小组,是开阔眼界、了解行业新动态或仅仅接触兴趣相同的人的好方法。许多大城市都会有 JavaScript 聚会。这类聚会往往是可以免费参加的,而且我发现我参加过的那些聚会都非常友好热情。

在最后的项目章节中,我们的目标是建立网站,管理特定技能分享会的讨论内容。假设一个小组的人会在成员办公室中定期举办关于独轮车的聚会。上一个组织者搬到了另一个城市,并且没人可以站出来接下来他的任务。我们需要一个系统,让参与者可以在系统中发言并相互讨论,这样就不需要一个中心组织人员了。

就像上一章一样,本章中的一些代码是为 Node.js 编写的,并且直接在你正在查看的 HTML页面中运行它不太可行。 该项目的完整代码可以从eloquentjavascript.net/code/skillsharing.zip下载。

设计

本项目的服务器部分为 Node.js 编写,客户端部分则为浏览器编写。服务器存储系统数据并将其提供给客户端。它也提供实现客户端系统的文件。

服务器保存了为下次聚会提出的对话列表。每个对话包括参与人员姓名、标题和该对话的相关评论。客户端允许用户提出新的对话(将对话添加到列表中)、删除对话和评论已存在的对话。每当用户做了修改时,客户端会向服务器发送关于更改的 HTTP 请求。

image

我们创建应用来展示一个实时视图,来展示目前已经提出的对话和评论。每当某些人在某些地点提交了新的对话或添加新评论时,所有在浏览器中打开页面的人都应该立即看到变化。这个特性略有挑战,网络服务器无法建立到客户端的连接,也没有好方法来知道有哪些客户端现在在查看特定网站。

该问题的一个解决方案叫作长时间轮询,这恰巧是 Node 的设计动机之一。

长轮询

为了能够立即提示客户端某些信息发生了改变,我们需要建立到客户端的连接。由于通常浏览器无法接受连接,而且客户端通常在路由后面,它无论如何都会拒绝这类连接,因此由服务器初始化连接是不切实际的。

我们可以安排客户端来打开连接并保持该连接,因此服务器可以使用该连接在必要时传送信息。

但 HTTP 请求只是简单的信息流:客户端发送请求,服务器返回一条响应,就是这样。有一种名为 WebSocket 的技术,受到现代浏览器的支持,是的我们可以建立连接并进行任意的数据交换。但如何正确运用这项技术是较为复杂的。

本章我们将会使用一种相对简单的技术:长轮询(Long Polling)。客户端会连续使用定时的 HTTP 请求向服务器询问新信息,而当没有新信息需要报告时服务器会简单地推迟响应。

只要客户端确保其可以持续不断地建立轮询请求,就可以在信息可用之后,从服务器快速地接收到信息。例如,若 Fatma 在浏览器中打开了技能分享程序,浏览器会发送请求询问是否有更新,且等待请求的响应。当 Iman 在自己的浏览器中提交了关于“极限降滑独轮车”的对话之后。服务器发现 Fatma 在等待更新请求,并将新的对话作为响应发送给待处理的请求。Fatma 的浏览器将会接收到数据并更新屏幕展示对话内容。

为了防止连接超时(因为连接一定时间不活跃后会被中断),长轮询技术常常为每个请求设置一个最大等待时间,只要超过了这个时间,即使没人有任何需要报告的信息也会返回响应,在此之后,客户端会建立一个新的请求。定期重新发送请求也使得这种技术更具鲁棒性,允许客户端从临时的连接失败或服务器问题中恢复。

使用了长轮询技术的繁忙的服务器,可以有成百上千个等待的请求,因此也就有这么多个 TCP 连接处于打开状态。Node简化了多连接的管理工作,而不是建立单独线程来控制每个连接,这对这样的系统是非常合适的。

HTTP 接口

在我们设计服务器或客户端的代码之前,让我们先来思考一下两者均会涉及的一点:双方通信的 HTTP 接口。

我们会使用 JSON 作为请求和响应正文的格式,就像第二十章中的文件服务器一样,我们尝试充分利用 HTTP 方法。所有接口均以/talks路径为中心。不以/talks开头的路径则用于提供静态文件服务,即用于实现客户端系统的 HTML 和 JavaScript 代码。

访问/talksGET请求会返回如下所示的 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?"}

为了支持长轮询,如果没有新的信息可用,发送到/talksGET请求可能会包含额外的标题,通知服务器延迟响应。 我们将使用通常用于管理缓存的一对协议头:ETagIf-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的处理器,用户使用它创建新对话。该函数需要检查数据中是否有presentersummary属性,这些属性都是字符串。任何来自外部的数据都可能是无意义的,我们不希望错误请求到达时会破坏我们的内部数据模型,或者导致服务崩溃。

若数据看起来合法,处理器会将对话转化为对象,存储在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 为/talksGET请求到来时,它可能是一个常规请求或一个长轮询请求。

我们可能在很多地方,将对话列表发送给客户端,因此我们首先定义一个简单的辅助函数,它构建这样一个数组,并在响应中包含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-MatchPrefer标头。 Node 在其小写名称下存储协议头,根据规定其名称是不区分大小写的。

router.add("GET", /^\/talks$/, async (server, request) => {let tag = /"(.*)"/.exec(request.headers["if-none-match"]);let wait = /\bwait=(\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文件。

<!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/打开两个浏览器窗口,你可以看到在一个窗口中执行动作时,另一个窗口中会立即做出反应。

习题

下面的习题涉及修改本章中定义的系统。为了使用该系统进行工作,请确保首先下载代码,安装了 Node,并使用npm install安装了项目的所有依赖。

磁盘持久化

技能分享服务只将数据存储在内存中。这就意味着当服务崩溃或以为任何原因重启时,所有的对话和评论都会丢失。

扩展服务使得其将对话数据存储到磁盘上,并在程序重启时自动重新加载数据。不要担心效率,只要用最简单的代码让其可以工作即可。

重置评论字段

由于我们常常无法在 DOM 节点中找到唯一替换的位置,因此整批地重绘对话是个很好的工作机制。但这里有个例外,若你开始在对话的评论字段中输入一些文字,而在另一个窗口向同一条对话添加了一条评论,那么第一个窗口中的字段就会被重绘,会移除掉其内容和焦点。

在激烈的讨论中,多人同时添加评论,这将是非常烦人的。 你能想出办法解决它吗?

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.luyixian.cn/news_show_762251.aspx

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

用户登录界面(1.8版本--网站框架改进)

1.创建数据库db_user与表users 1 CREATE DATABASE db_user CHARACTER SET utf8;2 3 CREATE TABLE users (4 userid TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,5 username varchar(20),6 passwd varchar(20),7 email varchar(30),8 priority int9 ); 10…

linux --xampp 配置多个网站

我们想要在本地安装两个测试域名&#xff0c;www.abc.tld, www.xyz.tld, 分别指向到 htdoc 目录下的 abc.tld 和 xyz.tld 文件夹下。tld 是顶级域名 the top domain 的缩写。如果你喜欢&#xff0c;也可以用 abc.com, abc.net 这样的顶级域名来代替。这里使用 tld 主要是为了避…

Dropbox泄密事件仍在蔓延 一家成人网站被爆掉80万帐号密码

上周黑客在暗网相继泄漏了 Dropbox 和 Last.fm 两家公司的上亿条账号密码&#xff0c;暴露了互联网公司在保护用户信息上的严重缺陷。但现在这场“泄密事件”仍没有停止的迹象&#xff0c;最新的消息是黑客团队泄漏了色情网站 Brazzers 近 80 万条用户账号密码。 这个被黑客公布…

Jenkins+Git+Gitlab+Ansible实现持续集成自动化部署动态网站(二)--技术流ken

项目前言 在上一篇博客《JenkinsGitGitlabAnsible实现持续化集成一键部署静态网站&#xff08;一&#xff09;--技术流ken》中已经详细讲解了如何使用这四个工具来持续集成自动化部署一个静态的网站。 如果大家可以熟练掌握以上内容&#xff0c;势必会在工作中减轻不小的工作量…

Asp.net网站的简单发布

概述 网站是由一个个页面组成的&#xff0c;是万维网具体的变现形式&#xff0c;关于万维网&#xff0c;网页的方面的理论知识&#xff0c;大家可以看一看这篇博客&#xff1a;万维网文档&#xff0c;在这里就不多说了。网站的发布要到达的一个目的就是&#xff0c;别人可以通过…

SharePoint 2010 类似人人网站内信功能实施

简介&#xff1a;用SharePoint代码加实施的方式&#xff0c;完成类似人人网站内信功能&#xff0c;当然&#xff0c;实现的比较简单&#xff0c;样式也比较难看&#xff0c;只为给大家一个实施的简单思路&#xff0c;如有谬误&#xff0c;还请见谅。当然&#xff0c;还有就是截…

2019最受欢迎开源免费CMS建站系统排行榜

2019独角兽企业重金招聘Python工程师标准>>> 互联网的蓬勃发展&#xff0c;免费且开源的建站系统的层出不穷&#xff0c;而我们经常在网上看见有人问及”哪个CMS系统最好用”、”企业建站用哪个CMS系统最多”等类似问题&#xff0c;我们今天来说一下&#xff0c;201…

“网络推广”企业做了网站效果没有达到自己的营销效果

我们有理由相信每一公司做的设计不是给自己看的&#xff0c;当然是给客户看的&#xff0c;我们长处就在于我们的技术是基础&#xff0c;创意是翅膀&#xff0c;只有充分利用好网络&#xff0c;创意才能把您们的价值体现出来&#xff0c;传播出去。 我们不只是为您们的企业披上外…

一步步学习SPD2010--第十章节--SP网站品牌化(3)--在内容页中识别样式

一步步学习SPD2010--第十章节--SP网站品牌化&#xff08;3&#xff09;--在内容页中识别样式 当你在SPD中显示内容页时&#xff0c;尽管设计视图显示了内容页与母版页的融合&#xff0c;你只能辨识储存在内容页的部件的CSS样式。使用页面的代码视图&#xff0c;来辨识储存在内容…

Challenge Checkio(python)—初尝python练习网站

最近在找点python语言练习的网站&#xff0c;发现这个网站不错 http://www.checkio.org/ 页面设计的也比较漂亮&#xff0c;比较适合学习python的语法知识。不过注册这个网站 开始就得解决一个python问题&#xff0c;不过很简单。 1 #python3.3 is inside 2 def checkio(els):…

[Hugo+Netlify]从零开始建立并发布一个网站

现在有越来越多的开发者选择把自己的博客以静态网站的方式托管在 GitHub 上, 这样的方式可以通过诸如 Jekyll, Hexo,Hugo 等等现有的静态博客生成工具, 非常便捷地搭建出一个样式美观的静态博客或文档页面。 通过对比&#xff0c;我选择了Hugo框架来创建网页&#xff0c;使用 N…

现在怎么访问安卓开发者网站

问题描述前两个月还能访问安卓开发者网站&#xff0c;现在就访问不了了&#xff0c;请问有什么方法么&#xff0c;以后想下官方最新的mac版本的ADT都困难。 解决方案1翻墙啊啊啊啊啊啊啊。 解决方案2翻墙软件&#xff0c;大家都懂的&#xff01; 解决方案3现在是不行了&#xf…

win8 iis安装及网站发布

win8 iis安装及网站发布 系统&#xff1a;win8 环境&#xff1a;vs2012 一&#xff1a;安装IIS 比较win7的安装来说&#xff0c;多选了几个钩钩&#xff0c;不然会报错&#xff0c;偶就遇到这样的错误。 控制面板-》程序和功能-》启动和关闭windows功能&#xff0c;钩钩图例&am…

Svg图片在asp网站上的使用

最近需要做一个动态的根据后台的返回数据而动态显示的导航图&#xff0c;然后我就采用了jqueryajaxSVG矢量图来实现这个功能。 首先&#xff0c;客户给了个ai的矢量图&#xff0c;我对这一块不懂就找以前同事帮我转成了svg图形&#xff0c;听说很简单&#xff0c;但是矢量图是封…

使用Let's Encrypt、Certbot为自己的网站加密

2019独角兽企业重金招聘Python工程师标准>>> 打开网站https://certbot.eff.org/选择服务器系统和软件环境以nginxubuntu为例&#xff1a;# 1. 安装需要软件 $ sudo apt-get update $ sudo apt-get install software-properties-common $ sudo add-apt-repository pp…

ASP.NET SignalR 与LayIM配合,轻松实现网站客服聊天室(三) 激动人心的时刻到啦,实现1v1聊天...

看起来挺简单&#xff0c;细节还是很多的&#xff0c;好&#xff0c;接上一篇&#xff0c;我们已经成功连接singalR服务器了&#xff0c;那么剩下的内容呢&#xff0c;就是一步一步实现聊天功能。 我们先看看缺什么东西 点击好友弹框之后&#xff0c;要给服务器发消息&#xff…

从一个新颖的网站看silverlight技术的视频广告前景

站点是&#xff1a;http://www.singtelrace.com/SingTel-Grid-Girls_Heart-Race.aspx 上面的MM们都是活动的&#xff0c;整体的视觉效果很有趣 这张截图看起来似乎没有什么&#xff0c;但要注意这个视频广告里的MM是活动的&#xff0c;为整个页面带来了生气。

长尾SEO策略应用之长尾词表制作

上周在博客上转载了一篇《热门还是长尾&#xff1f;大中型网站的关键词优化策略》的文章&#xff0c;文中提到长尾词在大中型网站中应用的种种好处。把这种长尾词策略应用到网站&#xff0c;我们所要做的就是&#xff1a;<?xml:namespace prefix o ns "urn:schemas-…

自动登录http://www.netyi.net/网站的工具

http://www.netyi.net/网站是一个书籍下载网站&#xff0c;大约一年多以前我就认识这个网站&#xff0c;经常从上面下载书籍。它的书还是不错的&#xff0c;很多书其他地方找不到&#xff0c;这里却有。但是下载书会减少你的积分&#xff0c;获得积分的的途径是上传书籍&#x…

详解网站性能测试指标

通用指标&#xff08;指Web应用服务器、数据库服务器必需测试项)Web服务器指标数据库服务器性能指标系统的瓶颈定义稳定系统的资源状态通俗理解&#xff1a;日访问量常用页面最大并发数同时在线人数访问相应时间案例&#xff1a;最近公司一个项目&#xff0c;是个门户网站&…