在上一讲中,Spring Boot 后端实现 WebSocket 已创建过后端项目,现在开始补充前端
在项目下新增一个模块frontend【与后端src目录平级】
在前端目录下执行npm install
不看上一讲也可以,直接创建一个前后端项目即可,下面会给出完整代码
后端
依赖
pom.xml内容如下
XML
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
</dependency>
</dependencies>
文件位置与代码
src/main/java/.../config/WebSocketConfig.java
java
package your.package.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
代码功能
让Spring Boot项目能够支持WebSocket,实现浏览器和服务器之间的双向实时通信
注解作用
@Configuration:告诉Spring这是一个配置类
@Bean:创建一个可被Spring管理的对象
方法解释
serverEndpointExporter():创建WebSocket服务器端点导出器,它会自动注册所有带有@ServerEndpoint注解的类,让它们能处理WebSocket连接
被Spring管理的好处:
被Spring管理的对象,Spring会自动帮你:
创建对象 - 不用自己写new ServerEndpointExporter()
保存起来 - Spring把对象放进自己的"容器"里,随时可用
自动使用 - 其他地方需要时,Spring会自动送过去(自动注入)
简单说:
有了@Bean,Spring就会说:"这个对象我管了,谁要用就找我要,不用你们自己操心怎么创建和传递。"
src/main/java/.../ws/ChatEndpoint.java
java
package com.websocket.ws;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ServerEndpoint("/ws/chat/{username}")
@Component
public class ChatEndpoint {
private static final Map<String, Session> ONLINE = new ConcurrentHashMap<>();
private static final ObjectMapper MAPPER = new ObjectMapper();
// ====== 消息结构(最小可用)======
public static class Msg {
public String type; // SYSTEM / CHAT
public String from; // username
public String content; // 文本
public long time; // 时间戳
}
@OnOpen
public void onOpen(Session session, @PathParam("username") String username) {
ONLINE.put(username, session);
System.out.println("🟢 onOpen username=" + username + ", sessionId=" + session.getId());
broadcast(system(username + " 加入聊天室", username));
}
@OnMessage
public void onMessage(String payload, Session session, @PathParam("username") String username) {
System.out.println("📩 onMessage from=" + username + ", payload=" + payload);
// 允许前端发纯文本或 JSON:最小可用、兼容调试
String content = payload;
try {
Msg incoming = MAPPER.readValue(payload, Msg.class);
if (incoming != null && incoming.content != null && !incoming.content.isBlank()) {
content = incoming.content;
}
} catch (Exception ignore) { /* payload 不是 JSON 就当纯文本 */ }
broadcast(chat(content, username));
}
@OnClose
public void onClose(Session session, @PathParam("username") String username) {
ONLINE.remove(username);
System.out.println("🔴 onClose username=" + username + ", sessionId=" + session.getId());
broadcast(system(username + " 离开聊天室", username));
}
@OnError
public void onError(Session session, Throwable t, @PathParam("username") String username) {
System.out.println("⚠️ onError username=" + username + ", sessionId=" + session.getId());
t.printStackTrace();
}
// ====== 群发(最小可用)======
private void broadcast(String json) {
ONLINE.forEach((u, s) -> {
if (s == null || !s.isOpen()) return;
try {
s.getBasicRemote().sendText(json);
} catch (Exception e) {
e.printStackTrace();
}
});
}
private String system(String content, String from) {
return toJson("SYSTEM", from, content);
}
private String chat(String content, String from) {
return toJson("CHAT", from, content);
}
private String toJson(String type, String from, String content) {
try {
Msg msg = new Msg();
msg.type = type;
msg.from = from;
msg.content = content;
msg.time = System.currentTimeMillis();
return MAPPER.writeValueAsString(msg);
} catch (Exception e) {
// 兜底:极端情况下也别让广播炸掉
return "{\"type\":\"" + type + "\",\"from\":\"" + from + "\",\"content\":\"" + content + "\",\"time\":" + System.currentTimeMillis() + "}";
}
}
}
代码功能
这是一个 WebSocket 聊天服务器端点,实现了:
用户连接/断开管理 - 记录在线用户
消息广播 - 一人发消息,全员都能收到
消息格式化 - 将消息转为 JSON 格式发送
注解作用
@ServerEndpoint("/ws/chat/{username}"):声明这是一个 WebSocket 端点,路径中包含用户名参数
@Component:让 Spring 管理这个类的实例
@OnOpen:用户连接时自动调用
@OnMessage:收到用户消息时自动调用
@OnClose:用户断开时自动调用
@OnError:发生错误时自动调用
@PathParam("username"):从 URL 路径中获取用户名参数
主要方法
onOpen():用户连接时,记录到在线列表并通知所有人
onMessage():收到消息时,转发给所有在线用户
onClose():用户离开时,从在线列表移除并通知所有人
broadcast():遍历所有在线用户发送消息
toJson():将消息对象转为 JSON 字符串
前端
配置 Vite 代理
frontend/vite.config.js
javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
// 让 ws://localhost:5173/ws/... 代理到 Spring Boot :8080
'/ws': {
target: 'http://localhost:8080',
ws: true,
changeOrigin: true,
},
// 如果你后面还有 REST 接口,也可以统一走 /api
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
})
代码功能
这是 Vite前端开发服务器的代理配置,主要解决前端开发时访问后端API的问题。
这个配置让前端开发时能连接到后端,特别是让WebSocket聊天功能正常工作。
核心函数功能
defineConfig():定义Vite的配置
vue():启用Vue框架支持
配置项功能
plugins: [vue()]:使用Vite的Vue插件
server.proxy:设置代理服务器,将前端请求转发到后端
特殊参数含义
'/ws' 配置:
作用:所有以 /ws 开头的请求
target: 'http://localhost:8080' → 转发到Spring Boot后端
ws: true → 支持WebSocket连接(关键!让聊天功能生效)
changeOrigin: true → 改变请求头中的Origin,避免跨域问题
'/api' 配置:
作用:所有以 /api 开头的请求
转发REST API请求到后端
不包括ws: true,因为REST API不用WebSocket
代理服务器会:
收到前端的请求(Origin: http://localhost:5173)
转发请求到后端时
把请求头中的Origin改为目标服务器的地址
发送到后端:Origin: http://localhost:8080
WebSocket 客户端封装
frontend/src/utils/chatWs.js
javascript
export function createChatWs({ username, onMessage, onOpen, onClose, onError }) {
// 关键:用 location.host,这样开发期是 5173(走代理),生产期是 8080(同域)
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${proto}://${location.host}/ws/chat/${encodeURIComponent(username)}`
const ws = new WebSocket(url)
ws.onopen = () => onOpen && onOpen()
ws.onclose = () => onClose && onClose()
ws.onerror = (e) => onError && onError(e)
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
onMessage && onMessage(data)
} catch {
// 兜底:如果后端发的不是 JSON(理论上不会),也能显示
onMessage && onMessage({ type: 'CHAT', from: 'server', content: e.data, time: Date.now() })
}
}
return {
sendChat(content) {
if (ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'CHAT', content, time: Date.now() }))
},
close() {
ws.close()
}
}
}
代码整体功能
这是一个创建WebSocket聊天连接的工厂函数,封装了连接、发送、接收消息等操作,让使用更简单。
核心变量/常量功能
proto:判断用 ws(普通)还是 wss(加密)协议
url:生成WebSocket连接地址,例如:ws://localhost:5173/ws/chat/张三
ws:WebSocket连接对象,负责实际通信
WebSocket核心事件功能
onopen:连接成功时触发 → 调用 onOpen 回调
onclose:连接关闭时触发 → 调用 onClose 回调
onerror:连接出错时触发 → 调用 onError 回调
onmessage:收到消息时触发 → 解析消息并调用 onMessage 回调
返回方法功能
sendChat(content):发送聊天消息(自动格式化为JSON)
close():主动关闭WebSocket连接
关键逻辑含义
协议判断:
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
如果网页是 https:// 就用 wss://(加密WebSocket)
如果网页是 http:// 就用 ws://(普通WebSocket)
自动适应环境:
const url = `{proto}://{location.host}/ws/chat/...`
开发时:ws://localhost:5173/ws/chat/...(走代理到8080)
生产时:ws://你的域名:8080/ws/chat/...(直接连接)
JSON解析兜底:
try { JSON.parse(e.data) } catch { /* 备用处理 */ }
优先解析为JSON格式
如果解析失败(理论上不会),就用原始数据
连接状态判断:
if (ws.readyState !== WebSocket.OPEN) return
确保只有在连接成功时才能发送消息
避免连接中断时发送消息出错
聊天页面
frontend/src/App.vue
html
<template>
<div style="max-width: 720px; margin: 24px auto; font-family: Arial, sans-serif;">
<h2>WebSocket 最小聊天室(Spring Boot + Vue)</h2>
<div style="display:flex; gap: 8px; align-items:center; margin-bottom: 12px;">
<input v-model="username" placeholder="输入用户名,如 Tom" style="flex:1; padding: 8px;" />
<button @click="connect" :disabled="connected" style="padding: 8px 12px;">连接</button>
<button @click="disconnect" :disabled="!connected" style="padding: 8px 12px;">断开</button>
</div>
<div style="border: 1px solid #ddd; padding: 12px; height: 320px; overflow:auto; background:#fafafa;">
<div v-for="(m, idx) in messages" :key="idx" style="margin-bottom: 8px;">
<div v-if="m.type === 'SYSTEM'" style="color:#666;">
[系统] {{ m.content }}
</div>
<div v-else>
<b>{{ m.from }}</b>:{{ m.content }}
</div>
</div>
</div>
<div style="display:flex; gap: 8px; margin-top: 12px;">
<input
v-model="text"
placeholder="输入消息回车或点击发送"
style="flex:1; padding: 8px;"
@keyup.enter="send"
:disabled="!connected"
/>
<button @click="send" :disabled="!connected || !text.trim()" style="padding: 8px 12px;">发送</button>
</div>
<div style="margin-top: 10px; color:#666;">
状态:{{ connected ? '已连接' : '未连接' }}
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { createChatWs } from './utils/chatWs'
const username = ref('Tom')
const text = ref('')
const connected = ref(false)
const messages = ref([])
let client = null
function connect() {
if (!username.value.trim()) return
client = createChatWs({
username: username.value.trim(),
onOpen: () => { connected.value = true },
onClose: () => { connected.value = false },
onError: () => { connected.value = false },
onMessage: (msg) => { messages.value.push(msg) }
})
}
function disconnect() {
if (client) client.close()
client = null
}
function send() {
const c = text.value.trim()
if (!c || !client) return
client.sendChat(c)
text.value = ''
}
</script>
代码整体功能
这是一个完整的WebSocket聊天室前端界面,用户可连接/断开聊天室、发送/接收消息。
模板部分布局和交互元素
用户名输入+连接按钮:设置用户名并连接聊天室
消息显示区域:滚动显示系统消息和用户聊天消息
消息输入框+发送按钮:输入和发送聊天消息
状态显示:显示当前连接状态
Vue指令功能
v-model:双向绑定输入框内容和变量(如 username ↔ 输入框)
v-for:循环显示消息列表中的每条消息
v-if/v-else:判断显示系统消息还是普通消息
@click:点击按钮时触发函数
@keyup.enter:按回车键时触发函数(快速发送)
:disabled:根据条件禁用按钮(如未连接时禁用发送)
:key:为循环项提供唯一标识(提高渲染效率)
脚本核心变量作用
username:存储当前用户的名字
text:存储要发送的消息内容
connected:记录是否已连接到WebSocket(true/false)
messages:存储所有接收到的消息(数组)
client:存储WebSocket连接对象(用于发送/关闭)
核心函数功能
connect():
检查用户名是否为空
调用 createChatWs 创建WebSocket连接
设置回调函数(连接/断开/收消息时更新界面)
disconnect():
调用WebSocket的 close() 方法断开连接
清空连接对象
send():
检查消息是否为空、是否已连接
调用 client.sendChat() 发送消息
清空输入框
createChatWs使用逻辑
client = createChatWs({
username: username.value, // 当前用户名
onOpen: () => { connected.value = true }, // 连接成功时更新状态
onClose: () => { connected.value = false }, // 断开连接时更新状态
onError: () => { connected.value = false }, // 出错时也更新状态
onMessage: (msg) => { messages.value.push(msg) } // 收到消息时添加到列表
})
frontend/src/main.js
javascript
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
这是Vue应用的入口文件,像"启动器"一样,创建一个Vue应用实例并挂载到网页上。

