从零搭建HarmonyOS+Node.js全栈聊天室
从零搭建 HarmonyOS + Node.js 全栈聊天室
前言
网上 HarmonyOS 的教程不少,但几乎都是单页面 demo,看完还是不知道怎么做一个完整的 App。
这篇文章记录我从零搭建一个全栈聊天室的完整过程:前端用 HarmonyOS ArkTS,后端用 Node.js + WebSocket,最终实现了注册登录、私聊、群聊、好友系统、群组管理、未读消息等微信级功能,前后端合计约 6300 行代码。
过程中踩了 8 个 ArkTS 深坑,每个都是网上资料极少、官方文档含糊的实战问题,单独整理成一节,希望能帮你少走弯路。
一、项目概览
最终效果
- 用户注册/登录(头像选择、自动登录、登录限速)
- 一对一私聊(实时消息 + 历史记录)
- 群聊(创建群、搜索加群、群主踢人/解散)
- 群内私聊(群内对单人发消息,其他人看不到)
- 好友系统(搜索、申请、同意/拒绝、删除)
- 未读消息角标(会话级 + Tab 级汇总)
- 通知系统(好友申请、入群申请、系统通知分栏展示)
- 断线重连 + 消息队列(断网时消息缓存,重连后自动发送)
技术栈
| 层 | 技术 | 说明 |
|---|---|---|
| 前端 | HarmonyOS ArkTS | 声明式 UI,@Component/@State 体系 |
| 后端 | Node.js + TypeScript + ws | 纯 WebSocket,零 HTTP 端点 |
| 持久化 | JSON 文件 | 无数据库,fs 读写,500 条消息上限 |
| 通信 | WebSocket 全双工 | 20 种客户端消息,25+ 种服务端消息 |
代码结构
ChatRoom/
├── server/ # 后端
│ ├── server.ts # WebSocket 服务器入口(209行)
│ ├── handlers/
│ │ ├── auth.ts # 注册/登录(119行)
│ │ ├── friend.ts # 好友系统(114行)
│ │ ├── group.ts # 群组管理(419行)
│ │ ├── message.ts # 消息处理(287行)
│ │ └── types.ts # 类型定义(15行)
│ └── store/
│ ├── users.ts # 用户数据(185行)
│ ├── groups.ts # 群组数据(177行)
│ └── messages.ts # 消息数据(132行)
├── client/ # 前端(HarmonyOS 工程)
│ └── entry/src/main/ets/
│ ├── model/
│ │ ├── WebSocketManager.ets # WS 单例 + 重连 + 消息队列(217行)
│ │ └── Conversation.ets # 会话数据模型(11行)
│ └── pages/
│ ├── Index.ets # 入口 + 自动登录(159行)
│ ├── LoginPage.ets # 登录/注册(262行)
│ ├── chatListPage.ets # 主页4Tab(1978行)
│ ├── ChatPage.ets # 聊天页(569行)
│ ├── GroupDetailPage.ets # 群详情(581行)
│ ├── AddFriendPage.ets # 添加好友(194行)
│ ├── CreateGroupPage.ets # 创建群组(280行)
│ └── SearchGroupPage.ets # 搜索群组(219行)
二、后端设计
2.1 为什么是纯 WebSocket?
聊天室的典型交互是"发消息→对方收到",天然适合全双工的 WebSocket。我的选择是所有通信都走 WebSocket,包括注册、登录、搜索好友等通常用 HTTP 的操作。
好处:
- 前端只需维护一个连接,不用同时管 HTTP 请求和 WS 连接
- 服务端可以主动推送任何通知(好友申请、被踢出群等),不需要轮询
- 协议统一,所有消息都是
{ type, data }的 JSON 结构
2.2 消息协议
所有消息统一格式:
{
"type": "消息类型",
"data": { /* 具体数据 */ }
}
20 种客户端→服务端消息:
| type | 用途 |
|---|---|
login / register |
登录/注册 |
friend_search / friend_request / friend_respond / delete_friend |
好友搜索/申请/响应/删除 |
create_group / group_search / group_join_request / group_join_respond |
群创建/搜索/申请加入/审批 |
group_invite / group_leave / group_dissolve / group_kick / group_members |
群邀请/退群/解散/踢人/成员列表 |
private_chat / group_chat / group_private |
私聊/群聊/群内私聊 |
history_request / mark_read |
历史记录/标记已读 |
25+ 种服务端→客户端消息:每个操作对应一个 _response,广播类消息(group_dissolved、group_self_kicked、friend_deleted 等)直接推给相关用户。
2.3 文件存储设计
没有用数据库,直接用 JSON 文件:
server/data/
├── users.json # 用户列表
├── groups.json # 群组列表
└── messages/
├── private/ # 私聊消息(按两人ID排序命名:u_1001_u_1002.json)
└── groups/ # 群聊消息(g_2001.json)
关键设计:
- 用户 ID 自增:从
u_1001起,启动时扫描最大 ID 保证不重复 - 私聊文件名统一:两个用户 ID 按字母序排列,无论谁发的都读写同一个文件
- 消息上限 500 条:超出自动裁剪最旧的消息
- 空文件保护:所有
readFile都有safeRead包装,空内容返回空数组而非崩溃
2.4 密码安全
// 加盐 SHA-256,格式 "salt:hash"
function hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString('hex')
const hash = crypto.createHash('sha256').update(salt + password).digest('hex')
return `${salt}:${hash}`
}
同时兼容旧版无盐哈希(没有冒号的纯哈希串),平滑升级。
2.5 登录限速
滑动窗口限速:同一 username:ip 组合,60 秒内最多 10 次失败尝试。成功登录清零计数器。
const rateLimiter = new Map<string, { count: number; resetAt: number }>()
2.6 单会话强制
同一账号只允许一个 WebSocket 连接。新登录时主动关闭旧连接:
const existing = clientMap.get(userId)
if (existing && existing !== ws) {
existing.close(4001, '新设备登录')
}
clientMap.set(userId, ws)
⚠️ 关键顺序:必须先 clientMap.set 再发送 login_response,否则响应消息可能走旧连接导致丢失(这是我实际踩到的竞态 bug)。
三、前端设计
3.1 页面导航
使用 HarmonyOS 原生的 Navigation + NavPathStack:
Index(入口,自动登录判断)
→ LoginPage(手动登录/注册)
→ ChatListPage(主页,4个Tab)
→ ChatPage(聊天)
→ GroupDetailPage(群详情)
→ AddFriendPage / CreateGroupPage / SearchGroupPage
NavPathStack 通过 @Provide/@Consume 在页面间共享:
// Index.ets
@Provide('pageStack') pageStack: NavPathStack = new NavPathStack()
// 其他页面
@Consume('pageStack') pageStack: NavPathStack
3.2 WebSocketManager 单例
全局唯一的 WebSocket 连接管理器,核心能力:
自动重连:指数退避(3s → 6s → 12s → … 最大 30s)
private reconnect(): void {
this.reconnectAttempts++
const delay = Math.min(3000 * Math.pow(2, this.reconnectAttempts - 1), 30000)
setTimeout(() => { this.connect() }, delay)
}
消息队列:未连接时 send() 不丢消息,缓存在 pendingMessages[],连接建立后自动 flush。
send(data: string): void {
if (this.isConnect) {
this.ws.send(data)
} else {
this.pendingMessages.push(data)
}
}
监听器模式:各页面在 aboutToAppear 注册 addMessageListener,在 aboutToDisappear 移除,只处理自己关心的消息类型。
3.3 乐观插入 + FIFO 回滚
这是 ChatPage 最核心的发送机制:
- 用户点发送 → 立即插入本地消息列表(带
clientId标记),UI 瞬间响应 - 服务端返回
_sent确认 → 用真实timestamp替换临时值 - 服务端返回
error→ 按 FIFO 顺序回滚最早的 pending 消息
// 确认最早的 pending 消息
private confirmOldestPending(serverTime: string): void {
for (let i = 0; i < this.messages.length; i++) {
if (this.messages[i].pending) {
this.messages[i].pending = false
this.messages[i].timestamp = serverTime
break
}
}
this.messages = [...this.messages]
}
为什么用 FIFO 而不是删最后一条?因为网络可能乱序,第 2 条比第 1 条先收到 error,如果总删最后一条就会误删正常消息。
3.4 会话排序
三级优先级排序:
1. 有未读消息的会话(按时间倒序)
2. 有消息的会话(按时间倒序)
3. 无消息的会话(按时间倒序)
3.5 未读角标
- 会话级:每个会话右侧显示未读数
- Tab 级:消息 Tab 右上角汇总所有会话未读数,通知 Tab 汇总申请+系统通知未读数
- 进入聊天自动
mark_read清零 - 切到通知 Tab 自动清除所有红点
3.6 通知系统分栏
通知 Tab 分两个区域:
- 申请栏:好友请求 + 入群申请(带同意/拒绝按钮,可交互)
- 通知栏:群解散、被踢出、被删好友、主动退群等系统通知(只读 + 时间戳)
四、8 个 ArkTS 深坑实录
这 8 个坑是我实际开发中遇到的,每一个都导致过明显的 bug,且网上几乎没有解决方案。
坑 1:@Builder 值参数不追踪状态变化
现象:Tab 栏的未读角标数字永远不更新,始终是初始值。
根因:@Builder 的参数是值传递。调用 this.TabWithBadge('消息', this.getMsgTabUnread(), 0) 时,count 在调用时计算一次就固定了,后续 this.conversations 变化不会触发 count 更新。
// ❌ 错误:count 是值参数,不会响应式更新
@Builder TabWithBadge(text: string, count: number, tabIndex: number) {
if (count > 0) { Text(count.toString()) }
}
// ✅ 正确:去掉值参数,内部直接读 @State
@Builder TabWithBadge(text: string, tabIndex: number) {
if (tabIndex === 0 && this.getMsgTabUnread() > 0) {
Text(this.getMsgTabUnread().toString())
}
}
规则:@Builder 中需要响应式更新的数据,必须在内部直接读取 @State 变量,不能通过参数传入。
坑 2:NavDestination 没有 onPageShow
现象:从 ChatPage 返回 chatListPage 后,列表数据不刷新。
根因:onPageShow() 只存在于 @Entry 组件,NavDestination 上不存在这个生命周期回调,写了也不报错但永远不会执行。
// ❌ 错误:NavDestination 上 onPageShow 无效
NavDestination() { ... }.onPageShow(() => { this.syncFromAppStorage() })
// ✅ 正确:用 onShown 代替
NavDestination() { ... }.onShown(() => { this.syncFromAppStorage() })
可用的 NavDestination 生命周期:onShown、onHidden、aboutToAppear、aboutToDisappear(仅 pop 时触发)。
坑 3:@State 数组内对象属性修改不触发 UI 刷新
现象:修改 this.conversations[i].unreadCount = 0 后,UI 没有任何变化。
根因:ArkUI 的 @State 对数组的监听是浅层的——push、splice 等变异方法能触发刷新,但修改数组内对象的属性不会。
// ❌ 错误:直接修改属性,UI 不刷新
this.conversations[i].unreadCount = 0
// ✅ 正确:重新赋值整个数组
this.conversations[i].unreadCount = 0
this.conversations = [...this.conversations]
深层解决:我为 Conversation 加了 version 字段,每次修改递增 version,ForEach 的 key 用 ${item.targetId}_${item.version},确保 key 变化强制 ListItem 重建。
坑 4:TabContent 内容默认垂直居中
现象:消息列表从屏幕中间开始排列,顶部一大片空白。
根因:TabContent 的内容区域默认垂直居中对齐,而且 TabContent 没有 .alignContent() 属性。
// ❌ 错误:TabContent 没有 alignContent 属性
TabContent() { List() }.alignContent(Alignment.Top) // 编译报错
// ✅ 正确:内部加 Column 包裹,用 justifyContent 控制
TabContent() {
Column() {
List() { ... }
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Start)
}
坑 5:WebSocket on(‘message’) 的 data 可能不是 string
现象:后端偶尔崩溃,报 JSON.parse 失败。
根因:ws 库的 on('message') 回调的 data 参数类型是 RawData,可能是 Buffer、ArrayBuffer 或 string,不能直接 JSON.parse。
// ❌ 错误:假设 data 一定是 string
ws.on('message', (data) => { JSON.parse(data as string) })
// ✅ 正确:统一转 string
ws.on('message', (data) => {
let str = ''
if (typeof data === 'string') {
str = data
} else if (data instanceof Buffer) {
str = data.toString()
} else if (data instanceof ArrayBuffer) {
str = Buffer.from(data).toString()
}
const msg = JSON.parse(str)
})
坑 6:clientMap 竞态——先发响应还是先注册连接?
现象:登录偶尔失败,客户端收不到 login_response。
根因:如果先 clientMap.set(userId, ws) 再发 login_response,响应走新连接,没问题。但如果先发响应再注册,消息可能走了旧连接或者还没注册的连接。
// ❌ 错误:先发响应再注册
ws.send(JSON.stringify({ type: 'login_response', data: result }))
clientMap.set(userId, ws)
// ✅ 正确:先注册再发响应
clientMap.set(userId, ws)
ws.send(JSON.stringify({ type: 'login_response', data: result }))
坑 7:AppStorage.get 返回 undefined,不能直接当 string 用
现象:页面加载时崩溃,报 Cannot read property of undefined。
根因:AppStorage.get<string>('friends') 返回 string | undefined,如果 key 不存在就是 undefined,直接 JSON.parse 会崩。
// ❌ 错误:可能 undefined
let friends = JSON.parse(AppStorage.get<string>('friends'))
// ✅ 正确:给默认值
let friendsStr = AppStorage.get<string>('friends')
if (friendsStr === undefined) { friendsStr = '[]' }
let friends = JSON.parse(friendsStr)
坑 8:$$ 双向绑定需要 API 10+
现象:Tabs 的 index 双向绑定不生效,切换 Tab 后 currentTab 不更新。
根因:$$ 双向绑定语法从 API 10 才支持,低版本需用 onChange 回调手动同步。
// ✅ API 10+ 写法
Tabs({ barPosition: BarPosition.End, index: $$this.currentTab })
// 手动同步(兼容写法)
Tabs({ barPosition: BarPosition.End, index: this.currentTab })
.onChange((index: number) => { this.currentTab = index })
建议两种都写,$$ 提供初始化同步,onChange 保证切换时更新。
五、部署
后端
git clone git@github.com:tiaotiaotiao666/MyChat.git
cd MyChat/server
npm install
npm start # tsx server.ts,直接运行 TS 不编译
端口 3500。如需外网访问,用 TCP 隧道(如 frp)转发 3500 端口,注意隧道类型必须是 TCP 而不是 HTTP,否则 WebSocket 升级握手会被拦截。
前端
- DevEco Studio 打开
client/目录 - 修改
Index.ets第 11 行的WS_SERVER_URL为你的服务器地址 - Build → 部署到设备/模拟器
六、总结
这个项目从架构上没什么高深的东西——JSON 文件当数据库、WebSocket 当协议、ArkTS 写 UI,都是最简单的选择。但"简单"不等于"容易",HarmonyOS 生态的文档和社区还远不如 Android/iOS 成熟,很多坑只能自己踩。
8 个坑里,**坑 1(@Builder 值参数)和坑 3(@State 数组浅监听)**是最普遍的,几乎每个 ArkTS 项目都会遇到。如果你也在写 HarmonyOS 应用,记住这两条:
@Builder需要响应式的数据,内部直接读@State,别传参数- 修改
@State数组内对象属性后,必须重新赋值整个数组
项目源码:github.com/tiaotiaotiao666/MyChat
更多推荐

所有评论(0)