使用Vue全家桶和Nestjs编写的即时聊天应用

前言

这是一个DEMO!
这是一个参照 Fiora 项目的原型但技术栈完全不一样的DEMO!
这是一个大三🐕为了找实习而写的DEMO!

项目体验和源码

大家可以给我这个可怜的大三臭弟弟一个star🐎

关于原型

让各位看官老爷笑话了,由于本人在设计上没有任何天赋,因此该项目以 Fiora 项目其中的一个主题作为了项目原型进行设计与开发。

  • 首页(游客状态)

  • 登录界面

  • 联系人信息界面

关于技术栈

  • 前端技术栈:Vue , Vuex , Vue Router , element ui
  • 后端技术栈:Nestjs , TypeORM , Socket.IO
  • 数据库:MySql
  • 开发语言:前端以ES6和TypeScript混合开发为主,后端 All in TypeScript

关于功能

由于时间和精力有限(一时全栈一时爽,一直全栈…),该项目仅实现了一些基本功能:

  1. 群聊、私聊
  2. 消息列表、好友列表
  3. 好友添加(我好像忘了做好友搜索的功能…但是可以通过点击群聊中的头像进行添加)
  4. 默认表情发送,图片发送

另外,还使用了HTML5的一些新API,

  1. 桌面消息通知(通过notification实现)
  2. Web截图(通过Canvas实现,生产版本使用了kscreenshot插件)

关于socket连接

在客户端,基于Socket.IO-client,每个用户会得到一个socket实例,实例中的socketId为唯一标识符。

import client from 'socket.io-client';
// 为 URL 中的路径名指定的名称空间返回一个新的 Socket 实例
app.socket = client(socketUrl);
// 对于已注册用户,我们还需要携带查询参数来确定用户身份
app.socket = client(socketUrl,{
  query: {
    userId
  }
});

服务端处理socket连接

/**
 * 当有新的连接建立时调用该函数,已登录用户更新自身的socketId并加入所有群聊
 * @param socket 
 */
@UseFilters(new CustomWsExceptionFilter())
async handleConnection(socket: Socket) {
    const socketId = socket.id;
    // 获取握手的细节信息,包括查询参数对象以及客户端IP
    const {query,address as clentIp} = socket.handshake;
    if(query.userId) {
        const userId = query.userId;
        await this.websocketService.updateSocketMes(socketId,clientIp,userId);
        await this.websocketService.userJoinRooms(socket,userId);
    }
}

关于用户的登录与注册

除了socket通信之外,其他接口皆不涉及websocket协议。

用户注册

用户需要提供用户名和密码来进行注册。密码会通过bcrypt进行加盐处理

export async function genSaltPassword(pass: string): Promise<string> {
    const salt = await bcrypt.genSalt(saltRounds);
    const hash = await bcrypt.hash(pass,salt);
    return hash;
}

注册成功后(用户名不重复)会获得随机头像并加入默认群组

// 获得随机头像
const randomAvatarUrl = await this.randomAvatarService.getRandomAvatarUrl();
// 加入默认群组
const defaultGroup = await this.groupService.getDefaultGroup();
await this.groupRelationshipService.createGroupRelationship({
    user_id: userBasicMes.user_id,
    group_id: defaultGroup.group_id,
    top_if: 0,
    group_member_type: '普通'
})

用户登录

用户登录分为通过账号密码进行登录和通过客户端存储的token进行自动登录

通过账密进行登录

当通过账密进行登录时,服务端会验证账密的正确性

async validatePassword(userName: string, password: string): Promise<boolean> {
    // 验证该用户是否存在(通过用户名进行查询)以及密码是否匹配
    return this.userService.checkUserExistence({ user_name: userName }) &&
        this.userService.checkUserPassword(userName, password);
}

对于密码匹配,bcrypt会根据用户给定的密码和注册时存入数据库的加盐密码进行比对

if(user) {
    saltPassword = user.user_saltPassword;
    return await bcrypt.compare(password,saltPassword);
}

当身份验证通过后,服务端会生成一个jwt返回给客户端用于后续的自动登录处理

// 导入jwt-simple
import * as jwt from "jwt-simple";
async genJWT(userName: string) {
    const userId = (await this.userService.getUserBasicMes({user_name: userName})).user_id;
    const payload = {
        userId,
        enviroment: 'web',
        // 设置jwt的过期时间
        expires: Date.now() + jwtConfig.tokenExpiresTime
    };
    // 使用默认编码算法(HS256)进行编码
    const token = jwt.encode(payload,jwtConfig.jwtSecret);
    return token;
}

最后,更新用户的登录状态,socketId及其他信息

await this.userService.updateUser({
    user_id
},{
    user_lastLogin_time: datetime,
    user_state: 1,
    user_socketId,
    user_preferSetting_id
})

通过jwt自动登录

通过jwt自动登录的,服务端需要验证jwt的有效性

async validateToken(accessToken: string): Promise<boolean> {
    // 对token进行解码
    const decodedToken  = this.decodeToken(accessToken);
    // token过期的处理
    if(decodedToken.expires < Date.now()) {
        throw new HttpException("Token已过期,请重新登录",HttpStatus.FORBIDDEN);
    }
    if(this.userService.checkUserExistence({user_id: decodedToken.usrId})) {
        return true;
    }
    else {
        throw new HttpException("无效Token,此为非法登录",HttpStatus.FORBIDDEN);
    }
}

验证通过后,更新用户相关信息,用户登录成功!

关于消息

用户发送消息

客户端通过socket实例emit一个message事件来发送消息

socket.emit("message",{
    messageFromId,
    messageToId,
    messageType, // 消息类型(私聊还是群聊)
    messageContent,
    messageContentType // 消息内容类型(可分为text,img...)
},res => {
    // ...成功发送后的回调
})
// 发送消息失败时的处理
socket.once('messageException', err => {
    const {message} = err;
    showMessage(app,'warning',message,3000,true);
})

服务端会在websocket网关监听到该事件的发生

@UseFilters(new CustomWsExceptionFilter())
@SubscribeMessage('message')
async sendMessage(@ConnectedSocket() socket: Socket, @MessageBody() mes: any) {
    return await this.chatingMessageService.sendMessage(mes,socket);
}

然后将消息进行持久化到数据库中并发送给目标(用户或者群聊)

// 持久化消息
messageId = await this.messageService.createMessage(mes);
// 通过目标群聊名(唯一)发送消息到群聊中
socket.to(groupName).emit('groupMemberMessage',{
    messageFromUser,
    messageTarget: messageTarget,
    messageId,
    messageContent,
    messageContentType,
    messageType,
    messageCreatedTime
});
// 通过目标用户的socketId发送私聊消息
socket.to(targetSocketId).emit('messageFromFriend',{
    messageFromUser,
    messageTarget: messageTarget,
    messageId,
    messageContent,
    messageContentType,
    messageType,
    messageCreatedTime
});

用户接收消息

客户端通过监听messageFromFriend和groupMemberMessage事件分别接受私聊消息和群聊消息

socket.on('messageFromFriend', resolveReceiveMes)
socket.on('groupMemberMessage', resolveReceiveMes)

接受到消息之后,该消息会被加入到对应的消息列表中,

// 如果该消息对应的对话信息不存在,那么创建相应的对话信息框
if(!store.state.messageMap[dialogTargetId]) {
    await store.dispatch('resolveMessageMap',{
        dialogId,
        dialogTargetId,
        page: 1,
        limit: 50
    })
}
// 如果存在则直接将消息压入到消息列表中
else store.commit('addNewMessage',{
    dialogId,dialogTargetId,messageContentMes
})

另外,如果用户允许了桌面消息通知功能,那么会通过得到(以正则的方式)发送来的消息内容类型以来显示对应的消息

if(store.state.notification) {
    // ...
    if (/image\/(gif|jpeg|jpg|png|svg)/g.test(message.messageContentType)) {
        notificationContent = `[图片]`
    }
    else {
        notificationContent = resolveEmoji(message,'messageContent');
    }
    if(messageType === 0) {
        notificationTile = `好友 ${notifiFromName} 向您发来了一条新消息:`;
        notificationAvatar = notifiFromAvatar;
    }
    else {
        notificationTile = `群组 ${notifiTargetName} 新增一条成员信息`;
        notificationAvatar = notifiTargetAvatar;
        notificationContent = `${notifiFromName}: ` + notificationContent;
    }
    createNotification(notificationTile,{
        body: notificationContent,
        icon: notificationAvatar
    })
}

获取历史消息

客户端通过分页的方式来获取历史消息。getHistoryMessages接口接受以下三个参数:

  1. dialogId 对话Id
  2. page 页数
  3. limit 每页的数量

为了每次切换对话框时不需要重新去请求历史消息,因此,客户端会在Vuex中全局设置一个messageMap的哈希表来保存已经得到的消息数据

/**
 * 处理messageMap
 * 具体为在messageMap中创建一个以dialogId为键的消息列表
 * @param param0 
 * @param option 
 */
async resolveMessageMap({commit},option) {
    const {dialogId,dialogTargetId,page,limit,app} = option;
    const messages = await historyMessages(dialogId,page,limit);
    // page大于1确保当一个对话没有任何信息时,不会提示没有更多历史消息了
    if((!messages || messages.length === 0) && page > 1) {
        noMoreMessage(app);
    }
    commit('setMessageMap',{dialogTargetId,dialogId,messages});
}

另外,客户端采用无限上滑的方式来获取更多的历史消息

// 当scrollTop等于scrollHeight-clientHeight时,表示滑动条滑倒了对话框的顶端,那么就加载更多的历史消息
if(Math.floor(element.scrollTop)+1 < element.scrollHeight - element.clientHeight) {
    // getHistoryMessages
}
// 为了避免某些手速快的男孩子在数据返回之前多次触发事件,在这里设置了一个滚动条回弹5px以及防抖处理
element.scrollTop = 5;
import * as _ from "lodash";
_.debounce();

scrollTop和scrollHight的图示如下:

关于对话列表和联系人列表

对话列表

对话列表项中最后一条消息的时间会根据实际时间与当前时间的间隔而显示出不同的格式

export function resolveTime(time,option) {
    const mesDatetime = new Date(time); // 获取当前时间戳
    const {year: curYear, month: curMonth, date: curDate} = getDatetimeMes(new Date());
    const {
        year: mesYear,
        month: mesMonth,
        date: mesDate
    } = getDatetimeMes(mesDatetime);
    // 如果是今天的消息,那么只返回时间,例如: 15:00
    if(curYear === mesYear && curMonth === mesMonth && curDate === mesDate) {
        return mesDatetime.toTimeString().slice(0,5);
    }
    else if(curYear === mesYear && curMonth === mesMonth) {
        switch (curDate - mesDate) {
            case 1 : 
                return '昨天' + option; // 昨天的消息,只返回 "昨天"
            case 2:
                return '前天' + option; // 前天的消息,只返回 "前天"
            default: return mesDatetime.toLocaleDateString().slice(5) + option; // 返回日期,例如 2/1
        }
    }
    else if(curYear === mesYear){
        return mesDatetime.toLocaleDateString().slice(5) + option; // 如果是往年的消息,返回年月日,例如2019/2/1
    }
    else {
        return mesDatetime.toLocaleDateString() + option;
    }
}

联系人列表

联系人列表根据联系人首字母的大写进行分类,如果首字母为数字或其他非[A-Z]形式,则放入“#”类中。

// 引入cnchar来获取首字母
var cnchar = require('cnchar');
/**
 * 根据首字母进行分类(非26个大写字母则分到#)
 * @param allFriendsMes 
 */
classifyFriendsByChar(allFriendsMes: Array<UserBasicMes>) {
    const friendsCharMap: Map<string,Array<UserBasicMes>> = new Map();
    allFriendsMes.forEach(friendMes => {
        const firstLetter: string = cnchar.spell(friendMes.user_name,'array','first')[0].toLocaleUpperCase();
        const pattern = /[A-Z]/g;
        if(pattern.test(firstLetter)) {
            // 如果首字母为[A-Z],那么加入到以该字母为键名的键值(数组)中
            this.solveCharMap(friendsCharMap,firstLetter,friendMes);
        }
        else {
            // 否则加入到以 "#" 为键名的键值(数组)中
            this.solveCharMap(friendsCharMap,'#',friendMes);
        }
    })
    const friendsList: {[char: string]: UserBasicMes[]} = {};
    friendsCharMap.forEach((friendsMes,char)=>{
        // 将数组内(同一类别)的好友根据unicode进行排序
        this.sortFriends(friendsMes);
        friendsList[char] = friendsMes;
    })
    return friendsList;
}

关于图片发送

图片和截图在发送前都会被上传到服务器作为静态资源,然后将该静态资源的url进行持久化以及作为消息进行发送。

前端文件上传

前端通过element ui的文件上传组件进行文件上传,在上传之前,我们需要确保上传的文件为图片且大小符合预期:

beforeImageUpload(file) {
    const imagepattern = /image\/(gif|jpeg|jpg|png|svg)/g;
    const isJPG = imagepattern.test(file.type);
    const isLt2M = file.size / 1024 / 1024 < 2;
    
    if (!isJPG) {
      showMessage(this,'error','您当前只能上传图片哦',0,true)
    }
    else if (!isLt2M) {
      showMessage(this,'error','上传图片大小不能超过 2MB!',0,true)
    }
    else {
      this.$store.commit('setImageLoading',true);
    }
}

后端文件处理

首先我们需要设置一个静态资源服务器,使得我们可以通过url的方式来获取得到静态资源

// 将该目录设置为静态资源目录
app.useStaticAssets(join(__dirname,'../public/','static'), {
    prefix: '/static/',
});

后端会通过FileInterceptor()装饰器和@UploadedFile()装饰器来获得文件对象并交由provider进行处理

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(@UploadedFile() file) {
  return await this.uploadService.getUrl(file);
}

获取到文件对象之后,我们需要将文件流写入到静态资源目录中,

async saveFile(file): Promise<string> {
    const datetime: number = Date.now();
    const fileName: string = datetime+file.originalname;
    // 文件的buffer
    const fileBuffer: Buffer = file.buffer;
    const filePath: string = join(__dirname,'../../public/','static',fileName);
    const staticFilePath: string = staticDirPath + fileName;
    // 创建一个写入流
    const writeFile: WriteStream = createWriteStream(filePath);
    return await new Promise((resolve,reject)=> {
        writeFile.write(fileBuffer,(error: Error) => {
            if(error) {
                throw new HttpException('文件上传失败',HttpStatus.FORBIDDEN);
            } 
            resolve(staticFilePath);
        });
    })        
}

关于桌面消息通知

通过浏览器全局对象window中是否有全局属性Notification来判断当前浏览器是否支持Notification

export function supportNotification(): boolean {
    return ("Notification" in window);
}

如果支持,我们可以从Notification.permission中获取当前是否允许授权通知。其有三种可能:

  1. denied 拒绝授权通知
  2. granted 允许授权通知
  3. default 默认,因为不知道用户的选择,所以浏览器的行为与 denied 时相同

为了更少程度的打扰用户,对于选择denied的用户我们不会再此弹出授权通知提示,只有当permission依然为default时,我们会通过Notification.requestPermission()来请求向用户获取权限

if (Notification.permission === 'defalut') {
    Notification.requestPermission(function (permission) {
      // 如果用户同意,就可以向他们发送通知
      if (permission === "granted") {
        var notification = new Notification("Hi there!");
      }
    });
}

关于Web截图

在开发版本中,Web截图功能的实现主要依赖于库html2canvas和canvas来实现。其主要思路如下:

  1. 设置两个canvas,其中原页面在最下层,中间一层通过html2canvas将当前页面通过读取DOM并应用样式,从而生成为canvas图片,最上层用于截图效果的实现

    const middleCanvas = await html2canvas(document.body);
    const topCanvas = document.createElement("canvas");
    // 设置最上层canvas的宽高为body的宽高
    const {offsetWidth,offsetHeight} = document.body;
    topCanvas.width = offsetWidth;
    topCanvas.height = offsetHeight;
    
  2. 实现截图效果,监听鼠标的按下,移动,和松开

    // 鼠标按下事件,获取到鼠标按下的位置作为截图的初始位置
    onMousedown(e) {
        const {clipState} = this.$store.state;
        if(!clipState) return;
        const {offsetX,offsetY} = e;
        this.start = {
            startX: offsetX,
            startY: offsetY
        }
    }
    // 鼠标移动事件,用来生成截图的区域
    onMousemove(e) {
      if(!this.start) return;
      const {start,clipArea} = this;
      this.fillClipArea(start.startX,start.startY,e.offsetX-start.startX,e.offsetY-start.startY);
    }
    // 鼠标松开,截图结束,将canvas转化为图片进行下一步操作
    onMouseup(e) {
        this.canvasToClipImage(this.bodyCanvas);
        this.start = null;
    }
    
  3. fillClipArea(生成截图区域)函数的实现

    fillClipArea(x,y,w,h) {
      // 获取绘制canvas接口对象
      const ctx = this.topcanvas.getContext('2d');
      if(!ctx) return;
      ctx.fillStyle = 'rgba(0,0,0,0.6)'; // 设置截图区域的填充颜色
      ctx.strokeStyle="green"; // 设置截图区域的轮廓
      const width = document.body.offsetWidth;
      const height = document.body.offsetHeight;
      // 每次移动位置时,都需要擦除之前的绘制内容进行重新绘制(否则无法得到想要的效果)
      ctx.clearRect(0,0,width,height); 
      // 开始创建一条新路径
      ctx.beginPath();
      // 创建遮罩层
      ctx.globalCompositeOperation = "source-over";
      ctx.fillRect(0,0,width,height);
      //画框
      ctx.globalCompositeOperation = 'destination-out';
      ctx.fillRect(x,y,w,h);
      //描边
      ctx.globalCompositeOperation = "source-over";
      // 将路径的起点移动到(x,y)坐标
      ctx.moveTo(x,y);
      // 绘制一个矩形
      // lineto方法会使用直线连接子路径的终点到x,y坐标的方法(并不会真正地绘制)
      ctx.lineTo(x+w,y);
      ctx.lineTo(x+w,y+h);
      ctx.lineTo(x,y+h);
      ctx.lineTo(x,y);
      // 绘制当前已有的路径
      ctx.stroke();
      // 从当前点到起始点绘制一条直线路径,如果图形已经是封闭的或者只有一个点,那么此方法不会做任何操作。
      ctx.closePath();
      this.clipArea = {
          x,
          y,
          w,
          h
      }
    }
    
  4. canvasToClipImage(canvas转为截图)函数的实现

    canvasToClipImage(canvas) {
        if(!canvas) return;
        // 创建一个新的canvas用于将截到的canvas数据绘制上去
        const newCanvas = document.createElement("canvas");
        const {x,y,w,h} = this.clipArea;
        newCanvas.width = w;
        newCanvas.height = h;
        // 获取中间层canvas(也就是通过html2canvas将body绘制成的canvas)的绘制接口对象
        const canvasCtx = this.middleCanvas.getContext('2d');
        const newCanvasCtx = newCanvas.getContext('2d');
        //获取到截图区域的图像数据(值得注意的是:它会获得区域隐含的像素数据,因此,截图效果并不十分理想)
        const imageData = canvasCtx.getImageData(0,0,w,h);
        // 将该图像数据绘制到新创建的canvas上
        newCanvasCtx.putImageData(imageData,0,0);
        // 将该canvas转化为 data URI 
        const dataUrl = newCanvas.toDataURL("image/png"); 
        console.log(dataUrl);
        //this.downloadImg(dataUrl);
    }
    

https://juejin.im/post/5e5deb1351882549522ac67c

「点点赞赏,手留余香」

    还没有人赞赏,快来当第一个赞赏的人吧!
0 条回复 A 作者 M 管理员
    所有的伟大,都源于一个勇敢的开始!
欢迎您,新朋友,感谢参与互动!欢迎您 {{author}},您在本站有{{commentsCount}}条评论