2000字范文,分享全网优秀范文,学习好帮手!
2000字范文 > 基于jQuery/express/socket.io实现的匿名聊天室

基于jQuery/express/socket.io实现的匿名聊天室

时间:2019-11-04 03:19:07

相关推荐

基于jQuery/express/socket.io实现的匿名聊天室

关键词

前后端分离、jQuery、Ajax、express、mongoose、template模板引擎、cookie身份识别、JS动画、图片文件上传、分页、express中间件、定时删除、级联删除、倒计时、滚动条位置调整、回退自动刷新、轮询、websocket

目录

前言

项目介绍

功能实现

将Ajax请求的分页数据拼接在template模板中渲染

C3盒子模型完美实现房间信息的展示

前后端共用一个昵称池js文件

FormData实现带文件的表单提交

如何实现房间的定时删除,及相关信息的级联删除?

socket.io实现Websocket通信

写在最后

前言

项目源码:/will-iu/chat

php原版博客链接:/qq_42748385/article/details/103995128

项目介绍

这个项目对上一个PHP实现的匿名聊天室进行了改写,采用的前后端分离开发,后端使用NodeJs的express框架,使用mongoose模块操作数据库mongoDB,利用template模板引擎将后端返回的数据渲染在前端,客户端使用jquery封装好的ajax请求,聊天页面使用socket.io实现的多人即时通讯,并且对前端样式进行了优化,代码相比规范了许多。

下图是项目目录结构及服务器端使用的一些第三方模块,

public 文件夹存放静态资源文件,包括前端的html页面文件、css文件夹、js文件夹、前端使用的插件、img系统图片、upload用户上传的图片文件等。

index.html首页

页面实现有三个功能,用户将鼠标放在“说明书”字体图标上,会有C3动画显示出网站的使用指南,另外两个按钮点击后会进入聊天室大厅页面,两个按钮的区别是,后者会先清除此网站的cookie,这里简单说一下,网站是靠cookie识别用户身份,因为是匿名聊天室,所以数据库没有user这个集合,整体的页面样式是仿照mongoose官网写的。

room.html页面

聊天室大厅页面,当用户点击顶部的tab选项卡会向服务器发送ajax请求查询房间信息及页码信息渲染在客户端,并且tab选项卡切换时样式也会发生改变,而且相比上一个项目增加了创建带有图片的房间,用户选择图片后也会回显在客户端,若不选择图片则使用系统图片。当鼠标停留在房间上会显示房间描述信息,房间人数达到上限时修改样式且不可选中。分页的前后页按钮也会根据是否达到第一页和最后一页变得不可选中。

chat.html页面

当点击任意房间或创建房间后会携带房间id跳转到聊天页面,如果是第一次加入房间会随机分配一个不重复昵称,右上角会倒计时显示房间的剩余时间,当时间截至时,将自动退出并提示房间已销毁。如何退出房间?用户可以点击背景中的门退出房间,但考虑到会有用户使用浏览器的回退功能返回上一页,使用这种方法退出的房间还需要自动执行一遍刷新才能在“已加入房间”的列表中看到新加入的房间。

下面测试socket实现的多人即时聊天,

这里我使用了两个浏览器做测试,因为同一个浏览器的多个标签页还是会使用一个cookie,不能当作两个身份。使用socket的广播功能,可以实现在同一个房间里,任意一方发的消息会被同房间所有人接收到,请忽略录制软件的画质问题……

功能实现

记录几个相对重要的功能是如何实现的,和遇到的一些问题

将Ajax请求的分页数据拼接在template模板中渲染

项目里用到Ajax的地方很多,拿客户端room.js文件举例说明,有一个获取“加入的房间”列表的函数,这个函数在页面加载、点击顶部tab选项卡和底部的页码时调用,函数会接受一个page的参数,如果没有向函数传入参数,page会默认等于1,比如点击tab时。

然后就使用jQuery封装好的Ajax向服务器端发送get请求,请求成功会把获取到的数据拼接到定义好的模板里。

// 向服务器端发送请求 获取房间信息function getMyRoom(page = 1) {$.ajax({type: 'get',url: '/myRoom/' + page,success: function (response) {// console.log(response)// 将获取的数据与html定义的模板拼接var myRoomHTML=template('myRoomTpl', response);// 将拼接好的html代码插入到目标节点下$('#myRoom').html(myRoomHTML);// 页码模板渲染$('#pageBox1').html(template('pageTpl1', response));}})}

下面是服务器端定义的路由代码,用来响应客户端的http请求,因为一并实现了分页功能,代码有些长。在写代码时,先想清楚要给客户端返回哪些数据,在这里除了返回数据库查询的所有房间信息,还有实现分页会用到的当前页、总页数等,我还额外返回了一个页码的数组”[0,1,2...]“,因为这比较方便模板直接拼接。在这说一下populate,是express框架提供数据库关联查询的方法,使用前提是集合规则里定义了ref外键。

// 获取已加入房间的信息app.get('/myRoom/:page', async (req, res) => {// '+'将字符型转为Number类型,为统一返回的数据格式let page = +req.params.page;let num = 18;let totalPage = 0;let display;// 获取集合中的文档总数,count已被废弃,支持回调Role.countDocuments({user_id: id}, function (err, count) {// page总页数totalPage = Math.ceil(count / num);// 创建一个 1~n 的数组,作为分页的模板数据display = new Array(totalPage).toString().split(',').map(function (item, index) {return index + 1;});});// populate是根据规则定义的外键room_id级联查询角色相关的房间信息,过滤掉left和life// skip和limit实现分页查询,skip跳过前n条结果 limit限制显示n条结果let myRoom = await Role.find({user_id: id}).populate('room_id', '-room_left -room_life').skip((page - 1) * num).limit(num);res.send({myRoom,page,totalPage,display});})

当服务器端成功返回数据时,调用了客户端success回调,然后执行模板拼接,以下是定义在room.html文件里的房间模板,模板拼接完成就可以把html代码插入到目标节点上了,

<ul class="room_list" id="myRoom"></ul>……<!-- 加入过的房间模板 --><script type="text/html" id="myRoomTpl">{{each myRoom}}<li><!-- 因为服务器端已配置过静态资源路径,所以href可以简写 --><a href="/chat.html?id={{$value.room_id._id}}" class="room_item"><img src="{{$value.room_id.room_picture}}" title="{{$value.room_id.room_detail}}" alt=""><h5>{{$value.room_id.room_name}}</h5></a></li>{{/each}}</script>

这是分页模板,这里踩了个坑,禁用了a标签默认行为并指定点击事件,出现函数未定义错误,后来排查发现是顺序问题,模板拼接要在函数定义之后,我写习惯了,在getMyRoom这个函数的代码外面使用了$(function () {……}) ;规定最后加载。

<!-- 加入过的房间分页模板 --><script type="text/html" id="pageTpl1"><li><a href="javascript:;" onclick="getMyRoom({{page - 1}})" class="{{if page <= 1}}dis_pointer{{/if}}">«</a></li>{{each display}}<li><a href="javascript:;" onclick="getMyRoom({{$value}})"class="{{if $value == page}}active{{/if}}">{{$value}}</a></li>{{/each}}<li><a href="javascript:;" onclick="getMyRoom({{page + 1}})"class="{{if page >= totalPage}}dis_pointer{{/if}}">»</a></li></script>

C3盒子模型完美实现房间信息的展示

上一个项目在css样式这里遇到了问题,当时并不清楚每个属性的用法,只想着拼凑出我想要的样式就完事了。在一段时间的学习后,想实现出一个样式会先想能用到哪些属性,首先考虑到和布局有关的属性,比如这里定义三个宽度为父元素1/3的三个盒子,并使它们左浮动,然后针对每个盒子不同情况再分别定义其他样式,比如我让左边盒子里面的文字向左对齐,中间盒子的文字居中对齐,右边则右对齐,再使用box-sizing声明为C3盒子模型可以在不额外修改宽度前提下设置内边距,细微调整文字的位置,最后再写文字的样式,当然这可能不是最好的方案。

.box_top ul li {float: left;box-sizing: border-box;width: 250px;height: 50px;line-height: 50px;font-weight: 800;font-family: '宋体', Courier, monospace;}.role_name {padding-left: 10px;text-align: left;color: white;font-size: 1.6em;}.room_name {text-align: center;color: #FFD700;font-size: 2em;}.left_time {padding-right: 10px;text-align: right;color: #e6e2c3;font-style: italic;font-size: 1.3em;}

前后端共用一个昵称池js文件

关于随机昵称的获取是在后端实现的,如果在前端获取还要再发送请求把昵称发送给后端存入数据库。在前端执行动画前,昵称已经得知了,而执行随机动画也要用到存储所有昵称的js文件,首先后端引用其他js文件要使用module.exports导出,而前端再使用这个文件遇到module.exports不认识,会报错,还好在万能的百度上找到一个解决方法,感谢大佬的分享,

if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {module.exports = {roleNames};} else {if (typeof define === 'function' && define.amd) {define([], function () {return roleNames;});} else {window.roleNames = roleNames;}}

根据前端控制台的报错信息,没有module这个关键字,会将其识别为变量,而这个变量未被定义,所以判断它的类型是不是 “underfined”,是则隐藏下面的代码。当然能用json格式的文件存昵称池更好,也不会遇到以上的问题,但是原来的昵称都是单引号括着的,改起来麻烦就没用json。

FormData实现带文件的表单提交

// 创建新房间$('#submit').on('click', function () {var fd = new FormData($('#new_room')[0]);$.ajax({type: 'post',url: '/newRoom',data: fd,//formdata包含文件就属于复杂对象了,不设置contenttype会自动以url拼接方式传递,会报错contentType: false,//取消帮我们格式化数据,是什么就是什么processData: false,success: function (response) {// 创建完成后自动跳转location.href = 'chat.html?id=' + response._id;}})})// 当用户选择完文件以后,回显在客户端$('#room_picture').on('change', function () {// 1 创建文件读取对象var reader = new FileReader();// 2 读取文件reader.readAsDataURL(this.files[0]);// 3 监听onload事件reader.onload = function () {// 修改节点的属性和样式$('#preview').prop('src', reader.result);$('#preview').css('display', 'block');}});

顺便记录在此处踩的三个坑,

在使用ajax提交formData表单对象时,出现了错误,但浏览器页面自动刷新,错误一闪而过(实际上这个刷新是执行了表单提交,当时并不知道),console验证发现ajax并没有执行。正当我一筹莫展时发现了一处奇怪的地方,刷新后的url多了我的表单参数,我于是想到是不是进行了get方式提交,可是我form标签中没写任何提交方式,而且用的普通button标签按钮,又不是submit类型的input标签,怎么会提交呢?

当时我并没有在意这个问题,猜测应该是formData导致了表单自动提交。因为报的错误就是使用了formData后出现的,之前没做图片上传,用的serialize获取的表单数据,这让我以为问题都是出在formData上,事实并不是,当时内心OS“formData这玩应报错就自动提交?好奇怪的设定啊”,当时这里卡了几个小时,大脑都开始不正常思考了[/笑哭]。

var fd= new FormData();fd.append('room_name', '错哪了呢??');

这样写就不报错了,Ajax顺利执行,但页面还是一闪而过,纳闷了?难道不能像下面直接获取表单对象,只能一个个添加?

var fd = new FormData($('#new_room'));

在我把表单所有的输入数据一个个append到表单对象上时,又报错了……(这是后来的截图)

(没错,这是第三个BUG……)可页面仍然一闪而过,鬼知道具体错误是什么啊喂。

不知过了多久,我想到把button标签放在form表单外面尝试一下(我终于想起它了,哭了),页面不刷新啦!错误终于看得清了,上网查询得知,formdata包含图片文件就属于复杂对象了,不设置contenttype会自动以url拼接方式传递,就报错了。这个错误我清楚啊,给Ajax请求中添加这两句,以json对象字符串形式发送,而且后端还需要JSON.parse()将字符串对象转化为json,但formdata需要使用后端的 formidable 模块进行解析,还是遇到了其他问题,

data: JSON.stringify(params),contentType: 'application/json',

最后解决办法是给Ajax添加这样两行代码,原博客连接

//不用设置请求头,但是jq帮我们自动设置了,这样的话需要我们自己取消掉contentType: false,//取消帮我们格式化数据,是什么就是什么processData: false,

之后,我开始思考为什么将表单转换为表单对象的方式有问题呢,一定要一个个append吗?最后发现,jQuery获取到的和原生js获取到的是不一样的,原生获取的结果是当前的dom节点,jQuery获取的结果是一个数组。原博客链接

var fd = new FormData(document.getElementById('new_room'));正确var fd = new FormData($('#new_room'));错误var fd = new FormData($('#new_room')[0]);正确

还剩最后一个问题了,知道和button有关,就好解决多了,使用button 按钮提交表单的时候,要设置type="button" button在浏览器中默认的类型为submit,原博客链接

这次的经历让我认识到一个问题,少一点自我为是,我认为除了submit类型的input,正常使用button标签和表单提交扯不上关系,可我不知道的东西太多了,要是能早点验证button也不会浪费那么多时间,吃了不好好看MDN的亏。

如何实现房间的定时删除,及相关信息的级联删除?

在上一个项目里实现定时删除用的方法是,在用户创建房间时把用户输入的分钟累加在当前时间上,因为php有封装好这样的方法,而且判断房间是否过期用mysql数据库delete between过去某个时间and当前时间的语句即可完成,

但是在mongoDB中,如此操作十分麻烦,还要引入其他的模块,于是我有了更好的办法。首先这个房间的过期时间有两个地方需要用到,一个是判断房间是否过期,一个是在进入房间时用倒计时的方式显示,这样的话不如直接将时间转换为毫秒数存储在数据库中,

// 获取房间截止时间的毫秒数let room_life = +new Date() + fields.room_life * 60 * 1000;

把用户输入的以分钟为单位的房间寿命,转换为毫秒并与当前时间的毫秒数相加,判断房间过期只要查询数据库中房间寿命小于当前时间毫秒数的房间,将其删除即可。不过不能只删除房间信息,还要把相关房间中的角色和消息记录一并删除,才更节省空间,如果定义了外键在MySQL中是可以级联删除的,但在mongoDB中没找到这个功能,只能手写代码实现级联删除,

// 获取现在时间毫秒数let nowTime = +new Date();// find的第三个参数是回调函数,作用是先获取过期房间id数组,然后分别删除与此房间相关的角色、消息等// 查询条件:房间截止日期小于当前时间await Room.find({room_life: {$lt: nowTime}}, '_id', async function (err, person) {// map 将对象转换为数组,结合$in,$in是数据库一种查询规则——包含在其中let deleteId = person.map(item => item._id);await Room.deleteMany({_id: {$in: deleteId}});await Role.deleteMany({room_id: {$in: deleteId}});await Msg.deleteMany({room_id: {$in: deleteId}});});

因为在获取“加入的聊天室”、“聊天室大厅”和进入一个聊天室时都需要对房间进行判断是否过期,如果过期则先执行删除再查询,所以可以把以上功能封装在一个函数里,需要时调用。但是用中间件实现更好,在我印象里一个中间件只能对应一个路由,有三个路由则需要写三个中间件,而中间件的代码都一样,这样太过冗余了。

不过一次意外尝试,惊喜发现路由也可以用数组的形式!

// 中间件。作用是每次get房间和进入聊天室时,先要判断房间是否过期app.use(['/myRoom', '/allRoom', '/roomInfo'], async (req, res, next) =>{//此处填写以上的删除代码……next();});// 获取已加入房间的信息app.get('/myRoom/:page', async (req, res) => {})// 获取全部房间的信息app.get('/allRoom/:page', async (req, res) => {})// 获取进入的房间信息app.get('/roomInfo', async (req, res) => {})

socket.io实现Websocket通信

在上一个项目里,使用的轮询每隔几秒获取后台的新消息,这种方式缺点很明显,频繁建立请求浪费带宽和服务器资源,而且多半是无用请求。轮询的方式属于服务器端被动发送数据,而此项目基于socket.io实现的双向数据通信,关于socket.io和websocket 网上有更专业的解答,我目前还只处在会用的阶段,所了解的socket.io是一个库,是实现基于websocket协议双向通信的工具。

说一下如何使用socket.io,先在shell控制台下载socket.io模块,并在服务器端引入,以下是服务器端代码

const express = require('express');const app = express();// socket模块,官方推荐这么使用,还需要注意一下下面的监听端口设置是http,不是appconst http = require('http').Server(app);const io = require('socket.io')(http);http.listen(3000, () => console.log('服务器启动成功'));// socket模块,当用户打开聊天窗口时触发,将客户端socket实例作为参数传入,socket参数用来识别是哪台主机的哪个应用,即ip+端口号io.on('connection', function (socket) {// console.log('a user connected');let roomId;//监听客户端join请求,目的是把当前用户划分到所属房间,使每个房间聊天互不影响socket.on('join', roomid => {roomId = roomid;socket.join(roomId); // 加入房间 })// 监听客户端发送消息的事件,并得到发送的消息相关数据socket.on('sendMsg', async (req) => {// 在数据库插入一条消息文档,返回插入的数据给reslet res = await Msg.create({_id: mongoose.Types.ObjectId(),role_id: req.role_id,room_id: req.room_id,msg_context: req.msg_context,msg_time: moment().format('YYYY-MM-DD HH:mm:ss')})// 将新消息发送给所有在此房间的用户,也包括自己io.sockets.in(roomId).emit('newMsg', res, req.role_name);});});

客户端websocket通信代码

<script src="/socket.io/socket.io.js"></script><script>// 创建 socket.io 实例,向服务器发送连接请求,默认连接到提供当前页面的主机var socket = io();// 发送事件并携带所在房间idsocket.emit('join', room_id);// 发送输入框消息,并清空输入框,调整滚动条$('.sendbtn').on("click", function () {let sendmsg = $('.txtmsg').val();if (sendmsg) { //判断是否有内容$('.txtmsg').val(''); //清空输入内容// 为什么要发送rolename,虽然插入数据库用不到,但是消息发送给其他用户时要显示的,而且插入数据时没有级联查询得不到var msg = {role_id: role_id,role_name: role_name,room_id: room_id,msg_context: sendmsg}socket.emit('sendMsg', msg);$('.box_center').scrollTop($('#chat_msg').height());}})// 监听服务器发送的事件,接受某用户发送的消息socket.on('newMsg', (res, roleName) => {// console.log(res)let my_msg = '';// 如果是自己发送的,则使气泡右浮动if (res.role_id == role_id) {my_msg = 'my_msg';}// 接收新消息前,获取混动条位置。let before = $('#chat_msg').height();// 给消息列表追加新消息节点$('#chat_msg').append("<li class='" + my_msg + "'>" + roleName + "&nbsp" + res.msg_time +"<br><div class='msgbox'>" + res.msg_context + "</div></li>");//如果位置不在最下方,说明在看之前的消息,那么新消息来时,滚动条位置不变;否则,说明在等新消息,滚动条滚至最下方,模拟qqif ($('.box_center').scrollTop() + $('.box_center').outerHeight(true) == before) {$('.box_center').scrollTop(before);}});</script>

详细见

/socket/socket-ulbj2eii.html

/qq_29484367/article/details/50806374

写在最后

最后,我想说真是万事开头难,无论是项目开发初始还是项目开发完写博客都是无从下手,尤其是对于我这样一个完美主义的人来说,会出现拖延的现象。如果有同样困扰的朋友,我的建议是,先实现最简单的部分,尝试先跑通程序,然后再逐步完善,不要一上来就想做到尽善尽美,不要放弃坚持下去,走出这第一步,后面就顺理成章了,共勉。

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。