【招聘App】—— React/Nodejs/MongoDB全栈项目:socket.io&聊天实现
前言:最近在学习Redux+react+Router+Nodejs全栈开发高级课程,这里对实践过程作个记录,方便自己和大家翻阅。最终成果github地址:https://github.com/66Web/react-antd-zhaoping,欢迎star。
一、Socket.io基础知识
基于事件的实时双向通信库
- 基于websocket协议
- 前后端通过事件进行双向通信
- 配合express,快速开发实时应用
Socket.io和Ajax区别
- 基于不同的网络协议
- 注意:Socket.io是实现websocket协议的一个库
- Ajax基于http协议,单向,实时获取数据只能轮询
- socket.io基于websocket双向通信协议,后端可以主动推送数据
Socket.io通信模型
Socket.io后端API
- 配合express
- 前端:import io from 'socket.io-client'
npm install socket.io-client --save
- 后端:Io = require('socket.io')(http)
npm install socket.io --save
- io.on 监听事件
- io.emit 触发事件
聊天页面的跳转
- componet目录下:创建chat聊天组件目录,获取this.props.match(命中).params(参数) .user(用户名)
import React from 'react' class Chat extends React.Component{ render(){ //console.log(this.props) return ( <h2>chat with user: {this.props.match.params.user}</h2> ) } } export default Chat
-
usercard.js中:通过withRouter,this.props.history.push(`拼接后的路由地址`),实现聊天页面的跳转
import {withRouter} from 'react-router-dom' @withRouter handleClick(v){ this.props.history.push(`/chat/${v.user}`) } <Card key={v._id} onClick={() => this.handleClick(v)} >
Socket.io前后端联通
- 后端server.js中:绑定socket与express
const app = express() //work with express const server = require('http').Server(app) //express server 用http包裹 const io = require('socket.io')(server) //再传给socket.io对象,使其与express关联起来 //监听connection事件 io.on('connection', function(socket){ console.log('user login') }) //改app.listen 为server.listen:使socket.io 与express成功绑定 server.listen(9093, function(){ console.log('Node app start at port 9093') })
-
前端chat.js中:发起socket连接,这里因为前后端端口号不一致,需要跨域手动连接socket
import io from 'socket.io-client' const socket = io('ws://localhost:9093') //前后端端口不一,跨域需要手动连接socket
二、socket.io前后端实时显示消息
- chat.js中:实现信息输入且广播到全局
- 点击【发送】时通过socket.emit触发sendmsg事件:传递state中存储的输入信息,同时将state中信息清空
handleSubmit(){ // console.log(this.state.text) socket.emit('sendmsg',{text: this.state.text}) //触发事件 this.setState({text: ''}) }
- 在componetDidMount中通过socket.on监听recvmsg事件:接收后端传来的全局信息,扩展到当前state中存储的msg数组中
componentDidMount(){ socket.on('recvmsg', (data) => { // console.log(data) this.setState({ msg: [...this.state.msg, data.text] }) }) }
-
完整代码 ↓
import React from 'react' import {List, InputItem} from 'antd-mobile' import io from 'socket.io-client' const socket = io('ws://localhost:9093') //前后端端口不一,跨域需要手动连接socket class Chat extends React.Component{ constructor(props){ super(props) this.state={text: '', msg:[]} } componentDidMount(){ socket.on('recvmsg', (data) => { // console.log(data) this.setState({ msg: [...this.state.msg, data.text] }) }) } handleSubmit(){ // console.log(this.state.text) socket.emit('sendmsg',{text: this.state.text}) //触发事件 this.setState({text: ''}) } render(){ // console.log(this.props) return ( <div> {this.state.msg.map(v=>{ return <p key={v}>{v}</p> })} <div className="stick-footer"> <List > <InputItem placeholder='请输入' value={this.state.text} onChange={v =>{ this.setState({ text:v }) }} extra={<span onClick={()=>this.handleSubmit()}>发送</span>} >信息</InputItem> </List> </div> </div> ) } } export default Chat
-
server.js中:获取并发送全局信息
-
通过socket.on监听sendmsg事件,获取前端传来的信息
-
再通过socket.emit触发resvmsg事件,将信息传递给前端
//监听connection事件 io.on('connection', function(socket){ //io全局的请求 console.log('user login') socket.on('sendmsg', function(data){ //socket当前连接的请求 console.log(data) io.emit('recvmsg', data) }) })
三、聊天页面redux链接
- model.js中:设置Chat聊天相关的mongodb数据字段
chat: { 'chatid':{'type':String, 'require':true}, 'from':{'type':String, 'require':true}, 'to':{'type':String, 'require':true}, 'read':{'type':Boolean, 'default':false}, 'content':{'type':String, 'require':true, 'default':''}, 'create_time':{'type':Number, 'default':new Date().getTime()} }
-
chat.redux.js中:设置聊天信息相关的reducer和action creator,首先获取当前用户的信息列表存储到redux中管理
import axios from 'axios' import io from 'socket.io-client' const socket = io('ws://localhost:9093') //action type const MSG_LIST = 'MSG_LIST' //获取聊天列表 const initState = { chatmsg: [], unread: 0 //实时维护未读消息的数量 } //reducer export function chat(state=initState, action){ switch(action.type){ case MSG_LIST: return {...state, chatmsg:action.payload, unread:action.payload.filter(v=>!v.read).length} default: return state } } //action creator function msgList(msgs){ return {types:'MSG_LIST', payload:msgs} } //操作数据的方法 export function getMsgList(){ return dispatch=>{ axios.get('/user/getmsglist') .then(res=>{ if(res.status==200 && res.data.code==0){ dispatch(msgList(res.data.msgs)) } }) } }
-
reducer.js中:合并入chat reducer
import { chat } from './redux/chat.redux' export default combineReducers({user, chatuser, chat})
-
chat.js中: 连接组件和redux,调用getMsgList方法
-
通过axios.get调用接口,获取聊天信息列表数据
-
通过dispatch提交action修改后的数据存储到redux中
import {connect} from 'react-redux' import {getMsgList} from '../../redux/chat.redux' @connect( state => state, {getMsgList} ) componentDidMount(){ this.props.getMsgList() }
-
user.js中:通过Router.get方法,获取到当前cookies中的user,并查找出Chat中符合条件的所有数据
//获取聊天信息列表 1.to user 2.from user Router.get('/getmsglist', function(req, res){ const user = req.cookies.user //查询多个条件,用$or区分:'$or': [{from:user, to:user}] Chat.find({}, function(err, doc){ if(err){ return res.json({code:1, msg:'后端出错了'}) } if(doc){ return res.json({code:0, msgs:doc}) } }) })
四、聊天功能实现
- usercard.js中:修改路由的参数为v._id,因为_id是用户在mongodb中的唯一标识
this.props.history.push(`/chat/${v._id}`)
-
chat.js中:将当前用户id,与选择的用户id,以及聊天信息msg发送给后端
handleSubmit(){ // socket.emit('sendmsg',{text: this.state.text}) //触发事件 const from = this.props.user._id const to = this.props.match.params.user const msg = this.state.text this.props.sendMsg({from, to, msg}) this.setState({text: ''}) }
-
chat.redux.js中:定义sendMsg方法调用socket.emit触发sendmsg事件,发送数据
export function sendMsg({from, to, msg}){ return dispatch=>{ socket.emit('sendmsg', {from, to, msg}) } }
-
server.js中:通过socket.on监听sendmsg事件,获取数据
//监听connection事件 io.on('connection', function(socket){ //io全局的请求 socket.on('sendmsg', function(data){ //socket当前连接的请求 console.log(data) }) })
-
server.js中:将from和to代表的用户id进行组合,定义为chatid,作为聊天数据的标识
-
重新创建Chat数据库:将数据存入mongodb数据库
-
同时通过io.emit触发recvmsg事件:将Chat数据库中的数据全部发送给前端
//监听connection事件 io.on('connection', function(socket){ //io全局的请求 socket.on('sendmsg', function(data){ //socket当前连接的请求 console.log(data) const {from, to, msg} = data const chatid = [from, to].sort().join('_') Chat.create({chatid, from, to, content:msg}, function(err, doc){ //数据库存入数据 io.emit('recvmsg', Object.assign({}, doc._doc)) }) }) })
-
chat.redux.js中:设置读取信息相关的reducer、action creator以及操作数据的方法
-
通过socket.on监听recvmsg事件,获取后端传来的Chat数据
-
调用dispatch触发action,将获取到的Chat数据存入redux中
//action type const MSG_RECV = 'MST_RECV' //读取信息 //reducer中添加 case MSG_RECV: return {...state, chatmsg:[...state.chatmsg, action.payload], unread:state.unread+1} } //action creator function msgRecv(msg){ return {type:MSG_RECV, payload:msg} } //操作数据的方法 export function recvMsg(){ return dispatch=>{ socket.on('recvmsg', function(data){ console.log('recvmsg', data) dispatch(msgRecv(data)) }) } }
-
chat.js中:在componentDidMount时调用recvMsg方法,读取信息数据并存入redux中
从props中获取到所有聊天数据,展示到页面中
render(){ const user = this.props.match.params.user const Item = List.Item return ( <div id='chat-page'> <NavBar mode='dark'> {this.props.match.params.user} </NavBar> {this.props.chat.chatmsg.map(v=>{ return v.from == user ? ( <List key={v._id}> <Item >{v.content}</Item> </List> ) : ( <List key={v._id}> <Item extra={'avatar'} className='chat-me' >{v.content}</Item> </List> ) })}
五、聊天未读消息实时展示
- dashboard.js中:修改getMsgList和recvMsg的时机,在显示TabBar的页面中都可以获取聊天信息相关数据
import {getMsgList, recvMsg} from '../../redux/chat.redux' @connect( state => state, {getMsgList, recvMsg} ) componentDidMount(){ this.props.getMsgList() this.props.recvMsg() }
-
navlink.js中:获取redux中的chat数据,判断当v.path为‘/msg’时,通过badge徽标显示unread未读消息数
import {connect} from 'react-redux' @connect( state => state.chat ) <TabBar.Item badge={v.path == '/msg' ? this.props.unread : ''}
六、聊天头像显示
- user.js中:修改获取聊天信息列表的方法
- 查找到所有用户:将每个用户的信息以id:{name, avatar}的形式,创建user实例
- 获取到当前cookie中的userid:通过$or查找到from或to为userid的聊天信息,将user实例插入返回的json中
//获取聊天信息列表 Router.get('/getmsglist', function(req, res){ const user = req.cookies.userid User.find({}, function(e, userdoc){ let users = {} userdoc.forEach(v=>{ users[v._id] = {name:v.user, avatar:v.avatar} }) Chat.find({'$or':[{from:user},{to:user}]}, function(err, doc){ if(err){ return res.json({code:1, msg:'后端出错了'}) } if(doc){ return res.json({code:0, msgs:doc, users:users}) } }) }) })
-
chat.redux.js中:修改MSG_LIST相关reducer和action creator,以及getMsgList()将users数据存入redux
//reducer中 case MSG_LIST: return {...state, users:action.payload.users, chatmsg:action.payload.msgs,
unread:action.payload.msgs.filter(v=>!v.read).length} //action creator function msgList(msgs, users){ return {type:MSG_LIST, payload:{msgs, users}} } //操作数据的方法 export function getMsgList(){ return dispatch=>{ axios.get('/user/getmsglist') .then(res=>{ if(res.status==200 && res.data.code==0){ dispatch(msgList(res.data.msgs, res.data.users)) } }) } } -
chat.js中:通过路由参数获取选择用户的userid,从props.chat中获取users,实现导航用户名和头像的显示
const userid = this.props.match.params.user const Item = List.Item const users = this.props.chat.users if(!users[userid]){ return null } //header用户名显示 <NavBar mode='dark' icon={<Icon type="left"/>} onLeftClick={() => { this.props.history.goBack() }} > {users[userid].name} </NavBar> const avatar = require(`../img/${users[v.from].avatar}.png`) //对方:左边显示 thumb={avatar} //自己:右边显示 extra={<img src={avatar}/>}
七、修正未读消息数量
- 消息详情页中:【只显示与当前选择用户的信息】
-
util.js中:定义工具函数getChatId,将当前用户userid、选择用户targetid连接在一起
export function getChatId(userId, targetId){ return [userId, targetId].sort().join('_') }
-
chat.js中:过滤聊天信息,判断当连接好的id与chatid相同时显示
import {getChatId} from '../../utils' const chatid = getChatId(userid, this.props.user._id) const chatmsgs = this.props.chat.chatmsg.filter(v=>v.chatid==chatid) {chatmsgs.map(v=>{ ……
-
坑:【重复获取聊天信息并展示出多条相同信息】
- 原因:dashboard.js多次切换页面时,getMsgList和recvMsg会不停的调用
-
解决:添加判断,只有当获取到的chat.chatmsg为空时,才执行调用方法,获取消息列表和读取消息
componentDidMount(){ if(!this.props.chat.chatmsg.length){ this.props.getMsgList() this.props.recvMsg() } }
-
坑:【未读消息数中包含自己发出的信息】
-
解决:chat.redux.js中在获取unread时添加过滤的判断条件,只有当to的值为当前用户id时,计算为一条未读的消息
-
知识点:redux中使用其它地方的数据,通过getState获取全部的状态
- getMsgList和recvMsg方法中:通过getState获取到全部状态,将userid传入action
//action creator function msgList(msgs, users, userid){ return {type:MSG_LIST, payload:{msgs, users, userid}} } function msgRecv(msg, userid){ return {userid, type:MSG_RECV, payload:msg} } //操作数据的方法 export function recvMsg(){ return (dispatch, getState)=>{ socket.on('recvmsg', function(data){ // console.log('recvmsg', data) const userid = getState().user._id dispatch(msgRecv(data, userid)) }) } } export function getMsgList(){ return (dispatch, getState)=>{ axios.get('/user/getmsglist') .then(res=>{ if(res.status==200 && res.data.code==0){ //console.log('getState',getState()) const userid = getState().user._id dispatch(msgList(res.data.msgs, res.data.users, userid)) } }) } }
- reducer中:添加unread的过滤判断条件,比较action中的to与userid,若相同算为未读信息
case MSG_LIST: return {...state, users:action.payload.users, chatmsg:action.payload.msgs, unread:action.payload.msgs.filter(v=>!v.read&&v.to==action.payload.userid).length} case MSG_RECV: const n = action.payload.to==action.userid ? 1 : 0 return {...state, chatmsg:[...state.chatmsg, action.payload], unread:state.unread+n}
八、发送emoji表情
- emoji表情:同样属于文本编码,可以使用输入法表情输入。【更多emoji选择】
- 定义为一串字符串:使用空格分隔开emoji表情,遍历后用空格符分隔,过滤掉有间隔两个空格的返回为text:v对象
const emoji = '😁 😂 😃 😄 😅 😆 😇 😈 😉 😊 😋 😌 😍 😎 😏 😐 😒 😓 😔 😖 😘 😚 😜 😝 😞 😠 😡 😢 😣 😤 😥 😨 😩 😪 😫 😭 😰 😱 😲 😳 😵 😶 😷 😸 😹 😺 😻 😼 😽 😾 😿 🙀 🙅 🙆 🙇 🙈 🙉 🙊 🙋 🙌 🙍 🙎 🙏 🚀 🚃 🚄 🚅 🚇 🚉 🚌 🚏 🚑 🚒 🚓 🚕 🚗 🚙 🚚 🚢 🚤 🚥 🚧 🚨 🚩 🚪 🚫 🚬 🚭 🚲 🚶 🚹 🚺 🚻 🚼 🚽 🚾 🛀 ' .split(" ") .filter(v=>v) .map(v=>({text:v}));
-
点击【提交】时:设置state中的showEmoji为false,控制emoji列表隐藏
this.setState({ text: '', showEmoji: false })
-
通过Grid组件显示emoji列表:判断state中的showEmoji,为true时显示
{this.state.showEmoji ? <Grid data={emoji} columnNum={9} carouselMaxRow={4} isCarousel={true} onClick={el=>{ this.setState({ text: this.state.text+el.text }) }} > : null}
-
坑:Grid组件官方Bug,滚动列表的初始状态只显示一行,只有手动触发一个事件才能显示全部
-
解决:官方推荐解决方法是,定义一个方法,手动发送一个事件
fixCarousel(){ setTimeout(function(){ window.dispatchEvent(new Event('resize')) }, 0) }
-
点击【😃】时:控制emoji列表的显示隐藏,同时调用fixCarousel方法修正bug
<span style={{marginRight:15}} onClick={()=>{ this.setState({ showEmoji: !this.state.showEmoji }) this.fixCarousel() }} >😃</span>
注:项目来自慕课网