前言
项目开始是因为工作需要一个聊天室功能, 但是因为某些原因最终选用的是基于 xmpp 协议的 Strophe.JS 写的. 于是就想用 node 自己写一套, 本来只是想简单的写个聊天页面, 但是写完了又不满意, 所以不断的重构(似乎可以理解产品经理为什么老是改需求了๑乛乛๑).
很多东西, 比如 MongoDB, 我也是第一次用, 以前只接触过 MySQL. 所以都是一边学一边写, 利用工作之余的时间, 断断续续的写了几个月(这次讲的是 V0.9.0 版本, 项目还在更新中...), 包含了一整套的前后端交互. uI 是按照自己的感觉来的, 没有设计天分(话说主题切换到现在还只有一套主题, 实在是不好设计啊~), 轻喷 ---. 项目还有很多需要优化完善的地方, 欢迎大家提到 issues(文末有 q 群, 欢迎一起学习交流).
闲话少说, 本文主要讲项目的设计流程, 以及部分功能实现思路. 对项目感兴趣的同学请移步源码 Vchat - 从头到脚, 撸一个在线聊天的 web 应用(vue + node + MongoDB) https://github.com/wuyawei/Vchat .
image
* 这是分隔线 --------------------------------------- 深夜码字, 最近真冷
相关地址
在线预览 (计划本月上线)
https://github.com/wuyawei/Vchat
码云 https://gitee.com/wuywd/Vchat
掘金 https://juejin.im/post/5c0a00fb6fb9a049d4419d3a
知乎 https://zhuanlan.zhihu.com/p/51963164
项目架构
技术栈
image
前端主要采用了 vue 全家桶, 没什么多说的, 脚手架构建项目, vuex 状态管理, vue-router 控制路由, axios 进行前后端交互. 后端是基于 node 搭的服务, 用的是 express. 我为什么不用 koa 呢, 纯粹是图方便, 因为 koa 不熟(捂脸). 聊天最重要的当然是通信, 项目用 socket.io 来进行前后端通信.
数据库是 MongoDB, 主要有用户, 好友, 群聊, 消息, 表情, 号码池等.
功能概览
image
功能设计
登录注册
image
Vchat 中用户注册时, 会随机指定一个 code 号码, 而这个 code 号是从预先生成的一个号码池 (号码池存在 MongoDB) 中取的. 初始指定 10000001-10001999 的号码段为用户 code, 100001-100999 的号码段为群聊 code. 用户可以凭借 code 号或者账号登录.
// 号码池设计
* code 号码
* status 1 已使用 0 未使用
* type 1 用户 2 群聊
* random 随机数索引, 用于随机查找某一条
// user 表主要字段
* name 账号
* pass 密码
* avatar 头像
* signature 个性签名
* nickname 昵称
* email 邮件
* phone 手机
* sex 性别
* bubble 气泡
* projectTheme 项目主题
* wallpaper 聊天壁纸
* signUpTime 注册时间
* lastLoginTime 最后一次登录时间
* chatColor 聊天文字颜色
* province 省
* city 市
* town 县
* conversationsList 会话列表
* cover 封面列表
注册时, 需要判断账号是否已存在, 以及随机取得的 code 需要在号码池中标记为已被使用, 用户密码用 md5 加密等.
- // md5 密码加密
- const md5 = pass => { // 避免多次调用 MD5 报错
- let md5 = crypto.createHash('md5');
- return md5.update(pass).digest("hex");
- };
登录同样需要判断用户是否已注册, 以及支持账号和 code 两种方式登录.
- const login = (params, callback) => { // 登录
- baseList.users
- .find({ // MongoDB 中可以直接用 $or 表示或关系
- $or: [{"name": params.name}, {"code": params.name}]
- })
- .then(r => {
- if (r.length) {
- let pass = md5(params.pass);
- if (r[0]['pass'] === pass) {
- // 更新最后一次登录时间 此处直接写 Date.now 会报错 需要 Date.now()!!!;
- baseList.users.update({name: params.name}, {lastLoginTime: Date.now()}).then(raw => {
- console.log(raw);
- });
- callback({code: 0, data: {name: r[0].name, photo: r[0].photo}});
- } else {
- callback({code: -1});
- }
- } else {
- callback({code: -1});
- }
- })
- };
登录权限管理
后端设置全局中间件, 将没有登录的 API 请求统一返回 status: 0
- App.use('/v*', (req, res, next) => {
- if (req.session.login) {
- next();
- } else {
- if (req.originalUrl === '/v/user/login' || req.originalUrl === '/v/user/signUp') {
- next();
- } else {
- res.JSON({
- status: 0
- });
- }
- }
- });
前端用 axios 统一设置拦截器
- // http response 服务器响应拦截器, 这里拦截未登录和 401 错误, 并重新跳入登页重新获取 token
- instance.interceptors.response.use(
- response => { // 拦截未登录
- if (response.data.status === 0) {
- router.replace('/');
- }
- return response;
- },
- error => {
- if (error.response) {
- switch (error.response.status) {
- case 401:
- // 这里写清除 token 的代码
- router.replace('/');
- }
- }
- return Promise.reject(error.response.data)
- });
消息
vchat 中, 消息种类包括好友或者加群申请, 回复申请(同意 or 拒绝), 入群通知, 聊天消息(文字, 图片, 表情, 文件)
image
image
在实现消息发送之前, 需要大体的了解一些 socket.io 的 API. 详细 API 文档可以查看 socket.io
- // 所有的消息请求都是建立在已连接的基础上的
- io.on('connect', onConnect);
- // 发送给当前客户端
- socket.emit('hello', 'can you hear me?', 1, 2, 'abc');
- // 发送给所有客户端, 除了发送者
- socket.broadcast.emit('broadcast', 'hello friends!');
- // 发送给同在'game' 房间的所有客户端, 除了发送者
- socket.to('game').emit('nice game', "let's play a game");
- // 发送给同在'game' 房间的所有客户端, 包括发送者
- io.in('game').emit('big-announcement', 'the game will start soon');
加入房间
加入会话列表中的房间, 会话列表在好友申请成功或者加群成功时会自动添加. 但是你也可以手动移除或添加, 移除后将不会再收到被移除会话的消息(类似于屏蔽).
- // 前端 发起加入房间的请求
- this.conversationsList.forEach(v => {
- let val = {
- name: this.user.name,
- time: utils.formatTime(new Date()),
- avatar: this.user.photo,
- roomid: v.id
- };
- this.$socket.emit('join', val);
- });
- // 后端 接受请求后执行加入操作, 记录每个房间加入的成员, 以及回信告知指定房间已上线成员
- socket.on('join', (val) => {
- socket.join(val.roomid, () => {
- if (OnlineUser[val.name]) {
- return;
- }
- OnlineUser[val.name] = socket.id;
- io.in(val.roomid).emit('joined', OnlineUser); // 包括发送者
- });
- });
- ```
2. 多房间
> 同时加入多个聊天房间会出现一个问题, socket 可以加入多个房间并给指定房间发送消息, 但是接受消息的时候并不会区分房间. 换句话说, 所有房间的消息, 会一起发送给客户端. 所以我们需要自己区分哪条消息是哪个房间的并进行分发. 这样就需要一个房间标识来过滤, Vchat 用的是房间 id.
- ```JavaScript
- mes(r) { // 只有本房间的消息才展示
- if (r.roomid === this.currSation.id) {
- this.chatList.push(Object.assign({}, r, {type: 'other'}));
- }
- }
发消息
- // 前端
- send(params, type = 'mess') { // 发送消息
- if (!this.message && !params) {
- return;
- }
- let val = {
- name: this.user.name,
- mes: this.message,
- time: utils.formatTime(new Date()),
- avatar: this.user.photo,
- nickname: this.user.nickname,
- read: [this.user.name],
- roomid: this.currSation.id,
- style: 'mess',
- userM: this.user.id
- };
- this.chatList.push(Object.assign({},val,{type: 'mine'})); // 更新视图
- this.$socket.emit('mes', val);
- this.message = '';
- }
- // 后端 接收消息后存储到数据库, 并转发给房间内其他成员, 不包括发送者.
- socket.on('mes', (val) => { // 聊天消息
- apiList.saveMessage(val);
- socket.to(val.roomid).emit('mes', val);
- });
消息记录
所有的消息都会存到 MongoDB 中, 当切换房间的时候, 会获取历史消息. 而处在当前房间时, 只会把最新消息追加到 dom 中, 不会从数据库获取. 聊天窗口默认只展示最新 100 条消息, 更多消息可在聊天记录中查看.
- // 前端 获取指定房间的历史消息
- this.$socket.emit('getHistoryMessages', {roomid: v.id, offset: 1, limit: 100});
- // 后端 关联表, 分页, 排序
- messages.find({roomid: params.roomid})
- .populate({path: 'userM', select: 'signature photo nickname'}) // 关联用户基本信息
- .sort({'time': -1})
- .skip((params.offset - 1) * params.limit)
- .limit(params.limit)
- .then(r => {
- r.forEach(v => { // 防止用户修改资料后, 信息未更新
- if (v.userM) {
- v.nickname = v.userM.nickname;
- v.photo = v.userM.photo;
- v.signature = v.userM.signature;
- }
- });
- r.reverse();
- callback({code: 0, data: r, count: count});
- }).catch(err => {
- console.log(err);
- callback({code: -1});
- });
项目展示
主页
image
聊天窗口, 可拖拽或缩放, 聊天壁纸及文字颜色设置.
image
个人设置
image
应用空间
image
来源: http://www.jianshu.com/p/49165c6477e4