从零构建 Node.js TCP 多人聊天室 - 完整实战教程
从零构建 Node.js TCP 多人聊天室 - 完整实战教程
使用 Node.js net 模块打造实时多人聊天系统,深入理解 Socket 编程、事件驱动和状态管理
📖 目录
🎯 项目演示
功能特性
✅ 多人实时聊天 - 支持多个客户端同时连接
✅ 昵称系统 - 用户可自定义昵称
✅ 消息广播 - 消息自动发送给所有在线用户
✅ 命令系统 - /quit、/users、/help 等实用命令
✅ 优雅退出 - Ctrl+C 或输入命令均可正常断开
✅ 局域网支持 - 其他设备可通过 IP 地址连接
运行效果
# 启动服务器
$ npx ts-node 聊天室.ts
========================================
聊天室服务器已启动
========================================
本地访问: localhost:3500
局域网访问: 192.168.1.100:3500
========================================
# 客户端 1
$ npx ts-node 客户端.ts
正在连接到 localhost:3500...
已连接到服务器!
=== 欢迎进入聊天室 ===
请输入你的昵称:
> 艾魅
你的昵称是: 艾魅
现在可以开始聊天了!
> 你好
# 客户端 2(看到的消息)
[艾魅] 你好
🛠️ 技术栈
| 技术 | 用途 | 说明 |
|---|---|---|
| Node.js | 运行时环境 | v18+ 推荐 |
| net 模块 | TCP 网络通信 | Node.js 内置模块 |
| TypeScript | 类型安全 | 严格模式开发 |
| readline 模块 | 命令行交互 | 处理用户输入 |
| os 模块 | 获取本地 IP | 显示局域网地址 |
| Map 数据结构 | 客户端管理 | O(1) 查找和删除 |
💡 核心概念解析
在深入代码之前,让我们先理解几个核心概念。我会用生活中的例子来解释,让你更容易理解。
1. Socket(套接字)是什么?
想象一下打电话的场景:
- Socket = 电话听筒 📞
- 你和对方各有一个听筒
- 通过听筒说话(发送数据)和听话(接收数据)
- 通话建立后,双方可以随时交流
在代码中:
// 服务器创建一个"总机"
const server = net.createServer((socket) => {
// 每个客户端连接进来,就分配一个"听筒"(socket)
socket.write('你好!'); // 通过听筒说话
socket.on('data', (msg) => {
console.log(msg); // 通过听筒听话
});
});
2. 端口(Port)是什么?
继续用电话举例:
- IP 地址 = 大楼地址 🏢(如 192.168.1.100)
- 端口 = 房间号 🚪(如 3500)
// 服务器监听 3500 端口
server.listen(3500, '0.0.0.0', () => {
console.log('服务器在 3500 房间等待连接');
});
// 客户端连接到 3500 端口
const client = net.createConnection({
host: '192.168.1.100', // 大楼地址
port: 3500 // 房间号
});
为什么需要端口?
- 一台服务器可以同时运行多个服务(Web、FTP、邮件等)
- 端口用来区分不同的服务
- 常见端口:HTTP(80)、HTTPS(443)、SSH(22)
3. 事件驱动模型
Node.js 的核心思想:不是"我问你有没有数据",而是"有数据时你通知我"
传统方式(阻塞式):
// ❌ 一直问:"有数据吗?有数据吗?..."
while (true) {
const data = socket.read();
if (data) {
process(data);
}
}
事件驱动(非阻塞式):
// ✅ 注册监听器,有数据时自动触发
socket.on('data', (data) => {
process(data); // 有数据时才执行
});
// 程序可以继续做其他事,不会被卡住
console.log('我可以继续执行其他代码');
类比:
- 阻塞式 = 站在门口等快递,什么都不干
- 事件驱动 = 去忙别的,快递员到了按门铃通知你
4. Map 数据结构
为什么用 Map 而不是普通对象 {}?
// ❌ 普通对象 - 键只能是字符串
const obj = {};
obj['socket1'] = '艾魅'; // 需要把 socket 转成字符串
// ✅ Map - 键可以是任何类型(包括对象)
const map = new Map();
map.set(socket, '艾魅'); // 直接用 socket 对象作为键
优势对比:
| 特性 | Object | Map |
|---|---|---|
| 键类型 | 仅字符串/符号 | 任意类型 |
| 性能 | O(n) 遍历 | O(1) 查找 |
| 大小查询 | 手动计数 | map.size |
| 迭代顺序 | 不确定 | 插入顺序 |
🖥️ 服务器端实现
整体架构
聊天室服务器 (聊天室.ts)
├── 初始化
│ ├── 创建 TCP 服务器
│ ├── 获取本地 IP
│ └── 监听端口 3500
│
├── 客户端管理
│ ├── clientMap: Map<Socket, string> // 存储 socket -> 昵称
│ ├── 新连接处理
│ ├── 昵称设置
│ └── 断开连接清理
│
├── 消息处理
│ ├── 接收消息 (on 'data')
│ ├── 命令解析 (/quit, /users, /help)
│ └── 消息广播
│
└── 优雅关闭
├── 捕获 SIGINT (Ctrl+C)
├── 通知所有客户端
├── 主动断开连接
└── 关闭服务器
核心代码详解
1. 服务器初始化
import * as net from 'net';
import * as os from 'os';
// 存储客户端连接:Socket -> 昵称
const clientMap: Map<net.Socket, string> = new Map();
let isShuttingDown = false; // 防止重复关闭
// 获取本地局域网 IP
function getLocalIP(): string {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]!) {
// IPv4 且非内部地址(127.0.0.1)
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return '127.0.0.1';
}
// 创建 TCP 服务器
const server = net.createServer((socket) => {
// 每个新连接都会执行这里的代码
const clientInfo = `${socket.remoteAddress}:${socket.remotePort}`;
console.log(`新客户端加入: ${clientInfo}`);
// ... 后续处理
});
// 监听所有网络接口(0.0.0.0 表示接受任何 IP 的连接)
const PORT = 3500;
const localIP = getLocalIP();
server.listen(PORT, '0.0.0.0', () => {
console.log(`本地访问: localhost:${PORT}`);
console.log(`局域网访问: ${localIP}:${PORT}`);
});
关键点解释:
-
0.0.0.0vslocalhost:localhost(127.0.0.1) - 只允许本机连接0.0.0.0- 允许任何 IP 连接(包括局域网其他设备)
-
os.networkInterfaces():- 返回所有网络接口信息
- 我们需要找到非内部的 IPv4 地址(即局域网 IP)
2. 客户端连接与昵称设置
server = net.createServer((socket) => {
const clientInfo = `${socket.remoteAddress}:${socket.remotePort}`;
socket.write('=== 欢迎进入聊天室 ===\n');
socket.write('请输入你的昵称:\n');
let nickname = ''; // 当前客户端的昵称
let isSettingNickname = true; // 是否正在设置昵称
let hasLeftChat = false; // 是否已主动离开(防止重复广播)
socket.on('data', (data) => {
const msg = data.toString().trim(); // 转为字符串并去除空白
// === 阶段 1:设置昵称 ===
if (isSettingNickname) {
nickname = msg || `用户${Date.now()}`; // 空昵称使用时间戳
clientMap.set(socket, nickname); // 存入 Map
isSettingNickname = false; // 标记设置完成
// 欢迎消息
socket.write(`你的昵称是: ${nickname}\n`);
socket.write('现在可以开始聊天了!\n> ');
// 通知其他用户
clientMap.forEach((name, client) => {
if (client !== socket && client.writable) {
client.write(`[系统] ${nickname} 加入了聊天室\n`);
}
});
return;
}
// === 阶段 2:正常聊天 ===
if (!msg) return; // 空消息忽略
// ... 命令处理和消息广播
});
});
闭包的作用:
每个客户端连接都有独立的 nickname、isSettingNickname、hasLeftChat 变量,这是因为它们定义在 createServer 的回调函数内部,形成了闭包。
// 客户端 A 连接
socket_A -> nickname = "艾魅", isSettingNickname = false
// 客户端 B 连接
socket_B -> nickname = "斯派克", isSettingNickname = false
// 互不干扰!
3. 命令处理与消息广播
socket.on('data', (data) => {
const msg = data.toString().trim();
if (isSettingNickname) {
// ... 昵称设置逻辑
return;
}
if (!msg) return;
// === 命令处理 ===
// 退出聊天室
if (msg === '/quit' || msg === '/exit') {
hasLeftChat = true; // 标记已主动离开
const leaveName = clientMap.get(socket) || nickname;
// 广播离开消息
clientMap.forEach((name, client) => {
if (client !== socket && client.writable) {
client.write(`[系统] ${leaveName} 离开了聊天室\n`);
}
});
console.log(`${leaveName} 退出了聊天室`);
clientMap.delete(socket); // 从 Map 移除
socket.write('[系统] 再见!\n');
socket.end(); // 关闭连接
return;
}
// 查看在线用户
if (msg === '/users') {
const users = Array.from(clientMap.values());
socket.write(`[系统] 在线用户 (${users.length}人): ${users.join(', ')}\n`);
return;
}
// 帮助信息
if (msg === '/help') {
socket.write('[系统] 可用命令:\n');
socket.write(' /quit 或 /exit - 退出聊天室\n');
socket.write(' /users - 查看在线用户\n');
socket.write(' /help - 显示帮助信息\n');
return;
}
// === 普通消息广播 ===
console.log(`[${nickname}] ${msg}`);
const broadcastMsg = `[${nickname}] ${msg}\n`;
clientMap.forEach((name, client) => {
if (client.writable) { // 检查连接是否仍然有效
client.write(broadcastMsg);
}
});
});
为什么要检查 writable?
当客户端断开连接时,socket 可能还没有完全关闭,此时向其写入数据会抛出错误。writable 属性可以判断连接是否仍然可用。
if (client.writable) {
client.write(message); // 安全写入
} else {
// 连接已断开,跳过
}
4. 断开连接处理
socket.on('close', () => {
// 如果已经主动离开(通过 /quit),就不需要再处理
if (hasLeftChat) {
return;
}
const name = clientMap.get(socket);
const displayName = name || nickname || clientInfo;
if (name) {
console.log(`客户端已断开: ${name}`);
clientMap.delete(socket);
// 向其他在线用户广播离开消息
clientMap.forEach((_, client) => {
if (client.writable) {
client.write(`[系统] ${displayName} 离开了聊天室\n`);
}
});
} else {
// 没有昵称,说明可能是在设置昵称前就断开了
console.log(`客户端已断开: ${clientInfo}`);
}
});
socket.on('error', (err) => {
console.error(`Socket错误: ${err.message}`);
clientMap.delete(socket);
});
hasLeftChat 标志的重要性:
这是解决"重复广播离开消息"的关键!
场景 1:用户输入 /quit
1. 设置 hasLeftChat = true
2. 广播离开消息
3. 调用 socket.end()
4. 触发 close 事件
5. 检查 hasLeftChat,发现为 true,直接返回 ✅
场景 2:用户直接关闭终端(网络断开)
1. hasLeftChat 仍为 false
2. 触发 close 事件
3. 检查 hasLeftChat,发现为 false,执行正常流程 ✅
5. 服务器优雅关闭
process.on('SIGINT', () => {
// Windows 上 Ctrl+C 可能触发多次,防止重复处理
if (isShuttingDown) {
console.log('\n正在强制关闭...');
process.exit(1);
return;
}
isShuttingDown = true;
console.log('\n服务器正在关闭...');
// 1. 通知所有客户端服务器即将关闭
clientMap.forEach((name, client) => {
if (client.writable) {
client.write('[系统] 服务器正在关闭,请保存您的工作...\n');
}
});
// 2. 主动关闭所有客户端连接
clientMap.forEach((_, client) => {
try {
client.end();
} catch (err) {
console.error(`关闭客户端连接时出错: ${err}`);
}
});
// 3. 清空客户端列表
clientMap.clear();
// 4. 设置超时保护(5秒后强制退出)
const forceExitTimeout = setTimeout(() => {
console.log('\n超时,强制退出...');
process.exit(1);
}, 5000);
// 5. 关闭服务器
server.close(() => {
clearTimeout(forceExitTimeout);
console.log('服务器已关闭');
process.exit(0);
});
});
为什么需要这么复杂?
- 防止重复触发:Windows 上按 Ctrl+C 可能触发多次 SIGINT
- 通知客户端:让用户知道服务器要关闭了
- 主动断开:
server.close()只停止接受新连接,不会断开已有连接 - 超时保护:防止某些客户端无法正常断开导致服务器无法退出
💻 客户端实现
整体架构
聊天室客户端 (客户端.ts)
├── 连接建立
│ ├── 解析命令行参数(IP、端口)
│ ├── 创建 TCP 连接
│ └── 连接成功提示
│
├── 消息接收
│ ├── 监听 'data' 事件
│ ├── 清除当前行(避免显示混乱)
│ └── 重新显示提示符
│
├── 消息发送
│ ├── readline 模块处理输入
│ ├── 发送用户输入到服务器
│ └── 支持 /quit 命令
│
└── 优雅退出
├── 捕获 SIGINT (Ctrl+C)
├── 发送 /quit 命令
└── 关闭连接并退出
核心代码详解
1. 连接建立
import * as net from 'net';
import * as readline from 'readline';
// 从命令行参数获取服务器地址
const HOST = process.argv[2] || 'localhost';
const PORT = parseInt(process.argv[3] || '3500');
console.log(`正在连接到 ${HOST}:${PORT}...`);
// 创建 TCP 连接
const client = net.createConnection({ host: HOST, port: PORT }, () => {
console.log('已连接到服务器!\n');
});
// 连接错误处理
client.on('error', (err) => {
console.error(`连接错误: ${err.message}`);
process.exit(1);
});
// 连接关闭处理
client.on('close', () => {
console.log('\n与服务器断开连接');
process.exit(0);
});
process.argv 解析:
# 运行命令
$ npx ts-node 客户端.ts 192.168.1.100 3500
# process.argv 数组
[
'C:\\Program Files\\nodejs\\node.exe', // argv[0] - Node.js 路径
'C:\\project\\客户端.ts', // argv[1] - 脚本路径
'192.168.1.100', // argv[2] - 第一个参数(IP)
'3500' // argv[3] - 第二个参数(端口)
]
类型安全处理:
// ❌ 不安全 - process.argv[3] 可能是 undefined
const PORT = parseInt(process.argv[3]) || 3500;
// ✅ 安全 - 先提供默认值,再转换
const PORT = parseInt(process.argv[3] || '3500');
2. 命令行交互优化
// 创建交互式命令行
const rl = readline.createInterface({
input: process.stdin, // 从标准输入读取
output: process.stdout, // 向标准输出写入
prompt: '> ' // 输入提示符
});
// 显示初始提示符
rl.prompt();
为什么需要 readline?
如果只用简单的 process.stdin,会出现这个问题:
用户输入:你好
服务器消息:[艾魅] 你好
显示结果:
> 你好[艾魅] 你好 ← 混乱!
使用 readline 后:
用户输入:你好
服务器消息:[艾魅] 你好
显示结果:
[艾魅] 你好
> ← 清晰!
3. 消息接收与显示
client.on('data', (data: Buffer) => {
const message = data.toString();
// 清除当前行(输入提示符所在行)
process.stdout.write('\r' + ' '.repeat(80) + '\r');
// 显示服务器消息
process.stdout.write(message);
// 确保消息后有换行
if (!message.endsWith('\n')) {
process.stdout.write('\n');
}
// 重新显示输入提示符
rl.prompt(true);
});
逐行解释:
-
'\r' + ' '.repeat(80) + '\r':\r- 回车符,光标回到行首' '.repeat(80)- 80 个空格,覆盖当前行内容\r- 再次回车,准备写入
效果:清除用户正在输入的内容
-
process.stdout.write(message):- 显示服务器发来的消息
- 不使用
console.log,因为会自动添加换行
-
rl.prompt(true):- 重新显示
>提示符 true参数表示保留当前行的输入(如果有)
- 重新显示
4. 消息发送
rl.on('line', (line: string) => {
const input = line.trim();
if (input) {
client.write(input + '\n'); // 发送到服务器
}
});
rl.on('close', () => {
client.end();
console.log('再见!');
process.exit(0);
});
为什么要加 \n?
服务器使用 .trim() 去除消息两端的空白,包括换行符。如果不加 \n,服务器可能无法正确识别消息边界。
5. 优雅退出
// 捕获 Ctrl+C
process.on('SIGINT', () => {
client.write('/quit\n'); // 发送退出命令
});
这样服务器会收到 /quit 命令,执行正常的退出流程(广播离开消息等)。
🔧 关键问题与解决方案
问题 1:客户端断开时显示端口而非昵称
现象:
[系统] 192.168.1.100:54321 离开了聊天室 ← 显示端口
原因:
- 在
/quit处理中,先从clientMap删除了 socket socket.end()触发close事件close事件中clientMap.get(socket)返回undefined- 使用备用值
clientInfo(IP:端口)
解决方案:
添加 hasLeftChat 标志,在 close 事件中检查:
socket.on('close', () => {
if (hasLeftChat) {
return; // 已主动离开,不再处理
}
// ... 正常处理
});
问题 2:重复广播离开消息
现象:
[系统] 艾魅 离开了聊天室
[系统] 艾魅 离开了聊天室 ← 重复!
原因:
/quit命令处理中广播了一次socket.end()触发close事件又广播了一次
解决方案:
同上,使用 hasLeftChat 标志防止重复处理。
问题 3:Windows 上 Ctrl+C 触发多次 SIGINT
现象:
按一次 Ctrl+C,关闭逻辑执行多次
原因:
Windows 系统的信号处理特性
解决方案:
添加 isShuttingDown 标志:
let isShuttingDown = false;
process.on('SIGINT', () => {
if (isShuttingDown) {
process.exit(1); // 第二次按 Ctrl+C 强制退出
return;
}
isShuttingDown = true;
// ... 正常关闭流程
});
问题 4:输入提示符与接收消息冲突
现象:
> 你好[艾魅] 你们好 ← 混乱
原因:
用户正在输入时,收到服务器消息,直接追加到当前行
解决方案:
使用 readline 模块,收到消息时清除当前行并重新显示提示符:
client.on('data', (data) => {
process.stdout.write('\r' + ' '.repeat(80) + '\r'); // 清除当前行
process.stdout.write(message); // 显示消息
rl.prompt(true); // 重新显示提示符
});
⚙️ TypeScript 配置要点
常见问题
很多初学者会遇到这些 TypeScript 错误:
❌ 找不到名称"net"。是否需要安装 Node.js 的类型定义?
❌ 参数"socket"隐式具有"any"类型。
❌ 找不到名称"process"。
解决方案
1. 安装类型定义
npm install --save-dev @types/node
2. 配置 tsconfig.json
{
"compilerOptions": {
"rootDir": ".", // ✅ 源码在根目录
"outDir": "./dist",
"module": "nodenext",
"target": "esnext",
"types": ["node"], // ✅ 启用 Node.js 类型
"strict": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["**/*.ts"], // ✅ 包含所有 .ts 文件
"exclude": ["node_modules", "dist"]
}
常见错误配置:
{
"compilerOptions": {
"rootDir": "./src", // ❌ 源码不在 src 目录
"verbatimModuleSyntax": true // ❌ 与 CommonJS 冲突
},
"include": ["src/**/*"], // ❌ 不包含根目录文件
"exclude": ["*.ts"] // ❌ 排除了所有 .ts 文件
}
3. 重启 TypeScript 服务
如果配置正确但仍报错,重启 VSCode 的 TypeScript 服务:
- 按
Ctrl + Shift + P - 输入
TypeScript: Restart TS Server - 回车执行
🚀 扩展功能建议
基础功能
- 私聊功能 -
@用户名 消息只发送给指定用户 - 聊天记录 - 保存历史消息到文件
- 表情包支持 - 支持 emoji 或自定义表情
- 多房间 - 用户可以加入不同聊天室
- 管理员权限 - 踢人、禁言、查看 IP
进阶功能
- WebSocket 支持 - 浏览器客户端
- 用户认证 - 登录/注册系统
- 消息加密 - TLS/SSL 加密通信
- 文件传输 - 发送图片、文档等
- 语音聊天 - WebRTC 集成
性能优化
- 消息长度限制 - 防止超长消息
- 频率限制 - 防止刷屏(如每秒最多 5 条)
- 心跳检测 - 定期发送心跳包,检测死连接
- 连接数限制 - 限制最大客户端数量
- Buffer 池 - 减少内存分配开销
安全加固
- 内容过滤 - 过滤敏感词、XSS 攻击
- 昵称规范 - 禁止特殊字符、保留字
- IP 白名单 - 只允许特定 IP 连接
- 速率限制 - 防止 DDoS 攻击
- 日志记录 - 记录所有操作,便于审计
📝 总结
通过这个实战项目,我学习了:
核心技术
- Socket 编程 - 理解 TCP 连接、端口、IP 地址
- 事件驱动 - Node.js 的非阻塞 I/O 模型
- 状态管理 - 使用闭包和 Map 管理客户端状态
- 流处理 - Socket 作为双工流的读写操作
- 优雅关闭 - 正确处理资源释放和连接断开
工程实践
- TypeScript 类型安全 - 严格模式下的开发技巧
- 错误处理 - 各种异常情况的容错处理
- 代码组织 - 清晰的模块划分和职责分离
- 用户体验 - 友好的命令行交互设计
- 健壮性 - 防止重复操作、超时保护等
Happy Coding! 🎉
更多推荐

所有评论(0)