撸一个聊天室(vue+koa2+websokect+mongodb)

撸一个聊天室(vue+koa2+websokect+mongodb)

本篇博客主要介绍聊天室项目,作者学习vue和node时间较短,若有什么错误或建议,欢迎指出,谢谢~


贴上源码链接 -> 源码

vue的布局在这就不说了,大家可以查看源码。

效果图如下:

撸一个聊天室(vue+koa2+websokect+mongodb)_第1张图片
首先来看一个聊天室需要哪些功能:

  1. 发消息(单聊)
  2. 添加好友
  3. 发表情和图片
  4. 发文件

这三个算是聊天室需要的最基本的功能,其他功能还可以自己拓展,在我的源码中还实现了离线消息、禁止多端登录以及富文本功能。首先需要了解的是如何在vue中使用webSocket。

// 在vue中使用webSocket,需要使用vue-socket.io
// 下载
npm i vue-socket.io -D

// main.js
import VueSocketio from 'vue-socket.io'
Vue.use(VueSocketio, 'http://localhost:8888/') // 第二个参数为服务端地址
// 在vue中定义socket属性
sockets: { // 不能改
  // 定义服务端需要调用的方法
  getOnlineNum: function (num) {
    this.onlineNum = num
  }
}

// 调用方法
this.$socket.emit('signIn', 'username')

服务端使用的是koa2 + socket.io

const app = new Koa()

const server = require('http').createServer(app.callback())
const io = require('socket.io')(server)
server.listen(process.env.PORT) // 监听端口号

// socket
// 每个用户登录时,将他们的用户名和socketID通过键值对存起来,方便单聊时向指定用户发送
// 通过用户获取socketID,通过socketID获取用户名
let user = {} // 键值对:username: socketID
let socketID = {} // 键值对: socketID: username

// socket连接
io.on('connection', function (socket) {
  // 注册signIn方法,在vue登录时调用
  socket.on('signIn', function (username) {
    user[username] = socket.id
    socketID[socket.id] = username
    // 获取当前在线人数
    io.sockets.emit('getOnlineNum', Object.keys(socketID).length)
  })
  // 断开连接
  socket.on('disconnect', function () {
    const username = socketID[socket.id] // 获取下线的用户
    delete socketID[socket.id]
    delete user[username]
    io.sockets.emit('getOnlineNum', Object.keys(socketID).length)
    console.log(username + '下线')
  })
})

其实webSocket的用法就是在双向调用,客户端注册服务端要调用的方法,服务端注册客户端要调用的方法。

实现单聊

编写单聊功能时需要注意,需要给输入框绑定当前聊天好友的消息变量,没有好友时单独绑定一个变量。



// 点击发送按钮
send () {
  // 判断是否有好友
  if (this.friends.length > 0) {
    // 参数:1.方法名,2.消息,3.来自哪个用户,4.发送给哪个用户
    this.$socket.emit('receive', this.friends[this.nowChat].textmsg, this.curUsername, this.friends[this.nowChat].username)
  } else {
    alert('你还没有好友,先去加好友吧')
  }
}

// 服务端接收
socket.on('receive', function (msg, from, to) {
  let date = new Date().toTimeString().substr(0, 8) // 记录时间
  let socketId = user[to] // 查找接收方的socketID
  let meSocketId = user[from] // 查找发送方的socketID
  // 给自己发送一条
  io.sockets.sockets[meSocketId].emit('newMsg', {from: from, to: to, msg: msg, date: date})
  // 判断接收方是否在线
  if (socketId) {
    // 在线就直接发送
    io.sockets.sockets[socketId].emit('newMsg', {from: from, to: to, msg: msg, date: date})
  }
  // 将消息存储到数据库
  DBModule.NewsList.addNews({from: from, to: to, msg: msg, date: date})
})

// 客户端在页面挂载时请求获取当前用户的所有消息
getNewsList () {
  this.$axios.post('/getNews', {
    username: this.curUsername
  }).then(res => {
    this.newMsg = res.data.data
  }).catch(err => console.log(err))
}
mounted () {
  this.getNewsList()
}

添加好友

用户一端添加好友时,就将双方好友信息互存起来,再更新双方的页面的好友信息。

// vue
addFriend () {
  this.$axios.post('/addFriend', {
    username: this.curUsername,
    friend: this.friend
  }).then(res => {
    if (res.data.status === 200) {
      // 将存在store里的好友信息更新
      this.$store.commit('updateFriends', res.data.data)
      this.friends = res.data.data
      // 通过webSocket更新对方的好友信息
      this.$socket.emit('updateFriends', this.curUsername, this.friend)
    }
  }).catch(err => {
    console.log(err)
  })
}

// 服务端
socket.on('updateFriends', function (username, friend) {
  let socketId = user[friend]
  if (socketId) {
    io.sockets.sockets[socketId].emit('updateFriends', username)
  }
})

// vue中更新好友方法,写在socket中,服务端调用
updateFriends: function (data) {
  this.friends.push({
    username: data,
    textmsg: ''
  })
}

以上属于强行加好友系列。
笑哭
还可实现添加好友通知功能,只有对方用户同意添加后才能添加。

做一个简单的富文本编辑框

一开始输入框只想到了textarea,但在textarea中只能显示文本,那如何做到像qq一样可以将图片显示到输入框呢?经学长提示就决定使用“contenteditable”属性,将标签的“contenteditable”属性设置为true就可以使不可输入元素变为可输入元素。


export default {
  props: {
    child: {
      type: String,
      default: ''
    }
  },
  data () {
    return {
      innerText: this.child,
      lock: false
    }
  },
  watch: {
    child: {
      handler (newValue, oldValue) {
        if (!this.lock) {
          this.innerText = this.child
        }
      },
      deep: true
    }
  },
  methods: {
    changeTxt () {
      this.$emit('updateMsg', this.$refs.editDiv.innerHTML)
    },
    blur () {
      this.lock = false
    },
    setInnerText () {
      this.$refs.editDiv.innerHTML = ''
      this.innerText = this.child
    },
    sendMsg () {
      this.$emit('send')
    }
  }
}

// 父组件的方法,更新消息
updateMsg (msg) {
  if (this.friends.length > 0) {
    this.friends[this.nowChat].textmsg = msg
  } else {
    this.textmsg = msg
  }
}

当然,这里也可以用除pre以外的标签。还记得上面将输入框的消息与对应的好友消息绑定吧?这里我们依然需要实现双向绑定,将富文本编辑框作为一个子组件,接收父组件传过来的消息,展示在编辑框内。这里需要注意一个问题:此时若是去掉lock,就会发生输入字符不匹配以及光标置于最前的问题。参考博客才解决这个问题。实际上这样写,“this.innerText = this.child”只执行了一次,而setInnerText方法将innerText重新赋值。

发表情和图片

  1. 发表情
    表情包是我们自己提供的,所以一开始就将图片的放在服务端,这里就需要配置静态服务器,让客户端可以通过链接获取到服务端的资源。
// 服务端
const convert = require('koa-convert')
const koaStatic = require('koa-static')
// 由于koa-static目前不支持koa2,所以只能用koa-convert封装一下,配置资源所在路径。
app.use(convert(koaStatic(
  path.join(__dirname, `../static`)
)))

此时就可以通过链接访问服务端端资源,例如:localhost:8888/emoji/1.gif,static目录不用写。

// 传入表情的名字,服务端返回文件的url
addEmoji (index) {
  this.$axios.post('/searchEmojiUrl', {
    emojiId: index
  }).then(res => {
    if (this.friends.length > 0) {
      this.friends[this.nowChat].textmsg += '+ res.data.data.emojiUrl + '" alt="emoji">'
    } else {
      this.textmsg += '+ res.data.data.emojiUrl + '" alt="emoji">'
    }
    this.emojiShow = false
  }).catch(err => console.log(err))
}
  1. 发图片
    发图片与发表情一个很重要的区别是表情一开始就在服务器上,而图片不是,所以在获取图片的url之前,需要先将图片上传服务器。

    然而在富文本编辑框中会发现用户可以通过退格键删除图片,那么如何只将用户点击发送那一刻编辑框内所有的图片上传服务器,防止服务器接收过多无用的图片。

    经过很长的思考及询问,我决定采用暴力法。笑哭


    思路:先把点击上传以后的全部图片的名字和文件对象全部存在一个数组内,插入到富文本时将文件名字放在data-name属性内,而在富文本中使用fileReader.readAsDataURL(file)将图片解析成字符串显示,点击发送按钮时操作DOM查找富文本中的img标签,通过data-name属性拿到文件名,再去数组里找到文件对象,以键值对(文件名,文件)的方式存在FormData对象中。在发送消息之前将FormData对象发送给服务器,服务器存储后返回键值对(文件名,文件URL),再对img标签的src进行赋值,赋值为服务器端的地址。最后发送消息字符串。
// 在data中添加imgArr: [],保存每次输入添加的所有图片信息
send () {
  // 暴力操作DOM获取富文本下的img标签
  let imgList = document.querySelector('.msgText').querySelectorAll('img')
  let formData = new FormData() // 存储要上传到服务器的图片
  let isNull = true
  for (let i = 0; i < imgList.length; i++) { // 获取消息中剩余的图片
    this.imgArr.map((ele, idx) => {
      if (ele.name === imgList[i].getAttribute('data-name')) {
        isNull = false
        formData.append(ele.name, ele.fileObj)
      }
    })
  }
  this.imgArr = [] // 将数组清空,等待下次存储
  if (this.friends.length > 0) {
    if (!isNull) { // 若好友不为空,且发送的图片不为空,则发送上传图片的请求
      this.$axios.post('/uploadImg', formData, {
        headers: { // 传送文件设置请求头
          'Content-Type': 'multipart/form-data'
        }
      }).then(res => {
        if (res.data.status === 200) {
          for (let i = 0; i < imgList.length; i++) {
            for (let key in res.data.data) {
              if (imgList[i].getAttribute('data-name') === key) {
                imgList[i].src = res.data.data[key]
              }
            }
          }
          // 使用socket发送消息,这里的消息内容需要通过innerHTML获取,因为储存在父组件的消息字符串中img标签的src标签没有替换。
          this.$socket.emit('receive', document.querySelector('.msgText pre').innerHTML, this.curUsername, this.friends[this.nowChat].username)
          this.friends[this.nowChat].textmsg = '' // 将发出的消息清空
          this.$refs.editPre.setInnerText() // 将当前富文本清空
        }
      })
    } else {
      this.$socket.emit('receive', document.querySelector('.msgText pre').innerHTML, this.curUsername, this.friends[this.nowChat].username)
      this.friends[this.nowChat].textmsg = ''
      this.$refs.editPre.setInnerText()
    }
  } else {
    alert('你还没有好友,先去加好友吧')
  }
}

服务端上传文件代码,使用busboy上传文件

const path = require('path')
const fs = require('fs')
const Busboy = require('busboy')
const inspect = require('util').inspect
const baseController = require('./baseController')

/** * 同步创建文件目录 * @param {string} dirname 目录绝对地址 * @return {boolean} 创建目录结果 */
function mkdirSync (dirname) {
  if (fs.existsSync(dirname)) {
    return true
  } else {
    // path.dirname()用于获取一个路径中的目录名,当参数值为目录路径时,
    // 该方法返回该目录的上层;当参数为文件路径时,该方法返回该文件所在的目录
    if (mkdirSync(path.dirname(dirname))) {
      fs.mkdirSync(dirname)
      return true
    }
  }
}

/** * 获取上传文件的后缀名 * @param {string} fileName 获取上传文件的后缀名 * @param {string} 文件后缀名 */

function getSuffixName (fileName) {
  let nameList = fileName.split('.')
  return nameList[nameList.length - 1]
}

/** * 上传文件 * @param {object} ctx koa上下文 * @param {object} options 文件上传参数 fileType文件类型,path文件存放路径 * @return {promise} */
function uploadImg (ctx, options) {
  const req = ctx.req

  var busboy = new Busboy({ headers: req.headers })

  let fileType = options.fileType || 'common' // 默认文件夹为common
  let filePath = path.join(options.path, fileType)
  let mkdirResult = mkdirSync(filePath)
  if (mkdirResult) {
    return new Promise((resolve, reject) => {
      console.log('文件上传中')
      let result = { // 默认返回
        success: false,
        data: {}
      }
      busboy.on('file', function (fieldname, file, filename, encoding, mimetype) {
        let fileName = new Date().getTime() + '.' + getSuffixName(filename)
        console.log('File [' + fieldname + ']: filename: ' + filename)
        let _uploadFilePath = path.join(filePath, fileName)
        let saveTo = path.join(_uploadFilePath)

        // 文件保存到指定路径
        file.pipe(fs.createWriteStream(saveTo)) // 先读入再读出来写

        // 文件保存到指定路径
        file.on('end', function () {
          console.log('File [' + fieldname + '] Finished')
          result.data[filename] = `http://${ctx.host}/img/${fileType}/${fileName}`
        })
      })
      busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated) {
        console.log('Field [' + fieldname + ']: value: ' + inspect(val))
      })
      busboy.on('finish', function () {
        result.success = true
        result.message = '文件上传成功'
        console.log('文件上传成功')
        resolve(result)
      })
      busboy.on('error', function (err) {
        console.log(err)
        console.log('文件上传出错')
      })
      req.pipe(busboy)
    })
  }
}

// 调用方法
async (ctx, next) => {
  let serverFilePath = path.join(__dirname, '../static/img')
  const result = await uploadImg(ctx, {
    fileType: 'album',
    path: serverFilePath
  })
  if (result.success === true) {
    ctx.body = { status: 200, msg: '上传成功', data: result.data }
  } else {
    ctx.body = { status: 401, msg: '上传失败' }
  }
}

文件上传

文件上传同图片上传类似,没有图片那么复杂,点击文件上传按钮直接上传即可。在消息中显示为[file:文件地址],在消息显示时解析成你想要的结果。而文件下载功能,可以通过a标签,每一个a标签都会发出一个请求。

// 解析每一条消息
changeMsg (msg) {
  let fileName = ''
  let result = msg
  let fileReg = /\[file:.+?\]/g
  let fileMatch = fileReg.exec(msg)
  let fileType = ''
  while (fileMatch) {
    fileName = fileMatch[0].slice(6, -1)
    console.log(fileName)
    var index = fileName.lastIndexOf('/')
    var str = fileName.substring(index + 1, fileName.length)
    var strFileName = str.replace(/^.+?\\([^\\]+?)(\.[^.\\]*?)?$/gi, '$1')
    var fileExt = str.replace(/.+\./, '').toLowerCase()
    if (fileExt === 'doc') {
      fileType = 'word'
    } else if (fileExt === 'xlsx') {
      fileType = 'excel'
    } else {
      fileType = 'file'
    }
    result = result.replace(fileMatch[0], `

${strFileName}下载

`) fileMatch = fileReg.exec(msg) } return result }

小结

在写代码过程中,遇到过许多问题,也不断优化改进,以后也会实现新的功能。如果有什么建议,欢迎提出。再次贴出源码地址,如果对你有帮助,请给我的仓库一个star ,非常感谢。
撸一个聊天室(vue+koa2+websokect+mongodb)_第2张图片

你可能感兴趣的:(vue,koa2,webSocket,聊天室,vue,node,webSocket)