0%

长连接

长连接指在一个网络连接上可连续发送多个数据包的通信方式,连接保持期间若无数据传输,需通过链路检测包维持状态。与短连接(每次通信后断开)相比,长连接避免了频繁建立连接的开销,适用于点对点高频操作场景(如数据库通信),而短连接适用于高并发低频率场景(如HTTP服务)。

一、基础

  1. 定义:长连接指在一个网络连接上可连续发送多个数据包的通信方式,连接保持期间若无数据传输,需通过链路检测包维持状态。与短连接(每次通信后断开)相比,长连接避免了频繁建立连接的开销,适用于点对点高频操作场景(如数据库通信),而短连接适用于高并发低频率场景(如HTTP服务)。

二、使用说明

  1. 环境说明:
  • 系统:Windows10
  • Node:v20.19.0
  • Npm:11.3.0
  1. 代码说明
版本 功能说明
版本1 基础的长连接
版本2 增加断线重连和用户标识
版本3 断线重连后自动登录,无需再次点击【登录进入游戏】;增加心跳包机制(防止网关/nginx踢掉空闲长连接);增加用户列表面板,实时展示在线用户;限制用户名不能重复
版本4 修复用户名不能重复限制不生效问题
版本5 分数持久化(存入文件,服务重启分数不丢失);增加公共聊天框;重连策略改为指数退避(2s、4s、8s,防止大量客户端同时重连压垮服务);增加前端防频繁点击限流;增加踢下线功能
版本6 聊天敏感词过滤;聊天频率限流;在线用户增加颜色标识;代码模块化拆分(消息处理、用户管理分离);支持房间功能,多房间隔离聊天与分数
版本7 前端展示房间列表;房间密码;消息撤回;聊天记录持久化;管理员前端面板(web操作踢人,无需控制台);断线重连保留聊天历史
版本8 聊天支持上传图片;聊天支持emoji表情;房间销毁、自动清理长期无人房间;用户在线状态(在线/离开);前端房间分页、房间搜索;独立管理员后台页面
版本9 图片上传增加大小限制、前端压缩图片,限制过大base64;房间公告;私聊消息;黑名单功能;房间权限体系(房主、子管理员);消息@用户与消息提醒

三、代码版本

  1. 版本1:基础的长连接
  • index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>长连接小游戏</title>
<style>
*{box-sizing:border-box;font-family:system-ui;}
body{text-align:center;margin-top:80px;background:#f5f7fa;}
#score{font-size:48px;font-weight:bold;color:#222;margin:20px 0;}
#btn{padding:16px 40px;font-size:22px;background:#2563eb;color:#fff;border:none;border-radius:12px;cursor:pointer;}
#btn1{padding:16px 40px;font-size:22px;background: #eb5325;color:#fff;border:none;border-radius:12px;cursor:pointer;}
#btn:active{background:#1d4ed8;}
#status{margin-top:16px;color:#666;}
</style>
</head>
<body>
<h2>多人点击计分游戏(WebSocket长连接演示)</h2>
<div id="score">0</div>
<button id="btn">点击加分</button>
<button id="btn1">其他点击</button>
<div id="status">连接状态:正在连接...</div>

<script>
// 建立WebSocket长连接,一次握手,持续通信
const ws = new WebSocket('ws://127.0.0.1:8080');

const scoreDom = document.getElementById('score');
const statusDom = document.getElementById('status');
const btn = document.getElementById('btn');
const btn1 = document.getElementById('btn1');

// 连接成功回调
ws.onopen = () => {
statusDom.innerText = "连接状态:✅ 长连接已建立";
};

// 监听【服务端主动推送】过来的消息(长连接核心:服务端可以主动发消息给浏览器)
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if(data.type === 'init' || data.type === 'update'){
scoreDom.innerText = data.score;
}

if(data.type === 'cheat'){
window.alert(data.msg)
}
};

// 连接关闭
ws.onclose = () => {
statusDom.innerText = "连接状态:❌ 长连接断开";
};

ws.onerror = () => {
statusDom.innerText = "连接状态:⚠️ 连接出错";
};

// 按钮点击,向服务端发送消息
btn.onclick = () => {
if(ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({type:'click'}));
}
}

btn1.onclick = () => {
if(ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({type:'click1'}));
}
}
</script>
</body>
</html>
  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const WebSocket = require('ws');

// 创建ws服务,监听端口 8080
const wss = new WebSocket.Server({ port: 8080 });

// 全局分数
let score = 0;

console.log("WebSocket长连接服务启动,端口:8080");

// 有新客户端连接进来时触发
wss.on('connection', (ws) => {
console.log("✅ 一个玩家接入长连接");

// 新玩家上线,立刻把当前分数发给它
ws.send(JSON.stringify({
type: 'init',
score: score
}));

// 监听客户端发来的消息
ws.on('message', (data) => {
const msg = JSON.parse(data);
if(msg.type === 'click'){
score++;
console.log(`有人点击,当前分数:${score}`);
// 广播:发给【所有在线客户端】(长连接的核心!服务端主动推消息)
wss.clients.forEach(client => {
if(client.readyState === WebSocket.OPEN){
client.send(JSON.stringify({
type: 'update',
score: score
}));
}
})
}
if(msg.type === 'click1'){
console.log(`有人作弊`);
wss.clients.forEach(client => {
if(client.readyState === WebSocket.OPEN){
client.send(JSON.stringify({
type: 'cheat',
msg: '有人作弊'
}));
}
})
}
});

// 客户端断开连接
ws.on('close', () => {
console.log("❌ 玩家断开长连接");
})

ws.on('error', err => {
console.error("连接错误", err);
})
});

  1. 版本2:增加断线重连、用户标识
  • index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>长连接小游戏(带重连+用户)</title>
<style>
*{box-sizing:border-box;font-family:system-ui;}
body{text-align:center;margin-top:40px;background:#f5f7fa;}
#score{font-size:48px;font-weight:bold;color:#222;margin:20px 0;}
#btn{padding:16px 40px;font-size:22px;background:#2563eb;color:#fff;border:none;border-radius:12px;cursor:pointer;}
#btn:active{background:#1d4ed8;}
#status{margin-top:16px;color:#666;font-size:16px;}
#noticeBox{width:500px;margin:20px auto;height:200px;border:1px solid #ccc;padding:10px;text-align:left;overflow-y:auto;background:#fff;border-radius:8px;}
.login-area{margin-bottom:20px;}
#usernameInput{padding:8px;font-size:16px;width:220px;}
#loginBtn{padding:8px 16px;font-size:16px;cursor:pointer;}
</style>
</head>
<body>
<h2>多人点击计分游戏(WebSocket长连接|断线重连+用户标识)</h2>

<div class="login-area">
<input id="usernameInput" placeholder="输入你的用户名" />
<button id="loginBtn">登录进入游戏</button>
</div>

<div id="score">0</div>
<button id="btn" disabled>点击加分</button>
<div id="status">连接状态:正在连接...</div>

<div id="noticeBox">消息日志:<br></div>

<script>
// ========== 配置 断线重连 ==========
let ws = null;
let reconnectTimer = null;
const RECONNECT_INTERVAL = 2000; // 2秒重试一次
let username = "";
let isLogin = false;

const scoreDom = document.getElementById('score');
const statusDom = document.getElementById('status');
const btn = document.getElementById('btn');
const noticeBox = document.getElementById('noticeBox');
const usernameInput = document.getElementById('usernameInput');
const loginBtn = document.getElementById('loginBtn');

// 追加日志
function addLog(text){
const div = document.createElement('div');
div.innerText = text;
noticeBox.appendChild(div);
noticeBox.scrollTop = noticeBox.scrollHeight;
}

// 创建WebSocket连接函数(可反复调用用于重连)
function createWS(){
ws = new WebSocket('ws://127.0.0.1:8080');

ws.onopen = () => {
statusDom.innerText = "连接状态:✅ 长连接已建立";
addLog("✅ WebSocket连接成功");
// 如果之前已经登录过,重连成功自动重新登录
if(isLogin && username){
ws.send(JSON.stringify({
type:'login',
username: username
}));
}
clearTimeout(reconnectTimer);
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type){
case 'init':
scoreDom.innerText = data.score;
addLog(data.msg);
break;
case 'update':
scoreDom.innerText = data.score;
addLog(`🔔【${data.user}】点击加分,当前分数:${data.score}`);
break;
case 'notice':
addLog(data.content);
break;
}
};

ws.onclose = () => {
statusDom.innerText = "连接状态:❌ 长连接断开,准备重连...";
addLog("❌ 连接断开,2s后尝试重连");
btn.disabled = true;
// 启动重连定时器
reconnectTimer = setTimeout(()=>{
createWS();
}, RECONNECT_INTERVAL);
};

ws.onerror = () => {
statusDom.innerText = "连接状态:⚠️ 连接出错";
};
}

// 登录按钮
loginBtn.onclick = function(){
const name = usernameInput.value.trim();
if(!name){
alert("请输入用户名");
return;
}
username = name;
isLogin = true;
// ws必须处于OPEN状态才能发消息
if(ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({
type:'login',
username: username
}));
btn.disabled = false;
}else{
addLog("⚠️ 尚未连上服务器,请等待连接成功");
}
};

// 加分按钮
btn.onclick = () => {
if(ws.readyState === WebSocket.OPEN && isLogin){
ws.send(JSON.stringify({type:'click'}));
}
};

// 初始化,第一次建立连接
createWS();
</script>
</body>
</html>
  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

let score = 0;
// 保存所有在线用户 {ws连接对象: username}
const userMap = new Map();

console.log("WebSocket长连接服务启动,端口:8080");

wss.on('connection', (ws) => {
let username = null;
console.log("✅ 新客户端通道建立,等待用户登录");

// 收到客户端消息
ws.on('message', (rawData) => {
try {
const msg = JSON.parse(rawData);
switch (msg.type) {
// 用户登录:提交用户名
case 'login':
username = msg.username;
userMap.set(ws, username);
console.log(`用户【${username}】上线`);
// 给当前新用户初始化分数
ws.send(JSON.stringify({
type: 'init',
score: score,
msg: `欢迎你,${username}`
}));
// 广播全员:xxx用户上线
broadcast({
type: 'notice',
content: `🎉 用户【${username}】加入游戏!`
});
break;

// 用户点击加分
case 'click':
if (!username) return;
score++;
console.log(`【${username}】点击加分,当前分数:${score}`);
broadcast({
type: 'update',
score: score,
user: username
});
break;
}
} catch (e) {
console.error("消息解析异常", e);
}
});

// 客户端断开
ws.on('close', () => {
if (username) {
userMap.delete(ws);
console.log(`用户【${username}】下线`);
broadcast({
type: 'notice',
content: `👋 用户【${username}】离开游戏!`
});
}
});

ws.on('error', err => {
console.error("连接错误", err);
});
});

/**
* 广播消息:发给所有在线客户端
*/
function broadcast(data) {
const json = JSON.stringify(data);
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(json);
}
});
}
  1. 版本3:输入用户名
  • index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>长连接小游戏(带重连+用户)</title>
<style>
*{box-sizing:border-box;font-family:system-ui;}
body{text-align:center;margin-top:40px;background:#f5f7fa;}
#score{font-size:48px;font-weight:bold;color:#222;margin:20px 0;}
#btn{padding:16px 40px;font-size:22px;background:#2563eb;color:#fff;border:none;border-radius:12px;cursor:pointer;}
#btn:active{background:#1d4ed8;}
#status{margin-top:16px;color:#666;font-size:16px;}
#noticeBox{width:500px;margin:20px auto;height:200px;border:1px solid #ccc;padding:10px;text-align:left;overflow-y:auto;background:#fff;border-radius:8px;}
.login-area{margin-bottom:20px;}
#usernameInput{padding:8px;font-size:16px;width:220px;}
#loginBtn{padding:8px 16px;font-size:16px;cursor:pointer;}
</style>
</head>
<body>
<h2>多人点击计分游戏(WebSocket长连接|断线重连+用户标识)</h2>

<div class="login-area">
<input id="usernameInput" placeholder="输入你的用户名" />
<button id="loginBtn">登录进入游戏</button>
</div>

<div id="score">0</div>
<button id="btn" disabled>点击加分</button>
<div id="status">连接状态:正在连接...</div>

<div id="noticeBox">消息日志:<br></div>

<script>
// ========== 配置 断线重连 ==========
let ws = null;
let reconnectTimer = null;
const RECONNECT_INTERVAL = 2000; // 2秒重试一次
let username = "";
let isLogin = false;

const scoreDom = document.getElementById('score');
const statusDom = document.getElementById('status');
const btn = document.getElementById('btn');
const noticeBox = document.getElementById('noticeBox');
const usernameInput = document.getElementById('usernameInput');
const loginBtn = document.getElementById('loginBtn');

// 追加日志
function addLog(text){
const div = document.createElement('div');
div.innerText = text;
noticeBox.appendChild(div);
noticeBox.scrollTop = noticeBox.scrollHeight;
}

// 创建WebSocket连接函数(可反复调用用于重连)
function createWS(){
ws = new WebSocket('ws://127.0.0.1:8080');

ws.onopen = () => {
statusDom.innerText = "连接状态:✅ 长连接已建立";
addLog("✅ WebSocket连接成功");
// 如果之前已经登录过,重连成功自动重新登录
if(isLogin && username){
ws.send(JSON.stringify({
type:'login',
username: username
}));
}
clearTimeout(reconnectTimer);
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type){
case 'init':
scoreDom.innerText = data.score;
addLog(data.msg);
break;
case 'update':
scoreDom.innerText = data.score;
addLog(`🔔【${data.user}】点击加分,当前分数:${data.score}`);
break;
case 'notice':
addLog(data.content);
break;
}
};

ws.onclose = () => {
statusDom.innerText = "连接状态:❌ 长连接断开,准备重连...";
addLog("❌ 连接断开,2s后尝试重连");
btn.disabled = true;
// 启动重连定时器
reconnectTimer = setTimeout(()=>{
createWS();
}, RECONNECT_INTERVAL);
};

ws.onerror = () => {
statusDom.innerText = "连接状态:⚠️ 连接出错";
};
}

// 登录按钮
loginBtn.onclick = function(){
const name = usernameInput.value.trim();
if(!name){
alert("请输入用户名");
return;
}
username = name;
isLogin = true;
// ws必须处于OPEN状态才能发消息
if(ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({
type:'login',
username: username
}));
btn.disabled = false;
}else{
addLog("⚠️ 尚未连上服务器,请等待连接成功");
}
};

// 加分按钮
btn.onclick = () => {
if(ws.readyState === WebSocket.OPEN && isLogin){
ws.send(JSON.stringify({type:'click'}));
}
};

// 初始化,第一次建立连接
createWS();
</script>
</body>
</html>
  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

let score = 0;
// 保存所有在线用户 {ws连接对象: username}
const userMap = new Map();

console.log("WebSocket长连接服务启动,端口:8080");

wss.on('connection', (ws) => {
let username = null;
console.log("✅ 新客户端通道建立,等待用户登录");

// 收到客户端消息
ws.on('message', (rawData) => {
try {
const msg = JSON.parse(rawData);
switch (msg.type) {
// 用户登录:提交用户名
case 'login':
username = msg.username;
userMap.set(ws, username);
console.log(`用户【${username}】上线`);
// 给当前新用户初始化分数
ws.send(JSON.stringify({
type: 'init',
score: score,
msg: `欢迎你,${username}`
}));
// 广播全员:xxx用户上线
broadcast({
type: 'notice',
content: `🎉 用户【${username}】加入游戏!`
});
break;

// 用户点击加分
case 'click':
if (!username) return;
score++;
console.log(`【${username}】点击加分,当前分数:${score}`);
broadcast({
type: 'update',
score: score,
user: username
});
break;
}
} catch (e) {
console.error("消息解析异常", e);
}
});

// 客户端断开
ws.on('close', () => {
if (username) {
userMap.delete(ws);
console.log(`用户【${username}】下线`);
broadcast({
type: 'notice',
content: `👋 用户【${username}】离开游戏!`
});
}
});

ws.on('error', err => {
console.error("连接错误", err);
});
});

/**
* 广播消息:发给所有在线客户端
*/
function broadcast(data) {
const json = JSON.stringify(data);
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(json);
}
});
}
  1. 版本4:增加心跳+用户名唯一性
  • index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>长连接小游戏|心跳+自动重连登录+在线列表+用户名唯一</title>
<style>
*{box-sizing:border-box;font-family:system-ui;}
body{text-align:center;margin-top:20px;background:#f5f7fa;}
.container{display:flex;gap:20px;max-width:900px;margin:0 auto;}
.main{flex:1}
.sidebar{width:220px;text-align:left;background:#fff;padding:16px;border-radius:8px;border:1px solid #ddd;}
#score{font-size:48px;font-weight:bold;color:#222;margin:20px 0;}
#btn{padding:16px 40px;font-size:22px;background:#2563eb;color:#fff;border:none;border-radius:12px;cursor:pointer;}
#btn:active{background:#1d4ed8;}
#status{margin-top:16px;color:#666;font-size:16px;}
#noticeBox{width:100%;height:220px;border:1px solid #ccc;padding:10px;text-align:left;overflow-y:auto;background:#fff;border-radius:8px;margin-top:10px;}
.login-area{margin-bottom:20px;}
#usernameInput{padding:8px;font-size:16px;width:220px;}
#loginBtn{padding:8px 16px;font-size:16px;cursor:pointer;}
.user-item{padding:4px 0;border-bottom:1px solid #eee;}
</style>
</head>
<body>
<h2>多人点击计分游戏(WebSocket长连接完整版)</h2>
<div class="container">
<div class="main">
<div class="login-area">
<input id="usernameInput" placeholder="输入你的用户名" />
<button id="loginBtn">登录进入游戏</button>
</div>

<div id="score">0</div>
<button id="btn" disabled>点击加分</button>
<div id="status">连接状态:正在连接...</div>
<div id="noticeBox">消息日志:<br></div>
</div>
<div class="sidebar">
<h3>在线用户列表</h3>
<div id="userListBox"></div>
</div>
</div>

<script>
let ws = null;
let reconnectTimer = null;
let heartbeatTimer = null;
const RECONNECT_INTERVAL = 2000; // 断线2秒重连
const HEARTBEAT_SEND_INTERVAL = 3000; // 客户端每3秒发送ping心跳
let username = "";
let isLogin = false;

const scoreDom = document.getElementById('score');
const statusDom = document.getElementById('status');
const btn = document.getElementById('btn');
const noticeBox = document.getElementById('noticeBox');
const userListBox = document.getElementById('userListBox');
const usernameInput = document.getElementById('usernameInput');
const loginBtn = document.getElementById('loginBtn');

// 日志追加
function addLog(text){
const div = document.createElement('div');
div.innerText = text;
noticeBox.appendChild(div);
noticeBox.scrollTop = noticeBox.scrollHeight;
}

// 更新在线用户列表UI
function renderUserList(list){
userListBox.innerHTML = "";
list.forEach(name => {
const div = document.createElement('div');
div.className = "user-item";
div.innerText = name;
userListBox.appendChild(div);
})
}

// 启动心跳
function startHeartbeat(){
clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => {
if(ws && ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({type:'ping'}));
}
}, HEARTBEAT_SEND_INTERVAL);
}

// 停止心跳
function stopHeartbeat(){
clearInterval(heartbeatTimer);
}

// 创建ws连接(重连复用)
function createWS(){
ws = new WebSocket('ws://127.0.0.1:8080');

ws.onopen = () => {
statusDom.innerText = "连接状态:✅ 长连接已建立";
addLog("✅ WebSocket连接成功");
startHeartbeat();
clearTimeout(reconnectTimer);
// ========= 重点:断线重连自动登录 =========
if(isLogin && username){
addLog(`🔄 重连成功,自动登录【${username}】`);
ws.send(JSON.stringify({
type:'login',
username: username
}));
}
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type){
case 'init':
scoreDom.innerText = data.score;
addLog(data.msg);
btn.disabled = false;
break;
case 'update':
scoreDom.innerText = data.score;
addLog(`🔔【${data.user}】点击加分,当前分数:${data.score}`);
break;
case 'notice':
addLog(data.content);
break;
case 'userList':
renderUserList(data.list);
break;
case 'loginError':
addLog(data.msg);
// =========【修复】登录失败,清空本地登录标记!=========
isLogin = false;
username = "";
btn.disabled = true;
break;
case 'pong':
// 收到服务端pong,心跳正常,无需操作
break;
}
};

ws.onclose = () => {
statusDom.innerText = "连接状态:❌ 长连接断开,准备重连...";
addLog("❌ 连接断开,2s后尝试重连");
btn.disabled = true;
stopHeartbeat();
reconnectTimer = setTimeout(()=>{
createWS();
}, RECONNECT_INTERVAL);
};

ws.onerror = () => {
statusDom.innerText = "连接状态:⚠️ 连接出错";
};
}

// 登录按钮
loginBtn.onclick = function(){
const name = usernameInput.value.trim();
if(!name){
addLog("⚠️ 请输入用户名");
return;
}
username = name;
isLogin = true;
if(ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({
type:'login',
username: username
}));
}else{
addLog("⚠️ 尚未连上服务器,请等待连接成功");
}
};

// 加分按钮
btn.onclick = () => {
if(ws && ws.readyState === WebSocket.OPEN && isLogin){
ws.send(JSON.stringify({type:'click'}));
}
};

// 初始化连接
createWS();
</script>
</body>
</html>
  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

let score = 0;
// key: ws对象, value: username
const userMap = new Map();
// 保存已使用用户名,用来查重
const userNameSet = new Set();

console.log("WebSocket服务启动,端口:8080");

wss.on('connection', (ws) => {
let username = null;
let heartbeatTimer = null;
const HEARTBEAT_TIMEOUT = 5000; // 5秒没收到心跳,判定离线

// 收到消息
ws.on('message', (rawData) => {
try {
const msg = JSON.parse(rawData);
switch (msg.type) {
// 心跳 ping
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
// 重置超时计时器
clearTimeout(heartbeatTimer);
heartbeatTimer = setTimeout(() => {
ws.close();
}, HEARTBEAT_TIMEOUT);
break;

// 用户登录
case 'login':
const inputName = msg.username.trim();
// 【修复】如果当前连接已经登录,直接跳过
if(username){
ws.send(JSON.stringify({
type: 'init',
score: score,
msg: `你已经登录【${username}】`
}));
break;
}
// 用户名重复校验
if (userNameSet.has(inputName)) {
ws.send(JSON.stringify({
type: 'loginError',
msg: '❌ 用户名已存在,请换一个名字'
}));
break;
}
// 登录成功
username = inputName;
userMap.set(ws, username);
userNameSet.add(username);
console.log(`用户【${username}】上线`);

// 启动心跳超时检测
clearTimeout(heartbeatTimer);
heartbeatTimer = setTimeout(() => {
ws.close();
}, HEARTBEAT_TIMEOUT);

// 发给当前用户初始化信息
ws.send(JSON.stringify({
type: 'init',
score: score,
msg: `欢迎你,${username}`
}));
// 广播全员:有人上线 + 更新在线列表
broadcast({
type: 'notice',
content: `🎉 用户【${username}】加入游戏!`
});
broadcastUserList();
break;

// 点击加分
case 'click':
if (!username) return;
score++;
console.log(`【${username}】点击加分,当前分数:${score}`);
broadcast({
type: 'update',
score: score,
user: username
});
break;
}
} catch (e) {
console.error("消息解析异常", e);
}
});

// 连接关闭
ws.on('close', () => {
clearTimeout(heartbeatTimer);
if (username) {
userMap.delete(ws);
userNameSet.delete(username);
console.log(`用户【${username}】下线`);
broadcast({
type: 'notice',
content: `👋 用户【${username}】离开游戏!`
});
broadcastUserList();
}
});

ws.on('error', err => {
clearTimeout(heartbeatTimer);
console.error("连接错误", err);
});
});

/**
* 广播消息给所有客户端
*/
function broadcast(data) {
const json = JSON.stringify(data);
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(json);
}
});
}

/**
* 广播在线用户列表
*/
function broadcastUserList() {
const userList = Array.from(userNameSet);
broadcast({
type: 'userList',
list: userList
});
}
  1. 版本5:持久化+聊天+指数退避+限流+终端踢下线
  • index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>长连接完整版|持久化+聊天+指数退避+限流+踢下线</title>
<style>
*{box-sizing:border-box;font-family:system-ui;}
body{text-align:center;margin-top:20px;background:#f5f7fa;}
.container{display:flex;gap:20px;max-width:1100px;margin:0 auto;}
.main{flex:1}
.sidebar{width:240px;text-align:left;background:#fff;padding:16px;border-radius:8px;border:1px solid #ddd;}
#score{font-size:48px;font-weight:bold;color:#222;margin:20px 0;}
#btn{padding:16px 40px;font-size:22px;background:#2563eb;color:#fff;border:none;border-radius:12px;cursor:pointer;}
#btn:active{background:#1d4ed8;}
#btn:disabled{background:#94a3b8;cursor:not-allowed;}
#status{margin-top:16px;color:#666;font-size:16px;}
#noticeBox{width:100%;height:180px;border:1px solid #ccc;padding:10px;text-align:left;overflow-y:auto;background:#fff;border-radius:8px;margin-top:10px;}
#chatBox{width:100%;height:180px;border:1px solid #ccc;padding:10px;text-align:left;overflow-y:auto;background:#fff;border-radius:8px;margin-top:10px;}
.chat-input-area{display:flex;gap:8px;margin-top:8px;}
#chatInput{flex:1;padding:8px;font-size:14px;}
#sendChatBtn{padding:8px 12px;cursor:pointer;}
.login-area{margin-bottom:20px;}
#usernameInput{padding:8px;font-size:16px;width:220px;}
#loginBtn{padding:8px 16px;font-size:16px;cursor:pointer;}
.user-item{padding:4px 0;border-bottom:1px solid #eee;}
</style>
</head>
<body>
<h2>多人点击计分游戏(WebSocket长连接终极版)</h2>
<div class="container">
<div class="main">
<div class="login-area">
<input id="usernameInput" placeholder="输入你的用户名" />
<button id="loginBtn">登录进入游戏</button>
</div>

<div id="score">0</div>
<button id="btn" disabled>点击加分</button>
<div id="status">连接状态:正在连接...</div>
<div id="noticeBox">系统日志:<br></div>

<h3>全局聊天</h3>
<div id="chatBox"></div>
<div class="chat-input-area">
<input id="chatInput" placeholder="输入聊天消息" />
<button id="sendChatBtn" disabled>发送</button>
</div>
</div>
<div class="sidebar">
<h3>在线用户列表</h3>
<div id="userListBox"></div>
</div>
</div>

<script>
let ws = null;
let reconnectTimer = null;
let heartbeatTimer = null;
// ========= 指数退避配置 =========
let reconnectDelay = 2000; // 初始2s
const MAX_RECONNECT_DELAY = 8000; // 最大8s
const HEARTBEAT_SEND_INTERVAL = 3000;
let username = "";
let isLogin = false;

const scoreDom = document.getElementById('score');
const statusDom = document.getElementById('status');
const btn = document.getElementById('btn');
const noticeBox = document.getElementById('noticeBox');
const userListBox = document.getElementById('userListBox');
const usernameInput = document.getElementById('usernameInput');
const loginBtn = document.getElementById('loginBtn');
const chatBox = document.getElementById('chatBox');
const chatInput = document.getElementById('chatInput');
const sendChatBtn = document.getElementById('sendChatBtn');

// 系统日志
function addLog(text){
const div = document.createElement('div');
div.innerText = text;
noticeBox.appendChild(div);
noticeBox.scrollTop = noticeBox.scrollHeight;
}
// 聊天消息
function addChat(text){
const div = document.createElement('div');
div.innerText = text;
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
}
// 用户列表
function renderUserList(list){
userListBox.innerHTML = "";
list.forEach(name => {
const div = document.createElement('div');
div.className = "user-item";
div.innerText = name;
userListBox.appendChild(div);
})
}

function startHeartbeat(){
clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => {
if(ws && ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({type:'ping'}));
}
}, HEARTBEAT_SEND_INTERVAL);
}
function stopHeartbeat(){
clearInterval(heartbeatTimer);
}

function createWS(){
ws = new WebSocket('ws://127.0.0.1:8080');

ws.onopen = () => {
statusDom.innerText = "连接状态:✅ 长连接已建立";
addLog("✅ WebSocket连接成功");
startHeartbeat();
clearTimeout(reconnectTimer);
// 指数退避重置:连接成功,下次断线恢复初始延时
reconnectDelay = 2000;

if(isLogin && username){
addLog(`🔄 重连成功,自动登录【${username}】`);
ws.send(JSON.stringify({type:'login', username: username}));
}
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type){
case 'init':
scoreDom.innerText = data.score;
addLog(data.msg);
btn.disabled = false;
sendChatBtn.disabled = false;
break;
case 'update':
scoreDom.innerText = data.score;
addLog(`🔔【${data.user}】点击加分,当前分数:${data.score}`);
break;
case 'notice':
addLog(data.content);
break;
case 'userList':
renderUserList(data.list);
break;
case 'loginError':
addLog(data.msg);
isLogin = false;
username = "";
btn.disabled = true;
sendChatBtn.disabled = true;
break;
case 'chat':
addChat(`【${data.user}】:${data.content}`);
break;
case 'kick':
addLog(`⚠️ ${data.msg}`);
// 被踢下线,清空本地登录状态,不再自动重连登录
isLogin = false;
username = "";
btn.disabled = true;
sendChatBtn.disabled = true;
break;
case 'pong':
break;
}
};

ws.onclose = () => {
statusDom.innerText = "连接状态:❌ 长连接断开,准备重连...";
addLog("❌ 连接断开");
btn.disabled = true;
sendChatBtn.disabled = true;
stopHeartbeat();

reconnectTimer = setTimeout(()=>{
createWS();
}, reconnectDelay);
// 指数退避:延时翻倍,上限8s
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
addLog(`⏱️ 下一次重连延时:${reconnectDelay/1000}s`);
};

ws.onerror = () => {
statusDom.innerText = "连接状态:⚠️ 连接出错";
};
}

// 登录按钮
loginBtn.onclick = function(){
const name = usernameInput.value.trim();
if(!name){
addLog("⚠️ 请输入用户名");
return;
}
username = name;
isLogin = true;
if(ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({type:'login', username: username}));
}else{
addLog("⚠️ 尚未连上服务器,请等待连接成功");
}
};

// 加分按钮
btn.onclick = () => {
if(ws && ws.readyState === WebSocket.OPEN && isLogin){
ws.send(JSON.stringify({type:'click'}));
}
};

// 发送聊天
sendChatBtn.onclick = function(){
const text = chatInput.value.trim();
if(!text) return;
ws.send(JSON.stringify({
type:'chat',
content:text
}));
chatInput.value = '';
};
chatInput.addEventListener('keydown', e=>{
if(e.key === 'Enter') sendChatBtn.click();
});

// 初始化
createWS();
</script>
</body>
</html>
  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');

const wss = new WebSocket.Server({ port: 8080 });
const scoreFile = path.join(__dirname, 'score.json');

// 读取保存的分数
let score = 0;
try {
if(fs.existsSync(scoreFile)){
const raw = fs.readFileSync(scoreFile, 'utf8');
const data = JSON.parse(raw);
score = Number(data.score) || 0;
}
}catch(e){
console.log("读取分数文件失败,初始分数=0");
}

// 保存分数到文件
function saveScore(){
fs.writeFile(scoreFile, JSON.stringify({score}), err=>{
if(err) console.error("保存分数失败", err);
})
}

// 用户映射
const userMap = new Map(); // ws => username
const userNameSet = new Set();
// 限流:记录每个用户上次点击时间
const userLastClick = new Map();
const CLICK_LIMIT_MS = 1000; // 1s最多点击一次

console.log("WebSocket服务启动,端口:8080");
console.log("👉 管理员指令:kick 用户名 例如:kick tom");

// 监听控制台输入,实现踢人
process.stdin.on('data', buf=>{
const input = buf.toString().trim();
const arr = input.split(/\s+/);
if(arr[0] === 'kick' && arr[1]){
const targetName = arr[1];
let targetWs = null;
for(let [ws, name] of userMap.entries()){
if(name === targetName){
targetWs = ws;
break;
}
}
if(targetWs){
targetWs.send(JSON.stringify({
type:'kick',
msg:`你被管理员踢出房间`
}));
targetWs.close(1008, "被踢下线");
console.log(`✅ 已踢掉用户:${targetName}`);
}else{
console.log(`❌ 用户【${targetName}】不存在或者不在线`);
}
}
})

wss.on('connection', (ws) => {
let username = null;
let heartbeatTimer = null;
const HEARTBEAT_TIMEOUT = 5000;

ws.on('message', (rawData) => {
try {
const msg = JSON.parse(rawData);
switch (msg.type) {
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
clearTimeout(heartbeatTimer);
heartbeatTimer = setTimeout(() => ws.close(), HEARTBEAT_TIMEOUT);
break;

case 'login':
const inputName = msg.username.trim();
if(username){
ws.send(JSON.stringify({
type: 'init',
score,
msg: `你已经登录【${username}】`
}));
break;
}
if (userNameSet.has(inputName)) {
ws.send(JSON.stringify({
type: 'loginError',
msg: '❌ 用户名已存在,请换一个名字'
}));
break;
}
username = inputName;
userMap.set(ws, username);
userNameSet.add(username);
userLastClick.set(username, 0);
console.log(`用户【${username}】上线`);

clearTimeout(heartbeatTimer);
heartbeatTimer = setTimeout(() => ws.close(), HEARTBEAT_TIMEOUT);

ws.send(JSON.stringify({
type: 'init',
score,
msg: `欢迎你,${username}`
}));
broadcast({
type: 'notice',
content: `🎉 用户【${username}】加入游戏!`
});
broadcastUserList();
break;

case 'click':
if (!username) return;
const now = Date.now();
const lastClick = userLastClick.get(username) || 0;
// 限流判断
if(now - lastClick < CLICK_LIMIT_MS){
ws.send(JSON.stringify({
type:'notice',
content:'⚠️ 点击太快,请稍后再试'
}));
return;
}
userLastClick.set(username, now);
score++;
saveScore();
console.log(`【${username}】点击加分,当前分数:${score}`);
broadcast({
type: 'update',
score,
user: username
});
break;

// 聊天消息
case 'chat':
if(!username) return;
const chatContent = msg.content.trim();
if(!chatContent) return;
broadcast({
type:'chat',
user:username,
content:chatContent
});
break;
}
} catch (e) {
console.error("消息解析异常", e);
}
});

ws.on('close', (code, reason) => {
clearTimeout(heartbeatTimer);
if (username) {
userMap.delete(ws);
userNameSet.delete(username);
userLastClick.delete(username);
console.log(`用户【${username}】下线,code:${code}, reason:${reason}`);
// 区分是正常下线,还是被管理员踢下线
if(reason !== "被踢下线"){
broadcast({
type: 'notice',
content: `👋 用户【${username}】离开游戏!`
});
}
broadcastUserList();
}
});

ws.on('error', err => {
clearTimeout(heartbeatTimer);
console.error("连接错误", err);
});
});

function broadcast(data) {
const json = JSON.stringify(data);
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(json);
}
});
}

function broadcastUserList() {
const userList = Array.from(userNameSet);
broadcast({
type: 'userList',
list: userList
});
}
  1. 版本6:敏感词+限流+用户颜色+模块化后端
  • index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>多房间长连接游戏|敏感词+限流+用户颜色+模块化后端</title>
<style>
*{box-sizing:border-box;font-family:system-ui;}
body{text-align:center;margin-top:16px;background:#f5f7fa;}
.container{display:flex;gap:20px;max-width:1200px;margin:0 auto;}
.main{flex:1}
.sidebar{width:260px;text-align:left;background:#fff;padding:16px;border-radius:8px;border:1px solid #ddd;}
#score{font-size:48px;font-weight:bold;color:#222;margin:10px 0;}
#btn{padding:16px 40px;font-size:22px;background:#2563eb;color:#fff;border:none;border-radius:12px;cursor:pointer;}
#btn:active{background:#1d4ed8;}
#btn:disabled{background:#94a3b8;cursor:not-allowed;}
#status{margin-top:10px;color:#666;font-size:16px;}
#noticeBox{width:100%;height:160px;border:1px solid #ccc;padding:10px;text-align:left;overflow-y:auto;background:#fff;border-radius:8px;margin-top:10px;}
#chatBox{width:100%;height:180px;border:1px solid #ccc;padding:10px;text-align:left;overflow-y:auto;background:#fff;border-radius:8px;margin-top:10px;}
.chat-input-area{display:flex;gap:8px;margin-top:8px;}
#chatInput{flex:1;padding:8px;font-size:14px;}
#sendChatBtn{padding:8px 12px;cursor:pointer;}
.login-area{margin-bottom:16px;display:flex;gap:8px;justify-content:center;align-items:center;flex-wrap:wrap;}
#usernameInput,#roomInput{padding:8px;font-size:16px;width:180px;}
#loginBtn{padding:8px 16px;font-size:16px;cursor:pointer;}
.user-item{padding:4px 0;border-bottom:1px solid #eee;}
</style>
</head>
<body>
<h2>多人点击计分游戏|多房间隔离</h2>
<div class="container">
<div class="main">
<div class="login-area">
<input id="roomInput" placeholder="房间ID,默认default" value="default" />
<input id="usernameInput" placeholder="输入你的用户名" />
<button id="loginBtn">登录进入房间</button>
</div>

<div id="score">0</div>
<div>当前房间:<span id="roomName">default</span></div>
<button id="btn" disabled>点击加分</button>
<div id="status">连接状态:正在连接...</div>
<div id="noticeBox">系统日志:<br></div>

<h3>全局聊天(本房间)</h3>
<div id="chatBox"></div>
<div class="chat-input-area">
<input id="chatInput" placeholder="输入聊天消息" />
<button id="sendChatBtn" disabled>发送</button>
</div>
</div>
<div class="sidebar">
<h3>本房间在线用户列表</h3>
<div id="userListBox"></div>
</div>
</div>

<script>
let ws = null;
let reconnectTimer = null;
let heartbeatTimer = null;
let reconnectDelay = 2000;
const MAX_RECONNECT_DELAY = 8000;
const HEARTBEAT_SEND_INTERVAL = 3000;
let username = "";
let roomId = "default";
let isLogin = false;

const scoreDom = document.getElementById('score');
const roomNameDom = document.getElementById('roomName');
const statusDom = document.getElementById('status');
const btn = document.getElementById('btn');
const noticeBox = document.getElementById('noticeBox');
const userListBox = document.getElementById('userListBox');
const usernameInput = document.getElementById('usernameInput');
const roomInput = document.getElementById('roomInput');
const loginBtn = document.getElementById('loginBtn');
const chatBox = document.getElementById('chatBox');
const chatInput = document.getElementById('chatInput');
const sendChatBtn = document.getElementById('sendChatBtn');

function addLog(text, color="#333"){
const div = document.createElement('div');
div.style.color = color;
div.innerText = text;
noticeBox.appendChild(div);
noticeBox.scrollTop = noticeBox.scrollHeight;
}
function addChat(text, color="#333"){
const div = document.createElement('div');
div.style.color = color;
div.innerText = text;
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
}
function renderUserList(list, colorMap){
userListBox.innerHTML = "";
list.forEach(name => {
const div = document.createElement('div');
div.className = "user-item";
div.style.color = colorMap[name] || "#333";
div.innerText = name;
userListBox.appendChild(div);
})
}

function startHeartbeat(){
clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => {
if(ws && ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({type:'ping'}));
}
}, HEARTBEAT_SEND_INTERVAL);
}
function stopHeartbeat(){
clearInterval(heartbeatTimer);
}

function createWS(){
ws = new WebSocket('ws://127.0.0.1:8080');

ws.onopen = () => {
statusDom.innerText = "连接状态:✅ 长连接已建立";
addLog("✅ WebSocket连接成功");
startHeartbeat();
clearTimeout(reconnectTimer);
reconnectDelay = 2000;
if(isLogin && username){
addLog(`🔄 重连成功,自动登录【${username}】房间【${roomId}】`);
ws.send(JSON.stringify({type:'login', username, roomId}));
}
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type){
case 'init':
scoreDom.innerText = data.score;
roomNameDom.innerText = data.roomId;
addLog(data.msg);
btn.disabled = false;
sendChatBtn.disabled = false;
break;
case 'update':
scoreDom.innerText = data.score;
addLog(`🔔【${data.user}】点击加分,当前分数:${data.score}`, data.color);
break;
case 'notice':
addLog(data.content, data.color || "#333");
break;
case 'userList':
const colorMap = {};
data.list.forEach(u=>{
let hash=0;for(let i=0;i<u.length;i++)hash=u.charCodeAt(i)+((hash<<5)-hash);
let c=(hash&0xFFFFFF).toString(16);while(c.length<6)c="0"+c;
colorMap[u]="#"+c;
})
renderUserList(data.list, colorMap);
break;
case 'loginError':
addLog(data.msg,"red");
isLogin = false;
username = "";
roomId = "default";
btn.disabled = true;
sendChatBtn.disabled = true;
break;
case 'chat':
addChat(`【${data.user}】:${data.content}`, data.color);
break;
case 'kick':
addLog(`⚠️ ${data.msg}`,"red");
isLogin = false;
username = "";
roomId = "default";
btn.disabled = true;
sendChatBtn.disabled = true;
break;
case 'pong': break;
}
};

ws.onclose = () => {
statusDom.innerText = "连接状态:❌ 长连接断开,准备重连...";
addLog("❌ 连接断开");
btn.disabled = true;
sendChatBtn.disabled = true;
stopHeartbeat();
reconnectTimer = setTimeout(()=>{createWS();}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
addLog(`⏱️ 下一次重连延时:${reconnectDelay/1000}s`);
};

ws.onerror = () => {
statusDom.innerText = "连接状态:⚠️ 连接出错";
};
}

loginBtn.onclick = function(){
const name = usernameInput.value.trim();
const rid = roomInput.value.trim() || "default";
if(!name){
addLog("⚠️ 请输入用户名","red");
return;
}
username = name;
roomId = rid;
isLogin = true;
if(ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({type:'login', username, roomId}));
}else{
addLog("⚠️ 尚未连上服务器,请等待连接成功");
}
};

btn.onclick = () => {
if(ws && ws.readyState === WebSocket.OPEN && isLogin){
ws.send(JSON.stringify({type:'click'}));
}
};

sendChatBtn.onclick = function(){
const text = chatInput.value.trim();
if(!text) return;
ws.send(JSON.stringify({type:'chat', content:text}));
chatInput.value = '';
};
chatInput.addEventListener('keydown', e=>{
if(e.key === 'Enter') sendChatBtn.click();
});

createWS();
</script>
</body>
</html>
  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const WebSocket = require('ws');
const roomMgr = require("./roomManager6");
const msgHandler = require("./msgHandler6");

roomMgr.loadRooms();
const wss = new WebSocket.Server({ port:8080 });
console.log("WS服务启动 端口:8080");
console.log("管理员指令 kick 用户名");

// 控制台踢人
process.stdin.on('data', buf=>{
const input = buf.toString().trim();
const arr = input.split(/\s+/);
if(arr[0] === "kick" && arr[1]){
msgHandler.kickUser(wss, arr[1]);
}
})

wss.on("connection", ws=>{
// 每个连接独立状态
const heartbeatMap = {
username: null,
roomId: null,
timer: null
};

ws.on("message", rawData=>{
try{
const msg = JSON.parse(rawData);
msgHandler.handleMessage(ws, msg, heartbeatMap);
}catch(e){
console.error("消息解析异常", e);
}
});

ws.on("close", (code, reason)=>{
clearTimeout(heartbeatMap.timer);
if(reason !== "被踢下线"){
msgHandler.handleClose(ws, heartbeatMap);
}
});

ws.on("error", err=>{
clearTimeout(heartbeatMap.timer);
console.error("连接错误", err);
});
});
  • userManager.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 用户名集合全局:{roomId:Set()}
const userNameMap = {};
// 点击限流记录 {username: timestamp}
const userClickLastTime = {};
// 聊天限流记录 {username: timestamp}
const userChatLastTime = {};
// 聊天限流间隔 ms
const CHAT_LIMIT_MS = 3000;
const CLICK_LIMIT_MS = 1000;

// 敏感词列表
const badWords = ["垃圾","傻逼","妈的","操"];

// 敏感词替换
function filterBadWord(text){
let res = text;
for(const w of badWords){
const reg = new RegExp(w,"g");
res = res.replace(reg, "*".repeat(w.length));
}
return res;
}

// 根据用户名生成固定颜色
function getColorByName(name){
let hash = 0;
for(let i=0;i<name.length;i++){
hash = name.charCodeAt(i) + ((hash <<5) - hash);
}
let c = (hash & 0xFFFFFF).toString(16);
while(c.length <6) c = "0"+c;
return `#${c}`;
}

// 检查用户名在房间内是否重复
function isNameExist(roomId, username){
if(!userNameMap[roomId]) userNameMap[roomId] = new Set();
return userNameMap[roomId].has(username);
}

// 添加用户名到房间集合
function addUserName(roomId, username){
if(!userNameMap[roomId]) userNameMap[roomId] = new Set();
userNameMap[roomId].add(username);
}

// 删除房间用户名
function removeUserName(roomId, username){
if(!userNameMap[roomId]) return;
userNameMap[roomId].delete(username);
if(userNameMap[roomId].size === 0) delete userNameMap[roomId];
}

// 点击限流校验
function checkClickLimit(username){
const now = Date.now();
const last = userClickLastTime[username] || 0;
if(now - last < CLICK_LIMIT_MS) return false;
userClickLastTime[username] = now;
return true;
}

// 聊天限流校验
function checkChatLimit(username){
const now = Date.now();
const last = userChatLastTime[username] || 0;
if(now - last < CHAT_LIMIT_MS) return false;
userChatLastTime[username] = now;
return true;
}

// 用户下线清理缓存
function cleanUserCache(username){
delete userClickLastTime[username];
delete userChatLastTime[username];
}

module.exports = {
filterBadWord,
getColorByName,
isNameExist,
addUserName,
removeUserName,
checkClickLimit,
checkChatLimit,
cleanUserCache
};
  • roomManager.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

const room = getRoom(roomId);
const nameSet = new Set();
for(const [ws,name] of room.users.entries()){
nameSet.add(name);
}
return Array.from(nameSet);
}

module.exports = {
rooms,
loadRooms,
saveRooms,
getRoom,
getUserList
};
  • msgHandler.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
const roomMgr = require("./roomManager6");
const userMgr = require("./userManager6");
const HEARTBEAT_TIMEOUT = 5000;

function broadcastRoom(roomId, data){
const room = roomMgr.getRoom(roomId);
const json = JSON.stringify(data);
for(const [ws] of room.users.entries()){
if(ws.readyState === ws.OPEN){
ws.send(json);
}
}
}

function broadcastUserList(roomId){
const list = roomMgr.getUserList(roomId);
broadcastRoom(roomId, {
type:"userList",
list
});
}

function handleMessage(ws, msg, heartbeatMap){
const type = msg.type;
switch(type){
case "ping":{
ws.send(JSON.stringify({type:"pong"}));
clearTimeout(heartbeatMap.timer);
heartbeatMap.timer = setTimeout(()=> ws.close(), HEARTBEAT_TIMEOUT);
break;
}

case "login":{
const {username, roomId} = msg;
const inputName = username.trim();
const rid = roomId || "default";
const room = roomMgr.getRoom(rid);
// 当前ws已经登录
if(heartbeatMap.username){
ws.send(JSON.stringify({
type:"init",
score: room.score,
msg:`你已经登录【${heartbeatMap.username}】房间${rid}`
}));
break;
}
// 房间用户名查重
if(userMgr.isNameExist(rid, inputName)){
ws.send(JSON.stringify({
type:"loginError",
msg:"❌ 用户名已存在,请换一个名字"
}));
break;
}
// 登录成功
heartbeatMap.username = inputName;
heartbeatMap.roomId = rid;
room.users.set(ws, inputName);
userMgr.addUserName(rid, inputName);
console.log(`用户【${inputName}】进入房间【${rid}】`);

clearTimeout(heartbeatMap.timer);
heartbeatMap.timer = setTimeout(() => ws.close(), HEARTBEAT_TIMEOUT);

ws.send(JSON.stringify({
type:"init",
score: room.score,
msg:`欢迎你【${inputName}】进入房间【${rid}】`,
roomId: rid
}));

broadcastRoom(rid, {
type:"notice",
content:`🎉 用户【${inputName}】加入房间【${rid}】!`,
color: userMgr.getColorByName(inputName)
});
broadcastUserList(rid);
break;
}

case "click":{
const username = heartbeatMap.username;
const roomId = heartbeatMap.roomId;
if(!username || !roomId) return;
if(!userMgr.checkClickLimit(username)){
ws.send(JSON.stringify({
type:"notice",
content:"⚠️ 点击太快,请稍后再试"
}));
return;
}
const room = roomMgr.getRoom(roomId);
room.score++;
roomMgr.saveRooms();
console.log(`【${username}】房间【${roomId}】加分:${room.score}`);
broadcastRoom(roomId,{
type:"update",
score: room.score,
user: username,
color: userMgr.getColorByName(username)
});
break;
}

case "chat":{
const username = heartbeatMap.username;
const roomId = heartbeatMap.roomId;
if(!username || !roomId) return;
const rawContent = msg.content.trim();
if(!rawContent) return;
if(!userMgr.checkChatLimit(username)){
ws.send(JSON.stringify({
type:"notice",
content:"⚠️ 聊天频率太快,3秒后再发"
}));
return;
}
const safeText = userMgr.filterBadWord(rawContent);
broadcastRoom(roomId,{
type:"chat",
user: username,
color: userMgr.getColorByName(username),
content: safeText
});
break;
}
}
}

// 用户下线清理
function handleClose(ws, heartbeatMap){
const username = heartbeatMap.username;
const roomId = heartbeatMap.roomId;
if(!username || !roomId) return;
const room = roomMgr.getRoom(roomId);
room.users.delete(ws);
userMgr.removeUserName(roomId, username);
userMgr.cleanUserCache(username);
console.log(`用户【${username}】离开房间【${roomId}】`);

broadcastRoom(roomId, {
type:"notice",
content:`👋 用户【${username}】离开房间【${roomId}】`,
color: userMgr.getColorByName(username)
});
broadcastUserList(roomId);
}

// 踢人
function kickUser(wss, targetName){
for(const [ws, username] of wss.clients.entries()){
if(username === targetName){
ws.send(JSON.stringify({
type:"kick",
msg:`你被管理员踢出房间`
}));
ws.close(1008, "被踢下线");
console.log(`✅ 已踢用户【${targetName}】`);
return true;
}
}
console.log(`❌ 用户【${targetName}】不存在`);
return false;
}

module.exports = {
handleMessage,
handleClose,
kickUser
};

三、参考

  1. 参考一