落地前端WebSocket+layim客服聊天功能详解

贤心大佬做的web端聊天layim总体是不错的,界面简洁、易用,但是只有基础的界面demo,想要能真正用起来还要许多工作,下面我们就使用websocket结合layim来实现一个聊天系统。当然聊天界面也可以自己做一套新的,只要写好自己的api、钩子就行。

WebSocket是什么?

websocketTCP上的一种双向通信协议,使用http完成了一部分握手,然后需要http响应101状态码进行当前协议升级为websocket即可双向发送或接受信息(不仅客户端可以发送给服务端,服务端也可以自己主动发送信息给客户端),websocket比传统的无状态http开销更小,http不仅有着比websocket更大的请求头,而且只能单向主动发信息,如果依赖http使用长轮询还会造成一定的服务器压力问题,消息有延迟。所以使用websocket是实现web聊天功能的首选。
常用的函数有:WebSocket.onmessage(当接收到消息),WebSocket.send(消息发送),WebSocket.close(关闭连接),WebSocket.onopen(当连接建立)。

更多websocket原理细节不是本篇文章的重点,并且本篇主要介绍聊天功能前端部分的实现步骤,没有加入服务端内容,默认服务端已实现了websocket收发功能及消息分发问题。

初始化聊天窗界面、建立连接:

创建一个chatDialog.js,主要内容是聊天功能的主要逻辑,首先引入layuilayim模块,并依照文档初始化layim的界面:

chatDialog.js

layui.use(["layim", "table", "form", "layer",'upload'], function (layim) {
  var kfId = 'xiaoming';
  layim.config({
    //简约模式(不显示主面板)
    brief: true,
    uploadImage: {
      url: "", //(返回的数据格式见下文)
      type: "", //默认post
    },
    mine: {
      username: kfId, //我的昵称
      id: kfId, //我的ID
      status: "online", //在线状态 online:在线、hide:隐身
      sign: "", //我的签名
      avatar: "../../images/kficon.png", //我的头像
    },
    chatLog: "../DialogueManagement/DialogueRecord/DialogueRecordInfo.html", //聊天记录地址
  });
  request({
    url: "personalConfig/getInfo",
    method: "get",
    success: function (res) {
      layim.config({
        brief: true,
        uploadImage: {
          url: "", //(返回的数据格式见下文)
          type: "", //默认post
        },
        mine: {
          username: res.data.name, //我的昵称
          avatar: fileServiceUrl + res.data.image, //我的头像
          id: kfId,
        },
        chatLog: "../DialogueManagement/DialogueRecord/DialogueRecordInfo.html", //聊天记录地址
      });
    },
    error: function (res) {
      console.log("网络错误");
    },
  });
});

这里定义了一个简约模式的聊天功能,通过请求接口获取到用户信息(昵称、头像)又重新更新了一遍当前用户的信息。kfId就是当前用户的唯一账号。这里chatLog相当于在右侧新开了一个DialogueRecordInfo页面,可以在DialogueRecordInfo单独写消息记录的列表逻辑,这里就不介绍了。

聊天窗界面有了,接下来我们创建websocket,首先定义一个websocket的地址(也就是后端websocket服务的地址),并拼接url(这里有个规定:当是http的情况下就使用ws,https对应使用wss)后面加上当前登录用户的kfId表示当前xiaoming用户请求建立websocket连接(代表xiaoming登录聊天,以让后端确定xiaoming已经登录,可以接受其他人的消息),定义一个webSocket全局变量存放实例:

  var chatServerUrl = "http://11.11.11.12:9981/"; //聊天服务地址
  var kfId = 'xiaoming';
  var url =
    chatServerUrl.replace("http", "ws").replace("https", "wss") +
    "websocket/" +
    kfId; //聊天websocket服务地址

  webSocket = null;
  createWebSocket();
  /**
   * websocket启动
   */
  function createWebSocket() {
    if (kfId) {
      if ("WebSocket" in window) {
        try {
          webSocket = new WebSocket(url);
          initWs(webSocket);
        } catch (e) {
          console.log("catch" + e);
          console.log("------连接失败,聊天连接已关闭...正在重连");
          reconnect();
        }
      } else {
        // 浏览器不支持 WebSocket
        alert(
          "您的浏览器不支持客服发送聊天信息 WebSocket! 请使用新版谷歌浏览器打开网站"
        );
      }
    } else {
      if (webSocket.readyState == WebSocket.CLOSED) {
        reconnect();
        console.log(webSocket.readyState, WebSocket.CLOSED);
      } else {
        console.log("no account");
      }
    }
  }


  function initWs() {
    webSocket.onopen = function () {
      if (webSocket.readyState == 1) {
        heartCheck.start();
      }
      alert("成功进入会话消息接收状态,onopen...");
      window.onbeforeunload = function () {
        webSocket.close();
      };
    };

    //连接错误
    webSocket.onerror = function (r) {
      console.log("连接错误onerror,聊天连接已关闭...开始重连" + r);
      console.log("断开");
      reconnect();
    };
    webSocket.onclose = function (e) {
      // 关闭 websocket,清空信息板
       console.log("聊天连接已关闭onclose...开始重连---");
      heartCheck.timeoutObj && clearTimeout(heartCheck.timeoutObj);
      heartCheck.serverTimeoutObj && clearTimeout(heartCheck.serverTimeoutObj);
      console.log(
        "websocket 断开: " + e.code + " " + e.reason + " " + e.wasClean
      );
      reconnect();
    };
  }

createWebSocket用于创建一个websocket实例,在创建时加入了try用于捕获到创建时的错误,在这一过程中发生错误就会执行reconnect()进行断线重连,断线重连后面会说。
当创建成功时执行initWs()定义一些websocket不同状态下的钩子函数,当websocket onopen的时候连接建立并webSocket.readyState为1时表示连接成功,开始eartCheck.start()心跳,心跳检测后面会说。

window.onbeforeunload时,也就是网页关闭之前需要关闭掉当前websocket的连接webSocket.close,目的是告知后端当前用户退出了聊天(强制),同时也是为了避免一些奇怪的问题出现。

webSocket.onerror是在使用websocket期间发生了一些错误,这里也是需要进行reconnect()进行断线重连。

webSocket.onclose表示当前连接关闭了(断开)因为已经断开了连接就不需要心跳检测了,clearTimeout(heartCheck.timeoutObj)清除掉心跳检测的计时器,同时也是要进行重连。

初始钩子定义好了,接下来看核心功能消息接入、消息收发部分

消息接入、消息收发:

接受到的数据因为是字符串,需要转换一下,这里我们设计status有多种状态,因为onmessage不能只是单一的接收聊天消息:
status 1:新消息,
status 2:有新用户接入聊天,
status 4: 有消息转接进入(其他客服转接过来)
status 5:你的转接被客服拒绝
status 9 :服务器的心跳
成功进入onmessage就已经证明服务器心跳正常,继续在initWs函数中加入如下代码:

  function initWs() {
    ...
      var wsuserObj = {};
   //websocket消息接收
    webSocket.onmessage = function (res) {
      heartCheck.start();
      var status = JSON.parse(res.data).status;
      var data = JSON.parse(res.data).data;
      console.log(res);
      if (status == 1) {
        //用户发送消息
        wsuserObj.userId;
        layer.msg("您有新消息");
        layim.getMessage({
          username: data.userName,
          avatar: "../../images/yonghu.png",
          id: data.userId,
          type: "kefu",
          kfId: data.kfId,
          kfName: data.kfId,
          userId: data.userId,
          content: layim.content(data.content),
          timestamp: data.createTime,
        });
        wsuserObj = data;
      }

      if (status == 2) {
        //有新接入
        layer.msg("您有新消息请求接入");
        //新接入的id为userid
        wsuserObj = data;
        wsuserObj.userId = data.id;
      }
      if (status == 5) {
        //转接被拒,消息退回
        addCount();
        layer.alert("目标客服坐席不接受转接,会话保持");
        layim.getMessage({
          username: data.userName,
          avatar: "../../images/yonghu.png",
          id: data.id,
          type: "kefu",
          kfId: kfId,
          kfName: kfId,
          userId: data.id,
          content: "系统消息:目标客服坐席不接受消息转接,会话保持",
          timestamp: new Date().getTime(),
        });
      }

      if (status == 4) {
        //收到坐席转接消息
        layer.open({
          content:
            "您收到一个来自其他客服转接过来的会话消息,是否接受处理该用户消息?",
          closeBtn: false,
          btn: ["接受", "不接受"],
          btnAlign: "c",
          yes: function (index, layero) {
            webSocket.send(
              toStr({
                respStatus: 4, //4接受  5不接受
                userId: data.id,
                kfId: kfId,
              })
            );

            layer.close(index);
            layim.getMessage({
              username: data.userName,
              avatar: "../../images/yonghu.png",
              id: data.id,
              type: "kefu",
              kfId: kfId,
              kfName: kfId,
              userId: data.id,
              content: "系统消息:转接成功",
            });
            layim.show();
          },
          btn2: function (index, layero) {
            webSocket.send(
              toStr({
                respStatus: 5,
                userId: data.id,
              })
            );
            layer.close(index);
          },
        });
      }
      if (status == 9) {
        console.warn("----心跳正常pong---", new Date());
      }
    };
}

status为1是新的聊天消息,使用layim.getMessage触发新消息显示出来的效果,这里data带着一些发送人的信息(网名、内容、唯一id、时间)为了标识这条信息是这个用户发的。
当有新消息或新用户接入聊天时都会对全局变量wsuserObj进行一次发送方数据的覆盖,维护一个当前正在聊天的发送方对象。

由于当前如有弹窗的话调用getMessage会把弹窗进行关闭 ,和我们现在的逻辑冲突,我们不需要达到这种效果, 所以要在getMessage函数源码最后这里需要修改一下setChatMin逻辑:
layui/lay/modules/layim.js

return setChatMin前加入    
 if (!$("#layui-layim-min").length) {  
     return;
}

接下来是消息的发送,发送同样也是需要先调用toStr把参数转换成字符串再发送,当有新用户接入时我们的设计不是立即弹出聊天窗口,因为不确定当前客服在不在电脑前,所以只能进行一个提示“当前有新用户接入”,用户看到时需要点击openDialog(接入)按钮接入聊天才能开始聊天,此时使用webSocket.send发送respStatus:2和自己id和发送方的信息交给后端进行接入:


    ...
      var wsuserObj = {};
    ...
  //objec转string
  function toStr(obj) {
    return JSON.stringify(obj);
  }

  //点击接入
  $("#openDialog").click(function () {
    if (webSocket == null) {
      alert("接入失败:ws连接服务器失败,请检查网络是否正常!");
      return;
    }
    if (!wsuserObj.userId) {
      alert("当前无用户消息!");
      return;
    }
    layer.confirm(
      "是否确认接入一个新的用户消息会话?",
      function (index) {
        //do something
        webSocket.send(
          toStr({
            respStatus: 2,
            kfId: kfId,
            userId: wsuserObj.id,
            userName: wsuserObj.userName,
          })
        );
        layim.getMessage({
          username: wsuserObj.userName,
          avatar: "../../images/yonghu.png",
          id: wsuserObj.id,
          type: "kefu",
          kfId: kfId,
          kfName: kfId,
          userId: wsuserObj.id,
          content: "系统消息:用户已接入",
        });
        layer.close(index);
        layim.show();
      },
      function (index) {
        //do something
        webSocket.send(
          toStr({
            respStatus: 3, //不接入,给其他人接吧
            kfId: kfId,
            userId: wsuserObj.userId,
            userName: wsuserObj.userName,
          })
        );
        layer.close(index);
        reduceCount();
      }
    );
  });

  //监听发送消息
  layim.on("sendMessage", function (data) {
    var To = data.to;
    console.log(data);
    if (!webSocket || webSocket.readyState != 1) {
      alert("发送失败,会话消息连接服务器错误ws");
    }
    webSocket.send(
      toStr({
        kfId: data.mine.id,
        kfName: data.mine.username,
        userId: data.to.id,
        userName: data.to.username,
        content: data.mine.content,
      })
    );
  });
  //layim建立就绪
  layim.on("ready", function (res) {});

可以看到webSocket.send就是websocket发送消息 非常简单,当我填好内容点击发送按钮时,首先监听聊天窗的发送事件layim.on("sendMessage"),并从回调参数里获取到发送方数据和发送内容就很好办了,直接通过webSocket.send把我和对方的id同时发给后端处理就行了,注意这里要检查一下webSocket.readyState的状态,当为不正常或断开状态下提示用户不能发消息。
有哪些状态?分别表示什么?

落地前端WebSocket+layim客服聊天功能详解_第1张图片

心跳检测、断线重连:

websocket连接过程中出现网络中断,或者心跳失败,执行报错时不能对当前已失败的连接置之不理,需要重新进行连接直到连接正常为止:

    //避免重复连接
    var lockReconnect = false,
      tt;
    /**
     * websocket重连
     */
    function reconnect() {
      if (lockReconnect) {
        return;
      }
      lockReconnect = true;
      tt && clearTimeout(tt);
      tt = setTimeout(function () {
        console.log("重连中...");
        lockReconnect = false;
        createWebSocket();
      }, 5000);
    }

当连接异常时调用reconnect(),每五秒调用一次createWebSocket创建websocket,创建失败时同时也会继续执行reconnect(),lockReconnect标识为了避免多个重连操作。

接下来看看心跳检测,什么是心跳检测?
我们知道聊天功能核心是要客户端和服务端必须要同时正常运行,当有一端不正常(如服务器宕机),或者客户端因为某些原因死掉了,此时服务器还不知道客户端已死掉还在发送多余信息,这时就表示当前聊天功能出现问题不能再使用了,需要重连操作,所以需要一个机制每隔一段时间去检测一下两端有没有死掉(是否还有心跳?)

    //心跳检测
    var heartCheck = {
      timeout: 30000, //每隔30秒发送心跳
      severTimeout: 5000, //服务端超时时间
      timeoutObj: null,
      serverTimeoutObj: null,
      start: function () {
        var _this = this;
        this.timeoutObj && clearTimeout(this.timeoutObj);
        this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);
        this.timeoutObj = setTimeout(function () {
          //这里发送一个心跳,后端收到后,返回一个心跳消息,
          //onmessage拿到返回的心跳就说明连接正常
          console.warn("----心跳发送ping----", new Date());
          webSocket.send(
            toStr({
              respStatus: 9, //心跳9
              kfId: kfId,
            })
          ); // 心跳包
          //计算答复的超时时间
          _this.serverTimeoutObj = setTimeout(function () {
            console.error("----心跳超时,关闭连接----", new Date());
            webSocket.close();
          }, _this.severTimeout);
        }, this.timeout);
      },
    };

heartCheck.start中重点执行了两个定时器,首先timeoutObj是用于每30秒向服务端发送心跳包(2个入参),当服务器收到也会立刻发送心跳status:9给客户端。
客户端心跳发送后立即执行serverTimeoutObj计时器5秒后如果onmessage 里的heartCheck.start()没执行(没收到服务端的心跳回信),那就会导致serverTimeoutObj计时器没及时清除掉,因为此时是需要服务端的心跳回应后才能进行客户端的下一步心跳,现在没回应,判定为心跳超时,serverTimeoutObj就会关闭掉当前websocket连接。
如果心跳正常的情况下,也就是服务器在5秒之内回复了消息,heartCheck.start()将会执行成功并清除掉了超时检测器,这样就可以进行下一次心跳了,以上操作循环往复。
注意在websocket连接中状态时执行心跳检测会导致错误。

到这里聊天功能基本算是完成了也能正常使用了,接下来可以额外加些东西丰富一下:

退出会话、常用语:

退出会话功能是让用户能主动退出websocket连接,需要调用后端接口,后端会通过id把你从后端维护的在线列表中踢出来,退出会话和常用语功能layim都没有,可以这里可以自己通过修改layim源码实现,在源码里面手写加入事件逻辑,然后在外面进行注册,当对应事件触发时就会执行外面的回调函数,修改以下代码,加入常用语按钮、把发送傍边的按钮改成endChat “退出此会话”,注意修改layim-event属性:

layui/lay/modules/layim.js

    '',
    "{{# if(d.base && d.base.uploadImage){ }}",
    '',
    "{{# }; }}",
    '',
    '',
    "{{# if(d.base && d.base.chatLog){ }}",
    '聊天记录',
    "{{# }; }}",
    "
", '
', '
', '
', "{{# if(!d.base.brief){ }}", '关闭', "{{# } }}", '退出此会话',

然后添加原型方法commonLang,同时也在events中添加 commonLang、endChat方法,最后会通过layui.each(call.commonLang)触发chatDialog.js注册的事件:

layui/lay/modules/layim.js

  LAYIM.prototype.close = function(content){
    changeChat(null, 1);
  };
  
  LAYIM.prototype.show = function (content) {
     layui.each(cache.chat, function (i, item) {
       popchat(item);
     });
  };

  LAYIM.prototype.commonLang = function (data) {
      events.commonLang(data);
    };
        
     endChat: function () {
        layui.each(call.endChat, function (index, item) {
          item && item(thisChat());
        });
        changeChat(null, 1);
      },

      commonLang: function (othis,e) {
          thatChat = thisChat(),
          hide = function () {
            layer.close(events.commonLang.index);
          };
           layui.each(call.commonLang, function (index, item) {
             item && item(function (listdom) {
                    events.commonLang.index = layer.tips(listdom, othis, {
                      tips: 1,
                      time: 0,
                      fixed: true,
                      skin: "layui-box layui-layim-commonLang",
                      success: function (layero) {
                        layero.find("div.cl-item").on("click", function () {
                          focusInsert(thatChat.textarea[0], $(this).text());
                          layer.close(events.face.index);
                        });
                      },
                    });
             });
           });
      

        $(document).off("mousedown", hide).on("mousedown", hide);
        $(window).off("resize", hide).on("resize", hide);
        stope(e);
 },

这里的commonLang事件往外传了一个匿名回调函数,函数功能主要是显示一个弹窗在输入框上面,参数是一个列表dom(常用语数据)然后渲染出来,随后监听列表点击,点击时把常用语插入到光标位置处。

源码部分改完接着看看chatDialog.js,在chatDialog.js中注册endChat事件,这里的退出会话就是个简单的接口调用,源码中有关闭弹窗的操作:

  //结束当前会话
  layim.on("endChat", function (data) {
    if (!data.data.userId) {
      layer.msg("当前会话不存在,无法退出");
      return;
    }
    request({
      url: chatServerUrl + "overSession/overUser",
      data: {
        kfId: kfId,
        userId: data.data.userId,
      },
      method: "get",
      success: function (resp) {
        layer.msg(resp.msg);
      },
      error: function (resp) {
        layer.msg("退出失败");
      },
    });
  });


  //常用语
  layim.on("commonLang", function (showDialog) {
    commonLangList(showDialog);
  });
  function commonLangList(cb) {
    request({
      url: baseUrl + "sessionComm/sessionCommList",
      data: JSON.stringify({
        content: "",
        page: 1,
        pageCount: 999,
        source: "",
      }),
      method: "post",
      success: function (res) {
        var vListDom = "";
        res.data.list.forEach(function (item) {
          vListDom += '
' + item.content + "
"; }); cb && cb('
' + vListDom + "
"); }, error: function (res) { console.log(res); }, }); }

常用语这里主要是调用接口拿取常用语数据后,开始拼装常用语列表dom,传入回调函数进行执行(显示出弹窗)。

效果:

落地前端WebSocket+layim客服聊天功能详解_第2张图片

加入emoji:

layim的默认表情是一张张图片,这是在同样的layim上使用倒是没问题,但如果消息是发到移动端上因为没有layim源码对标识的内部转换功能表情就显示不出来了,这里可以改成emoji跨平台比较好,同样是要修改源码:

layui/lay/modules/layim.js

  //表情库
  var faces = function(){
    var arr = [ "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "☺", "", "", "", "", "", "", "", "",
     "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", 
     "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", 
     "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "☝", 
     "", "", "✌", "✋", "", "", "", "✊", "", "", "", "", "✍", "", "", "", "", "", "", "", "", "", "", "", "", "", "", 
     "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ];
    // layui.each(alt, function(index, item){
    //   arr[item] = layui.cache.dir + 'images/face/'+ index + '.gif';
    // });
    return arr;
  }();

这样emoji就能用啦

落地前端WebSocket+layim客服聊天功能详解_第3张图片

chatDialog.js最终代码放到我的GitHub上了:
https://github.com/booms21/la...

以上就是WebSocket+layim聊天功前端能实现的所有内容了。从消息的收到发、心跳重连详解、额外功能实现, 整个篇幅有些长,写文章不易,如果对你有帮助,希望能支持一下(给个赞),谢谢~

你可能感兴趣的:(前端websocket聊天室)