首页
/ freeCodeCamp 高级 Node.js 与 Express:用 Socket.IO 广播用户上线/下线公告(Announce New Users 挑战详解)

freeCodeCamp 高级 Node.js 与 Express:用 Socket.IO 广播用户上线/下线公告(Announce New Users 挑战详解)

2026-09-04 10:24:14作者:贡沫苏Truman

本篇技术文章围绕 freeCodeCamp 课程「Advanced Node.js and Express」认证中编号为 589fc832f9fc0f352b528e78 的项目型挑战 Announce New Users(公告新用户的上线/下线)展开。该挑战是聊天室项目序列中的关键一步:在前序挑战中你已经实现了 currentUsers 计数与 'user count' 事件的收发、disconnect 处理和 Socket.IO 的 Passport 会话鉴权,本篇将把这两个独立的「用户数」事件合并升级为一个携带 usernamecurrentUsersconnected 三个字段的 'user' 事件,并在浏览器端用 jQuery 更新在线人数、追加「某某已进入/离开了聊天室」的公告。读完后,你将掌握 Socket.IO 广播事件的数据设计方式、io.emitsocket.on 的服务端/客户端配对关系,以及 freeCodeCamp 平台如何用正则断言对 server.jsclient.js 的代码做自动化验收。

1. 挑战在聊天室项目中的位置

该挑战位于 advanced-node-and-express 挑战顺序文件 的第 21 位(共 21 个挑战),处于四个连续聊天室挑战的中间环节。按 挑战结构 中的 challengeOrder 排列,前后关系是:

  1. Set up the Environment589fc830f9fc0f352b528e74):在 Express 应用上挂载 http.createServer(app),用 require('socket.io')(http) 实例化 io,监听 io.on('connection', ...);客户端在 client.jslet socket = io();
  2. Communicate by Emitting589fc831f9fc0f352b528e75):定义 let currentUsers = 0;,在 connect 监听器中 ++currentUsers;io.emit('user count', currentUsers);,客户端用 socket.on('user count', ...) 接收。
  3. Handle a Disconnect589fc831f9fc0f352b528e76):在 connect 监听器内部再挂 socket.on('disconnect', ...),断开时把 currentUsers 减一并再次 emit 'user count'
  4. Authentication with Socket.IO589fc831f9fc0f352b528e77):通过 passport.socketio + cookie-parser + connect-mongo 解析 express.sid 会话 Cookie,把用户对象反序列化后挂到 socket.request.user 上。
  5. Announce New Users(本文档,589fc832f9fc0f352b528e78):合并并升级事件,广播用户上线/下线公告。
  6. Send and Display Chat Messages589fc832f9fc0f352b528e79):最终接入 'chat message' 事件的收发,完成整个聊天室。

理解这个序列很重要:本挑战的三块原料——currentUsers(来自挑战 2)、disconnect 监听(来自挑战 3)、socket.request.user.username(来自挑战 4)——都是前序挑战埋好的伏笔。若跳过前置步骤直接做本挑战,socket.request.user 会是 undefined

2. 服务端:把两个 'user count' 事件合并为一个 'user' 事件

原始文档(Announce New Users 挑战文件)给出的核心要求是:

把事件名改为 'user',随事件传递一个包含 usernamecurrentUsersconnected 字段的对象(连接时为 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',并向 idmessages 的无序列表追加一个 <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 反向引用保证事件名的引号成对,且字段顺序被刻意放宽:只要 nameusernamename 均可)出现在 currentUsers 之前、currentUsers 出现在 connected 之前即可。注释也说明了这是有意为之——字段键名 nameusername 都能通过。

断言二:客户端必须在 '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 EmittingHandle a Disconnect 中的断言结构与之完全一致;客户端渲染入口则涉及 independent-lower-jaw 组件,它负责在挑战页面中呈现这类项目型挑战的编辑器与测试输出区域。

5. 常见错误与自检清单

结合文档要求与断言逻辑,提交前可以对照检查:

  1. 两处 emit 是否都改了:connect 与 disconnect 分支都必须 emit 'user',且 connected 分别为 true / false。漏改 disconnect 处不会让断言一失败(正则只要求存在一条 io.emit.*user),但功能上是错的——用户离开时在线人数不再更新。
  2. currentUsers 的自增/自减位置:connect 处必须「先自增、后广播」,disconnect 处必须「先自减、后广播」,否则广播出去的是旧值。
  3. 客户端事件名拼写:服务端 emit 的是 'user',客户端 socket.on 的必须是同一字符串,引号风格不限(正则 ('|")user\1 兼容单双引号)。
  4. #num-users 文本格式:断言二要求监听器内出现 num-users,功能上文本应为 '{NUMBER} users online'currentUsers 为数字而非字符串拼接产物。
  5. 公告列表项:必须以 <li> 追加到 #messages,文案区分 joined / left,取决于 connected 字段。
  6. 鉴权前置:若尚未完成 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.0passport.socketio@~3.7.0connect-mongo@~3.2.0cookie-parser@~1.4.5 已由平台预置为依赖)。文中所有代码示例、断言正则与文件路径均取自当前仓库实际内容;对未直接展示的实现细节,均已用「从源码结构看」「可以推断」等措辞标注。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
903
1.82 K
docsdocs
暂无描述
Markdown
888
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.51 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341