h5聊天工具的开发过程及思路
这个产品的主要技术栈有,网易nim即时通信,vue-cli,muse-ui
1、在拿到这个需求时,脑袋里空的,什么想法都没有,完全懵逼,进了网易云通信的官网api查看,由于我做的是客户端的,所以重点看了客户端的api,当然,服务端的也看了一点,自己用nodejs实现的接口,后面也会贴出来。这个是api的地址:http://dev.netease.im/docs/interface/%E5%8D%B3%E6%97%B6%E9%80%9A%E8%AE%AFWeb%E7%AB%AF/NIMSDK-Web/NIM.html
这个是官网提供的初始化的代码,后来证明,这个代码很扯淡,在我的vue工程里是很扯淡的
var data = {}; var nim = new NIM({ // 初始化SDK // debug: true appKey: 'appKey', account: 'account', token: 'token', onconnect: onConnect, onerror: onError, onwillreconnect: onWillReconnect, ondisconnect: onDisconnect, // 多端 onloginportschange: onLoginPortsChange, // 用户关系 onblacklist: onBlacklist, onsyncmarkinblacklist: onMarkInBlacklist, onmutelist: onMutelist, onsyncmarkinmutelist: onMarkInMutelist, // 好友关系 onfriends: onFriends, onsyncfriendaction: onSyncFriendAction, // 用户名片 onmyinfo: onMyInfo, onupdatemyinfo: onUpdateMyInfo, onusers: onUsers, onupdateuser: onUpdateUser, // 群组 onteams: onTeams, onsynccreateteam: onCreateTeam, onteammembers: onTeamMembers, onsyncteammembersdone: onSyncTeamMembersDone, onupdateteammember: onUpdateTeamMember, // 会话 onsessions: onSessions, onupdatesession: onUpdateSession, // 消息 onroamingmsgs: onRoamingMsgs, onofflinemsgs: onOfflineMsgs, onmsg: onMsg, // 系统通知 onofflinesysmsgs: onOfflineSysMsgs, onsysmsg: onSysMsg, onupdatesysmsg: onUpdateSysMsg, onsysmsgunread: onSysMsgUnread, onupdatesysmsgunread: onUpdateSysMsgUnread, onofflinecustomsysmsgs: onOfflineCustomSysMsgs, oncustomsysmsg: onCustomSysMsg, // 同步完成 onsyncdone: onSyncDone }); function onConnect() { console.log('连接成功'); } function onWillReconnect(obj) { // 此时说明 `SDK` 已经断开连接, 请开发者在界面上提示用户连接已断开, 而且正在重新建立连接 console.log('即将重连', obj); } function onDisconnect(error) { // 此时说明 `SDK` 处于断开状态, 开发者此时应该根据错误码提示相应的错误信息, 并且跳转到登录页面 console.log('连接断开', error); if (error) { switch (error.code) { // 账号或者密码错误, 请跳转到登录页面并提示错误 case 302: break; // 重复登录, 已经在其它端登录了, 请跳转到登录页面并提示错误 case 417: break; // 被踢, 请提示错误后跳转到登录页面 case 'kicked': break; default: break; } } } function onError(error, obj) { console.log('发生错误', error, obj); } function onLoginPortsChange(loginPorts) { console.log('当前登录帐号在其它端的状态发生改变了', loginPorts); } function onBlacklist(blacklist) { console.log('收到黑名单', blacklist); data.blacklist = nim.mergeRelations(data.blacklist, blacklist); data.blacklist = nim.cutRelations(data.blacklist, blacklist.invalid); refreshBlacklistUI(); } function onMarkInBlacklist(obj) { console.log(obj.account + '被你' + (obj.isAdd ? '加入' : '移除') + '黑名单', obj); if (obj.isAdd) { addToBlacklist(obj); } else { removeFromBlacklist(obj); } } function addToBlacklist(obj) { data.blacklist = nim.mergeRelations(data.blacklist, obj.record); refreshBlacklistUI(); } function removeFromBlacklist(obj) { data.blacklist = nim.cutRelations(data.blacklist, obj.record); refreshBlacklistUI(); } function refreshBlacklistUI() { // 刷新界面 } function onMutelist(mutelist) { console.log('收到静音列表', mutelist); data.mutelist = nim.mergeRelations(data.mutelist, mutelist); data.mutelist = nim.cutRelations(data.mutelist, mutelist.invalid); refreshMutelistUI(); } function onMarkInMutelist(obj) { console.log(obj.account + '被你' + (obj.isAdd ? '加入' : '移除') + '静音列表', obj); if (obj.isAdd) { addToMutelist(obj); } else { removeFromMutelist(obj); } } function addToMutelist(obj) { data.mutelist = nim.mergeRelations(data.mutelist, obj.record); refreshMutelistUI(); } function removeFromMutelist(obj) { data.mutelist = nim.cutRelations(data.mutelist, obj.record); refreshMutelistUI(); } function refreshMutelistUI() { // 刷新界面 } function onFriends(friends) { console.log('收到好友列表', friends); data.friends = nim.mergeFriends(data.friends, friends); data.friends = nim.cutFriends(data.friends, friends.invalid); refreshFriendsUI(); } function onSyncFriendAction(obj) { console.log('收到好友操作', obj); switch (obj.type) { case 'addFriend': console.log('你在其它端直接加了一个好友' + obj); onAddFriend(obj.friend); break; case 'applyFriend': console.log('你在其它端申请加了一个好友' + obj); break; case 'passFriendApply': console.log('你在其它端通过了一个好友申请' + obj); onAddFriend(obj.friend); break; case 'rejectFriendApply': console.log('你在其它端拒绝了一个好友申请' + obj); break; case 'deleteFriend': console.log('你在其它端删了一个好友' + obj); onDeleteFriend(obj.account); break; case 'updateFriend': console.log('你在其它端更新了一个好友', obj); onUpdateFriend(obj.friend); break; } } function onAddFriend(friend) { data.friends = nim.mergeFriends(data.friends, friend); refreshFriendsUI(); } function onDeleteFriend(account) { data.friends = nim.cutFriendsByAccounts(data.friends, account); refreshFriendsUI(); } function onUpdateFriend(friend) { data.friends = nim.mergeFriends(data.friends, friend); refreshFriendsUI(); } function refreshFriendsUI() { // 刷新界面 } function onMyInfo(user) { console.log('收到我的名片', user); data.myInfo = user; updateMyInfoUI(); } function onUpdateMyInfo(user) { console.log('我的名片更新了', user); data.myInfo = NIM.util.merge(data.myInfo, user); updateMyInfoUI(); } function updateMyInfoUI() { // 刷新界面 } function onUsers(users) { console.log('收到用户名片列表', users); data.users = nim.mergeUsers(data.users, users); } function onUpdateUser(user) { console.log('用户名片更新了', user); data.users = nim.mergeUsers(data.users, user); } function onTeams(teams) { console.log('群列表', teams); data.teams = nim.mergeTeams(data.teams, teams); onInvalidTeams(teams.invalid); } function onInvalidTeams(teams) { data.teams = nim.cutTeams(data.teams, teams); data.invalidTeams = nim.mergeTeams(data.invalidTeams, teams); refreshTeamsUI(); } function onCreateTeam(team) { console.log('你创建了一个群', team); data.teams = nim.mergeTeams(data.teams, team); refreshTeamsUI(); onTeamMembers({ teamId: team.teamId, members: owner }); } function refreshTeamsUI() { // 刷新界面 } function onTeamMembers(obj) { console.log('收到群成员', obj); var teamId = obj.teamId; var members = obj.members; data.teamMembers = data.teamMembers || {}; data.teamMembers[teamId] = nim.mergeTeamMembers(data.teamMembers[teamId], members); data.teamMembers[teamId] = nim.cutTeamMembers(data.teamMembers[teamId], members.invalid); refreshTeamMembersUI(); } function onSyncTeamMembersDone() { console.log('同步群列表完成'); } function onUpdateTeamMember(teamMember) { console.log('群成员信息更新了', teamMember); onTeamMembers({ teamId: teamMember.teamId, members: teamMember }); } function refreshTeamMembersUI() { // 刷新界面 } function onSessions(sessions) { console.log('收到会话列表', sessions); data.sessions = nim.mergeSessions(data.sessions, sessions); updateSessionsUI(); } function onUpdateSession(session) { console.log('会话更新了', session); data.sessions = nim.mergeSessions(data.sessions, session); updateSessionsUI(); } function updateSessionsUI() { // 刷新界面 } function onRoamingMsgs(obj) { console.log('漫游消息', obj); pushMsg(obj.msgs); } function onOfflineMsgs(obj) { console.log('离线消息', obj); pushMsg(obj.msgs); } function onMsg(msg) { console.log('收到消息', msg.scene, msg.type, msg); pushMsg(msg); } function pushMsg(msgs) { if (!Array.isArray(msgs)) { msgs = [msgs]; } var sessionId = msgs[0].sessionId; data.msgs = data.msgs || {}; data.msgs[sessionId] = nim.mergeMsgs(data.msgs[sessionId], msgs); } function onOfflineSysMsgs(sysMsgs) { console.log('收到离线系统通知', sysMsgs); pushSysMsgs(sysMsgs); } function onSysMsg(sysMsg) { console.log('收到系统通知', sysMsg) pushSysMsgs(sysMsg); } function onUpdateSysMsg(sysMsg) { pushSysMsgs(sysMsg); } function pushSysMsgs(sysMsgs) { data.sysMsgs = nim.mergeSysMsgs(data.sysMsgs, sysMsgs); refreshSysMsgsUI(); } function onSysMsgUnread(obj) { console.log('收到系统通知未读数', obj); data.sysMsgUnread = obj; refreshSysMsgsUI(); } function onUpdateSysMsgUnread(obj) { console.log('系统通知未读数更新了', obj); data.sysMsgUnread = obj; refreshSysMsgsUI(); } function refreshSysMsgsUI() { // 刷新界面 } function onOfflineCustomSysMsgs(sysMsgs) { console.log('收到离线自定义系统通知', sysMsgs); } function onCustomSysMsg(sysMsg) { console.log('收到自定义系统通知', sysMsg); } function onSyncDone() { console.log('同步完成'); }
2、看了api之后,就找怎样引入sdk,参考链接如下:
http://dev.netease.im/docs/product/IM%E5%8D%B3%E6%97%B6%E9%80%9A%E8%AE%AF/SDK%E5%BC%80%E5%8F%91%E9%9B%86%E6%88%90/Web%E5%BC%80%E5%8F%91%E9%9B%86%E6%88%90/%E9%9B%86%E6%88%90%E6%96%B9%E5%BC%8F
我采用的是cmd的方式引入
import SDK from 'NIM_Web_SDK.js' const nim = SDK.NIM.getInstance({ // ... })
引入之后毫无反应,继续往下看,如果开发者选用 webpack/babel 来打包, 那么请使用 exclude 将 SDK 文件排除, 避免 babel 二次打包引起的错误:
// Webpack 参考配置 module: { rules: [ { test: /\.js$/, loader: 'babel-loader', exclude: /NIM_Web_SDK.*\.js/, query: { presets: [ // ... ], // ... } // ... }, // ... ], // ... }
这样就成功的引入了网易的sdk
3、之后就开始进行组件的开发,开发聊天工具,我需要一个表情包的库,这个库,我去网上搜了一些,不怎么满意,其实也有一些好的,
比如twemoji,emojify,react-emoji,react-emojify,感觉也不错
总之自己找了一圈之后感觉没有什么很好的选择,就自己动手丰衣足食了
倒腾半天写出来了github地址如下:https://github.com/Windseek/vue-emoji
写的很粗糙,但是很简单,就是弄个表情图片库,然后做个json库,每个表情地址对应一个解释文字,然后组件里会提供一个解析表情与文字排版的方法。
4、组件准备好之后,就进行页面布局了,产品很简单就两个页面,一个聊天列表页,一个聊天内容页,我起的名字分别叫chatList,和chatContent,在页面布局的时候遇到一些坑,就是,点击input框的时候,要保证键盘弹出来,页面往上移动,点击表情按钮,表情层会从下面弹出来,这个时候,页面整体布局绝对不能使用绝对定位或者fixed布局,一定要使用正常的定位,relative,或者static。
5、这两个交互的页面画好之后,我就开始了后台对接,第一个列表页没什么难度,搜索,上滑分页,这些都是现成的控件,拿来用就好了,当onsession钩子里有更新时就更新数据,当然此时还要判断是在chatList页面时才能进行数据请求更新,不然的化后台会很有很大压力,之后就是chatContent页面了,这里又很多的坑,因为要在业务上区分是直接进来的,还是通过列表页点进来的,直接进来可能是客服自己跟自己聊,可能是普通用户跟客服聊,可能是外面推送的消息点进来的,所以,业务很多。。。。上代码看逻辑:
created(){ let vue=this; //每次进来时清空store里的数据,防止聊天记录闪一下 vue.$store.commit('chatContent/clearMsgs'); //取消微信分享 wx.ready(function(){ // config信息验证后会执行ready方法,所有接口调用都必须在config接口获得结果之后,config是一个客户端的异步操作,所以如果需要在页面加载时就调用相关接口,则须把相关接口放在ready函数中调用来确保正确执行。对于用户触发时才调用的接口,则可以直接调用,不需要放在ready函数中。 wx.hideMenuItems({ menuList: ['menuItem:share:timeline'] // 要隐藏的菜单项,只能隐藏“传播类”和“保护类”按钮,所有menu项见附录3 }); }); //根据用户信息,进行分别取历史消息 if((localStorage.getItem("csFlag")-0)){ //是客服进来的 //初始化并传入连接后的回调函数,这个回调函数在连接后,调用nim的历史消息 function nimHistory(){ nimInit(vue,_=>{ //这个to是存放在路由里的,保证刷新后聊天记录也在 //是客服进来的,客服也可能从外面微信推送的消息点入进来的,此时的to就是自己 vue.to=vue.$route.query.to||localStorage.getItem("wangyiaccid"); const to = vue.to; vue.nim.getHistoryMsgs({ scene: 'p2p', to: to, done: getHistoryMsgsDone }); function getHistoryMsgsDone(error, obj){ console.log("vue.fromHeader.img",vue.fromHeader.img); if (!error&&obj.msgs.length!=0) { let opt={ merge:vue.nim.mergeMsgs, msgs:obj.msgs, sessionId:obj.msgs[0].sessionId } //客户聊天列表页点击后要更新状态管理器中的sessionId vue.$store.commit('chatContent/setSessionId',opt.sessionId); //客户聊天列表页点击后要更新状态管理器中的消息 vue.$store.commit('chatContent/updateMsgs',opt); //数据改变后,聊天列表要滚动到最下边 vue.$nextTick(()=>{ vue.resetScroll(); }) }else{ console.log(error); } } }); } //拿到自己的头像和昵称 function getMyNickName(){ return mlkAxiosFactory.mobileAxios.get("/vchat/user_info/"+localStorage.getItem("wangyiaccid")).then((data)=>{ vue.fromHeader.img=data.data.retData[0].localimgurl; vue.fromHeader.nickname=data.data.retData[0].nickname; }) } getMyNickName(); //拿到客服头像和昵称 function getCsNickName() { return mlkAxiosFactory.mobileAxios.get("/vchat/user_info/"+vue.to).then((data)=>{ vue.toHeader.img=data.data.retData[0].localimgurl; vue.toHeader.nickname=data.data.retData[0].nickname; }) } getCsNickName(); Promise.all([getMyNickName(),getCsNickName()]).then(()=>{ nimHistory() }) }else{ //是普通用户进来的 //连接后调用回调 function getHistoryList(){ nimInit(vue,_=>{ vue.nim.getHistoryMsgs({ scene: 'p2p', to: vue.to, done: getHistoryMsgsDone, limit:vue.limit-0, beginTime:wangyistamp }); }); function getHistoryMsgsDone(error, obj){ if (!error&&obj.msgs.length) { let opt={ merge:vue.nim.mergeMsgs, msgs:obj.msgs, sessionId:obj.msgs[0].sessionId } //普通用户进来后创建会话后要更新状态管理器中的sessionId vue.$store.commit('chatContent/setSessionId',opt.sessionId); //普通用户进来后后创建会话后要更新状态管理器中的消息 vue.$store.commit('chatContent/updateMsgs',opt); //普通用户进来后创建会话后获取自己的头像和昵称,这样在localstorage里就有了accid getNickName(); vue.$nextTick(()=>{ vue.resetScroll(); }) }else{ console.log("error",error) } } } let tenantId=localStorage.getItem("tenantId"); let getNickName; //拿到自己的头像和昵称 getNickName=function(){ //保证是登录状态 //更新数据,dom图后进行滚动到底部 //拿到自己的头像和昵称 mlkAxiosFactory.mobileAxios.get("/vchat/user_info/"+localStorage.getItem("wangyiaccid")).then((data)=>{ vue.fromHeader.img=data.data.retData[0].localimgurl; vue.fromHeader.nickname=data.data.retData[0].nickname; }) //拿到客服头像和昵称 mlkAxiosFactory.mobileAxios.get("/vchat/user_info/"+vue.to).then((data)=>{ vue.toHeader.img=data.data.retData[0].localimgurl; vue.toHeader.nickname=data.data.retData[0].nickname; }) } mlkAxiosFactory.mobileAxios.get('/vchat/get_cs',{params:{tenantId}}).then((data)=>{ //拿到客服的账号信息后,赋值给to,这样在查看历史消息 vue.to=data.data.retData[0].accid; //创建会话,并且拿到历史消息 getHistoryList(); }) } },
6、在做chatcontent遇到很多的技术难点,以前没有遇到的,还好这次解决了,最大的就是滑动了,因为,在聊天的时候,如果有一条新的消息进来,我要判断,当前聊天者阅读到哪个位置了,如果在最后一条位置,那就往下滚动到最后,如果在上面的化,就不让滚动了。
7、仍然后chatContent页面,这个问题直接导致了我第一次上线失败,在发消息的时候,会发现串消息,就a,b同时跟c发消息,c都收到了,结果是在一个会话框里收到的,搞得跟群聊一样,然后查到半夜1点,大致知道了原因,由于自己实在没有精力弄了,就走了,然后第二天,跟身边那哥们闹了些不愉快,这个很可能导致我走掉。第二天过公司来,我讲sessionID分开来写,然后,收到消息后分组,分别进入不同的sessionId里,这样就不会错了
上代码:
function onMsg(msg) { console.log('收到消息', msg.scene, msg.type, msg); pushMsg(msg); function pushMsg(msgs) { //判断收到的消息,类型是否是数组,如果是就不做处理,如果不是就转化成数组 if (!Array.isArray(msgs)) { msgs = [msgs]; } //获取到收到消息的sessionId var sessionId = msgs[0].sessionId; let opt={ //网易提供的merge方法,最好用网易的,自己写的话,会merge不到nim对象的其他属性 merge:nim.mergeMsgs, msgs:msgs, sessionId:sessionId } //拼装成网易需要的数据结构,放入状态管理器里,更新消息列表 gContext.$store.commit('chatContent/updateMsgs',opt); } //如果用户是客服且当前页面是chatList页面才进行接口更新,否则不更新 //此处根据route对象的name属性来进行判断页面 if(gContext.$route.name=="chatList"&&(localStorage.getItem("csFlag")-0)){ console.log("如果用户是客服且当前页面是chatList页面才进行接口更新,否则不更新"); //更新chatList列表数据,并保存到gContext对象里 gContext.mobileAxios.get('/vchat/history?openid='+openid+'&name='+name+'&tenantId='+tenantId).then((data)=>{ if(data.data&&data.data.retCode=="0000"){ gContext.listData=data.data.retData; } }) } } updateMsgs: function (state, opt) { //从状态管理器里拿到消息,这时的状态管理器应该还没有收到消息 state.msgs = state.msgs || {}; //过滤掉新的消息放到对应的session会话里,通过state.seesionId来区分 //在聊天列表页里点击会获取一个sessionId,在普通用户里也会生成一个sessionId //判断收到的消息,类型是否是数组,如果是就不做处理,如果不是就转化成数组 if (!Array.isArray(opt.msgs)) { opt.msgs = [opt.msgs]; } var arr=opt.msgs.filter((item,index)=>{ return item.sessionId===state.sessionId }) //如果当前会话什么消息都没有,就什么都不做 if(arr.length==0){ return } //如果有了新消息 //根据sessionid进行添加消息,此时是添加到msgs里面了,相当于维护了msgs的信息 state.msgs[opt.sessionId] = opt.merge.call(Vue.prototype.nim, state.msgs[opt.sessionId], arr) || []; //清空排序后并且格式化后的消息 state.sortMsgs = []; //循环新的消息列表,排序后并且进行格式化 state.msgs[opt.sessionId].forEach(item => { let unforMatItem = Object.assign({}, item); //格式化表情包与文字 unforMatItem.text = emoUtil.formatText(unforMatItem.text); //格式化时间 unforMatItem.time=formatTime(unforMatItem.time); //将格式化后的消息push进排序数组 state.sortMsgs.push(unforMatItem); }); console.log("state.sortMsgs", state.sortMsgs) }
8、当然,这还不算完,要能再手机上测试,手机上点击,在本地代码还能debugger这样才行,这时就用了natapp外网穿透技术,就是把本地服务映射到外网,通过外网访问本地服务,本地改动后,在手机上立马能看到,类似与react开发的那个,expo工具,是将本地服务映射到手机上。