底层的 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>