前端 websocket.js 及使用

底层的 websocket.js

javascript 复制代码
const WS_URL = "ws://127.0.0.1:8080/";

const MAX_RETRIES = 10  // 最多重试 10 次,约 3 分钟
let socket = null;
let reconnectTimer = null;    //重连定时器
let reconnectCount = 0;       //重连次数
let manualClose = false   // 标记是否手动关闭(手动关闭不重连)
// 此处 handlers是全局的, 它的格式是
// {
// "text_msg":function(data){这里的文字处理},
// "image_msg": function(data){这里是图片消息处理},
// "auth":function(data){这里的认证消息处理}
// }

let handlers = {};

//ws的连接
//connect 方法 创建了socket 的连接,定义了, onopen   onmessage  onclose onerror 的回调
function connect(){
  const token = localStorage.getItem("token");
  if(!token) return ;
  //当调用 connect 方法的时候, 把手动关闭不重连标记设为 false
  manualClose = false
  socket = new WebSocket(WS_URL);

  //1. 当握手完成的时候  客户端触发了 socket.onopen 事件   ,  服务端为触发 onConnect 事件,
  //2. 常用的方法是用户在 onopen -> onconnect 这个过程中完成用户认证
  //   其具体做法是在  WS_URL = "ws://127.0.0.1:8080/token=XXXXXXX"  那么当  new WebSocket 的时候就会把 token发送给后端
  //   后端可以通过$server->on('open', function (Server $server, Request $request)   在 request 中获取token
  //   然后进行token的认证,一旦 Token 无效,应立即调用 close() 或 disconnect() 关闭连接,不要尝试发送任何业务消息


  //3. 还有一种不太推荐的做法, 就是 WS_URL = "ws://127.0.0.1:8080/" 其中不要加 token ,  在服务端的 connection 事件中, 不做任何处理
  //   但是在  socket.onopen 的事件中, send token给服务器完成认证, 服务器在onmessage中去接收  像我下面的做法


  socket.onopen = () => {  //onopen 事件处理函数是在连接成功后才执行的
    console.log("ws 连接成功")
    reconnectCount = 0;  //只要连接成功,重置重连计数器
    socket.send({type:"auth",data:{token,user_type:"customer"}})  //发送认证信息给服务器  auth 是后端message的类型,    token 就是用户的token
  }


  socket.onmessage = (event) => {
    console.log("ws 接收到消息", event.data)
    try{
      const data = JSON.parse(event.data);
      const handler= handlers[data.type];  //这里后端返回的 data.type 与前端的 handlers 的 key 是一致的
      if(handler) handler(data);
    }catch(e){
      console.log("ws 接收到消息,但是解析失败", event.data)
    }
  }

  socket.onclose = (event) => {
    console.log("ws 断开连接");
    if(manualClose) return;  //如果是手动关闭,则不重连
    clearTimeout(reconnectTimer)
    if(reconnectCount >= MAX_RETRIES){
      console.error(`ws 重连失败,超过最大重试次数`);
      // 通知 UI:连接彻底断开
      const fn = handlers["disconnect"];
      if(fn) fn({reason:"服务器无响应,已达到最大重连次数"});
      return;
    }
    //指数退避 第一次重连 3秒后, 第二次6秒, 第三次12秒, 第四次24秒, 第五次就是最大的 30秒了
    const delay = Math.min(3000*Math.pow(2,reconnectCount), 30000);
    reconnectCount++;
    //只要是断开连接,就会触发 onclose事件, 所以每次connect连接失败,就会触发onclose
    reconnectTimer = setTimeout(connect, delay)
  }

  socket.onerror = (err) => {
    console.error("socket 连接失败",err)
  }

}

// 发送消息
// send方法就是封装了 socket.send 方法, 在发送之前, 先判断 socket 是否已经连接, 如果已经连接, 就直接发送, 如果没有连接, 就先连接, 然后再发送
function send(data){
  if(socket && socket.readyState === WebSocket.OPEN){
    socket.send(JSON.stringify(data))
  }
}


//这个on方法,就是把不同的消息类型, 与不同的处理函数, 进行绑定, 在 onmessage 事件中, 根据不同的消息类型, 调用不同的处理函数
//相当于是一个注册方法的容器
function on(type, handler){
  handlers[type] = handler;
}


//删除消算类型, 就是把这个类型对应的处理函数, 删除掉
function off(type){
  delete handlers[type];
}

function close(){
  manualClose = true;
  clearTimeout(reconnectTimer);
  if(socket){
    socket.close();
    handlers = {};
  }
}

export default { connect, send, on, off, close }

上面是一个小型的封装,也是最基础的用法

下面的配合封装的一个示例

javascript 复制代码
<template>
  <div class="chat-page">
    <div class="top-bar">
      <span class="back-btn" @click="$router.back()">← 返回</span>
      <span class="title">客服会话</span>
      <span></span>
    </div>

    <div class="msg-area" ref="msgArea">
      <div v-if="status === 'waiting'" class="sys-msg">客服繁忙,请稍等...</div>

      <div v-for="(msg, i) in messages" :key="i" class="msg-wrap" :class="msg.sender_type === 'user' ? 'right' : 'left'">
        <!-- 退款卡片 -->
        <div v-if="msg.msg_type === 'refund'" class="refund-card">
          <div class="r-title">退款通知</div>
          <div>金额: ¥{{ msg.card.amount }}</div>
          <div>原因: {{ msg.card.reason }}</div>
          <div class="r-ok">退款成功</div>
        </div>
        <!-- 文字 -->
        <div v-else-if="msg.msg_type === 'text'" class="bubble">{{ msg.content }}</div>
        <!-- 图片 -->
        <img v-else-if="msg.msg_type === 'image'" :src="msg.image_url" class="msg-img" @click="preview(msg.image_url)" />
        <!-- 系统 -->
        <div v-else-if="msg.msg_type === 'system'" class="sys-text">{{ msg.content }}</div>
      </div>

      <div v-if="isTyping" class="typing">对方正在输入...</div>
    </div>

    <div class="input-bar">
      <div class="input-row">
        <button class="img-btn" @click="chooseImage">📷</button>
        <input v-model="text" class="text-inp" placeholder="输入消息" @keyup.enter="sendText" @input="onInput" />
        <button class="send-btn" @click="sendText" :disabled="!text.trim()">发送</button>
      </div>
    </div>
  </div>
</template>

<script>
import api from '@/utils/request'
import ws from '@/utils/websocket'

export default {
  data() {
    return {
      orderId: '',
      sessionId: '',
      status: '',
      text: '',
      messages: [],
      typingTimer: null
    }
  },
  computed: {
    isTyping() { return this.$store.state.isTyping }
  },
  created() {
    this.orderId = this.$route.query.order_id
    this.sessionId = this.$route.query.session
    if (this.sessionId && this.sessionId !== 'new') {
      this.loadHistory()
    }
    ws.connect()   // 这里初始化了 websocket并进行了连接
    this.setupWS()  // 这里绑定了所有消息接收的处理函数, 后端发来的数据 onmessage的处理
    //注意这里的setupWS 只是在往 onmessage 函数中写规则,并不是在处理信息
  },
  beforeDestroy() {  //当销毁页面时,把注册过的处理函数都清理掉
 		ws.off('text_message'); 
 		ws.off('image_message')
    ws.off('refund_result'); 
    ws.off('typing'); 
    ws.off('system_message');
    ws.off('disconnected')
  },
  methods: {
    setupWS() {  //注册后端信息及处理方法
    // 消息类型为 文字
      ws.on('text_message', (d) => { this.messages.push(d); this.$nextTick(this.scrollBottom) })
      //消息类型为图片
      ws.on('image_message', (d) => { this.messages.push({ ...d, msg_type: 'image' }); this.$nextTick(this.scrollBottom) })
     //消息类型为 退款请求
      ws.on('refund_result', (d) => { this.messages.push({ ...d, msg_type: 'refund' }); this.$nextTick(this.scrollBottom) })
     //消息类型为  正在输入  (用户正在输入)
      ws.on('typing', (d) => {
        this.$store.commit('SET_TYPING', d.status === 'typing')
        setTimeout(() => this.$store.commit('SET_TYPING', false), 3000)
      })
      ws.on('system_message', (d) => { this.messages.push({ ...d, msg_type: 'system' }) })
      ws.on('disconnected', (d) => {
        this.messages.push({ msg_type: 'system', content: `⚠️ ${d.reason},请刷新页面重试` })
      })
    },
    async loadHistory() {
      try {
        const r = await api.get('/message/history', { session_id: this.sessionId, pageSize: 50 })
        this.messages = (r.list || []).reverse()
        this.$nextTick(this.scrollBottom)
      } catch {}
    },
		//聊天点击发送消息
    sendText() {
      const t = this.text.trim()
      if (!t) return
      this.messages.push({ sender_type: 'user', content: t, msg_type: 'text', created_at: new Date().toLocaleTimeString() })
      ws.send({ type: 'text_message', data: { session_id: this.sessionId, content: t } })
      this.text = ''
      this.$nextTick(this.scrollBottom)
    },
    async chooseImage() {
      const input = document.createElement('input')
      input.type = 'file'; input.accept = 'image/*'
      input.onchange = async (e) => {
        const file = e.target.files[0]; if (!file) return
        const fd = new FormData(); fd.append('file', file); fd.append('session_id', this.sessionId)
        try {
          const r = await api.upload('/upload/image', fd)
          this.messages.push({ sender_type: 'user', image_url: r.image_url, msg_type: 'image', created_at: new Date().toLocaleTimeString() })
          ws.send({ type: 'image_message', data: { session_id: this.sessionId, image_url: r.image_url } })
          this.$nextTick(this.scrollBottom)
        } catch {}
      }
      input.click()
    },
    onInput() {
      ws.send({ type: 'typing', data: { session_id: this.sessionId, status: 'typing' } })
      clearTimeout(this.typingTimer)
      this.typingTimer = setTimeout(() => {
        ws.send({ type: 'typing', data: { session_id: this.sessionId, status: 'idle' } })
      }, 2000)
    },
    scrollBottom() {
      const el = this.$refs.msgArea
      if (el) el.scrollTop = el.scrollHeight
    },
    preview(url) { window.open(url) }
  }
}
</script>

<style scoped>
.chat-page { height: 100%; display: flex; flex-direction: column; background: #f5f5f5; }
.top-bar { height: 44px; background: #1989FA; color: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; font-size: 16px; flex-shrink: 0; }
.back-btn { font-size: 14px; cursor: pointer; }
.msg-area { flex: 1; padding: 12px; overflow-y: auto; }
.sys-msg, .sys-text { text-align: center; color: #999; font-size: 13px; margin: 16px 0; }
.msg-wrap { display: flex; margin-bottom: 14px; }
.msg-wrap.left { justify-content: flex-start; }
.msg-wrap.right { justify-content: flex-end; }
.bubble { max-width: 75%; padding: 10px 14px; border-radius: 14px; font-size: 15px; line-height: 1.5; word-break: break-all; }
.left .bubble { background: #fff; color: #333; border-top-left-radius: 4px; }
.right .bubble { background: #1989FA; color: #fff; border-top-right-radius: 4px; }
.msg-img { max-width: 65%; border-radius: 8px; cursor: pointer; }
.refund-card { background: #FFF7E6; border: 1px solid #FFD591; border-radius: 10px; padding: 12px; font-size: 13px; line-height: 1.8; color: #333; }
.r-title { font-weight: bold; margin-bottom: 4px; }
.r-ok { color: #389E0D; font-weight: bold; }
.typing { text-align: center; color: #999; font-size: 12px; margin: 8px 0; }
.input-bar { background: #fff; border-top: 1px solid #e8e8e8; padding: 8px 12px; flex-shrink: 0; }
.input-row { display: flex; align-items: center; gap: 8px; }
.img-btn { font-size: 26px; border: none; background: none; cursor: pointer; padding: 0; line-height: 1; }
.text-inp { flex: 1; height: 36px; background: #f5f5f5; border: none; border-radius: 18px; padding: 0 14px; font-size: 15px; outline: none; }
.send-btn { height: 36px; padding: 0 16px; background: #1989FA; color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
.send-btn:disabled { opacity: .5; }
</style>