freeCodeCamp 高级 Node.js 与 Express:用 Socket.IO 广播用户上线/下线公告(Announce New Users 挑战详解)
本篇技术文章围绕 freeCodeCamp 课程「Advanced Node.js and Express」认证中编号为 589fc832f9fc0f352b528e78 的项目型挑战 Announce New Users(公告新用户的上线/下线)展开。该挑战是聊天室项目序列中的关键一步:在前序挑战中你已经实现了 currentUsers 计数与 'user count' 事件的收发、disconnect 处理和 Socket.IO 的 Passport 会话鉴权,本篇将把这两个独立的「用户数」事件合并升级为一个携带 username、currentUsers、connected 三个字段的 'user' 事件,并在浏览器端用 jQuery 更新在线人数、追加「某某已进入/离开了聊天室」的公告。读完后,你将掌握 Socket.IO 广播事件的数据设计方式、io.emit 与 socket.on 的服务端/客户端配对关系,以及 freeCodeCamp 平台如何用正则断言对 server.js 和 client.js 的代码做自动化验收。
1. 挑战在聊天室项目中的位置
该挑战位于 advanced-node-and-express 挑战顺序文件 的第 21 位(共 21 个挑战),处于四个连续聊天室挑战的中间环节。按 挑战结构 中的 challengeOrder 排列,前后关系是:
- Set up the Environment(589fc830f9fc0f352b528e74):在 Express 应用上挂载
http.createServer(app),用require('socket.io')(http)实例化io,监听io.on('connection', ...);客户端在client.js中let socket = io();。 - Communicate by Emitting(589fc831f9fc0f352b528e75):定义
let currentUsers = 0;,在 connect 监听器中++currentUsers;后io.emit('user count', currentUsers);,客户端用socket.on('user count', ...)接收。 - Handle a Disconnect(589fc831f9fc0f352b528e76):在 connect 监听器内部再挂
socket.on('disconnect', ...),断开时把currentUsers减一并再次 emit'user count'。 - Authentication with Socket.IO(589fc831f9fc0f352b528e77):通过
passport.socketio+cookie-parser+connect-mongo解析express.sid会话 Cookie,把用户对象反序列化后挂到socket.request.user上。 - Announce New Users(本文档,589fc832f9fc0f352b528e78):合并并升级事件,广播用户上线/下线公告。
- Send and Display Chat Messages(589fc832f9fc0f352b528e79):最终接入
'chat message'事件的收发,完成整个聊天室。
理解这个序列很重要:本挑战的三块原料——currentUsers(来自挑战 2)、disconnect 监听(来自挑战 3)、socket.request.user.username(来自挑战 4)——都是前序挑战埋好的伏笔。若跳过前置步骤直接做本挑战,socket.request.user 会是 undefined。
2. 服务端:把两个 'user count' 事件合并为一个 'user' 事件
原始文档(Announce New Users 挑战文件)给出的核心要求是:
把事件名改为
'user',随事件传递一个包含username、currentUsers、connected字段的对象(连接时为true,断开时用户为false)。务必修改两处'user count'事件的发射点,并把 disconnect 那处的connected设为false,而不是像 connect 事件那样发送true。
2.1 事件数据结构的三个字段
为什么是这三块数据?因为客户端要完成两件事:显示当前在线人数、公告某个用户进入了或离开了聊天。把信息一次性打包进一个事件,比让客户端自行拼接多个事件更可靠(避免两次 emit 之间的竞态),也少一个网络往返。三个字段的语义:
| 字段 | 类型 | 说明 |
|---|---|---|
username |
string | 发生连接/断开行为的用户名,取自 socket.request.user.username |
currentUsers |
number | 广播时刻的在线用户总数(connect 时已自增、disconnect 时已自减后的值) |
connected |
boolean | true 表示该用户上线,false 表示下线 |
2.2 完整的服务端改造
结合前序挑战的铺垫,本挑战完成后的 server.js 中 Socket.IO 部分应当长成这样(++currentUsers 与 emit 的顺序、--currentUsers 的位置均沿用前序挑战的写法):
// 依赖来自前序挑战:http 服务、io 实例、passport.socketio 鉴权
let currentUsers = 0;
io.on('connection', socket => {
// 来自 "Authentication with Socket.IO" 挑战:socket.request.user 已由
// passport.socketio 中间件从 express.sid cookie 反序列化得到
++currentUsers;
io.emit('user', {
username: socket.request.user.username,
currentUsers,
connected: true
});
// 来自 "Handle a Disconnect" 挑战:disconnect 必须在 socket 上监听
socket.on('disconnect', () => {
--currentUsers;
io.emit('user', {
username: socket.request.user.username,
currentUsers,
connected: false
});
});
});
原始文档明确给出的最小核心代码片段是:
io.emit('user', {
username: socket.request.user.username,
currentUsers,
connected: true
});
文档同时强调「Be sure to change both 'user count' events」——即 connect 处和 disconnect 处两处都要改,且 disconnect 处的 connected 必须是 false。这是本挑战最常见的扣分点:只改了连接处、忘了断开处,或者断开处照抄了 true,客户端就会把「离开」错误地显示成「进入」。
2.3 关键细节:username 在 disconnect 时依然可读
一个容易踩的坑是:断开连接时 socket.request.user 还在吗?从 Authentication with Socket.IO 挑战 的实现方式看,passportSocketIo.authorize 是在连接建立之前的中间件阶段完成会话解析并挂上 socket.request.user 的,之后整个 socket 生命周期内(包括触发 'disconnect' 时)该对象都保持可读。因此 disconnect 分支里同样可以用 socket.request.user.username 报出是哪个用户离开。
3. 客户端:监听 'user' 事件并用 jQuery 更新页面
文档原文给出的客户端实现如下,这里完整保留并补充说明:
socket.on('user', data => {
$('#num-users').text(data.currentUsers + ' users online');
let message =
data.username +
(data.connected ? ' has joined the chat.' : ' has left the chat.');
$('#messages').append($('<li>').html('<b>' + message + '</b>'));
});
文档对页面行为的描述是:用 jQuery 把 #num-users 的文本更新为 '{NUMBER} users online',并向 id 为 messages 的无序列表追加一个 <li>,内容为 '{NAME} has {joined/left} the chat.'。逐行拆解:
socket.on('user', data => {...}):客户端监听的事件名必须与服务端 emit 的'user'完全一致;data就是服务端传出的那个三字段对象。$('#num-users').text(data.currentUsers + ' users online'):直接以服务端下发的计数为准覆盖文本,不做本地累加,保证多客户端之间始终与服务端单一事实源(server 端的currentUsers)一致。- 三元运算符
data.connected ? ' has joined the chat.' : ' has left the chat.'是connected布尔字段的消费方式——字段值在这里转化为「joined/left」两种文案。 $('#messages').append($('<li>').html('<b>' + message + '</b>')):公告消息以加粗的列表项追加进消息区,与后续挑战(Send and Display Chat Messages)中真正的聊天消息共用同一个#messages列表。
注意此时 'user count' 事件的旧监听器应当被本监听器取代——事件名已改名,旧监听器即使保留也不会再收到数据,但它已无意义,清理掉是合理的。
4. 自动化验收:平台如何用测试断言检查你的代码
freeCodeCamp 的 challengeType: 2(项目型)挑战不是靠快照对比,而是把你在平台编辑器里保存的代码当作真实运行的服务,由测试代码向 /_api/server.js 与 /public/client.js 发请求取回源码文本,再用正则做结构性断言。这一点从 Announce New Users 挑战文件 内嵌的 --hints-- 测试代码可以直接读出来,完整继承如下:
断言一:服务端必须 emit 携带三个字段 'user' 事件。
const url = new URL("/_api/server.js", code);
const res = await fetch(url);
const data = await res.text();
// Regex is lenient to match both `username` and `name` as the key on purpose.
assert.match(
data,
/io.emit.*('|")user\1.*name.*currentUsers.*connected/s,
'You should have an event emitted named user sending name, currentUsers, and connected'
);
注意正则里 \1 反向引用保证事件名的引号成对,且字段顺序被刻意放宽:只要 name(username 或 name 均可)出现在 currentUsers 之前、currentUsers 出现在 connected 之前即可。注释也说明了这是有意为之——字段键名 name 与 username 都能通过。
断言二:客户端必须在 'user' 监听器里更新 #num-users 文本。
const url = new URL("/public/client.js", code);
const res = await fetch(url);
const data = await res.text();
assert.match(
data,
/socket.on.*('|")user\1[^]*num-users/s,
'You should change the text of "#num-users" within on your client within the "user" event listener to show the current users connected'
);
断言三:客户端必须在同一监听器里向 #messages 追加 <li>。
assert.match(
data,
/socket.on.*('|")user\1[^]*messages.*li/s,
'You should append a list item to "#messages" on your client within the "user" event listener to announce a user came or went'
);
这三条断言解释了为什么服务端必须 emit 到 io(而不是某个单个 socket):io.emit 才是广播到全部已连接 socket 的语义。它同时也解释了第 2 节里为什么 disconnect 处一定要补一条 emit——虽然断言本身只检查 io.emit.*user 是否存在,但「两处都改」是功能正确性的要求,只改一处会导致在线人数与公告文案错乱。
从源码结构看,这套 new URL("/_api/server.js", code) + fetch + assert.match 的测试写法是该 block 所有挑战共享的项目测试范式,例如 Communicate by Emitting 与 Handle a Disconnect 中的断言结构与之完全一致;客户端渲染入口则涉及 independent-lower-jaw 组件,它负责在挑战页面中呈现这类项目型挑战的编辑器与测试输出区域。
5. 常见错误与自检清单
结合文档要求与断言逻辑,提交前可以对照检查:
- 两处 emit 是否都改了:connect 与 disconnect 分支都必须 emit
'user',且connected分别为true/false。漏改 disconnect 处不会让断言一失败(正则只要求存在一条io.emit.*user),但功能上是错的——用户离开时在线人数不再更新。 currentUsers的自增/自减位置:connect 处必须「先自增、后广播」,disconnect 处必须「先自减、后广播」,否则广播出去的是旧值。- 客户端事件名拼写:服务端 emit 的是
'user',客户端socket.on的必须是同一字符串,引号风格不限(正则('|")user\1兼容单双引号)。 #num-users文本格式:断言二要求监听器内出现num-users,功能上文本应为'{NUMBER} users online',currentUsers为数字而非字符串拼接产物。- 公告列表项:必须以
<li>追加到#messages,文案区分 joined / left,取决于connected字段。 - 鉴权前置:若尚未完成 Authentication with Socket.IO 挑战,
socket.request.user不存在,socket.request.user.username会直接抛 TypeError,连接建立即断开,形成无限重连。
文档末尾提示:若运行中遇到错误,可参照论坛中「项目完成到这一步」的参考进度(原文指向 freeCodeCamp 论坛的 advanced-node-and-express 帖子锚点 #announce-new-users-10)。
6. 延伸:这套事件模式为后续挑战铺了什么路
本挑战确立的「服务端广播结构化事件 + 客户端单一监听器更新多处 DOM」模式,会被下一个挑战 Send and Display Chat Messages 直接复用:客户端在表单提交时 socket.emit('chat message', messageToSend),服务端监听后 io.emit('chat message', { username, message }),客户端再把消息追加进同一个 #messages 列表。也就是说,第 2、3 节中你写好的 #messages 列表此时已经同时承载两类内容——加粗的进出公告和正常的聊天消息,这正是完整聊天室的最终形态。
对照 挑战顺序文件 可知,这是「Advanced Node.js and Express」认证最后一组实操挑战;完成它之后,整个从 Express 路由、Passport 鉴权、express-session 会话到 Socket.IO 实时通信的完整后端应用链路即告收尾。
适用前提与范围说明:本文基于 freeCodeCamp 课程仓库中的英文挑战文档(challengeType: 2 项目型挑战)撰写,所涉代码运行在 freeCodeCamp 平台提供的项目沙箱中(依赖 socket.io@~2.3.0、passport.socketio@~3.7.0、connect-mongo@~3.2.0、cookie-parser@~1.4.5 已由平台预置为依赖)。文中所有代码示例、断言正则与文件路径均取自当前仓库实际内容;对未直接展示的实现细节,均已用「从源码结构看」「可以推断」等措辞标注。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00