前言
最近在學習websocket全雙工通信,想要做一個聯機小游戲,做游戲之前先做一個聊天室練練手。
跟著本篇博客,可以從0搭建一個屬于你自己的聊天室。
準備階段
什么人適合學習本篇文章?
答:前端開發者,有一定的HTML+CSS+JavaScript基礎,會使用fetch進行網絡請求
技術棧
- 前端三大件:HTML+CSS+JavaScript
- Redis數據庫(本篇博客涉及到的是Redis基礎中的基礎,小白也可以輕松拿捏!)
- Node.js+Express框架+pug模板引擎(本篇博客后端部分使用Node.js的Express框架完成,不了解的同學也不需要害怕,代碼簡單易懂,開箱即用,直接復制即可使用)
工具準備
1.編譯器
首先你需要有一個寫代碼的編譯器,這里推薦兩個(是我自己用的最多的,簡單方便的):
- vscode:老牌編譯器,功能強大,有豐富的插件資源,免費
官網直接下載: https://code.visualstudio.com/ - trea:AI編譯器,新時代熱門編譯器,有國內和海外兩個版本
國內:https://www.trae.cn/ide/download
優點:完全免費內置許多國內ai,也可自添模型
缺點:沒有國外ai,模型數量比國外版少很多
海外:https://www.trae.ai/download
優點:部分免費,可充值為pro版,內置許多國外國內ai,可自添模型
缺點:國外熱門ai如GPT,Claude模型等普通版要排隊很久,pro版不用排隊,但是每個月也有次數限制,一般夠用
2.Node.js環境
兩個方法:
1.使用nvm下載node版本
2.直接下載node
這里不管是新手還是老手都強烈建議使用nvm來下載node,nvm的優勢在于可以同時管理多個node版本,并且在需要的時候隨時切換不同版本
vnm
(下載安裝中文網敘述的很清楚,這里不在贅述,注意一點,安裝nvm前需要把電腦已有的node卸載):
下載:https://nvm.uihtm.com/doc/download-nvm.html
安裝:https://nvm.uihtm.com/doc/install.html
node
(如果想要快速開始的話,可直接下載node版本,建議下載v20.x.x版本較穩定):
下載:https://nodejs.org/en/download
3.Redis數據庫
redis是linux環境下開發的高并發性能好的鍵值類型數據庫,要想在windows環境下使用,常用有三種方法:
- 使用windows虛擬機模擬linux環境,運行redis
- 使用Docker Desktop拉取鏡像并在容器中運行
- 使用windows版redis,雖說比不上linux版的redis但是日常練習和教學完全夠用了
這里我們主要講第三種方法,想要使用windows版的redis需要訪問GitHub上的開源庫:https://github.com/redis-windows/redis-windows/releases,可能需要翻墻這個看運氣
確保自己的電腦是windows,64位的,前四個都可下載,帶service 的要比不帶service的多一個自動添加服務的功能,你可以理解為有一個開機自啟動Redis的功能,因為我們不需要這個功能,所以我選擇下載的是第四個
下載好之后解壓到一個文件夾里面,解壓后的文件大概長這樣
想要使用redis的服務,首先就是要運行起服務端
打開黑窗口運行:redis-server.exe redis.conf
出現下圖的圖案就算運行服務端成功了
兩個注意點:
- 運行命令的文件路徑需要是你解壓后的文件夾路徑,例如我這里就是:C:\software\redis,運行時需要換成你自己的路徑否則會報命令不存在的錯誤
- 運行完之后這個黑窗口不能關,關了就取消服務了,就會訪問不到redis了
解決方法:
要解決上面提到的找不到命令的問題,除了在正確的路徑下運行命令外,還有一個很常用的方法,就是配置一下環境變量,這個很簡單,這里就不講了,如果有不懂的同學可以在評論區問我,到時候再解答
開始實踐
如果你跟著步驟看到這里,你應該已經擁有了一個編譯器(寫代碼的地方),Node.js環境(運行后端服務的地方),redis數據庫(存儲數據的地方)
有了這些之后可以正式開始寫代碼了!
1.搭建項目
找一個存放項目的文件夾,新建
chat-room
文件夾,用來存放項目
在trea里面打開
chat-room
文件夾(使用vscode打開也是一樣的,不影響項目運行)
剛開始什么都沒有,讓我們先初始化一個package.json配置文件用來管理項目
使用npm工具來初始化package.json,這里我們使用
npm init -y
來快捷創建默認的配置文件
執行完命令后你的工作區應該長這個樣子,并且內容如下
我們的聊天室前端部分pug語法渲染要和express框架結合,所以我們先下載express框架,引入pug語法,使用
npm install express pug nodemon
下載依賴(nodemon是我后來加的,下面圖片沒有,是可選的,推薦下載可以用來實現node服務運行的時候熱重載)
按照下圖,創建項目的基礎結構
現在我們逐個文件填充代碼,接下來的文件可直接復制使用,如果有疑問可直接復制給ai講解代碼,學會運用ai是當下我們程序員要培養的基本素養了
app.js:node服務啟動文件
// 引入 express 模塊const express = require("express");
const path = require("path");// 創建應用實例
const app = express();// 1. 引入靜態資源
app.get(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 視圖目錄
app.set("views", path.join(__dirname, "views"));// 攔截/地址路由,渲染登錄頁面
app.use("/", (req, res) => {res.render("login", {title: "請登錄",});
});// 監聽listen
app.listen(3000, () => {console.log("服務器啟動成功");
});
layout.pug:布局文件
doctype html
htmlheadmeta(charset='utf-8')meta(name='viewport', content='width=device-width, initial-scale=1.0')title= title link(rel='stylesheet', href='/css/style.css')bodyblock content
login.pug:登錄頁面
extend layout
block content .container.login-container h1= titleform(method='post', action='/login' id='loginForm')input(type='text',name='username',placeholder='請輸入用戶名',required,autocomplete='off')button(type='submit') 進入聊天室#error-message script(src='/js/login.js')
style.css:樣式文件(完整版的)
:root {--primary-color: #4caf50;--shadow-color: rgba(0, 0, 0, 0.1);
}* {margin: 0;padding: 0;box-sizing: border-box;
}body {font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;background: #f5f5f5;min-height: 100vh;display: flex;align-items: center;justify-content: center;
}.container {width: 100%;max-width: 400px;padding: 20px;
}.login-container {background: white;padding: 2rem;border-radius: 8px;box-shadow: 0 4px 6px var(--shadow-color);
}h1 {color: var(--primary-color);text-align: center;margin-bottom: 1.5rem;font-size: 1.8rem;
}form {display: flex;flex-direction: column;gap: 1rem;
}input {padding: 12px;border: 2px solid #e0e0e0;border-radius: 4px;font-size: 16px;transition: border-color 0.3s;
}input:focus {outline: none;border-color: var(--primary-color);
}button {background: var(--primary-color);color: white;border: none;padding: 12px;border-radius: 4px;font-size: 16px;cursor: pointer;transition: opacity 0.3s;
}button:hover {opacity: 0.9;
}#error-message {display: none;padding: 10px;margin-top: 15px;border-radius: 4px;background-color: #fef2f2;border: 1px solid #fecaca;color: #ef4444;transition: opacity 0.3s ease;
}#error-message.show {display: block;animation: fadeIn 0.3s ease;
}@keyframes fadeIn {from {opacity: 0;transform: translateY(-10px);}to {opacity: 1;transform: translateY(0);}
}
寫完這四個文件并且package.json中配置好啟動命令,就可以啟動項目了,我這里用的nodemon啟動的項目,沒有的同學可以把
start
后面的命令改為node app.js
,但是還是推薦先下載一下npm install nodemon
使用nodemon
啟動項目
2.準備登錄接口和靜態聊天室
現在開始準備表單提交邏輯
public/js/login.js
// 獲取表單元素,監聽提交事件
document.getElementById("loginForm").addEventListener("submit", async (e) => {// 阻止表單默認提交e.preventDefault();const formData = new FormData(e.target);const username = formData.get("username");try {// 發送登錄請求const response = await fetch("/login", {method: "POST",headers: {"Content-Type": "application/json",},body: JSON.stringify({ username }),});const data = await response.json();if (!response.ok) {throw new Error(data.error || "登錄失敗");}// 登錄成功,重定向到聊天頁面if (data.success) {window.location.href = "/chat";}} catch (error) {showError(error.message);}
});function showError(message) {// 顯示錯誤提示const errorDiv = document.getElementById("error-message");errorDiv.textContent = message;errorDiv.classList.add("show");// 3秒后自動隱藏錯誤提示setTimeout(() => {errorDiv.classList.remove("show");}, 3000);
}
下載兩個依賴
npm install jsonwebtoken dotenv
用來提高登錄功能的健壯性,根目錄下新建.env
文件,更新根目錄下的app.js
添加登錄接口
// 引入 express 模塊
const express = require("express");
const path = require("path");
const jwt = require("jsonwebtoken");
require("dotenv").config();// 創建應用實例
const app = express();// 1. 引入靜態資源
app.use(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 視圖目錄
app.set("views", path.join(__dirname, "views"));
// 4. 解析 JSON 請求體
app.use(express.json());// 攔截/地址路由,渲染登錄頁面
app.get("/", (req, res) => {res.render("login", {title: "請登錄",});
});// 處理登錄接口
app.post("/login", async (req, res) => {try {const { username } = req.body;if (!username) {return res.status(400).send("用戶名不能為空");}// 生成 tokenconst token = jwt.sign({ username }, process.env.JWT_SECRET, {expiresIn: "2h",});res.cookie("token", token, { httpOnly: true });res.json({success: true,});} catch (e) {console.error("登錄失敗:", e);res.status(500).send("服務器錯誤");}
});// 監聽listen
app.listen(3000, () => {console.log("服務器啟動成功");
});
現在可以登錄了,但是還需要準備chat頁面和對應的鑒權邏輯
新建views/chat.pug
extend layout
block content .container .chat-container#messages form#form.chat-forminput#input(type='text',placeholder='請輸入消息...',autocomplete='off')button(type='submit') 發送script(src='/js/chat.js')
下載 npm install cookie-parser
依賴
更新app.js
// 引入 express 模塊
const express = require("express");
const path = require("path");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const cookieParser = require("cookie-parser");// 創建應用實例
const app = express();// 1. 引入靜態資源
app.use(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 視圖目錄
app.set("views", path.join(__dirname, "views"));
// 4. 解析 JSON 請求體
app.use(express.json());
// 5. 解析 Cookie
app.use(cookieParser());// 攔截/地址路由,渲染登錄頁面
app.get("/", (req, res) => {res.render("login", {title: "請登錄",});
});// 處理登錄接口
app.post("/login", async (req, res) => {try {const { username } = req.body;if (!username) {return res.status(400).send("用戶名不能為空");}// 生成 tokenconst token = jwt.sign({ username }, process.env.JWT_SECRET, {expiresIn: "2h",});res.cookie("token", token, { httpOnly: true });res.json({success: true,});} catch (e) {console.error("登錄失敗:", e);res.status(500).send("服務器錯誤");}
});
// 處理聊天室路由
app.use("/chat", mustAuth, (_, res) =>res.render("chat", { title: "實時聊天室" })
);/* ====== 中間件:JWT 校驗 ====== */
function mustAuth(req, res, next) {const token = req.cookies.token;if (!token) return res.redirect("/");try {jwt.verify(token, process.env.JWT_SECRET);next();} catch {res.redirect("/");}
}// 監聽listen
app.listen(3000, () => {console.log("服務器啟動成功");
});
3.websocket實現實時通訊聊天室
現在登錄功能和靜態的聊天室頁面已經準備好了,接下來準備設置websocket,實現實時通訊
設置websocket服務端:根目錄下新建ws-server.js
,并引入npm install ws
依賴
ws.server.js
const WebSocket = require("ws");
const jwt = require("jsonwebtoken");// 定義用戶狀態常量
const USER_STATUS = {ONLINE: 1,OFFLINE: 2,
};// 啟動 WebSocket 服務
module.exports = (server) => {const wss = new WebSocket.Server({server,// 握手階段攔截verifyClient: (info, cb) => {const cookies = info.req.headers.cookie || "";console.log("cookies", cookies);// 從 cookies 中提取 tokenconst token = cookies.match(/token=([^;]+)/)?.[1];if (!token) return cb(false, 401, "Missing token");try {jwt.verify(token, process.env.JWT_SECRET);cb(true); // 放行} catch {cb(false, 401, "Invalid token");}},});// 監聽客戶端連接wss.on("connection", (ws, req) => {console.log("客戶端連接成功");// 解析用戶名const cookies = req.headers.cookie || "";const token = cookies.match(/token=([^;]+)/)?.[1];ws.username = "匿名用戶";if (token) {try {const payload = jwt.verify(token, process.env.JWT_SECRET);ws.username = payload.username || ws.username;} catch (e) {// token無效,保持匿名console.error("Token 驗證失敗:", e);}}// 廣播歡迎消息給所有客戶端wss.clients.forEach((client) => {if (client.readyState === WebSocket.OPEN) {client.send(JSON.stringify({type: "sys",text: `歡迎 ${ws.username} 進入聊天室`,number: wss.clients.size,}));}});// 廣播ws.on("message", (data) => {console.log("收到消息:", data);const message = JSON.parse(data);// 廣播給所有客戶端wss.clients.forEach((client) => {if (client.readyState === WebSocket.OPEN) {client.send(JSON.stringify({type: "chat",from: ws.username,text: message.text,time: new Date().toLocaleString(),number: wss.clients.size,}));}});});// 監聽斷開連接ws.on("close", async () => {console.log(`用戶 ${ws.username} 斷開連接`);try {if (ws.username && ws.username !== "匿名用戶") {// 廣播用戶離開消息wss.clients.forEach((client) => {if (client !== ws && client.readyState === WebSocket.OPEN) {client.send(JSON.stringify({type: "sys",text: `${ws.username} 離開了聊天室`,number: wss.clients.size, // 減去即將斷開的連接}));}});}} catch (error) {console.error(`更新用戶 ${ws.username} 狀態失敗:`, error);}});});
};
準備websocket客戶端,
public/js
目錄下新建chat.js
public/js/chat.js
const socket = new WebSocket(`ws://${location.host}`);
const messages = document.getElementById("messages");
const form = document.getElementById("form");
const input = document.getElementById("input");// 監聽消息,并展示
socket.onmessage = (event) => {const { type, from, text, time, number } = JSON.parse(event.data);const div = document.createElement("div");div.innerHTML =type === "sys"? `<em>${text},當前在線人數: ${number}</em>`: `<strong>${from}</strong>: <small>${time}</small>:${text}`;messages.appendChild(div);messages.scrollTop = messages.scrollHeight; // 滾動到底部
};// 發送消息
form.addEventListener("submit", (e) => {e.preventDefault();const message = input.value.trim();if (!message) return;try {socket.send(JSON.stringify({ text: message }));input.value = ""; // 清空輸入框} catch (err) {console.error("發送消息失敗:", err);alert("發送失敗,請檢查網絡連接");}
});
更新app.js,建立websocket連接
// 引入 express 模塊
const express = require("express");
const path = require("path");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const cookieParser = require("cookie-parser");// 創建應用實例
const app = express();// 1. 引入靜態資源
app.use(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 視圖目錄
app.set("views", path.join(__dirname, "views"));
// 4. 解析 JSON 請求體
app.use(express.json());
// 5. 解析 Cookie
app.use(cookieParser());// 攔截/地址路由,渲染登錄頁面
app.get("/", (req, res) => {res.render("login", {title: "請登錄",});
});// 處理登錄接口
app.post("/login", async (req, res) => {try {const { username } = req.body;if (!username) {return res.status(400).send("用戶名不能為空");}// 生成 tokenconst token = jwt.sign({ username }, process.env.JWT_SECRET, {expiresIn: "2h",});res.cookie("token", token, { httpOnly: true });res.json({success: true,});} catch (e) {console.error("登錄失敗:", e);res.status(500).send("服務器錯誤");}
});// 處理聊天頁面路由
app.use("/chat", mustAuth, (_, res) =>res.render("chat", { title: "實時聊天室" })
);/* ====== 中間件:JWT 校驗 ====== */
function mustAuth(req, res, next) {const token = req.cookies.token;if (!token) return res.redirect("/");try {jwt.verify(token, process.env.JWT_SECRET);next();} catch {res.redirect("/");}
}app.use((err, req, res, next) => {// 錯誤處理中間件console.error(err);
});// 監聽listen
app.listen(3000, () => {console.log("服務器啟動成功");
});// 啟動 WebSocket 服務
require("./ws-server")(server);
到這里我們的實時聊天室已經實現了,項目根目錄下運行
npm run start
就可以把我們的聊天室在本地localhost:3000
跑起來了
4.項目引入Redis,禁止重復登錄
雖然我們的聊天室已經完成了,但是基礎好的同學就會發現,我們的聊天室對于登錄的賬號是沒有限制的,所以就可能出現同一個賬戶重復登錄的情況
重復登錄情況
現在我們要借用redis記錄登錄狀態,進而防止重復登錄
本地啟動redis服務端
win+R
打開運行窗口輸入cmd
打開黑窗口
在安裝redis的目錄下輸入redis-server.exe redis.conf
啟動redis服務,啟動完服務后,這個黑窗口不能關,一關,redis就斷開連接了
我們根目錄下新建configs/redis.js,同時下載
npm install ioredis
用來在項目中配置連接redis
configs/redis.js
// 引入 ioredis 模塊
const Redis = require("ioredis");// 連接 Redis 數據庫const redis = new Redis({host: process.env.REDIS_HOST || "127.0.0.1",port: process.env.REDIS_PORT || 6379,password: process.env.REDIS_PASSWORD || "123456",
});// 監聽 Redis 連接事件redis.on("connect", () => {console.log("Redis 連接成功");
});
// 監聽 Redis 錯誤事件redis.on("error", (err) => {console.error("Redis 連接失敗:", err);
});
// 導出 Redis 實例module.exports = redis;
更新app.js
// 引入 express 模塊
const express = require("express");
const path = require("path");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const cookieParser = require("cookie-parser");
const redis = require("./configs/redis"); // 新引入redis實例// 創建應用實例
const app = express();// 1. 引入靜態資源
app.use(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 視圖目錄
app.set("views", path.join(__dirname, "views"));
// 4. 解析 JSON 請求體
app.use(express.json());
// 5. 解析 Cookie
app.use(cookieParser());// 攔截/地址路由,渲染登錄頁面
app.get("/", (req, res) => {res.render("login", {title: "請登錄",});
});// 處理登錄接口
app.post("/login", async (req, res) => {try {const { username } = req.body;if (!username) {return res.status(400).send("用戶名不能為空");}// 將用戶信息存儲到 Redisconst userKey = `user:${username}`;if ((await redis.hget(userKey, "status")) === "1") {return res.status(400).json({ error: "用戶已登錄,請勿重復登錄" });}// 生成 tokenconst token = jwt.sign({ username }, process.env.JWT_SECRET, {expiresIn: "2h",});// 如果用戶已存在,更新狀態await redis.hset(userKey, "status", 1);// 設置過期時間(2小時)await redis.expire(userKey, 7200);res.cookie("token", token, { httpOnly: true });res.json({success: true,});} catch (e) {console.error("登錄失敗:", e);res.status(500).send("服務器錯誤");}
});// 處理聊天頁面路由
app.use("/chat", mustAuth, (_, res) =>res.render("chat", { title: "實時聊天室" })
);/* ====== 中間件:JWT 校驗 ====== */
function mustAuth(req, res, next) {const token = req.cookies.token;if (!token) return res.redirect("/");try {jwt.verify(token, process.env.JWT_SECRET);next();} catch {res.redirect("/");}
}app.use((err, req, res, next) => {// 錯誤處理中間件console.error(err);
});// 監聽listen
const server = app.listen(3000, () => {console.log("服務器啟動成功");
});// 啟動 WebSocket 服務
require("./ws-server")(server);
更新完app.js后,你再運行項目,就會發現,不能同時登錄同一個賬戶了,但是還有一個問題,那就是即使我們退出一個賬號,想再次登錄的時候仍然登錄不了!這是因為沒有做用戶離開聊天室時,對登錄狀態更改的邏輯!
更新根目錄下的ws-server.js
處理用戶聊天室后,取消登錄狀態
ws-server.js
const WebSocket = require("ws");
const jwt = require("jsonwebtoken");
const redis = require("./configs/redis");// 定義用戶狀態常量
const USER_STATUS = {ONLINE: 1,OFFLINE: 2,
};// 啟動 WebSocket 服務
module.exports = (server) => {const wss = new WebSocket.Server({server,// 握手階段攔截verifyClient: (info, cb) => {const cookies = info.req.headers.cookie || "";console.log("cookies", cookies);// 從 cookies 中提取 tokenconst token = cookies.match(/token=([^;]+)/)?.[1];if (!token) return cb(false, 401, "Missing token");try {jwt.verify(token, process.env.JWT_SECRET);cb(true); // 放行} catch {cb(false, 401, "Invalid token");}},});// 監聽客戶端連接wss.on("connection", (ws, req) => {console.log("客戶端連接成功");// 解析用戶名const cookies = req.headers.cookie || "";const token = cookies.match(/token=([^;]+)/)?.[1];ws.username = "匿名用戶";if (token) {try {const payload = jwt.verify(token, process.env.JWT_SECRET);ws.username = payload.username || ws.username;// 設置用戶在線狀態const userKey = `user:${ws.username}`;redis.hset(userKey, "status", USER_STATUS.ONLINE).catch((err) => console.error("設置用戶在線狀態失敗:", err));} catch (e) {// token無效,保持匿名console.error("Token 驗證失敗:", e);}}// 廣播歡迎消息給所有客戶端wss.clients.forEach((client) => {if (client.readyState === WebSocket.OPEN) {client.send(JSON.stringify({type: "sys",text: `歡迎 ${ws.username} 進入聊天室`,number: wss.clients.size,}));}});// 廣播ws.on("message", (data) => {console.log("收到消息:", data);const message = JSON.parse(data);// 廣播給所有客戶端wss.clients.forEach((client) => {if (client.readyState === WebSocket.OPEN) {client.send(JSON.stringify({type: "chat",from: ws.username,text: message.text,time: new Date().toLocaleString(),number: wss.clients.size,}));}});});// 監聽斷開連接ws.on("close", async () => {console.log(`用戶 ${ws.username} 斷開連接`);try {if (ws.username && ws.username !== "匿名用戶") {const userKey = `user:${ws.username}`;// 更新用戶狀態為離線await redis.hset(userKey, "status", USER_STATUS.OFFLINE);console.log(`用戶 ${ws.username} 狀態已更新為離線`);// 廣播用戶離開消息wss.clients.forEach((client) => {if (client !== ws && client.readyState === WebSocket.OPEN) {client.send(JSON.stringify({type: "sys",text: `${ws.username} 離開了聊天室`,number: wss.clients.size, // 減去即將斷開的連接}));}});}} catch (error) {console.error(`更新用戶 ${ws.username} 狀態失敗:`, error);}});});
};
未完待續
到此為止,我們已經擁有了一個有一定登錄權限控制的實時聊天室了
但是我想我們的追求不應該止步于此,后續我會持續更新幾個新功能
感興趣的小伙伴可以適時再來查看這篇博客
- 2025.8.17:完成UI樣式更新
- 2025.8.20:新增好友功能,實現單聊
有問題的同學歡迎在評論區討論學習!!!
本篇博客涉及到的學習資源如下,想深入學習的同學可自行閱讀:
WebSocket:https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket
Express:https://express.js.cn/en/guide/routing.html
Redis:https://www.runoob.com/redis/redis-tutorial.html