仿微信·聊天模块初尝试(暂完)

3,315 阅读4分钟

前言

最近突发奇想,想尝试看看自己能不能实现一下聊天交互,因为微信是日常最常见的通信工具之一,所以就打算仿照微信。

因为不熟悉所以肯定会遇到很多问题,就想着把这次开发过程慢慢记录下来,同时也希望能够获得大家的一些建议来改进规范自己,在这里先谢谢大家啦。

项目展示

动画.gif 动画.gif

项目简介

开发所使用的环境以及目前已经引用的组件:

  • 开发环境: vue2 + node.js + mongoDB
  • 引用组件: elementui + axios + touter + vuex + captcha + html2canvas

项目结构

├─chat  // 前端
    ├─src
        ├─assets  // 全局样式
        ├─components  // 可复用组件
        ├─router  // 路由配置文件
        ├─store  // vuex
        └─views  // 页面
      App.js  // 根页面
      main.js  // 入口文件
├─serve  //后端
    ├─config  // 数据库配置
    ├─models  // 数据库表设计
    ├─router // 交互接口
  index.js // 入口文件

前端配置

路由配置 router

  • router/index.js 代码如下:
import Vue from "vue";
import Router from "vue-router";
Vue.use(Router);

const routes = [
  {
    path:'/',
    redirect:'/login'
  },
  //......
]

const router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

数据流管理 vuex

  • store/index.js 代码如下:
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);

const store = new Vuex.Store({
  state:{ //相当于一个存储空间
     // ......
  },
  getters:{}, //state的计算数据 实现数据过滤的作用(get)
  mutations:{ //设置state中的数据(set)
    // ......
  },
  actions:{},  //可以发送异步请求
  modules:{},  //拆分存储空间模块化
})
//导出store对象
export default store

网络请求 axios

  • vue.config.js 部分代码:
devServer: {
  proxy: {
    '^/api': {
      target: "http://localhost:3000/",
      ws: true,
      changeOrigin: true,
      pathRewrite: {
        '^/api':''//重写路径
      }
    }
  }
},

后端配置

网络请求 axios

本项目中将所有的接口都放在了 router/index.js 文件中,接口所调用的函数都放在了 router/user.js 文件中

  • router/index.js 代码如下
const router = require('koa-router')()

module.exports = (app) => {
  router.post('/user/login',require('./user').login)
  // ......
  
  app
    .use(router.routes())
    .use(router.allowedMethods())
}
  • router/user.js 代码如下
const UserModel = require('../models/user') // 用户表结构
const bcrypt = require('bcryptjs') // bcryptjs 加密
module.exports = {
  // 登录
  async login(ctx, next) {
    console.log(ctx.request.body);
    ctx.body = { msg: 'success', }
  },
  // ......
}

数据库 mongoDB

数据库使用 mongoDB 主要考虑有:为了避免数据库大量占用,笔者认为聊天记录表最好是两两用户之间创建一个,而 mongoDB 动态建表操作较为方便

  • config/config.js 代码如下:
module.exports = {
  port: process.env.PORT || 3000,
  session: {
    key: 'chat'
  },
  mongodb: 'mongodb://localhost:27017/chat'
}

至此,路由、数据流、前后端接口以及数据库等基本的项目配置都完成了,那么就可以开始正式写页面了。

页面编写

既然是要实现聊天,那肯定是用户与用户之间交互,实现登录注册功能就是第一要务:

login/register页面:

动画.gif

前端的登录/注册数据 loginForm registerFormObject 格式,为了减少 json 转换的操作就使用了 post 请求。

  • 登录操作部分代码如下:
// 前端  views/login.vue 
async login(){
  if(this.code != this.vertify) this.$message.error('验证码错误');
    else {
    this.$axios.post('http://localhost:3000/user/login', this.loginForm)
      .then(res => {
        this.$message({
          message: res.data.msg,
          type: res.data.msg == '登录成功' ? 'success' : 'error',
        });
        if(res.data.msg == '登录成功'){
          let chatUserInfo = {
            isLogin: true,
            username: res.data.body.username,
            account: res.data.body.account,
          }
          localStorage.setItem('chatUserInfo', JSON.stringify(chatUserInfo));
          this.$store.commit('setChatUserInfo', chatUserInfo)
          setTimeout(() => { this.$router.push("/chat") }, 100)
        }
      })
  }
},

// 后端 router/user.js
async login(ctx, next) {
    // console.log(ctx.request.body);
    let { account, password } = ctx.request.body
    const user = await UserModel.findOne({ account })
    if((await UserModel.find({ account })).length && await bcrypt.compare(password, user.password)){
        ctx.body = {
            msg: '登录成功',
            body: user,
        }
    }else ctx.body = { msg: '用户名/密码错误' }
},

注册部分需要提前查表来防止同名账号的注册、还有就是加密密码,这里笔者使用的是 bcrypt 重复加密十次来进行加密操作。

加好友操作需要双方同意,所以笔者设计了一个 applyFriendListSchema 表结构,由 username, account, sentData 组成,同时为了便于查找给表的命名为account + 'FriendList' ,显然该表与用户注册需要同步,所以放在注册操作部分完成,用户的好友列表亦然。

  • 注册操作部分代码:
// 后端 router/user.js
async register(ctx, next) {
    let { username, account, password } = ctx.request.body
    if((await UserModel.find({ account })).length) ctx.body = { msg: '账号重复' }
    else{
        let newuser = {
            password: await bcrypt.hash(password, bcrypt.genSaltSync(10)), // 加密十次
            //......
        }
        await UserModel.create(newuser)
        // 好友列表
        mongoose.model(account + 'FriendList', friendsListSchema)
        await mongoose.model(account + 'FriendList', friendsListSchema).create({ 
            username, account,
            timeToBeFriend: new Date(),
        })
        // 申请好友列表
        mongoose.model(account + 'ApplyFriendList', applyFriendListSchema)
        ctx.body = { msg: '注册成功' }
    }
},

为了保持登录状态,笔者使用 localStorage + vuex 的储存方法,这样可以避免刷新操作会清空 vuex 的缓存导致登录状态丢失,具体操作为在 mounted 生命周期函数中读取通过 localStorage 在本地储存的登录信息 chatUserInfo ,再将登录信息重新储存到 vuex 中,并跳转/重新加载页面。

  mounted() {
    // 保持登录状态
    let chatUserInfo = JSON.parse(localStorage.getItem('chatUserInfo'))
    if (null === chatUserInfo) return;
    if (chatUserInfo.isLogin) {
      this.$store.commit('setChatUserInfo', chatUserInfo)
      this.$router.push("/chat")
    }
  },

至此,登录注册部分算是初步完成了,接下来需要完成的就是让用户之间连接起来才能完成通信,所以下一步先完成通讯录部分

通讯录页面

动画.gif

考虑到右边部分有 好友信息 和 新的朋友两种状态 所以这部分我将其分为三个部分 midArea, friendInfoArea, friendApplyArea 并由 index 统一调用,目录结构如下:

├─components
    ├─friend
        ├─index.vue
        ├─midArea.vue
        ├─friendInfoArea.vue
        └─friendApplyArea.vue

因为 midArea 与另外两个界面的数据存在互相交互的关系,但是它们之间为兄弟组件,所以我选择使用 vuex 储存需要交互的信息,跳转展示右边页面的信息存储在 chackRightArea 中,页面所需要的用户信息/好友申请列表等信息存储在 ShowUserInfoRight 中,并统一的由 midArea 中的 showUserInfoRight 修改存储数据

    showUserInfoRight(item){
      this.$store.commit('setShowUserInfoRight', item)
      this.$store.commit('setChackRightArea', 'friendInfoArea')
    },

搜索框部分其实涉及了两个搜索,好友列表搜索的主要就是使用 RegExp 实现模糊查询,用户搜索不同的是需要对 account 精确搜索同时在返回数据字段中添加了 isFriend 作搜索用户是否为好友判断

    // 搜索好友 部分代码
    let friendList = await mongoose.model(userData.account + 'FriendList', friendsListSchema).find({account: new RegExp(searchData)})
    
    // 搜索用户 部分代码
    let findUser = await UserModel.find({ account: searchData })
    let friendsListData = await mongoose.model(userData.account + 'FriendList', friendsListSchema).find({account: new RegExp(searchData)})
    let isFriend = friendsListData.length == [] ? false : true
    let remarksName = isFriend ? friendsListData[0].remarksName : findUser[0].username
    if(findUser.length){
        let userData = {
            isFriend, remarksName,
            username: findUser[0].username,
            account: findUser[0].account,
        }
    },

好友申请分为 unhandled accept reject 三个状态,进入 accept/reject 状态会调用 precessFriendRequest ,上述两状态均会调用 account + ApplyFriendList表 修改状态,accept 则会额外的调用两个用户的好友表并创建 applyUserData.account + userData.account + chatRecord 聊天记录表,这里采用表名为两用户账号排序后顺序连接命名,用以减少数据库查找时间

 async precessFriendRequest(ctx, next){
    // console.log(ctx.request.body);
    let { applyResult, applyUserData, userData } = ctx.request.body
    if (applyResult == 'accept') {
        await mongoose.model(userData.account + 'FriendList', friendsListSchema).create({
            remarksName: applyUserData.username,
            username: applyUserData.username, 
            account: applyUserData.account,
            timeToBeFriend: new Date(),
        })
        await mongoose.model(applyUserData.account + 'FriendList', friendsListSchema).create({
            remarksName: userData.username,
            username: userData.username, 
            account: userData.account,
            timeToBeFriend: new Date(),
        })
        let shcemName = [applyUserData.account, userData.account].sort()
        await mongoose.model(shcemName[0] + shcemName[1] + 'chatRecord', chatRecordSchema).create({
            chatRecord: '我们已经成为好友了', 
            sendUser: userData.account,
            sendTime: new Date(),
        })
    }
    await mongoose.model(userData.account + 'ApplyFriendList', applyFriendListSchema).updateOne({ 
        username: applyUserData.username,
        account: applyUserData.account,
    }, {
        processStatus: applyResult
    })
    ctx.body = { msg: '成功', }
},

搜索用户部分,微信设计为点击搜索信息框以外的部分则隐藏,笔者的设想为让搜索信息框获得焦点,当点击其他部位失去焦点则隐藏,但实际编写过程中发现 div 无法获得焦点,所以这里改为 click 后发送请求到后台获取信息, 当有信息以后 hover 展示搜索信息框

效果图(左侧微信 右侧笔者):

动画.gif 动画.gif
.userInfoCard{
 position: relative;
 top: -11.5%;
 left: 100%;
 display: none;
}
.hoverShow:hover + .userInfoCard, .userInfoCard:hover{
 display: block;
}

聊天界面

动画.gif 那么到这里就进入到仿·微信的主要功能点实现的部分了,这一部分呢主要功能点为:聊天交互 富文本输入框 表情发送 文件上传\下载 截图

聊天交互的实现笔者主要用到了 chatRecordSchema chatListSchema 两张表,分别用于储存两两用户之间的所有聊天记录和最近一条聊天记录,为了减少表的构建这里 chatRecordSchema 命名同样使用排序后的 [friendUserData.account, userData.account].sort + chatRecord

let chatRecordType = 'text'
if (sendMessage.indexOf('uploadFiles') != -1) { chatRecordType = 'files' } // 判断上传消息是否为 file
let shcemName = [chackUserData.account, userData.account].sort()
await mongoose.model(shcemName[0] + shcemName[1] + 'chatRecord', chatRecordSchema).create({
    chatRecord: sendMessage, chatRecordType,
    sendUser: userData.account,
    sendTime: new Date(),
})
await mongoose.model(userData.account + 'chatList', chatListSchema).updateOne({
    friendAccount: chackUserData.account,
},{ lastChatRecord: sendMessage, sendTime: new Date(), })
await mongoose.model(chackUserData.account + 'chatList', chatListSchema).updateOne({
    friendAccount: userData.account,
},{ lastChatRecord: sendMessage, sendTime: new Date(), })

在聊天消息部分为了保证展示最新的消息,需要检测使滚动条默认位于最下方,所以笔者在 updated 生命周期函数监控,当高度发生变化就将滚动条跳转至底部

<script>
  updated() {
    this.scrollDown()
  },  
  methods: {
      scrollDown() {
      this.$refs['myScrollbar'].wrap.scrollTop = this.$refs['myScrollbar'].wrap.scrollHeight
    },
  }
</script>

现在聊天肯定少不了表情包的输入,但是 input 输入框并不支持非 text 类型的文件输入,所以这里笔者选择采用 contenteditable="true" 实现简单的富文本框输入

// 前端  components\chat\rightArea.vue
<el-scrollbar ref="chatScrollbar" wrap-style="overflow-x:hidden;" style="height:60px;"> // 滚动条
  <div
    contenteditable="true" // 使 div 可编辑实现富文本框的根本ref="inputMsg"
    v-on:input="handleInput" 
    @keyup.ctrl.enter="sendMessage" // 快捷键发送 ctrl + enter
    class="divInput"
  ></div> 
</el-scrollbar>

handleInput(){ // 将富文本内容写入待发送信息
  this.waitSendMessage = this.$refs.inputMsg.innerHTML;
},

表情这里笔者发现微信的 emoticon 表情地址是https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/[0-104].gif ,于是便简单粗暴的直接通过地址引用并将该部分单独写了一个 emoticon 组件,这里为了方便组件间的传参用的是 this.$emit

// components\chat\rightArea.vue
<Emoticon ref="emoticon" @AppendInputValue="AppendMessageText"></Emoticon>

openEmoticons() {
  this.$refs.emoticon.openEmoticon();
},
AppendMessageText(EmotionChinese) {
  this.$refs.inputMsg.innerHTML += EmotionChinese
  this.waitSendMessage += EmotionChinese;
},

// components\chat\emoticon.vue
<div class="emoticonList w h flex vcenter">
  <div class="picItem flex vcenter lcenter" v-for="(item,i) in emoticonList" @click="Clickemoticon(i)" :key="i">
      <img :src=" 'https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/' + i + '.gif'" class="pointer">
  </div>
</div>

Clickemoticon(emoticonNo) {
  var That = this;
  That.Show = false;
  That.$emit('AppendInputValue',
    "<img src='" + "https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/" + emoticonNo + ".gif'" + " style='width:20px;height:20px;'>"
  )
},

截图部分目前实现的仅为点击截取可视部分聊天内容界面,这里笔者使用了 html2canvas 来实现截图,html2canvas 的作用是根据 DOM 生成对应的图片,所以暂时只能简单的实现固定 DOM 节点的截图,而不能实现随意截图

toImage () {
  const canvas = document.createElement('canvas')
  const canvasBox = document.querySelector('.chatContent')
  const width = canvasBox.clientWidth
  const height = canvasBox.clientHeight
  // 宽高 * 2 并放大 2 倍 是为了防止图片模糊
  canvas.width = width * 2
  canvas.height = height * 2
  canvas.width = width
  canvas.height = height
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  const context = canvas.getContext('2d')
  // context.scale(2, 2)
  context.scale(1, 1)
  const options = {
    backgroundColor: null,
    canvas: canvas,
    useCORS: true
  }
  html2canvas(canvasBox, options).then((canvas) => {
    const dataURL = canvas.toDataURL('image/png') // 图片格式转成base64
    this.$refs.inputMsg.innerHTML += "<img src='" + dataURL + "' >"
    this.waitSendMessage += "<img src='" + dataURL + "' >"
  })
},

文件的上传利用了 elementuiel-upload 封装好的 action 来将文件发送到后端并保存,因为其无法携带参数,笔者在上传成功的钩子中再次发送后台请求将相对应的文件信息存入 downloadFiles 下载文件表中,再将对应的 fileId 存入聊天信息表中

// 前端 components\chat\rightArea.vue
<el-upload
  ref="uploadFiles"
  action="http://localhost:3000/user/uploadFiles"
  :limit='1' :show-file-list='false' :auto-upload='true'
  :on-success="handleAvatarSuccess"
  class="files"
  >
  <i class="pointer el-icon-folder"></i>
</el-upload>
          
handleAvatarSuccess(res) {   
  // console.log(res);
  this.$axios.post('http://localhost:3000/user/editFilesDetail', { 
    userData: this.$store.state.chatUserInfo, 
    chackUserData: this.$store.state.chackChatUser, 
    name: res.name,
    path: res.path,
  })
    .then((res) => {
      // console.log(res);
      this.$refs.uploadFiles.clearFiles();
      this.waitSendMessage = res.data.sendMessage
      this.sendMessage()
    })

},

// 后端 
// index.js
app.use(koaBody({
  formidable: {
    //设置文件的默认保存目录,不设置则保存在系统临时目录下
    uploadDir: path.resolve(__dirname, '../chat/public') // 保存至前台 public 文件夹下以保证下载功能不报错
  },
  multipart: true // 支持文件上传
}));

app.use(koaStatic(
  path.resolve(__dirname, '../chat/public') // 保存至前台 public 文件夹下以保证下载功能不报错
));

//router\user.js
// 上传文件
async uploadFiles(ctx, next){
    // console.log(ctx.request.files.file);
    let { path, name } = ctx.request.files.file
    ctx.body = { msg: '成功', path, name };
},
async editFilesDetail(ctx, next){
    // console.log(ctx.request.body)
    let { path, name, userData, chackUserData } = ctx.request.body
    let newPath = path + '-' + userData.account + chackUserData.account + '-' + name
    fs.rename(path, newPath, (err)=>{ })
    let fileId = path.substring(path.lastIndexOf('\\') + 1, path.length)
    console.log(fileId);
    mongoose.model(userData.account + 'downloadFilesList', downloadFiles).create({
        fileId,
        fileName: name,
        fileRoute: newPath.substring(path.lastIndexOf('\\'), newPath.length),
        sendUser: chackUserData.account,
    })
    mongoose.model(chackUserData.account + 'downloadFilesList', downloadFiles).create({
        fileName: name,
        fileRoute: newPath.substring(path.lastIndexOf('\\'), newPath.length),
        sendUser: userData.account,
    })
    ctx.body = { msg: '成功', sendMessage: 'uploadFiles' + '-' +  fileId + '-' + name}
},

注:下载文件时,笔者在被 Not allowed to load local resource (由于安全性的问题,导致浏览器禁止直接访问本地文件)报错折磨了两天后,终于发现 vue 会默认且只能访问其自身 public 文件下的内容且引用地址必须是相对地址,所以只能修改文件的保存地址为'../chat/public'

前端将文件下载到本地主要是通过构建 a 标签

// 下载文件
downloadFiles(path){
  this.$axios.post('http://localhost:3000/user/downloadFiles', { fileId: path.split('-')[1],  userData: this.$store.state.chatUserInfo })
    .then((res) => {
      console.log(res);
      const a = document.createElement('a')
      a.href =  res.data.filesData.fileRoute
      a.download = res.data.filesData.fileName
      console.log(a);
      a.click()
    })
},

至此仿·微信聊天模块大体已经完成了,后续代码部分应该会将刷新、收藏等功能陆续更新,但可能就不会再更新该文章了

感谢大家的观看,如果觉得笔者写的不错的话希望各位能给笔者一个赞,如果各位有好的意见,欢迎在评论指出

源码

  • 项目地址:gitee 项目还在更新迭代中,欢迎star噢😘