上篇文章我们把框基本搭建起来,本篇文章,我们具体实现功能逻辑
已完成功能:
- 用户注册、登录
- 用户进入/离开聊天室,通知当前聊天室内所有用户
- 单个用户新增群聊,所有用户可以看到
- 用户可实时与所有人聊天
- 用户离线保留聊天列表、聊天记录
- 点击用户头像,新增私聊
- 用户可实时单人私聊
- 聊天室记录用户未读消息
- 用户正在输入功能
资源链接:https://github.com/zhangyongwnag/chat_room
文章目录
一、建立socket连接
上篇文章,我们把后台服务基本搭建起来,并且利用socket.io
服务端启动socket
服务
用过socket.io
的大家基本都了解,他必须与客户端的socket.io-client
搭配使用
1、下载
npm i socket.io-client -S
2、客户端创建连接
import React, {Component} from 'react'
let socket = require('socket.io-client')('http://127.0.0.1:3001')
我们可以看到已经创建连接成功了
3、测试交互
接下来,我们客户端与服务端进行交互
// 客户端
socket.emit('init','客户端发送了消息')
// 服务端
io.on('connection', socket => {
socket.on('init',data => {
console.log(data)
})
})
测试效果:我们可以看到控制打印出客户端发送的消息
二、客户端添加状态管理
客户端:我们用到了redux
和react-redux
来实现状态管理
1、下载
npm i redux react-redux -S //
redux
状态管理
npm i redux-logger redux-thunk -S //redux
中间件
2、使用
index.js
根目录
import React from 'react'
import ReactDOM from 'react-dom'
import App from './pages/App'
import './assets/css/index.css'
import {Provider} from 'react-redux' // Provider组件用来注入store对象
import {createStore,applyMiddleware,compose} from 'redux' // 挂在中间件
import reducer from './store' // 引入reducer
import thunk from "redux-thunk"; // 改造store.dispatch
import logger from 'redux-logger' // 控制台打印reducer日志
// 创建store对象
const store = createStore(
reducer,
compose(
applyMiddleware(thunk),
applyMiddleware(logger)
)
)
ReactDOM.render(
<Provider store={store}>
<App/>
</Provider>,
document.getElementById('app'))
if (module.hot){
module.hot.accept(() => {
})
}
创建store
目录,并且连接多个reducer
store/index.js
连接多个reducer
import {combineReducers} from 'redux' // 引入中间件
import room from './reducer/room' // 引入聊天室列表 reducer
import records from "./reducer/records"; // 引入聊天记录 reducer
export default combineReducers({
room, // 聊天室列表
records, // 聊天记录
})
store/module/room.js
聊天室列表reducer
/**
* @description 聊天术列表
* @param {
* {Object} state {
* {Object} room:当前所在的聊天室信息
* {Array} room_list:聊天室列表
* }
* }
*/
export default function (state = {}, action) {
switch (action.type) {
case 'get':
return [...state]
case 'add':
return [...state, action.data]
case 'set':
let result = Object.assign(state, action.data)
return {
room: result.room, // 当前所处的聊天室
room_list: [...result.room_list] // 聊天室列表
}
default:
return state
}
}
records
同理如此
3、注入store
import React, {Component} from 'react'
import {connect} from 'react-redux' // connect中间件,用来绑定store对象于props
class App extends Component {
constructor(props) {
super(props)
}
render(){
return (<div>init</div>)
}
}
function mapStateToProps(state) {
// 注册state
return {
room: state.room.room,
records: state.records
}
}
export default connect(mapStateToProps)(App) // 注入props
4、测试效果
...
componentDidMount() {
this.props.dispatch('set', {
data:{
room: {
room_id: '1',
room_item: {}
},
room_list:[1,2,3,4]
}
})
}
...
我们可以看到,控制台打印出我们操作的日志
三、实现功能
①:用户注册、登录
-
梳理流程
- 1. 用户进入客户端,如果本地用户信息存在,去登陆,反之去注册
- 2. 客户端发起注册申请,并携带用户注册名称,发送到服务端
- 3. 服务端判断用户是否已注册,如果未注册,将用户名称( 唯一约束 )插入到用户表,反之注册失败,返回失败信息,提示用户重新注册
- 4. 每个用户注册成功后,向聊天室列表里插入一条默认所有人的聊天室
- 5. 返回当前注册用户的聊天室列表
客户端:
如果本地用户信息不存在,前往注册,反之直接登录
...
let socket = require('socket.io-client')('http://127.0.0.1:3001') // 创建socket连接
class App extends Component {
constructor(props) {
super(props)
this.state = {
userInfo: {}, // 用户信息
}
}
componentDidMount() {
// 如果本地信息不存在,则去注册,反之获取聊天列表
if (localStorage.getItem('userInfo')) {
let userInfo = JSON.parse(localStorage.getItem('userInfo'))
socket.emit('login', userInfo._id)
} else {
this.register()
}
}
// 注册用户
register = () => {
let name = prompt('请输入用户名')
// 如果输入去掉空格
name != null ? name = name.replace(/\s+/g, "") : ''
if (name == null || !name) {
this.register()
} else if (name.length > 6) {
alert('用户名不得超过6位')
this.register()
} else if (name) {
// 去注册
socket.emit('chat_reg', name)
}
}
...
服务端:处理用户登录 | 用户注册 | 获取单个用户的聊天室列表
...
let User = require('./module/User') // 用户model
let Room = require('./module/Room') // 聊天室model
let Records = require('./module/Records') // 聊天记录model
io.on('connection', socket => {
/**
* @description 用户静默登录
* @param {String | ObjectId} userId:登录的用户id
*/
socket.on('login', userId => {
// 更新用户列表socketId
User.updateOne({_id: ObjectId(userId)}, {$set: {socket_id: socket.id}}, function (err, result) {
socket.emit('login', socket.id)
})
})
/**
* @description 用户注册
* @param {String} username:要注册的用户名称
*/
socket.on('chat_reg', username => {
let user = new User({
user_name: username,
current_room_id: '',
socket_id: socket.id
})
// 注册用户插入数据库
user.save()
.then(res => {
// 注册事件
socket.emit('chat_reg', createResponse(true, res))
let room = new Room({
user_id: res._id.toString(),
user_name: username,
room_name: '所有人',
status: 0,
num: 0,
badge_number: 0,
current_status: false
})
// 默认所有人聊天室插入数据库
room.save()
.then(response => {
// 首次发送用户聊天室列表
socket.emit('get_room_list', createResponse(true, {once: true, data: [response]}))
})
})
.catch(err => {
// 注册失败
socket.emit('chat_reg', createResponse(false, '注册失败,用户已注册'))
})
})
/**
* @description 请求聊天列表
* @param {String | ObjectId} userId:用户ID
*/
socket.on('get_room_list', userId => {
Room.find({user_id: userId})
.then(data => socket.emit('get_room_list', createResponse(true, {once: true, data})))
})
...
这里大家可能会注意到,有一个createResponse
事件,用来统一处理数据返回格式,200
是成功,100
是失败
/**
* @description 创建响应体
* @param {Boolean} status : 是否成功
* @param {String | Array | Object | Boolean | Number | Symbol} data : 返回的数据
*/
function createResponse(status, data) {
return {
code: status ? 200 : 100,
data,
msg: status ? 'success' : 'error'
}
}
客户端:
接下来,我们在客户端响应服务端的emit
事件消息
我们写一个方法统一管理socket
的事件回调
...
componentDidMount () {
...
// 开启监听socket事件回调
this.socketEvent()
}
socketEvent = () => {
// 获取注册结果,如果成功保存用户信息,获取聊天室列表,反之继续去注册
socket.on('chat_reg', apply => {
if (apply.code == 200) {
localStorage.setItem('userInfo', JSON.stringify(apply.data))
this.setState({
userInfo: apply.data
}, () => {
socket.emit('get_room_list', this.state.userInfo._id)
})
} else {
alert(apply.data)
this.register()
}
})
// 获取聊天列表
socket.on('get_room_list', apply => {...})
}
...
②:用户进入/离开聊天室,通知当前聊天室内所有用户
-
梳理流程
- 1. 用户注册成功后,获取到聊天室列表,默认选择加入第一个聊天室
- 2. 用户加入聊天室后,通知聊天室内所有用户:xxx加入聊天。反之通知聊天室内所有用户:xxx离开聊天室
服务端:注册成功或者登录返回聊天室列表,携带特殊标识once
,用来标识用户初始化连接,并默认加入聊天室
核心代码:
/**
* @description 请求聊天列表
* @param {String | ObjectId} userId:用户ID
*/
socket.on('get_room_list', userId => {
Room.find({user_id: userId})
.then(data => socket.emit('get_room_list', createResponse(true, {once: true, data})))
})
客户端:注册成功或者重新登录后,获取聊天室列表,并且根据once
标识选择加入默认第一个聊天室
核心代码:
// 获取聊天列表
socket.on('get_room_list', apply => {
let room_list = apply.data.data.filter(item => item.user_id == this.state.userInfo._id)
let room_id = room_list[0]._id.toString()
let room_item = room_list[0]
// 保存用户聊天室信息、列表
this.props.dispatch({
type: 'set',
data: {
room: {
room_id,
room_item,
},
room_list
}
})
// 如果存在首次获取标识once,用户加入聊天室
if (apply.data.once) {
// 加入某个聊天室
socket.emit('join', {
roomName: this.props.room.room_item.room_name,
roomId: this.props.room.room_id,
userId: this.state.userInfo._id,
userName: this.state.userInfo.user_name
})
}
})
接下来,服务端处理用户加入聊天室逻辑
-
梳理逻辑
-
1. 用户
join
聊天室,创建一个room
-
2. 校验用户当前聊天室,判断是否重复加入
-
3. 如果非重复加入
- 更新当前用户所处的聊天室
ID
(users
表 -current_room_id
字段) - 更新当前用户聊天室的状态 (
rooms
表 -current_status
字段) - 清空当前用户当前聊天室的未读消息 (
rooms
表 -badge_number
字段) - 获取当前聊天室下的聊天记录 (
records
表 -room_name
字段) - 更新当前聊天室的在线人数 (
rooms
表 -num
字段) - 给当前聊天室所有人推送服务消息:xxx加入聊天室
- 更新当前用户所处的聊天室
-
4. 加入聊天室成功
-
5. 这里的系统服务消息,不做入库处理
服务端核心代码:
/**
* @description 用户退出/加入聊天室
* @param data {
* {String | ObjectId} userId:当前离线用户ID
* {String | ObjectId} roomId:当前用户所处聊天室ID
* {String} roomName:当前用户所处聊天室名称
* }
*/
socket.on('join', data => {
// 创建room
socket.join(data.roomName)
// 找到用户的当前所在聊天室
User.findOne({_id: ObjectId(data.userId)}, function (error, user_data) {
// 如果用户的前后俩次聊天室一致,则不更新,反之加入成功
if (user_data.current_room_id != data.roomId) {
...
// 对所有用户发送消息,这里
io.sockets.in(data.roomName).emit('chat_message', createResponse(true, {
action: 'add', // 添加聊天消息
data: {
user_id: data.userId,
user_name: data.userName,
room_name: data.roomName,
chat_content: `${data.userName}加入了聊天室`,
status: 0 // 0代表系统服务消息,1代表用户消息
}
}))
}
})
客户端处理服务端推送的消息,并渲染到页面上:
...
// 获取聊天消息
socket.on('chat_message', data => {
if (data.data.action == 'set') {
this.props.dispatch({
type: 'set_records',
data: data.data.data
})
} else if (data.data.action == 'add') {
this.props.dispatch({
type: 'add_record',
data: data.data.data
})
}
// 聊天置底
this.updatePosition()
// 桌面消息通知有新的消息,这里因为安全问题,https可使用
// this.Notification()
})
// 聊天记录到达底部
updatePosition = () => {
let ele = document.getElementsByClassName('chat_body_room_content_scroll')[0]
ele.scrollTop = ele.scrollHeight
}
// 新消息通知
Notification = () => {
let n = new Notification('会话服务提醒', {
body: '您有新的消息哦,请查收',
tag: 'linxin',
icon: require('../assets/img/chat_head_img.jpg'),
requireInteraction: true
})
}
...
我们看下基础实现效果:
③:单个用户新增群聊,所有用户可以看到
-
梳理流程
- 1. 添加群聊,名称唯一约束
- 2. 给当前所用用户聊天室列表添加此群聊
-
3. 添加成功后
- 离开当前所在聊天室,给当前聊天室在线的用户发送离开消息(不包括自己),当前所在聊天室的人数
- 1
- 进入新添加的聊天室,新聊天的在线人数
+ 1
- 离开当前所在聊天室,给当前聊天室在线的用户发送离开消息(不包括自己),当前所在聊天室的人数
- 4. 离开当前聊天室 (socket.leave)!!!
服务端核心代码
// 更新离开的聊天室在线人数
Room.updateMany({room_name: data.leaveRoom.roomName}, {$inc: {num: -1}}, function () {})
// 给当前聊天室用户发送离开信息,不包括自己
socket.broadcast.to(data.leaveRoom.roomName).emit('chat_message', createResponse(true, {
action: 'add',
data: {
user_id: data.leaveRoom.userId,
user_name: data.leaveRoom.userName,
room_name: data.leaveRoom.roomName,
chat_content: `${data.leaveRoom.userName}离开了聊天室`,
status: 0
}
}));
// 离开聊天室
socket.leave(data.leaveRoom.roomName)
我们看下实现的效果:
④:用户可实时与所有人聊天
-
梳理流程
- 1. 用户发送消息到服务端,向当前聊天室在线的用户推送消息
- 2. 当前聊天室不在线用户的未读消息数量 + 1
- 3. 聊天消息入库处理
客户端
Html
...
<div className='chat_body_room_input'>
<div className='chat_body_room_input_warp'>
<input id='message' type="text" placeholder='请输入聊天内容'
onKeyUp={() => event.keyCode == '13' ? this.sendMessage() : ''}/>
</div>
<div className='chat_body_room_input_button' onClick={this.sendMessage}>点击发送</div>
</div>
...
App.js
// 发送消息
sendMessage = () => {
let ele = document.getElementById('message')
if (!ele.value) return
socket.emit('chat_message', {
roomName: this.props.room.room_item.room_name,
userId: this.state.userInfo._id,
userName: this.state.userInfo.user_name,
chat_content: ele.value
})
ele.value = ''
}
服务端核心代码:
/**
* @description 处理聊天信息
* @param data {
* {String | ObjectId} userId:当前离线用户ID
* {String} username:当前用户名称
* {String} roomName:当前用户所处聊天室名称
* {String} chat_content:聊天内容
* }
*/
socket.on('chat_message', data => {
// 更新当前聊天室不在线用户的未读消息数量
Room.updateMany({room_name: data.roomName, current_status: false}, {$inc: {badge_number: 1}})
.then(res => {
// 更新聊天列表
updateRoomList()
// 消息入库处理,并且发送消息至在线用户
insertChatMessage({
user_id: data.userId,
user_name: data.userName,
room_name: data.roomName,
chat_content: data.chat_content,
status: 1
})
})
})
/**
* @description 消息入库
* @param data {
* {String | ObjectId} userId:用户ID
* {String} username:用户名称
* {String} roomName:聊天室名称
* {String} chat_content:;聊天内容
* {Number} status:0是系统消息,其他代表用户消息
* }
*/
function insertChatMessage(data) {
let record = new Records(data)
record.save()
.then(res => {
sendMessageRoom(data)
})
.catch(err => {
console.log('插入失败')
})
}
/**
* @description 给当前聊天室用户发消息
* @param {Object} data:插入的聊天记录
*/
function sendMessageRoom(data) {
io.sockets.in(data.room_name).emit('chat_message', createResponse(true, {
action: 'add',
data,
}))
}
我们看下实现的效果:
⑤:用户离线保留聊天列表、聊天记录
用户在关闭浏览器时,监听用户离开,对用户的离线状态进行设置
梳理流程
: 1. 更新用户当前所处的聊天室ID(users
表 - current_room_id
字段)
: 2. 更新用户的所有聊天室的状态(rooms
表 - current_status
字段)
: 3. 更新当前聊天室在线人数 - 1
: 4. 给当前聊天室在线用户推送用户离线消息(不包括自己)
: 5. 用户离开房间(socket.leave
)!!!
客户端:
// 监听浏览器刷新/关闭事件
listenClose = () => {
if (navigator.userAgent.indexOf('Firefox')) {
window.onbeforeunload = () => {
socket.emit('off_line', {
userName: this.state.userInfo.user_name,
userId: this.state.userInfo._id,
roomName: this.props.room.room_item.room_name,
})
}
} else {
window.onunload = () => {
socket.emit('off_line', {
userName: this.state.userInfo.user_name,
userId: this.state.userInfo._id,
roomName: this.props.room.room_item.room_name,
})
}
}
}
服务端:
/**
* @description 用户离线
* @param data {
* {String | ObjectId} userId:当前离线用户ID
* {String} roomName:当前用户所处聊天室名称
* }
*/
socket.on('off_line', data => {
// 更新当前离线用户所处的聊天室
User.updateOne({_id: ObjectId(data.userId)}, {$set: {current_room_id: ''}})
.then(res => {
// 更新当前用户所有聊天室的所处状态
Room.updateMany({user_id: data.userId}, {$set: {current_status: false}})
.then(res => {...})
})
})
效果演示
⑥:点击用户头像,新增私聊
-
梳理流程
- 1. 用户点击聊天记录非自己头像,新增私聊
-
2. 用户离开上一个聊天室,进入新的聊天室
- 离开之前聊天室,如果是群聊,群聊在线人数 - 1,如果是私聊,用户离线
- 用户进入新的聊天室,如果是群聊,在线人数 + 1,如果是私聊,不做任何操作
- 如果是私聊,聊天室名称为
发起聊天的用户名-私聊的对方用户名
,所以我们这里判断要注意,这里用in
关键字 - 例如:A 发起与 B 聊天,那么聊天室名称为 A-B,反之聊天室名称为 B-A
客户端这里就不多介绍了,点击头像新增私聊,这里主要介绍服务端:
核心代码:
...
/**
* @description 新增私聊
* @param data {
* {String | ObjectId} userId:当前用户ID
* {String} username:当前用户名称
* {String} userOtherId:与之聊天的用户ID
* {String} userOtherName:与之聊天的用户名称
* }
*/
socket.on('add_private_chat', data => {
// 新增私聊聊天室
addPrivateRoom(socket, data)
})
/**
* @description 新增私聊用户
* @param {Object} socket:socket对象
* @param {Object} data:新增私聊用户信息
*/
function addPrivateRoom(socket, data) {
// 如果数据库不存在则添加,反之加入房间
Room.find({user_id: data.userId}).where('room_name').in([`${data.userName}-${data.userOtherName}`, `${data.userOtherName}-${data.userName}`]).exec((err, roomList) => {
if (err) return
if (roomList.length) {
socket.emit('add_private_chat', createResponse(true, roomList[0]))
} else {
let room = new Room({
user_id: data.userId.toString(),
user_name: data.userName,
room_name: `${data.userName}-${data.userOtherName}`,
status: 1,
num: 0,
badge_number: 0,
current_status: false
})
room.save()
.then(res => {
Room.find({user_id: data.userId})
.then(result => {
socket.emit('room_list_all', createResponse(true, result))
socket.emit('add_private_chat', createResponse(true, result.filter(item => item.room_name == `${data.userName}-${data.userOtherName}`)[0]))
})
})
}
})
}
...
⑦:用户可实时单人私聊
-
梳理流程
- 1. 用户点击非自己头像,可新增/加入私聊
- 2. 判断该用户是否拥有此聊天室,如果拥有,加入聊天室,反之创建聊天室并加入
-
3. 如果用户未拥有聊天数,则添加聊天室
- 离开当前所在聊天室,给当前聊天室在线的用户发送离开消息(不包括自己),当前所在聊天室的人数
- 1
- 加入新添加的私聊聊天室
- 当前用户在新添加的聊天室发送聊天消息时,判断私聊的对方是否拥有聊天室
- 如果未拥有,私聊的对方添加当前聊天室(属于2个人)并加入!!!
- 如果拥有,但未在线(焦点不在聊天室),私聊的对方未读消息数量 +1
- 离开当前所在聊天室,给当前聊天室在线的用户发送离开消息(不包括自己),当前所在聊天室的人数
- 4. 如果用户拥有此聊天室,直接加入聊天室即可
服务端核心代码:
/**
* @description 处理聊天信息
* @param data {
* {String | ObjectId} userId:当前离线用户ID
* {String} username:当前用户名称
* {String} roomName:当前用户所处聊天室名称
* {String} chat_content:聊天内容
* {Number} status:0为群聊,其他为私聊
* }
*/
socket.on('chat_message', data => {
// 如果是群聊
if (data.status == '0') {
// 更新当前聊天室不在线用户的未读消息数量
...
// 给所有在线用户发消息
...
}else if (data.status == '1'){
// 如果当前用户不存在聊天室,添加聊天室并且加入聊天室
...
// 如果当前用户存在聊天室,判断当前用户是否在线,如果不在线,未读消息数量 + 1
...
}
效果演示:
⑧:用户正在输入
-
梳理流程
- 1. 如果是私聊,监听用户输入
- 2. 客户端监听用户输入框输入,并告诉服务端用户开始输入
- 3. 如果接下来的一定时间内(这里500毫秒),用户继续输入,则不发送停止消息,否则告诉服务端用户停止输入
- 4. 这里主要通过函数防抖,来执行推送输入完毕消息到服务端,告知服务端用户输入完毕
客户端:
// html
...
<input id='message' type="text" placeholder='请输入聊天内容' onInput={this.inputting}
onKeyUp={() => event.keyCode == '13' ? this.sendMessage() : ''}/>
...
---------------------------------------------------------------------------
// 正在输入
inputting = () => {
// 如果是私聊,告诉服务端用户正在输入
if (this.props.room.room_item.status == '1') {
socket.emit('inputting', {
userName: this.state.userInfo.user_name,
roomName: this.props.room.room_item.room_name,
status: true
})
// 500秒后,告诉用户输入完毕
this.debounce(this.inputtingEnd, 500)
}
}
// 用户结束输入
inputtingEnd = () => {
socket.emit('inputting', {
userName: this.state.userInfo.user_name,
roomName: this.props.room.room_item.room_name,
status: false
})
}
// 函数防抖
debounce = (fun, delay) => {
clearTimeout(fun.timer)
fun.timer = setTimeout(() => {
fun()
}, delay)
}
服务端:
...
/**
* @description 私聊监听用户输入
* @param {String} username:当前用户名称
* @param {String} roomName:当前聊天室名称
* @param {Boolean} status: 用户是否正在输入
*/
socket.on('inputting', data => {
User.findOne({user_name: data.roomName.replace(data.userName, '').replace('-', '')})
.then(res => {
// 如果用户存在
if (res != null) {
res.roomName = data.roomName
res.status = data.status
// 给某个用户发消息
sendMessageSingleUser(res)
}
})
})
/**
* @description 给某个用户发消息
* @param user:用户信息
*/
function sendMessageSingleUser(user) {
// 如果用户不在线的话,不推送(我们在用户离线时,把他的current_room_id置为空)
if (user.current_room_id) {
Room.find({user_id: user._id}, function (err, data) {
io.sockets.sockets[user.socket_id].emit('inputting', createResponse(user.status, user))
})
}
}
...
大家可以看到给某个用户发消息(这里可以把消息发送到当前聊天室,客户端自行判断)
这里用到了io.sockets.sockets
,代表当前的socket连接服务,是一个Object
,以key-value
方式存储
因为用户每次连接socket
后的socket_id
都不一样,所以我们要在用户建立连接后,把他的socket_id
更新
客户端:
...
componentDidMount() {
// 如果本地信息不存在,则去注册,反之获取聊天列表
if (localStorage.getItem('userInfo')) {
let userInfo = JSON.parse(localStorage.getItem('userInfo'))
socket.emit('login', userInfo._id)
} else {
this.register()
}
}
...
// 获取登录结果
socket.on('login', socket_id => {
userInfo.socket_id = socket_id
localStorage.setItem('userInfo', JSON.stringify(userInfo))
this.setState({
userInfo
}, () => {
socket.emit('get_room_list', userInfo._id)
})
})
...
服务端:
...
/**
* @description 用户静默登录
* @param {String | ObjectId} userId:登录的用户id
*/
socket.on('login', userId => {
// 更新用户列表socketId
User.updateOne({_id: ObjectId(userId)}, {$set: {socket_id: socket.id}}, function (err, result) {
socket.emit('login', socket.id)
})
})
...
效果演示:
至此,我们就完成了所有功能!!!