之前学习使用uni-app简单实现一个在线聊天的功能,今天记录一下项目核心功能的实现过程。页面UI以及功能逻辑全部来源于微信,即时聊天业务的实现使用socket.io,前端使用uni-app开发,后端服务器基于node实现,数据库选择mongoDB。

首先在系统中注册两个用户,将对方添加为好友后,开始正常聊天,先简单看一下聊天功能的效果图,分为私聊和群聊两大部分

一对一聊天效果:

在好友列表中添加群成员创建群后即可群聊,群聊效果:

目录

聊天信息列表的渲染

聊天信息发送的相关问题

实现一对一聊天

关于websocket

建立连接

存储连接的用户

发送聊天信息

首页新消息提示

实现群聊

加入房间

发送群消息


聊天信息列表的渲染

聊天信息列表区域是一个滚动区,这里使用scroll-view组件,其中对于聊天信息展示,主要分为自己的消息和好友的消息,自己的消息位于右侧,好友的消息位于左侧,所以静态页面阶段要实现是左侧消息和右侧消息的页面布局,以及这些消息类型为文字,图片,语音,位置信息时的布局。

后端接口返回的聊天信息是按照时间顺序排列的,渲染聊天信息时使用v-for遍历接口返回的消息列表的内容即可,需要注意的是,还需要使用条件渲染v-if根据每一条消息的发送者id和当前用户的id判断消息的发送方和接受方,渲染在左右指定的区域,当前用户的id从本地存储localStorage中获取;还有就是使用条件渲染判断消息的类型,是文字,图片,语音或定位,合理展示。

{{handleTime(item.time)}}................................................

聊天信息发送的相关问题

点击发送按钮,正式将信息发送给服务器之前,还有几个问题需要解决,这里面有许多坑,在实现的时候走了不少弯路。

1.scroll-view如何始终定位在最底部?

如下图,当发送了一条聊天信息时,聊天信息列表就会增加这条消息,之所以能够看到这条消息,那是因为scroll-view的滚动条在消息添加时将位置定位到了最底部,这是需要进行一些处理的,默认效果是这样的

是不是很变扭?这样的用户体验很差,滚动条不会自动定位到底部,这里需要给scroll-view组件添加一个scroll-into-view属性,按照官方文档的说法它的值应为某子元素id。设置哪个方向可滚动,则在哪个方向滚动到该元素,也就是说可以动态的修改这个属性的值,从而让scroll-view组件的滚动到想要滚动的页面元素位置。

这里就给每一个scroll-view的子元素(聊天记录item)添加id属性,属性值为 msg + 每条聊天记录的id

......

在发送消息的方法中修改scroll-into-view的值scrollToView,让其为最新一条聊天记录即msg.length – 1的id值,必须使用在$nextTick回调中,这是为了在新的聊天记录渲染完毕后再去定位。

this.$nextTick(function(){this.scrollToView = 'msg' + this.msg[this.msg.length - 1].id;});

这样才能实现最终的效果

2.如何动态修改scroll-view的高度

如下图,点击 + 按钮发送位置信息时会弹出底部菜单栏,但此时scroll-view内的聊天内容会被覆盖,用户想要看最后一条记录还需操作滚动条,这也是不好的用户体验。

需要做到的是弹出底部菜单栏的同时减小聊天内容区域scroll-view组件的高度,让用户能够完整的看到最后的聊天记录。

需要获取底部菜单栏弹出的高度,随后让scroll-view组件减少这部分高度即可。在uni-app中无法操作dom,获取元素的尺寸使用createSelectorQuery获取页面节点,再用boundingClientRect查询节点的尺寸。官方文档:uni.createSelectorQuery() | uni-app官网

使用如下代码获取页面节点的尺寸,可能无法及时获取到(得到的可能是undefined),这里需要用定时器包裹,才能拿到菜单栏的高度

{{item.text}}......// 获取指定选择器元素的高度getHeight(classNa){setTimeout(() => {const query = uni.createSelectorQuery().in(this);query.select(classNa).boundingClientRect(data => {this.$emit('heightChange',data.height);}).exec();},10);},// 切换菜单栏显示隐藏changeMode(){if(this.showMore){this.showMore = !this.showMore;this.getHeight('.more-view');}},

拿到底部菜单栏的高度后,使用calc计算并修改行内样式,并修改scroll-view的元素内的子元素定位,这里修改scrollToView的值,一定要置空后再修改,否则会修改无效。

...... // 弹出菜单栏修改scroll-view高度handleHeightChange(height){this.scrollViewHeight= `calc(100vh - 208rpx - ${height}px - ${this.statusBarHeight}px)`;this.scrollToView = '';this.$nextTick(function(){this.scrollToView = 'msg' + this.msg[this.msg.length - 1].id;})}

实现一对一聊天

关于websocket

项目中使用的socket.io底层使用到的是websocket协议,可以实现服务器主动推送消息给客户端,一般应用于实时通信,在线支付等场景,虽然socket.io对其进行了封装,但对其原理的了解还是有必要的。

在websock出现之前,一般使用ajax轮询(设置定时器在相同时间间隔内反复发送请求到服务器拿到服务器最新的数据),长轮询(在指定时间内不让当前请求断开),流化技术等手段进行即时通信,这三者都基于http协议实现,但都非常占用服务器的资源, 显著增加了延时。

websocket协议解决这些缺点,它是一种全双工、双向、单套接字的连接,建立在TCP协议之上,当websocket连接建立后,服务器和客户端可以双向通信,具有以下特点:

1)建立在TCP协议之上,服务端的实现比较容易;

2)于HTTP协议有着良好的兼容性,默认的端口也是80和443,并且握手阶段采用HTTP协议;

3)数据格式轻量,性能开销小,通信高效;

4)可以发送文本,也可以发送二进制数据;

5)没有同源限制


http请求响应图解:

客户端发送请求,服务器响应,至此一次请求响应结束,再次获取服务端最新数据,需要再次重复上述过程;

websocket图解:

黄色部分是握手阶段,客户端给服务端发送请求,该请求基于http协议,服务器返回101状态码,代表成功建立连接,随后客户端和服务器可以开始全双工数据交互,且服务器可以主动推送消息给浏览器,下面是websocket的请求报文:

1.使用websocket请求行的路径是以ws开头,代表使用的是websocket协议

2.请求头Connection:Upgrade代表当前服务器这是一个升级的链接

3.请求头Upgrade:websocket代表需要将当前的链接升级为websocket链接

4.请求头Sec-WebSocket-Key: JnoOq+qL9WP3um80g1Sz3A==是客户端使用base64编码的24位随机字符序列,用户服务器标识当链接的客户端,同时要求服务器响应一个同样加密的Sec-WebSocket-Accept头作为应答;


websocket响应报文如下:

1.服务器响应101状态码代表websocket链接建立成功

2.响应头Sec-WebSocket-Accept: Eu6A8ipjouG1LVFt6xFMSrPFk1E=是对客户端请求头Sec-WebSocket-Key的应答,用于给客户端标识当前的服务器


客户端websocket实现

websocket是HTML5的新特性之一,首先你的浏览器必须支持websocket

1.创建WebSocket实例

const ws = new WebSocket('ws:localhost:8000');

参数url:ws://ip地址:端口号/资源名

2.WebSocket对象包含以下事件

open:连接建立时触发

message:客户端接收服务端数据时触发

error:通信发生错误时触发

close:连接关闭时触发

3.WebSocket对象常用方法

send():使用连接给服务端发送数据

客户端websocket代码模板:

;((doc,WebSocket) => {const msg = doc.querySelector('#msg');// 获取输入框,需要发送的消息const send = doc.querySelector('#send');// 发送按钮// 创建websocket实例const ws = new WebSocket('ws:localhost:8000');// 初始化const init = () => {bindEvent();}// 绑定事件function bindEvent () {send.addEventListener('click',handleSendBtnClick,false);ws.addEventListener('open',handleOpen,false);ws.addEventListener('close',handleClose,false);ws.addEventListener('error',handleError,false);ws.addEventListener('message',handleMessage,false);}function handleSendBtnClick () {const message = msg.value;// 将数据发送给服务器ws.send(JSON.stringify({message:message}));msg.value = '';}function handleOpen () {console.log('open');// 当连接建立时,一般做一些页面初始化操作}function handleClose () {console.log('close');// 当连接关闭时}function handleError () {console.log('error');// 当连接出现异常时}function handleMessage (e) {// 在这里获取后端广播的数据,数据通过事件对象e活得,数据存放在e.data中const showMsg = JSON.parse(e.data);}init();})(document,WebSocket)

由此可见,使用原生websocket完全可以进行聊天通信,但是它提供的事件和api有限,对于一些复杂的需求实现起来比较困难,socket.io是一个websocket库,它对于websocket进行了很好的封装,提供了许多api,以及自定义事件,使用起来比较灵活。


聊天功能的前后端交互顺序图

需要实现的是客户端A发送消息给客户端B,客户端B能够自动接收并显示,实现私聊的关键是要确定需要将消息发送给谁,所以在进入聊天界面的的时候,每一个连接服务器的客户端就需要将自己的id告诉服务器,服务器会维护一个对象专门用于存放当前已连接的用户id

客户端A进入聊天界面的的时候,还需要存放客户端B的用户id,在发送消息的时候将客户端B的id传递给服务器,让服务器知道当前的这条消息要发送给谁,服务器收到后就会查询存放用户id的对象,如果客户端B连接那么就将A的消息发送给它,这就是私聊的大致思路。

建立连接

能够实现客户端之间的通信首先需要将客户端与服务器建立连接,首先下载依赖,客户端使用weapp.socket.io,服务端使用socket.io

npm i socket.io@2.3.0 --savenpm i express@4.17.1 --savenpm i weapp.socket.io@2.1.0 --save

为了保证能连接正常,建议下载指定版本,前后端版本不匹配会导致连接失败报错。

官方文档英文:Socket.IO

W3Cschool中文文档:socket.io官方文档_w3cschool

客户端:

客户端下载完毕后,可以将weapp.socket.io.js文件单独拿出,其存放的文件位置如下图

将其放在项目指定文件夹下引入,这里放在socket文件下;随后在项目的main.js中引入使用,这里将io挂载在Vue的原型上,供全局使用,连接地址为服务器的地址,端口号需与服务器socket.io监听的端口保持一致;

import io from './socket/weapp.socket.io.js'Vue.prototype.socket = io('http://localhost:8000');

服务器:

服务器使用node的express框架搭建,在入口js中配置如下,io.on用于绑定事件,connection事件在连接时触发,它是socket.io内置事件之一。

const express = require('express');const app = express();let server = app.listen(8000);let io = require('socket.io').listen(server);io.on('connection',(socket) => { console.log("socket.io连接成功");});

socket.io建立连接会产生跨域问题,这里直接通过express的方式使用CORS解决跨域:

app.all('*', function(req, res, next) {res.header("Access-Control-Allow-Origin", "*");res.header("Access-Control-Allow-Headers", "Content-Type,Content-Length, Authorization, Accept,X-Requested-With");res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");res.header("X-Powered-By",' 3.2.1')if(req.method=="OPTIONS") res.send(200);/*让options请求快速返回*/elsenext();});

当然socket.io也提供了跨域的解决方案,具体可见Handling CORS | Socket.IO

完成以上配置后,启动项目,客户端便可使用socket.io与服务器正常连接。

观察浏览器network选项卡,请求类型为websocket,响应状态码101,可见socket.io的连接底层走的就是websocket协议

存储连接的用户

用户登陆成功跳转到index主页,每一位用户在注册时都会在数据库生成一个唯一的用户id,这里需要将每一个连接成功的用户id发送给服务器

=>

socket.io服务端除了connection(socket连接成功之后触发),message(客户端通过socket.send来传送消息时触发此事件),disconneting(socket失去连接时触发,包括关闭浏览器,主动断开,掉线等任何断开连接的情况) 等内置的默认事件外,还可以使用自定义事件,客户端也类似。

API上,使用emit()触发事件,使用on()绑定事件,进入首页后在客户端onLoad中触发自定义事件login,同时从本地存储中取出用户uid,上传服务器

export default {data() {return {uid:'',// 当前用户id},onLoad() {this.getStroage();this.addUserToSocket(this.uid);},methods:{// 获取本地存储getStroage(){const value = uni.getStorageSync('user');if(value){this.uid = value.id;} else {uni.navigateTo({ url:'/pages/login/login'})}},// 添加连接的用户addUserToSocket(uid){this.socket.emit('login',uid);},}}

在服务端绑定login事件,同时创建对象connectedUsers存放连接的用户,将用户uid作为key保存,value是socket.id,socket.id是connection回调参数的一个属性,socket.id用于socket.io唯一标识连接的用户

当用户退出应用时触发disconnecting事件,将此用户信息从connectedUsers对象中删除。

let connectedUsers = {}; io.on('connection',(socket) => { console.log("socket.io连接成功");// console.log(socket);// 用户进入主页时获取用户id保存socket.on('login',(id) => {console.log("socket.id:" + socket.id);socket.name = id;connectedUsers [id] = socket.id;});// 用户离开socket.on('disconnecting',() => {console.log('leave:' + socket.id);if(users.hasOwnProperty(socket.name)){delete connectedUsers [socket.name];}});});

总结:

1)io.on可用来给当前socket连接绑定connection事件,参数socket可以获取这次连接的配置信息,最常用的就是socket.id,它是本次连接的唯一标识

io.on(‘connection’,function(socket){ …… })

2)on用于绑定事件,用于接收传递的数据

socket.on(‘自定义事件名’,function(参数1,参数2,……,参数n) { …… });

3)emit用于触发事件,用于传递数据

socket.emit(‘自定义事件名’,参数1,参数2,……,参数n);

4)disconnecting在失去连接时时触发,断开可能是关闭浏览器,主动断开,掉线等导致

socket.on(‘disconnecting’,() => {})

发送聊天信息

客户端发送消息,将聊天内容加工处理后,触发自定义事件msg,将内容,发送者id和接收者id发送给服务器,代码如下:

客户端chatroom.vue:

// 发送聊天数据sendSocket(msg){if(this.type === '0'){// 1对1聊天this.socket.emit('msg',msg,this.uid,this.fid);} else {// 群消息this.socket.emit('gmsg',msg,this.uid,this.fid);}},

服务器绑定msg事件,得到客户端发来数据,首先需要操作数据库完成插入最新的聊天内容,更改最后的通讯时间等操作,如果对方用户在线,则connectedUsers 对象中必然存在该用户的id,使用socket.to(指定接收者的socket.io)将消息发送给指定的用户,同时触发自定义事件backMsg,用法如下:

发送给指定 socketid 的客户端(私密消息)

socket.to().emit(‘自定义事件名’, 参数);

注意:如果不使用socket.to方法直接调用emit,则会发送给所有在线的用户。

服务器代码:

// 引入数据库文件let dataBase= require("./dataBase");// 1对1消息发送socket.on('msg',(msg,fromId,toId) => {console.log('服务器收到用户' + fromId + '发送给' + toId + '的消息')console.log('发送的消息是:',msg);// 修改好友最后通讯时间dataBase.updateLastMsgTime(fromId,toId);dataBase.updateLastMsgTime(toId,fromId);// 添加消息dataBase.insertMsg(fromId,toId,msg.message,msg.types);console.log('数据库插入成功');// 将获取的消息发送给好友,users[toId]就是好友的socket.idif(connectedUsers[toId]){console.log('将消息发送给',toId,'成功');socket.to(connectedUsers[toId]).emit('backMsg',msg,fromId,0);} });

这样客户端绑定backMsg事件,就能拿到发送消息了!处理消息展示即可,但需要判断当前用户此时打开的聊天界面是否就是当前发送者聊天对话框即if(fromId === this.fid && type === 0),否则会造成聊天内容的错误展示,比如当前用户可能存在多个好友,客户端A给客户端B发消息时B打开的是和C的聊天对话框,此时就会在C的对话框中错误的收到A发来的消息

客户端chatroom.vue:

this.socket.on('backMsg',(msg,fromId,type) => {// 如果是1对1消息fromId是当前聊天窗口的好友id时执行if(fromId === this.fid && type === 0){......// 一条聊天记录let newMsg = {fromId:fromId,id:msg.id,imgUrl:msg.imgUrl,message:msg.message,types:msg.types,// 0 - 文字信息,1 - 图片信息, 2 - 音频time:new Date(),isFirstPlay:true,};this.msg.push(newMsg);// 如果消息是图片if(msg.types === '1') {this.msgImage.push(msg.message)}this.$nextTick(function(){this.scrollToView = 'msg' + this.msg[this.msg.length - 1].id;});......}});

测试效果如下:

服务器终端输出结果如下:

首页新消息提示

如下图,用户有新消息会在首页及时显示,并提示未读消息数量

需要给首页绑定获取消息的自定义事件backMsg,绑定时机是在生命周期onLoad中,事件一旦触发代表有好友向你发送消息了,会获取服务器传来的消息,在事件回调中要完成两个操作,首先查找发来新消息的好友在首页好友列表数组的索引下标,随后修改指定的数组元素内容,更新这个好友最后消息的时间、最后消息的内容、未读消息数;并将该元素现有位置删除,添加到整个数组的头部,即把这个好友item放到首页列表的最上方,首页index.vue相关代码如下:

{{item.unreadMsg}}{{item.nickName}}{{getTime(item.lastTime)}}{{item.lastMsgUsername " />{{item.lastMsgUsername ? item.lastMsgUsername : ''}}[图片]{{item.lastMsgUsername ? item.lastMsgUsername : ''}}[语音]{{item.lastMsgUsername ? item.lastMsgUsername : ''}}[位置]......onLoad() {this.receiveSocket('backMsg');}methods:{// 接收个人/群聊天信息receiveSocket(eventName){this.socket.on(eventName,(msg,fromId,type) => {if(type === 0){let index;if(eventName == 'backMsg') {// 获取有新消息的好友在整个好友数组中的索引下标index = this.friends.findIndex((item) => {return item.id === fromId});}// 修改未读消息数 this.getUnreadMsg(this.friends[index]);// 修改最后聊天时间this.friends[index].lastTime = msg.time;// 修改最后聊天信息this.friends[index].lastMsg = msg.message;// 修改最后聊天信息的类型this.friends[index].lastMsgType = msg.types;// 删除当前item,将其插入到数组的首部,即展示在列表最上方const tempItem = this.friends[index];this.friends.splice(index,1);this.friends.unshift(tempItem);}});},}

此外还有一个问题就是何时清空未读消息数,清空的操作需要进行两次,一次是用户进入聊天页面时进行清空,在聊天页生命周期onLoad中调用清空消息数的后端接口,清空现有的未读消息;另一次是在点击返回按钮如下图,返回首页时清空,在此按钮事件的回调中调用清空未读消息数的接口,这是为了清空用户和他人聊天时已读的消息,两次操作缺一不可。

实现群聊

群聊的前后端顺序图如下所示:

需要实现的是客户端A在群内发送了消息后,在同一群内的客户端BCD都能同时收到A发送的消息。群聊的大致思路和私聊基本相似,不同点在于群聊中引入了房间的概念,在房间内的成员就是这个群聊的群成员,任何群成员的群内发言就会在这个房间内进行广播所有在线的群成员都能及时够收到。

加入房间

使用socket.join()加入房间,具体使用如下:

socket.join(‘room’,function(){ …… });

room:房间id,是一个字符串,用户自定义,加入房间会触发参数二回调

socket.leave(room,function(){ …… })

与join相对应的是leave方法,即退出指定的房间,参数二异常回调函数为可选值。需要注意的是,当与客户端断开连接时,会自动将其从加入的房间中移除

在这个项目里房间id使用的是每一个群聊的群id号,它可以唯一标识一个群聊;

加入房间的操作同样是在用户登录成功进入首页时进行,一个用户可能加入了多个群聊,那么在主页请求用户群聊接口后,需要依次遍历接口返回的群聊列表,为每一个群聊触发addGroup事件,将当前的群id发送给后端,让当前用户加入每个群聊的房间。

index.vue

// 获取当前用户的群消息getGroup(){uni.request({url:`${this.baseUrl}/index/getGroupList`,method:'POST',data:{uid:this.uid,// 用户id},success: (res) => {let data = res.data.result;// 遍历当前用户的群列表for (var i = 0; i < data.length; i++) {......// 触发addGroup事件,携带群id,加入房间this.socket.emit('addGroup',data[i].id);}......}});},

服务器绑定addGroup事件,调用socket.join,让当前用户连接加入房间号为groupId的房间

io.on('connection',(socket) => { // 加入群socket.on('addGroup',(groupId) => {console.log('用户',socket.id,'加入了groupId为',groupId,'的群聊');socket.join(groupId);});}

效果:例如当前这个用户加入了三个群聊,首页加载后就会触发addGroup三次,依次加入这三个群id标识的房间。

服务器终端输出效果如下:

发送群消息

某一群成员在群内发送消息,会和私聊同样的方式将语音和图片这些静态资源上传服务器,返回服务器存放地址后进行封装,触发gmsg事件将处理后的消息提交服务器

// 发送聊天数据sendSocket(msg){if(this.type === '0'){// 1对1聊天this.socket.emit('msg',msg,this.uid,this.fid);} else {// 群消息this.socket.emit('gmsg',msg,this.uid,this.fid);}},

群内广播消息使用到的api是socket.to,具体使用如下:

将内容发送给同在房间名roomName的所有客户端,除了发送者

socket.to(roomName).emit(‘事件名’,参数1,参数2,……参数n);

如果需要包含发送者可以使用

io.in(roomName).emit(‘事件名’,参数1,参数2,……参数n);

也可以同时发送给在多间房间的客户端,使用to链式调用的形式,不包含发送者

socket.to(roomName1).to(roomName2).emit(‘事件名’,参数1,参数2,……参数n);

当然,当前项目中只需要使用第一种方式即可

服务器的gmsg事件回调中,同样需要将获取到的消息插入数据库,同时修改群最后通信时间以及全体成员的未读消息数,最后调用socket.to方法,触发groupMsg事件,将消息发送给群聊内的其它在线用户。

// 引入数据库文件let dataBase = require("./dataBase");// 接收群消息socket.on('gmsg',(msg,fromId,groupId) => {console.log('服务器接收到来自群',groupId,'的用户',fromId,'的消息',msg);// 修改群的最后通信时间dataBase.updateGroupLastTime(groupId);// 添加群消息dataBase.insertGroupMsg(fromId,groupId,msg.message,msg.types);//将所有成员的未读消息数加一dataBase.changeGroupUnreadMsgNum(groupId);console.log('消息',msg.message,'插入数据库成功')// 获取当前用户的名字和头像dataBase.userDetails(fromId).then((data) => {console.log('查询发送者用户名成功,用户名是:',data[0]);console.log('正在将信息',msg.message,'发送至群',groupId,'内');// 群内广播消息socket.to(groupId).emit('groupMsg',msg,fromId,0,data[0].name,groupId);});});

客户端在线群成员收到消息,执行groupMsg事件回调中的方法,内部大致逻辑和私聊完全一致,可以将其封装成公共方法使用,需要注意的依旧是要做群id一致性判断,防止获取的消息显示在其它聊天窗口中,即 if(fromId !== this.uid && groupId === this.fid)。

this.socket.on('groupMsg',(msg,fromId,type,friendName,groupId) => {// 判断当前打开的群id和接收消息的群id是否一致,防止消息错误显示if(fromId !== this.uid && groupId === this.fid){......// 模拟服务器数据let newMsg = {fromId:fromId,id:msg.id,imgUrl:msg.imgUrl,message:msg.message,types:msg.types,// 0 - 文字信息,1 - 图片信息, 2 - 音频time:new Date(),isFirstPlay:true,friendName:friendName // 群需显示发送消息用户的名字};this.msg.push(newMsg);// 如果消息是图片if(msg.types === '1') {this.msgImage.push(msg.message)}this.$nextTick(function(){this.scrollToView = 'msg' + this.msg[this.msg.length - 1].id;});......}});

效果演示:输入一段文字发送到群内

服务器此时终端输出如下

以上就是项目聊天功能难点的全部内容,前端实现实时聊天主要就是对于socket.io提供api的合理使用,剩余的难点就是页面显示的部分逻辑处理,用户体验的优化,还可以在此基础上添加更多的功能,若有不足之处恳请指正!