废话不多说直接上效果图
效果图
流程图
技术栈
前端:vuejs,vue-socket.io,better-scroll
后端:egg,egg-socket.io
数据库:redis
实现流程
socket的连接
1.vuex中定义socket模块,并且定义socket默认事件
const state = {
socketState: false,//连接状态
chat_list: getChatList(),//聊天记录列表
}
const getters = {
//消息未读总数
unread_num(state) {
let count = 0;
for (var i = 0; i < state.chat_list.length; i++) {
count += state.chat_list[i].unread_num || 0;
}
return count;
}
}
//socket默认事件
const mutations = {
socket_connect(state) {
console.log("连接成功");
state.socketState = true;
},
socket_reconnect(state, data) {
console.log("重新连接" + data);
},
socket_reconnecting(state, data) {
console.log("重新连接中" + data);
Toast('重新连接中')
},
socket_disconnect(state) {
console.log("断开连接");
state.socketState = false;
},
}
2.客户端发起socket连接(初始化socket)
if (!store.state.socket.socketState) {
Vue.use(
new VueSocketIO({
debug: true,
connection:
ip + "?token=" + window.localStorage.getItem("token"),
vuex: {
store,
actionPrefix: "socket_",
mutationPrefix: "socket_"
}
})
);
}
3.服务端响应socket连接
const { app, socket } = ctx;
const token = ctx.request.query.token;
const id = socket.id;
let username = '';
try {
username = (await ctx.app.jwt.verify(token, ctx.app.config.jwt.secret)).username;
let data = { id, username };
//判断用户是否在线 如果在线则强制退出
if (await app.redis.exists(username)) {
let receive = await app.redis.get(username);
receive = JSON.parse(receive);
console.log('已经有人在线');
ctx.socket.to(receive.id).emit('client_logout');
}
//把在线信息存入redis中
await app.redis.set(username, JSON.stringify(data));
} catch (error) {
//验证失败直接拒绝socket连接
console.log(error)
socket.emit('connect_deny');
socket.disconnect();
return;
}
其中,const { app, socket } = ctx;
中的socket对象,是每一个客户端连接都会生成的。对象里面有socketid,这个id是每一个客户端的唯一标识符(私聊推送需要用到);我们可以想象成,客户端认识用户的账号(username),服务端认识socketid,因此我们可以把这两个标识捆绑在一起,并且以username为key,value为{ username:'xxxx',socketid:'xxxx' }
保存于redis中。私聊推送的时候,前端知道推送的目标用户username,后端redis也会缓存着每一个登录用户的信息,如此我们就可以通过username给指定用户推送消息。
实现逻辑大致为:服务端验证客户端socket客户端的合法性,通过验证的连接会去redis缓存中读取key为username的记录,如果记录存在则触发断开socket事件。(单一登录功能)
消息推送
客户端推送
send (data, type) {
this.$socket.emit("chat", data, () => {
//消息推送成功回调函数
this.chat_item.chat_list.push(data);
//调用better事件,让聊天窗口拉到最底部
this.$nextTick(() => {
this.$refs.wrapper.refresh();
this.$refs.wrapper.scrollToEnd();
});
});
},
由于前端把socket挂载到了vuex中,因此可以通过this.$socket.emit("chat", data, cb)
推送消息。其中chat为事件类型,与服务端中定义的socket路由对应;data为推送的数据,应包括目标用户的id;cb为推送成功的回调函数。推送成功之后,this.nextTick中调用better事件,让聊天窗口拉到最底部。(better滚动条拉到最底是根据选择器实现的,而选择器是依赖于dom元素的,而vuedom更新是异步的,因此需要在this.$nextTick后再调用)
服务端定义路由
io.route('chat', app.io.controller.chat.index);//接收客户端emit('chat')事件
服务端接收和推送
const { ctx } = this
//读取用户推送的消息
const message = this.ctx.args[0]
const cb = this.ctx.args[1]
//判断目标用户是否在线
if (await app.redis.exists(message.receive_id)) {
let receive = await app.redis.get(message.receive_id)
receive = JSON.parse(receive)
//向目标用户发送消息
ctx.socket.to(receive.id).emit('client_receive_msg', message)
} else {
console.log('不在线哦')
//以message_+username为key维护一个队列,队列记录着关于用户的离线信息
//插入数据库
await app.redis.lpush(
'message_' + message.receive_id,
JSON.stringify(message)
)
}
cb && cb('推送成功啦')
服务端在chat.js中的index方法中拿到推送的消息,使用ctx.socket.to(receive.id)
推送给目标用户;根据username去redis中寻找目标用户的socketid(如果不存在key为username的记录代表目标用户不在线,把离线信息进行缓存起来等目标用户上线统一推送)
客户端接收
socket_client_receive_msg(state, data) {
//判断当前用户所在的页面是否是当前聊天用户的页面 如果是则未读信息不加1
let flag = false;
if (router.currentRoute.name == "SoloChat" && router.currentRoute.query.username == data.send_info.username) { //接受过来的信息时刻 用户正在此接收人的聊天窗内
flag = true;
}
for (var i = 0; i < state.chat_list.length; i++) {
if (state.chat_list[i].username == data.send_info.username) { //如果已经存在聊天对话框
if (!flag) {
state.chat_list[i].unread_num++;
}
state.chat_list[i].chat_list.push(data);
//置顶
state.chat_list.unshift(state.chat_list.splice(i, 1)[0]);
}
}
},
如上实现最简单版本的即时通讯,最后来个egg的目录结构: