从零搭建 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_dissolvedgroup_self_kickedfriend_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 最核心的发送机制:

  1. 用户点发送 → 立即插入本地消息列表(带 clientId 标记),UI 瞬间响应
  2. 服务端返回 _sent 确认 → 用真实 timestamp 替换临时值
  3. 服务端返回 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 生命周期onShownonHiddenaboutToAppearaboutToDisappear(仅 pop 时触发)。

坑 3:@State 数组内对象属性修改不触发 UI 刷新

现象:修改 this.conversations[i].unreadCount = 0 后,UI 没有任何变化。

根因:ArkUI 的 @State 对数组的监听是浅层的——pushsplice 等变异方法能触发刷新,但修改数组内对象的属性不会。

// ❌ 错误:直接修改属性,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,可能是 BufferArrayBufferstring,不能直接 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+

现象Tabsindex 双向绑定不生效,切换 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 升级握手会被拦截。

前端

  1. DevEco Studio 打开 client/ 目录
  2. 修改 Index.ets 第 11 行的 WS_SERVER_URL 为你的服务器地址
  3. Build → 部署到设备/模拟器

六、总结

这个项目从架构上没什么高深的东西——JSON 文件当数据库、WebSocket 当协议、ArkTS 写 UI,都是最简单的选择。但"简单"不等于"容易",HarmonyOS 生态的文档和社区还远不如 Android/iOS 成熟,很多坑只能自己踩。

8 个坑里,**坑 1(@Builder 值参数)和坑 3(@State 数组浅监听)**是最普遍的,几乎每个 ArkTS 项目都会遇到。如果你也在写 HarmonyOS 应用,记住这两条:

  1. @Builder 需要响应式的数据,内部直接读 @State,别传参数
  2. 修改 @State 数组内对象属性后,必须重新赋值整个数组

项目源码:github.com/tiaotiaotiao666/MyChat

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐