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工具,是将本地服务映射到手机上。

posted on 2018-01-31 14:48  杨龙飞  阅读(746)  评论(0编辑  收藏  举报

导航

Fork me on GitHub