实现一个聊天应用

493 阅读2分钟

废话不多说直接上效果图

效果图

流程图

技术栈

前端: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的目录结构:

完整版git地址

前端
后端