一、HTTP 协议的缺点和解决方案
用户在使用淘宝、京东这样的网站的时候,每当点击一个按钮其实就是发送一个 HTTP 请求。那我们先来回顾一下 HTTP 请求的请求方式。



一个完整的 HTTP 请求是被分为 request 请求节点和 response 响应阶段的,而且从来都是客户端给服务器端发送 HTTP 请求,从来没有服务器端主动给我们发送请求。
但是有的时候我们在玩一些单机游戏的时候,游戏上的人物总是能将供给数据发送给我们,那么像是这种服务器主动给浏览器不断发送数据的场景是怎么实现的呢?

二、如何实现服务器主动发数据
①:HTTP 定时轮询
定时轮询是一种很常见的处理方案,就是客户端不断的发送 HTTP 请求到服务器当中,服务器收到请求后响应消息,这是一种伪服务器推的方式,他其实不是服务器主动发送消息到客户端,而是客户端不断偷偷请求服务器,只是用户无感知而已!

使用这种常见的方式有很多,最常见的就是扫码登录。比如微信登录。当我们打开扫码页面以后,前端页面根本就不知道用户扫没扫码,于是不断的向服务器后端进行询问,询问的间隔是 1-2s 这样可以保证用户在扫码后,能在 1-2s 内得到及时的反馈!


存在的问题
第一:当我们打开 f12 的时候,满屏都是 HTTP 请求,虽然每个请求都很小,但是这么多的数量也会消耗很多带宽,同时也会加重服务器的负担。
第二:即使是最快的情况下,用户扫码完成后,同样需要等到 1-2s,等到下一次 HTTP 请求,才能从服务器当中将数据读取出来返回给用户,这样会给用户明显的卡段感。
②:HTTP 长轮询机制
我们知道 HTTP 请求一般会给服务器一定的时间去处理请求,然后返回数据。如果在这一段时间内浏览器没有接收到服务器的响应,那么浏览器就认为该 HTTP 请求超时,浏览器会进行重传。

但是如果我们将 HTTP 请求的超时设置的很大,比如 30s,在这 30s 内只要服务器接受到了扫码请求,就立马返回给前端网页数据。
如果超时那就立马发起下一次请求,这样就减少了 HTTP 请求的个数。并且大多数情况下用户都会在某个 30 秒的时间内,做出扫码操作,所以此时响应就会非常及时。
比如百度云网盘就是这样做的,所以我们扫码,在手机上点击确认,浏览器会马上变成登录状态。用户体验很好。(如果没有在有限时间进行扫码的话,就会有二维码失效)
向这样发起一个请求在较长时间内等待服务器响应的机制就是长轮询机制。我们常用的消息队列 RocketMQ 消费者取数据就是长轮询机制!
向这样在用户不感知的情况下服务器将数据推送给浏览器的技术就是服务器推送技术,它还有一个毫不沾边的英文名称---comet 技术
以上提到的两种解决方案本质上还是客户端主动从服务器端取数据,对于扫码登录这样的简单场景还能用用,但是如果是网页游戏,游戏一般会有大量的数据会从服务器端推送到客户端,那么如何实现呢?
三、WebSocket 的由来
我们首先要了解计算机网络当中的 TCP/IP 协议栈,下层协议要为上层协议提供基础。

TCP 协议属于传输层的协议,它是一种全双工协议。

而 HTTP 协议的访问流程是客户端浏览器给服务器发送数据,然后服务器处理后给浏览器返回响应,这是妥妥的半双工协议。
也就意味着好好的全双工协议到了 HTTP 就给限制为半双工!
这主要是因为在 HTTP 协议的设置之初,主要考虑的是查看网页文本的场景,能够做到客户端发送请求,再由服务器响应就够了,根本就没想到网页游戏这种客户端和服务器端相互主动发送大量数据的场景,所以为了更好的去应对这种场景,我们需要一个基于 TCP 的新协议,于是新的网络层协议 WebSocket 就被设计出来了。
注意:WebSocket 和 Socket 之间就像雷锋和雷峰塔一样毫无关系,别被名字带偏。
四、如何建立 WebSocket 链接
我们在浏览器上经常是一会儿刷刷图文、一会儿打会儿游戏。刷图文就是使用的 HTTP 协议,而打游戏就要使用 WebSocket 协议。为了兼容这些使用场景浏览器在 TCP 三次握手以后都使用 HTTP 协议进行通讯。

如果此时客户端发起的是普通的 HTTP 请求,那后续双方就还是老样子,继续用 HTTP 协议进行交互,这点没啥疑问。如果这时候是想建立 WebSocket 链接,就需要在 HTTP 请求的请求头 header 带上特殊的信息。
包含:
- Connection: Upgrade // 标识该 HTTP 请求是一个协议升级请求
- Upgrade: websocket // 协议升级为 WebSocket 协议
- Sec-sebSocket-Key: dGhlIHNhbx8sZSBub25SjZQ== // 随机生成的 Base64 码
GET ws://localhost/chat HTTP/1.1 // 请求协议为 ws
Host: localhost
Upgrade: websocket // 协议升级为 WebSocket 协议
Connection: Upgrade // 标识该 HTTP 请求是一个协议升级请求
Sec-sebSocket-Key: dGhlIHNhbx8sZSBub25SjZQ== // 客户端采用 base64 编码的 24 位随机字符序列,服务器
// 接受客户端 HTTP 协议升级的证明,要求服务器响应一个
// 对应加密的 Sec-webSocket-Accept 头信息作为应答
Sec-webSocket-Extensions: permessage-dflate // 协议扩展类型
Sec-webSocket-Version: 13 // 客户端支持 WebSocket 协议版本

将以上信息返送给服务器以后,如果服务器接收的是:升级为 WebSocket 协议,就会走 WebSocket 握手流程。同时将客户端给到的 Base64 码用公开算法变成另一段字符串。放在 HTTP 的响应头当中的 Sec-webSocket-Accept。同时在带上 101 状态码返回给客户端。101 状态码确实不常见,它其实是指协议切换。

HTTP/1.1 101 Switching Protocols // 服务器响应 101 代码说明握手成功
Upgrade: websocket
Connection: Upgrade
Sec-webSocket-Accept: s3pPLMBiTxaQ9kyGzzhZRbk+XOo=
Sec-webSocket-Extensions: permesssage-deflate

浏览器得到响应的数据以后也会使用同样的公开算法,**将之前发送给服务器的 Base64 码也转换成字符串。**如果这段字符串和传回来的字符串一致,那么就验证通过。

WebSocket 和 HTTP 一样都是应用层协议,他们也都是基于 TCP 的协议。WS 协议的流程是:
- 首先经过 TCP 的三次握手
- 利用 HTTP 协议升级为 WS 协议
- 后续双方通过 WebSocket 数据格式进行通信

五、WebSocket 的实现方式
- 后端实现方式:
- 编程式:即继承类 javax.websocket.Endpoint 并实现其方法。
- 注解式:即定义一个 POJO,并添加@ServerEndpoint 相关注解。
- 前端实现方式:
- 使用 HTML5 原生的 API。
- 使用 Socket.js / StompJS 框架提供的 API。
六、关于 WebRTC(Web Real-Time Communication)
WebRTC 是什么: 是浏览器内置的实时通信技术,能让网页直接实现音视频通话、数据传输,无需安装插件。
ICE 是什么:ICE(Interactive Connectivity Establishment)是 WebRTC 中用于解决 NAT 穿透(简单说就是让不同网络下的设备能找到彼此)的框架,而 iceServers 就是给 ICE 提供'辅助服务器'的配置。
**STUN 服务器:**STUN(Session Traversal Utilities for NAT),直译是'NAT 会话穿透工具',它是一种轻量级的网络服务器,核心作用是:帮助处于 NAT(网络地址转换)后的设备(比如你的电脑/手机),获取自己的公网 IP + 端口,以及 NAT 设备的类型,从而让不同 NAT 后的设备能找到彼此,建立点对点(P2P)连接。
先补个前提:为什么需要 STUN?
我们日常用的网络(比如家里的宽带、公司的内网),设备拿到的都是内网 IP(如 192.168.1.100),不是公网 IP。当两个内网设备要直接通信(比如 WebRTC 音视频通话),它们不知道对方的公网地址,就像两个人在不同的小区里,只知道自己的门牌号,却不知道小区的地址和对外的出入口——STUN 服务器就是帮它们查'小区地址 + 出入口'的工具。
用一个'打电话'的例子解释:
- 设备 A(内网) 向 STUN 服务器发送一个请求:'请告诉我,你看到的我的地址和端口是什么?'
- STUN 服务器 收到请求后,会记录下请求来源的公网 IP + 端口(这是 NAT 设备给设备 A 分配的对外端口),然后把这个信息返回给设备 A。
- 设备 A 拿到自己的公网地址后,通过信令服务器(比如 WebSocket)把这个地址告诉设备 B;同理,设备 B 也通过 STUN 服务器拿到自己的公网地址并告诉设备 A。
- 最后,设备 A 和设备 B 就可以用彼此的公网地址,直接建立 P2P 连接。
七、流程设计
平时用微信、QQ 视频聊天时,有没有想过背后是怎么实现的?为什么能实时看到对方的画面和听到声音?主要为实时传输、音视频采集、网络通信等关键问题。
核心原理:

① 视频聊天的核心流程拆解:
② 关键技术角色定位:
| 技术 | 作用 |
|---|---|
| SpringBoot | 快速搭建后端项目框架,管理 Netty 服务 |
| Netty | 高性能网络通信框架,实现 WebSocket 服务端,处理多客户端连接 |
| WebSocket | 保持客户端与服务端的长连接,传输信令消息(注册、呼叫、应答等) |
| WebRTC | 浏览器原生支持的实时音视频通信技术,实现音视频采集、编码、传输 |
| Vue | 搭建前端页面,实现用户交互(输入用户 ID、发起呼叫)和视频画面展示 |
③ 核心流程图示(流程展示):
客户端 A → WebSocket 连接 → Netty 服务端(注册) 客户端 B → WebSocket 连接 → Netty 服务端(注册) 客户端 A 发起呼叫 → 信令(含 SDP 提议) → 服务端转发 → 客户端 B 客户端 B 应答 → 信令(含 SDP 应答) → 服务端转发 → 客户端 A 双方交换 ICE 候选(网络地址) → 建立 WebRTC P2P 连接 → 实时音视频传输
- 信令交换:相当于'敲门 + 协商'——告诉对方'我要和你视频''我这边的音视频参数是什么''我的网络地址是什么',由 WebSocket+Netty 负责。
- 端到端音视频传输:协商完成后,直接在两个客户端之间传输音视频数据,不经过服务端中转(P2P),由 WebRTC 负责。
八、WebRTC 项目搭建与依赖配置
步骤 1:服务端开发(一)—— 项目搭建与依赖配置
<!-- SpringBoot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Netty WebSocket 依赖 -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.94.Final</version>
</dependency>
<!-- JSON 解析 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
依赖作用:Netty 是核心,负责网络通信;FastJSON 用于处理前后端传输的 JSON 格式信令
步骤 2:服务端开发(二)—— 核心实体类与消息处理器
- 创建消息实体类 Message.java
信令消息需要包含'消息类型''发送方 ID''接收方 ID''消息内容',所以定义对应的属性
public class Message {
// 消息类型:register(注册)、call(呼叫)、answer(应答)、ice(ICE 候选)
private String type;
// 发送方 ID
private String from;
// 接收方 ID
private String to;
// 消息内容(SDP/ICE 数据)
private String data;
}
- 创建 Netty WebSocket 处理器
核心作用:处理 WebSocket 连接的建立、消息接收与转发、连接断开等事件,是服务端的核心逻辑。
import com.alibaba.fastjson.JSON;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import java.util.concurrent.ConcurrentHashMap;
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
// 存储用户 ID 与 Channel 的映射(线程安全)
public static final ConcurrentHashMap<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();
/**
* 处理 WebSocket 连接建立成功事件
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
// 检测是否是 WebSocket 握手成功事件
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
System.out.println("WebSocket 连接建立成功:" + ctx.channel().id());
}
super.userEventTriggered(ctx, evt);
}
/**
* 处理接收到的文本消息
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
// 解析 JSON 消息
String text = msg.text();
Message JSON.parseObject(text, Message.class);
System.out.println( + text);
(message.getType()) {
:
USER_CHANNEL_MAP.put(message.getFrom(), ctx.channel());
System.out.println( + message.getFrom() + );
;
:
:
:
USER_CHANNEL_MAP.get(message.getTo());
(targetChannel != && targetChannel.isActive()) {
targetChannel.writeAndFlush( (text));
System.out.println( + message.getTo());
} {
System.out.println( + message.getTo() + );
}
;
:
System.out.println( + message.getType());
}
}
Exception {
USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue() == ctx.channel());
System.out.println( + USER_CHANNEL_MAP.size());
ctx.close();
}
Exception {
System.out.println( + cause.getMessage());
USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue() == ctx.channel());
ctx.close();
}
}
步骤 3:Netty 服务启动类与 SpringBoot 启动类
需要在 SpringBoot 项目启动时,自动启动 Netty 的 WebSocket 服务,监听指定端口(8081)
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
/**
* Netty WebSocket 服务端
*/
@Component
public class NettyWebSocketServer {
// 监听端口
private static final int PORT = 8081;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
/**
* Spring 初始化完成后启动 Netty 服务
*/
@PostConstruct
public void start() throws InterruptedException {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup();
try {
();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, )
.childOption(ChannelOption.SO_KEEPALIVE, )
.childHandler( <SocketChannel>() {
Exception {
ch.pipeline().addLast( ());
ch.pipeline().addLast( ());
ch.pipeline().addLast( ());
ch.pipeline().addLast( ());
ch.pipeline().addLast( ());
}
});
bootstrap.bind(PORT).sync();
System.out.println( + PORT);
future.channel().closeFuture().sync();
} {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
{
(bossGroup != ) bossGroup.shutdownGracefully();
(workerGroup != ) workerGroup.shutdownGracefully();
System.out.println();
}
}
- @PostConstruct 和 @PreDestroy:Spring 的注解,分别在项目启动后和销毁前执行,实现 Netty 服务的自动启停
- EventLoopGroup 线程组:bossGroup 负责接收连接,workerGroup 负责处理读写,是 Netty 高性能的核心
- 通道处理器链:按顺序添加处理器,WebSocket 基于 HTTP 握手,所以先添加 HTTP 相关处理器,最后添加自定义处理器
步骤 4:Vue 前端开发
1.编辑模版 template
<template>
<div>
<h2>WebRTC 视频聊天</h2>
<!-- 1. 用户 ID 输入与连接服务器区域 -->
<div>
<input v-model="userId" placeholder="输入你的用户 ID(如 user1)" type="text" />
<button @click="connect">连接服务器</button>
</div>
<!-- 2. 呼叫功能区域(仅连接成功后显示) -->
<div v-if="socketConnected">
<input v-model="targetUserId" placeholder="输入对方用户 ID(如 user2)" type="text" />
<button @click="call">发起视频呼叫</button>
</div>
<!-- 3. 视频展示区域 -->
<div>
<div>
<p>本地视频</p>
<!-- muted:本地视频静音,避免回声;autoplay:自动播放 -->
<video ref="localVideo" autoplay muted></video>
</div>
<div>
<p>远程视频</p>
<video ref="remoteVideo" autoplay></video>
</div>
</div>
</div>
</template>
<style scoped>
/* 全局容器:居中 + 固定宽度,避免页面太宽/太窄 */
.container {
width: 900px;
margin: 20px auto;
text-align: center;
font-family: "Microsoft YaHei", sans-serif;
}
/* 输入框 + 按钮组样式:统一间距和样式 */
.input-group {
margin: 25px 0;
}
input {
padding: 10px 15px;
width: 220px;
margin-right: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 10px 20px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #359469; /* 鼠标悬浮变色,提升交互体验 */
}
/* 视频容器:并排展示两个视频 */
.video-container {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 30px;
}
.video-item {
text-align: center;
}
.video-item p {
margin-bottom: 8px;
font-size: 16px;
color: #333;
}
/* 视频标签样式:固定尺寸,加边框,提升美观度 */
video {
width: 400px;
height: 300px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #f5f5f5; /* 未加载流时显示浅灰色背景 */
}
</style>
2.编写核心脚本(script setup),实现交互逻辑
导入依赖 + 定义基础变量
<script setup>
// 1. 导入 Vue 内置的响应式变量和生命周期钩子
import { ref, onUnmounted } from 'vue';
// 2. 定义响应式变量(页面上用到的动态数据)
const userId = ref(''); // 本地用户 ID
const targetUserId = ref(''); // 对方用户 ID
const socketConnected = ref(false); // WebSocket 连接状态(控制呼叫区域显示)
// 3. 定义视频 DOM 引用(用于绑定音视频流)
const localVideo = ref(null); // 本地视频 DOM
const remoteVideo = ref(null); // 远程视频 DOM
// 4. 定义非响应式全局变量(仅脚本内使用,无需页面响应)
let socket = null; // WebSocket 实例
let peerConnection = null; // WebRTC 核心实例
let localStream = null; // 本地音视频流(用于后续停止流)
</script>
- ref:创建响应式变量,变量值变化时,页面会自动更新(比如 socketConnected 变为 true,呼叫区域会显示);
- onUnmounted:页面销毁时执行的钩子,用于清理资源(避免内存泄漏);
- localStream:额外定义本地流变量,方便后续'挂断'时停止摄像头/麦克风。
配置 STUN 服务器 + 编写 WebSocket 连接函数
<script setup>
// (接上一步代码)
// 5. 配置 STUN 服务器(WebRTC 必需,用于获取公网 ICE 候选)
const iceServers = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // 谷歌免费 STUN(需外网)
{ urls: 'stun:stun.qq.com:3478' }, // 腾讯免费 STUN(国内更稳定)
{ urls: 'stun:stun.aliyun.com:3478' } // 阿里云免费 STUN(备用)
]
};
// 6. 连接 WebSocket 服务器函数(点击'连接服务器'按钮触发)
const connect = () => {
// 校验:用户 ID 不能为空
if (!userId.value.trim()) {
alert('请输入你的 ID!(不能为空/仅空格)');
return;
}
// 创建 WebSocket 连接(Netty 服务端地址:ws://localhost:8081/ws)
// 注意:如果服务端部署在其他机器,需替换为对应 IP(如 ws://192.168.1.100:8081/ws)
socket = new WebSocket(`ws://localhost:8081/ws`);
// 6.1 连接成功回调
socket.onopen = () => {
console.log('✅ WebSocket 连接成功');
socketConnected.value = true; // 更新连接状态,显示呼叫区域
// 发送注册消息:告诉服务端'我上线了'
sendMessage({ type: 'register', from: userId.value, to: '', data: '' });
};
socket. = {
{
message = .(e.);
.(, message);
(message);
} (err) {
.(, err);
}
};
socket. = {
.();
socketConnected. = ;
};
socket. = {
.(, err);
socketConnected. = ;
();
};
};
</script>
- STUN 服务器:必须配置,否则内网设备无法找到彼此的网络地址,导致视频无法连接;
- trim():去除用户 ID 前后空格,避免用户输入空字符/仅空格;
- try-catch:防止服务端返回非 JSON 格式消息,导致脚本崩溃;
- WebSocket 地址:ws:// 对应 HTTP,wss:// 对应 HTTPS,本地测试用 ws:// 即可。
编写通用消息发送函数
<script setup>
// (接上一步代码)
// 7. 通用发送 WebSocket 消息函数(复用,避免重复代码)
const sendMessage = (message) => {
// 校验:WebSocket 必须处于打开状态
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message)); // 转为 JSON 字符串发送
console.log('📤 发送消息:', message);
} else {
console.error('❌ WebSocket 未连接,无法发送消息');
alert('未连接服务器,请先点击'连接服务器'!');
}
};
</script>
- WebSocket.OPEN:状态码为 1,表示连接已打开;其他状态(0 = 连接中,2 = 关闭中,3 = 已关闭)都无法发送消息;
- 封装成通用函数:后续发送 call/answer/ice 消息时,直接调用即可,减少重复代码。
编写消息处理函数
<script setup>
// (接上一步代码)
// 8. 处理收到的信令消息
const handleMessage = async (message) => {
switch (message.type) {
case 'call':
// 收到呼叫请求:自动应答(实际项目可加'是否接听'弹窗)
await answerCall(message);
break;
case 'answer':
// 收到应答消息:设置远程 SDP
await setRemoteSDP(message.data);
break;
case 'ice':
// 收到 ICE 候选:添加到 PeerConnection
await addIceCandidate(message.data);
break;
default:
console.log('📌 未知消息类型:', message.type);
}
};
</script>
- 按消息类型分支处理:对应服务端的 register/call/answer/ice 四种类型;
- async/await:后续操作(如设置 SDP)是异步的,需等待完成,避免报错。
编写 WebRTC 核心函数
<script setup>
// (接上一步代码)
// 9. 初始化 PeerConnection(WebRTC 核心,复用函数)
const initPeerConnection = async () => {
// 如果已有 PeerConnection,先关闭(避免重复创建)
if (peerConnection) {
peerConnection.close();
}
// 创建 PeerConnection 实例(传入 STUN 服务器配置)
peerConnection = new RTCPeerConnection(iceServers);
// 9.1 监听 ICE 候选生成事件(本地网络地址)
peerConnection.onicecandidate = (e) => {
if (e.candidate) {
// 发送 ICE 候选给对方
sendMessage({ type: 'ice', from: userId.value, to: targetUserId.value, data: JSON.stringify(e.candidate) });
}
};
// 9.2 监听远程音视频流到达事件(关键:显示对方视频)
peerConnection.ontrack = (e) => {
// 将远程流绑定到远程视频 DOM
remoteVideo.value.srcObject = e.streams[0];
console.log('🎥 收到远程音视频流');
};
};
// 10. 发起视频呼叫函数(点击'发起视频呼叫'按钮触发)
const call = async () => {
// 校验:对方 ID 不能为空
if (!targetUserId..()) {
();
;
}
{
();
localStream = navigator..({
: ,
:
});
localVideo.. = localStream;
localStream.().( {
peerConnection.(track, localStream);
});
offer = peerConnection.();
peerConnection.(offer);
({ : , : userId., : targetUserId., : .(offer) });
.(, targetUserId.);
} (err) {
.(, err);
();
}
};
= () => {
targetUserId. = message.;
{
();
localStream = navigator..({
: ,
:
});
localVideo.. = localStream;
localStream.().( {
peerConnection.(track, localStream);
});
peerConnection.(.(message.));
answer = peerConnection.();
peerConnection.(answer);
({ : , : userId., : targetUserId., : .(answer) });
.(, targetUserId.);
} (err) {
.(, err);
();
}
};
= () => {
{
sdp = .(sdpStr);
peerConnection.( (sdp));
.();
} (err) {
.(, err);
}
};
= () => {
{
ice = .(iceStr);
peerConnection.( (ice));
.();
} (err) {
.(, err);
}
};
</script>
- getUserMedia:浏览器原生 API,请求音视频权限,返回本地流;如果用户拒绝权限,会抛出错误,所以用 try-catch 包裹;
- PeerConnection:WebRTC 的核心对象,负责协商连接、传输音视频流;
- ontrack 事件:对方的音视频流到达时触发,将流绑定到 remoteVideo 即可显示对方画面;
- onicecandidate 事件:本地生成网络地址(ICE 候选)时触发,发送给对方,双方才能建立 P2P 连接。
编写资源清理函数
<script setup>
// (接上一步代码)
// 14. 页面销毁时清理资源(避免内存泄漏/设备占用)
onUnmounted(() => {
// 关闭 WebSocket 连接
if (socket) {
socket.close();
console.log('🔌 关闭 WebSocket 连接');
}
// 关闭 PeerConnection
if (peerConnection) {
peerConnection.close();
console.log('🔌 关闭 PeerConnection');
}
// 停止本地音视频流(释放摄像头/麦克风)
if (localStream) {
localStream.getTracks().forEach(track => {
track.stop();
console.log('🔇 停止本地音视频流');
});
}
});
</script>
- 必须停止本地流:否则页面关闭后,摄像头/麦克风仍会被占用(浏览器标签栏会显示摄像头图标);
- onUnmounted:Vue3 的生命周期钩子,页面销毁时自动执行,确保资源全部清理。
步骤 5:补充'挂断'功能
在 video-container 上方添加:
<!-- 挂断按钮(仅连接成功且有远程流时显示) -->
<div v-if="socketConnected && remoteVideo.value?.srcObject">
<button @click="hangUp">挂断通话</button>
</div>
脚本添加挂断函数
<script setup>
// (添加在 initPeerConnection 之后)
// 15. 挂断通话函数
const hangUp = () => {
// 停止本地流
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localVideo.value.srcObject = null; // 清空本地视频
}
// 清空远程视频
if (remoteVideo.value) {
remoteVideo.value.srcObject = null;
}
// 关闭 PeerConnection
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
// 重置目标用户 ID
targetUserId.value = '';
console.log('📞 挂断通话');
alert('已挂断通话!');
};
</script>
总结:
在进行过上文的理解之后,我们对 WebSocket 的服务器主动给浏览器不断发送数据的场景进行总结,在网页游戏由服务器逻辑产生主动发送到客户端,客户端收到后展示对应的效果!
WebSocket 的出现,彻底解决了传统 HTTP 协议在实时通信场景中的不足——它通过一次握手建立持久连接,实现双向通信,降低了网络开销和延迟,让 Web 应用的实时体验更流畅。它不是 HTTP 的替代品,而是补充,两者各司其职,适配不同的开发场景。
WebRTC 的出现,彻底改变了浏览器端实时音视频通信的格局——它打破了插件的束缚,让开发者能够用简单的 API,快速实现高质量的实时通信功能,降低了实时音视频开发的门槛,也推动了在线教育、远程医疗、实时协作等行业的发展。


