一、为什么写这篇文章
websocket几乎是每一名前端和后端程序员都逃不过的一个节点,但没接触过的话,真的会完全一头雾水,根本不知道从哪里下手,尤其是来了相关开发任务时。
我一直坚信,看100篇文章,学100个教程,不如自己动手实操一遍。跑通整个流程,你自然就对其一知半解了,这之后再去研究源码是怎么实现的,就会事半功倍。
但要注意的是,本代码仅供初步了解websocket的前后端交互逻辑,以及代码实现,它仅仅是一个demo,请勿用于生产环境,生产环境请使用更为可靠更为成熟的Swoole/Workerman/Golang + Gorilla WebSocket 来实现。
二、实现效果
1、前端用户页面
2、PHP后台cli
三、开发环境部署
1、开发环境介绍
PHP:8.4.12(docker容器:haveyb/php)
Nginx:1.27(docker容器:openresty/openresty)
另外提一嘴,现在阿里源清华源163源等国内docker镜像源都用不了了,但还是有一些能用的,可以参考 docker更换国内加速镜像源2025
2、拉取PHP镜像
docker pull haveyb/php
3、创建PHP容器
docker run -itd --name php --privileged -p 9000:9000 -p 9501:9501 -v ~/Desktop/code/docker:/data haveyb/php
4、拉取openresty镜像
openresty是一个比较成熟的nginx+lua结合体的项目
docker pull openresty/openresty
5、创建nginx容器
docker run -itd --name openresty --privileged -p 80:80 -p 443:443 -v ~/Desktop/code/docker:/data openresty/openresty
6、宿主机创建docker网络,用于容器通信
docker network create --driver bridge haveyb
7、PHP容器加入该网络
docker network connect haveyb php
8、Nginx容器加入网络
docker network connect haveyb openresty
四、代码部分
1、前端代码 index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket聊天</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3B82F6',
secondary: '#10B981',
system: '#6B7280',
background: '#F3F4F6',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.content-auto {
content-visibility: auto;
}
.message-container {
@apply w-full h-[70vh] overflow-y-auto p-4 bg-background rounded-t-lg;
}
.message {
@apply max-w-[80%] my-2 px-4 py-2 rounded-lg;
}
.message-sent {
@apply bg-secondary text-white ml-auto;
}
.message-received {
@apply bg-white text-gray-800 mr-auto border border-gray-200;
}
.message-system {
@apply bg-system text-white mx-auto text-sm;
}
.input-area {
@apply w-full p-4 bg-white rounded-b-lg border-t border-gray-200 flex gap-2;
}
.chat-container {
@apply max-w-2xl mx-auto mt-8 border border-gray-300 rounded-lg shadow-lg overflow-hidden;
}
}
</style>
</head>
<body class="bg-gray-100">
<div class="chat-container">
<div class="bg-primary text-white p-4">
<h1 class="text-xl font-bold flex items-center">
<i class="fa fa-comments-o mr-2"></i>WebSocket实时聊天
</h1>
<div class="text-sm mt-1 flex items-center" id="connectionStatus">
<span class="inline-block w-2 h-2 bg-red-400 rounded-full mr-2" id="statusIndicator"></span>
<span>未连接</span>
</div>
</div>
<!-- 消息容器 -->
<div id="messages" class="message-container"></div>
<!-- 输入区域 -->
<div class="input-area">
<input
type="text"
id="messageInput"
placeholder="输入消息后按回车发送..."
class="flex-1 px-4 py-2 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-primary"
>
<button
onclick="sendMessage()"
class="bg-primary hover:bg-primary/90 text-white px-6 py-2 rounded-full transition-all"
>
<i class="fa fa-paper-plane"></i>
</button>
</div>
</div>
<script>
// 全局变量
let websocket = null;
let messagesContainer = null;
let messageInput = null;
let statusIndicator = null;
// 页面加载完成后初始化
window.onload = function() {
// 初始化DOM元素引用
messagesContainer = document.getElementById('messages');
messageInput = document.getElementById('messageInput');
statusIndicator = document.getElementById('statusIndicator');
// 验证必要元素
if (!messagesContainer) {
console.error('错误:未找到消息容器元素');
alert('页面加载出错,请刷新重试');
return;
}
// 绑定回车键发送
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
// 初始化WebSocket连接
initWebSocket();
};
// 初始化WebSocket连接 - 重点修改这里
function initWebSocket() {
// ********** 根据你的实际配置修改这里 **********
// 替换为你的Nginx服务器域名或IP,以及对应的wss路径
// 例如:你的Nginx配置了server_name test,则使用下面的地址
const wsUri = 'wss://test/wss';
console.log('尝试连接到WebSocket服务器:', wsUri);
websocket = new WebSocket(wsUri);
// 连接成功回调
websocket.onopen = function() {
console.log('WebSocket连接已打开');
updateConnectionStatus(true);
addMessage('system', '已成功连接到聊天服务器');
};
// 接收消息回调
websocket.onmessage = function(evt) {
console.log('收到消息:', evt.data);
addMessage('received', evt.data);
};
// 连接关闭回调
websocket.onclose = function() {
console.log('WebSocket连接已关闭');
updateConnectionStatus(false);
addMessage('system', '与服务器的连接已断开,3秒后尝试重连...');
setTimeout(initWebSocket, 3000);
};
// 错误回调
websocket.onerror = function(error) {
console.error('WebSocket错误:', error);
addMessage('system', '连接发生错误,请检查网络');
};
}
// 发送消息
function sendMessage() {
if (!messageInput) return;
const message = messageInput.value.trim();
if (!message) return;
if (!websocket || websocket.readyState !== WebSocket.OPEN) {
addMessage('system', '连接未就绪,请稍后再试');
return;
}
try {
websocket.send(message);
addMessage('sent', message);
messageInput.value = '';
} catch (e) {
console.error('发送消息失败:', e);
addMessage('system', '消息发送失败');
}
}
// 添加消息到界面
function addMessage(type, content) {
if (!messagesContainer) {
console.error('消息容器不存在,无法添加消息');
return;
}
const messageDiv = document.createElement('div');
messageDiv.className = `message message-${type}`;
messageDiv.textContent = content;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 更新连接状态显示
function updateConnectionStatus(isConnected) {
if (!statusIndicator) return;
if (isConnected) {
statusIndicator.className = 'inline-block w-2 h-2 bg-green-400 rounded-full mr-2';
statusIndicator.nextElementSibling.textContent = '已连接';
} else {
statusIndicator.className = 'inline-block w-2 h-2 bg-red-400 rounded-full mr-2';
statusIndicator.nextElementSibling.textContent = '未连接';
}
}
</script>
</body>
</html>
2、PHP代码 websocket_server.php
<?php
class WebSocketServer {
private $host;
private $port;
private $serverSocket;
private $clients = []; // 存储客户端socket(键为对象ID,值为Socket对象)
private $clientCount = 0;
public function __construct($host = '0.0.0.0', $port = 9501) {
$this->host = $host;
$this->port = $port;
$this->initServer();
}
// 初始化服务器socket
private function initServer() {
// 创建服务器socket(PHP 8.1+ 返回Socket对象)
$this->serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if (!$this->serverSocket) {
die("Socket创建失败: " . socket_strerror(socket_last_error()));
}
// 设置socket选项
socket_set_option($this->serverSocket, SOL_SOCKET, SO_REUSEADDR, 1);
socket_set_nonblock($this->serverSocket);
// 绑定并监听
if (!socket_bind($this->serverSocket, $this->host, $this->port)) {
die("Socket绑定失败: " . socket_strerror(socket_last_error()));
}
if (!socket_listen($this->serverSocket, 10)) {
die("Socket监听失败: " . socket_strerror(socket_last_error()));
}
echo "WebSocket服务器已启动,监听 {$this->host}:{$this->port}\n";
echo "等待客户端连接...\n";
}
// 主运行循环
public function run() {
while (true) {
$readSockets = $this->getAllSockets();
$writeSockets = null;
$exceptSockets = null;
// 监听可读socket
$numChanged = socket_select($readSockets, $writeSockets, $exceptSockets, 1);
if ($numChanged === false) {
echo "socket_select错误: " . socket_strerror(socket_last_error()) . "\n";
continue;
}
if ($numChanged === 0) {
continue;
}
// 处理可读socket
foreach ($readSockets as $socket) {
if ($socket === $this->serverSocket) {
$this->handleNewConnection(); // 新客户端连接
} else {
$this->handleClientMessage($socket); // 已有客户端消息
}
}
}
}
// 获取所有需要监听的socket
private function getAllSockets() {
$sockets = [$this->serverSocket];
// 只保留有效的客户端socket
foreach ($this->clients as $socket) {
if ($socket instanceof Socket) { // 检查是否为Socket对象
$sockets[] = $socket;
}
}
return $sockets;
}
// 处理新客户端连接
private function handleNewConnection() {
// 接受新连接(PHP 8.1+ 返回Socket对象)
$newClient = @socket_accept($this->serverSocket);
if (!$newClient || !($newClient instanceof Socket)) {
echo "新客户端连接无效(非Socket对象)\n";
return;
}
// 关键修复:用spl_object_id()获取对象唯一ID(替代(int)转换)
$clientId = spl_object_id($newClient);
echo "新客户端尝试连接,Socket对象ID: {$clientId}\n";
// 执行握手
if ($this->performHandshake($newClient)) {
// 用对象ID作为键存储客户端
$this->clients[$clientId] = $newClient;
$this->clientCount = count($this->clients);
echo "✅ 新客户端连接成功,当前在线: {$this->clientCount}\n";
// 广播新用户加入
$this->broadcast("系统通知: 新用户加入,当前在线{$this->clientCount}人", $newClient);
} else {
// 握手失败,关闭连接
socket_close($newClient);
echo "❌ 客户端握手失败,已关闭连接\n";
}
}
// WebSocket握手
private function performHandshake($client) {
// 读取握手请求
$request = '';
while (true) {
$buffer = @socket_read($client, 1024);
if ($buffer === false || $buffer === '') break;
$request .= $buffer;
if (strpos($request, "\r\n\r\n") !== false) break;
}
// 验证握手请求头
if (!preg_match('#Sec-WebSocket-Key: (.*?)\r\n#i', $request, $keyMatch) ||
!preg_match('#Sec-WebSocket-Version: (.*?)\r\n#i', $request, $versionMatch)) {
error_log("握手失败:缺失Sec-WebSocket-Key或Version");
return false;
}
$secKey = trim($keyMatch[1]);
$secVersion = trim($versionMatch[1]);
// 仅支持版本13
if ($secVersion != 13) {
error_log("握手失败:不支持的版本,客户端版本: {$secVersion}");
return false;
}
// 生成响应密钥
$secAccept = base64_encode(sha1($secKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
// 构建响应
$response = "HTTP/1.1 101 Switching Protocols\r\n";
$response .= "Upgrade: websocket\r\n";
$response .= "Connection: Upgrade\r\n";
$response .= "Sec-WebSocket-Accept: {$secAccept}\r\n\r\n";
// 发送响应
if (!socket_write($client, $response, strlen($response))) {
error_log("握手响应发送失败");
return false;
}
return true;
}
// 处理客户端消息
private function handleClientMessage($client) {
// 读取消息帧
$buffer = @socket_read($client, 4096);
if ($buffer === false || $buffer === '') {
// 连接断开
$this->removeClient($client);
return;
}
// 解析消息
$message = $this->decodeWebSocketFrame($buffer, $client);
if ($message === false) {
return;
}
$clientId = spl_object_id($client);
echo PHP_EOL;
echo "📥 收到客户端{$clientId}消息: {$message}\n";
// 广播消息(排除发送者)
$this->broadcast($message, $client);
}
// 解析WebSocket帧
private function decodeWebSocketFrame($frame, $client) {
$firstByte = ord($frame[0]);
$secondByte = ord($frame[1]);
// 检查是否为最后一帧
$isFinal = ($firstByte & 0x80) != 0;
if (!$isFinal) {
error_log("不支持分片消息");
return false;
}
// 处理关闭帧
$opcode = $firstByte & 0x0F;
if ($opcode == 8) {
echo "🔌 收到客户端关闭请求\n";
$this->sendCloseFrame($client);
$this->removeClient($client);
return false;
}
// 只支持文本消息
if ($opcode != 1) {
error_log("不支持的消息类型,Opcode: {$opcode}");
return false;
}
// 客户端消息必须带掩码
$hasMask = ($secondByte & 0x80) != 0;
if (!$hasMask) {
error_log("客户端消息必须带掩码");
return false;
}
// 解析 payload 长度(简化处理<=125字节)
$payloadLen = $secondByte & 0x7F;
if ($payloadLen > 125) {
error_log("不支持超过125字节的消息");
return false;
}
// 解掩码
$maskOffset = 2;
$dataOffset = $maskOffset + 4;
$mask = substr($frame, $maskOffset, 4);
$payload = substr($frame, $dataOffset, $payloadLen);
$message = '';
for ($i = 0; $i < $payloadLen; $i++) {
$message .= chr(ord($payload[$i]) ^ ord($mask[$i % 4]));
}
return $message;
}
// 发送关闭帧
private function sendCloseFrame($client) {
if ($client instanceof Socket) {
$closeFrame = chr(0x88) . chr(0x00); // 标准关闭帧
socket_write($client, $closeFrame, strlen($closeFrame));
}
}
// 广播消息
private function broadcast($message, $excludeClient = null) {
echo "\n=== 开始广播 ===";
echo "\n广播内容: {$message}";
echo "\n在线客户端数: {$this->clientCount}";
// 编码消息
$encodedMsg = $this->encodeWebSocketFrame($message);
if (!$encodedMsg) {
echo "\n广播失败:消息编码无效";
echo "\n=== 广播结束 ===\n";
return;
}
// 遍历所有客户端
foreach ($this->clients as $client) {
// 跳过发送者和无效客户端
if ($client === $excludeClient || !($client instanceof Socket)) {
continue;
}
// 发送消息
$sendLen = @socket_write($client, $encodedMsg, strlen($encodedMsg));
if ($sendLen === false) {
$clientId = spl_object_id($client);
echo "\n发送失败: 客户端ID {$clientId},错误: " . socket_strerror(socket_last_error($client));
$this->removeClient($client);
} else {
$clientId = spl_object_id($client);
echo "\n发送成功: 客户端ID {$clientId},发送字节: {$sendLen}";
}
}
echo "\n=== 广播结束 ===\n";
}
// 编码WebSocket帧
private function encodeWebSocketFrame($message) {
$len = strlen($message);
$frame = '';
// 第一字节:FIN=1,Opcode=1(文本)
$frame .= chr(0x81);
// 第二字节:Mask=0,长度
if ($len <= 125) {
$frame .= chr($len);
} elseif ($len <= 65535) {
$frame .= chr(126) . pack('n', $len);
} else {
$frame .= chr(127) . pack('J', $len);
}
// 消息内容
$frame .= $message;
return $frame;
}
// 移除客户端
private function removeClient($client) {
if (!($client instanceof Socket)) {
echo "\n忽略非Socket对象的移除请求";
return;
}
$clientId = spl_object_id($client);
echo "\n🔌 准备移除客户端,ID: {$clientId}";
// 关闭socket
socket_shutdown($client, 2);
socket_close($client);
echo "\n已关闭客户端socket";
// 从列表中删除
if (isset($this->clients[$clientId])) {
unset($this->clients[$clientId]);
$this->clientCount = count($this->clients);
echo "\n客户端已从列表移除,当前在线: {$this->clientCount}";
// 广播用户离开
$this->broadcast("系统通知: 用户离开,当前在线{$this->clientCount}人");
} else {
echo "\n客户端不在列表中";
}
echo "\n================\n";
}
// 析构函数:清理资源
public function __destruct() {
// 关闭所有客户端
foreach ($this->clients as $client) {
if ($client instanceof Socket) {
socket_close($client);
}
}
// 关闭服务器socket
if ($this->serverSocket instanceof Socket) {
socket_close($this->serverSocket);
}
}
}
// 启动服务器
$server = new WebSocketServer('0.0.0.0', 9501);
$server->run();
3、Nginx配置文件
(1)宿主机创建test.conf 配置文件
cd ~/Desktop/code/docker/test
touch test.conf
server {
listen 80;
server_name test;
# HTTP 重定向到 HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name test;
# SSL 证书配置
ssl_certificate /usr/local/openresty/nginx/cert/test+3.pem;
ssl_certificate_key /usr/local/openresty/nginx/cert/test+3-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 基础参数
charset utf-8;
root /data/test;
index index.html index.php;
error_log /usr/local/openresty/nginx/logs/error-test.log error;
# 重定向控制
server_name_in_redirect off;
absolute_redirect off;
# WebSocket配置(核心部分)
location /wss {
proxy_pass http://php:9501; # 用容器名,比IP更稳定
# 强制转发所有请求头(包括WebSocket握手必需的头)
proxy_pass_request_headers on;
# WebSocket协议升级必需
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Sec-WebSocket-Key $http_sec_websocket_key; # 关键:转发Sec-WebSocket-Key
proxy_set_header Sec-WebSocket-Version $http_sec_websocket_version; # 转发版本号
# 3. 禁用缓存(避免连接被缓存中断)
proxy_cache off;
proxy_buffering off;
proxy_set_header Cache-Control "no-cache";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# 主location规则
location / {
try_files $uri $uri/ @phpfallback;
}
# PHP处理(安全增强版)
location ~ \.php$ {
fastcgi_pass php:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
# 安全限制
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_intercept_errors on;
try_files $uri =404; # 防止任意文件执行
}
location @phpfallback {
rewrite ^ /index.php?$query_string last;
}
# 静态资源优化
location ~* \.(js|css|png|ico)$ {
expires 30d;
access_log off;
add_header Cache-Control "public";
}
# 安全防护
location ~ /\.(?!well-known) {
deny all;
}
location = /favicon.ico {
log_not_found off;
access_log off;
}
}
(2)加载配置文件
进入nginx容器
docker exec -it openresty bash
cp /data/test/test.conf /etc/nginx/conf.d/
nginx -t
nginx -s reload
注:/usr/local/openresty/nginx/conf/nginx.conf文件里定义了include /etc/nginx/conf.d/*.conf;
,你也可以修改nginx.conf添加其他配置文件目录。
(3)注意事项
还有需要注意的的是,可以看到,我在配置文件里添加了https的解析,这是我的个人偏好,如果你嫌麻烦,也可以将https的配置去掉,只对80端口进行监听。
如果你也想在本地配置https,可以参考文章 Macbook 本地生成 SSL 证书,根治谷歌浏览器搜索栏出现红色不安全感叹号
但要注意的是,有些浏览器是不支持本地https的,比如谷歌和edge浏览器,因此如果你配置了本地https,那么你可以用QQ浏览器或safari浏览器访问测试站点。
五、使用
1、PHP所在容器或所在环境运行脚本,进而开启websocket监听处理
php websocket_server.php
2、本地配置hosts
127.0.0.1 test
3、浏览器访问
https://test
4、代码实现了什么
前后端代码结合实现了一个基于WebSocket的实时聊天系统,前端通过HTML/JavaScript构建聊天界面并与后端通信,后端通过PHP实现WebSocket服务器处理多客户端连接、消息转发等核心逻辑。在实际业务中,这类系统可用于即时通讯(如客服聊天、团队协作工具)、实时通知(如订单状态更新、消息推送)等场景。
实时消息交互:支持多用户同时在线聊天,消息发送后无需刷新页面即可实时接收。
连接状态管理:前端实时展示连接状态(已连接/未连接),断开后自动重试,提升用户体验。
系统通知同步:新用户加入、用户离开等系统事件会广播给所有在线客户端,保持全局状态一致。
多客户端支持:后端通过非阻塞Socket监听多个客户端连接,实现消息“广播”(将某用户消息转发给其他所有用户)。
六、前后端交互逻辑
前后端交互基于WebSocket协议,分为“连接建立(握手)- 消息收发 - 连接关闭”三个核心阶段,具体流程如下:
1、WebSocket连接建立(握手阶段)
WebSocket是基于TCP的应用层协议,建立连接前需先完成“HTTP握手”,确保客户端与服务器协议一致。
前端初始化连接
- 页面加载完成后,前端通过
initWebSocket()
创建WebSocket实例,请求地址为wss://test/wss
(通过Nginx反向代理指向后端PHP服务器的9501端口)。 - 示例:
websocket = new WebSocket(wsUri)
,触发后端的“新连接监听”。
后端处理握手
- 后端PHP服务器通过
socket_listen(9501端口)
监听新连接,当收到前端请求时,通过handleNewConnection()
接受连接。 - 后端读取前端发送的HTTP握手请求头(含
Sec-WebSocket-Key
、Sec-WebSocket-Version
等关键字段),验证版本(仅支持13版)并生成Sec-WebSocket-Accept
响应密钥。 - 后端返回HTTP 101响应(状态码“Switching Protocols”),告知客户端切换到WebSocket协议,握手成功。
连接成功回调
- 前端
websocket.onopen
触发,更新界面状态为“已连接”,并添加“连接成功”的系统消息。 - 后端将新客户端的Socket对象存入
$clients
数组,广播“新用户加入”通知给其他在线客户端。
2、实时消息收发(核心交互阶段)
连接建立后,前后端可双向发送二进制/文本消息,这里仅支持文本消息
前端发送逻辑:
- 用户输入消息后,点击“发送”按钮或按回车触发
sendMessage()
。 - 校验逻辑:若消息为空或连接未就绪(如
websocket.readyState !== WebSocket.OPEN
),则提示错误;否则通过websocket.send(message)
将消息发送给后端。 - 前端同时将发送的消息添加到界面(标为“已发送”样式
message-sent
)。
后端接收与广播:
- 后端通过
socket_select()
监听客户端Socket的“可读事件”,当收到消息时触发handleClientMessage()
。 - 后端通过
decodeWebSocketFrame()
解析WebSocket消息帧(需解掩码,因为客户端消息必须带掩码),提取文本内容。 - 后端调用
broadcast()
将消息编码为WebSocket帧(无需掩码,服务器消息不强制掩码),并转发给$clients
数组中除发送者外的所有客户端。
前端接收逻辑:
- 当后端广播消息时,前端
websocket.onmessage
触发,接收evt.data
(后端转发的文本消息)。 - 前端通过
addMessage('received', evt.data)
将消息添加到界面(标为“已接收”样式message-received
),并自动滚动到最新消息。
连接主动关闭:
- 客户端关闭页面时,浏览器自动发送WebSocket关闭帧(Opcode=8),后端
decodeWebSocketFrame()
检测到后,通过removeClient()
关闭Socket、从$clients
中删除该客户端,并广播“用户离开”通知。
连接异常断开:
- 若网络中断或服务器异常,前端
websocket.onclose
触发,更新界面状态为“未连接”,并通过setTimeout(initWebSocket, 3000)
每3秒自动重试连接。 - 后端通过
socket_read()
返回空值检测到客户端断开,同样执行removeClient()
清理资源。