本篇博客主要介绍聊天室项目,作者学习vue和node时间较短,若有什么错误或建议,欢迎指出,谢谢~
贴上源码链接 -> 源码
vue的布局在这就不说了,大家可以查看源码。
效果图如下:
首先来看一个聊天室需要哪些功能:
- 发消息(单聊)
- 添加好友
- 发表情和图片
- 发文件
这三个算是聊天室需要的最基本的功能,其他功能还可以自己拓展,在我的源码中还实现了离线消息、禁止多端登录以及富文本功能。首先需要了解的是如何在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重新赋值。
// 服务端
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))
}
// 在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 ,非常感谢。