ghp / docs /architecture /design-patterns.md
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
|
Raw
History Blame Contribute Delete
10.6 kB
# 设计模式
## 前端模式
## Zustand Store 模式
5 个独立 store,各自管理一个领域的状态:
| Store | 职责 | 持久化 |
| --------------- | --------------------------------------------------- | ------------------------- |
| `playerStore` | 播放状态(曲目、进度、音量、歌词) | 音量持久化到 localStorage |
| `roomStore` | 房间状态(room、currentUser 自动推导自 room.users) | 无 |
| `chatStore` | 聊天(消息列表、未读数、开关状态) | 无 |
| `lobbyStore` | 大厅(房间列表、加载状态) | 无 |
| `settingsStore` | 设置(歌词对齐/动画/字体/翻译字体大小、背景参数) | 全部持久化到 localStorage |
使用方式:通过选择器订阅特定字段,避免不必要的渲染:
```typescript
const volume = usePlayerStore((s) => s.volume)
```
在 Socket 回调中使用 `getState()` 避免闭包问题:
```typescript
const room = useRoomStore.getState().room
```
## 自定义 Hooks 组合模式
两个核心组合 hook 各自编排多个子 hook:
```
usePlayer useRoom
├── useHowl (Howler.js 实例) ├── useRoomState (核心房间事件)
├── useLyric (歌词解析) ├── useChatSync (聊天事件)
└── usePlayerSync ├── useQueueSync (队列事件)
├── Scheduled Execution ├── useAuthSync (Cookie 持久化 + toast 反馈,永不删除 cookie)
└── Host Progress Reporting └── useConnectionGuard (断线重置)
SocketProvider (连接管理,无 NTP)
RoomPage
└── ClockSyncRunner → useClockSync (NTP 时钟同步,仅房间内运行)
```
通用工具 Hook:`useSocketEvent(event, handler)` 封装 `socket.on/off` 样板代码,已在 `useLobby``useVote` 中使用。
其他独立 hook:`useChat``useLobby``useQueue``useVote``useAuth``usePlaylist`,每个 hook 负责将 Socket 事件绑定到对应 Store。
`usePlaylist` 管理歌单功能:通过 Socket 获取用户歌单列表(`playlist:get_my``playlist:my_list`),通过 REST 分页获取歌单曲目(`GET /api/music/playlist?limit=100&offset=0`,返回 `{ tracks, total, offset, hasMore }`),提供 `loadMoreTracks()` 无限加载下一页、URL/ID 解析工具函数(`parsePlaylistInput`),以及单曲/批量添加到队列。切换歌单时立即重置状态防止闪旧数据,内部 `loadingMoreRef`(ref)做同步防重,避免 React 批量更新前的闭包竞态。URL 拼接通过 `buildPlaylistUrl()` 辅助函数集中管理。
## 受控 Dialog 模式
所有弹窗组件遵循统一的 prop 接口:
```typescript
interface DialogProps {
open: boolean
onOpenChange: (open: boolean) => void
// 业务回调...
}
```
父组件(页面)管理 `open` 状态,弹窗组件只负责渲染和用户交互。
## ResponsiveDialog 模式
`responsive-dialog.tsx` 通用组件根据视口宽度自动切换呈现方式:
- **桌面端**(≥640px):居中 Dialog(基于 Radix UI)
- **移动端**(<640px):底部 Drawer(基于 vaul,支持拖拽关闭)
通过 React Context 向子组件传递 `isMobile` 状态,提供一一映射的子组件:`ResponsiveDialog``ResponsiveDialogContent``ResponsiveDialogHeader``ResponsiveDialogTitle``ResponsiveDialogDescription``ResponsiveDialogFooter``ResponsiveDialogClose``ResponsiveDialogBody`。使用方只需替换 import 路径即可获得响应式行为。项目中所有业务弹窗(CreateRoomDialog、PasswordDialog、NicknameDialog、SearchDialog、SettingsDialog)均已迁移至此组件。
## Context Provider 模式
`SocketProvider` 通过 React Context 提供 Socket.IO 实例和连接状态,并内置断线/重连 Toast 提示:
```typescript
const { socket, isConnected } = useSocketContext()
```
`AbilityProvider` 通过 React Context 提供 CASL ability 实例,组件可通过 `useContext(AbilityContext)` 查询权限。
## 组件组合模式
`AudioPlayer` 组合 `NowPlaying` + `SongInfoBar` + `PlayerControls` + `LyricDisplay`,各组件独立负责自己的渲染逻辑。`RoomPage` 组合所有功能区域和覆盖层弹窗。
## 移动端双模式播放页面
移动端(竖屏)播放页面通过 `lyricExpanded` 状态实现两种模式切换,模仿 Apple Music 移动端交互:
- **默认模式**`lyricExpanded=false`):大封面 + 歌曲信息 + 控制器,封面自适应剩余空间(`flex-1 min-h-0` + `aspect-square max-h-full max-w-full`),控制器固定底部,不显示歌词
- **歌词模式**`lyricExpanded=true`):点击封面后,封面缩小到顶部变为 compact 横排(48px 小封面 `rounded-md` + 标题 `text-base` / 艺术家 `text-sm`),歌词区域 fade+slide-up 入场占满中间空间,控制器固定底部
布局策略:
- 移动端外层 padding `px-5 py-7`(水平 20px,垂直 28px),所有子元素 `w-full`,不使用 `max-w` 约束,边距由外层 padding 统一控制
- `NowPlaying` wrapper 使用 `flex-1 min-h-0`,默认模式下封面在剩余空间内居中缩放(`aspect-square max-h-full max-w-full`),避免封面过大挤压控件
关键技术:
- `NowPlaying` 组件支持 `compact` prop 切换大图/小图布局,`onCoverClick` 触发模式切换
- `framer-motion``layoutId`("cover-art" / "song-info")实现封面和文字在两种布局间的共享布局动画(0.45s Apple 风格贝塞尔缓动)
- `LayoutGroup` 包裹移动端内容区,确保跨组件 `layoutId` 动画生效
- `AnimatePresence` + `motion.div` 实现歌词区域的 fade+slide-up 入场/退场
- 歌词模式状态在切歌时保持不变,用户手动点击封面切换
- 桌面端保持左右分栏布局不受影响
## 后端模式
## 分层架构
```
Controller → Service → Repository / Utils
```
- **Controller**:注册 Socket 事件监听器,薄编排层(校验输入 → 调用 Service → 编排通知)。不包含业务逻辑。
- **Service**:业务逻辑、跨领域编排、Socket 广播。关键服务职责拆分:
- `roomService`:房间 CRUD + 角色管理 + 临时管理员协调(`reconcileRoomRoles`)+ conductor 选举(`electConductor`)+ 加入校验(`validateJoinRequest`)。Re-export `toPublicRoomState``broadcastRoomList` 以保持控制器调用方式不变。
- `roomLifecycleService`:房间空置删除定时器 + 防抖广播。不依赖 `roomService`,消除循环依赖。API:`scheduleDeletion``cancelDeletionTimer``broadcastRoomList``clearAllTimers`。角色宽限期已移除(conductor 自动选举,无需 grace period)。
- `playerService`:播放状态管理 + 流 URL 解析 + 切歌防抖 + 加入播放同步(`syncPlaybackToSocket`)+ 房间清理(`cleanupRoom`)。`playTrackInRoom` 通过 per-room Promise 链互斥锁防止并发竞态。`playNextTrackInRoom` / `playPrevTrackInRoom` 将 debounce + 队列导航 + 播放统一封装在 mutex 内部。`autoPlayIfEmpty` 在 mutex 内重新检查 `room.currentTrack`,防止并发 QUEUE_ADD 双重自动播放。
- **Repository**:数据存取(当前为内存 Map,接口抽象,可替换为数据库)
- **Utils**:纯函数工具(`toPublicRoomState` 等),无状态,可被任意层引用
## Repository 模式
使用 TypeScript 接口抽象数据访问:
```typescript
interface RoomRepository {
get(roomId: string): RoomData | undefined
set(roomId: string, room: RoomData): void
delete(roomId: string): void
getAll(): Map<string, RoomData>
// ...
}
```
当前实现为 `InMemoryRoomRepository`(`Map<string, RoomData>`),未来可替换为 Redis/数据库实现。
## Socket.IO 中间件链
```
withPermission(action, subject) → withRoom(io) → Handler
withOwnerOnly(io) → withRoom(io) → Handler
```
- `withRoom`:校验 Socket 是否在房间中,构建 `HandlerContext`(io, socket, roomId, room, user)
- `withPermission`:在 `withRoom` 基础上用 CASL `defineAbilityFor(role)` 检查 `(action, subject)` 权限
- `withOwnerOnly`:在 `withRoom` 基础上仅允许房主(`user.role === 'owner'`),用于设置和角色管理
错误统一通过 `ROOM_ERROR` 事件回传给客户端,错误码使用 `ERROR_CODE` 枚举(`shared/types.ts`),包括:`NOT_IN_ROOM`、`ROOM_NOT_FOUND`、`NO_PERMISSION`、`INVALID_DATA`、`QUEUE_FULL`、`RATE_LIMITED`、`INTERNAL` 等。
## 结构化日志(pino)
基于 [pino](https://github.com/pinojs/pino) 的薄封装,开发环境使用 `pino-pretty` 美化输出:
```typescript
logger.info('Room created', { roomId, socketId: socket.id })
logger.error('Failed to resolve stream URL', err, { roomId, trackId })
```
输出格式:`[ISO_TIMESTAMP] LEVEL message {JSON_CONTEXT}`
## 共享模式
## 类型驱动的事件系统
`EVENTS` 常量对象定义所有事件名,`ClientToServerEvents` / `ServerToClientEvents` 接口为每个事件定义精确的负载类型,确保前后端通信的类型安全。
## 构建优化
- **路由级懒加载**:`RoomPage` 和 `NotFoundPage` 使用 `React.lazy` + `Suspense`(`HomePage` 保持同步加载以保证首屏速度)
- **Vite manualChunks 分包**:react、socket.io、motion、radix-ui、pixi.js 分别打包为独立 chunk,利用浏览器长期缓存
- **React.memo**:列表项组件(`RoomCard`、`ChatMessage`、`TrackListItem`)和高频更新组件(`PlayerControls`)均使用 `React.memo` 避免不必要的 re-render
- **Zustand 细粒度 selector**:避免 `useRoomStore((s) => s.room)` 的粗粒度订阅,改用 `s.room?.name` 等精确字段
## 常量集中管理
`LIMITS` 和 `TIMING` 在 shared 包中统一定义,前后端共用:
```typescript
LIMITS.QUEUE_MAX_SIZE // 100
LIMITS.QUEUE_BATCH_MAX_SIZE // 100
LIMITS.CHAT_HISTORY_MAX // 200
TIMING.ROOM_GRACE_PERIOD_MS // 60_000
TIMING.PLAYER_NEXT_DEBOUNCE_MS // 500
TIMING.VOTE_TIMEOUT_MS // 30_000
NTP.INITIAL_INTERVAL_MS // 50
NTP.STEADY_STATE_INTERVAL_MS // 5_000
NTP.MAX_INITIAL_SAMPLES // 20
NTP.MIN_SCHEDULE_DELAY_MS // 300
NTP.MAX_SCHEDULE_DELAY_MS // 3_000
QR_STATUS.EXPIRED // 800
QR_STATUS.WAITING_SCAN // 801
QR_STATUS.SCANNED // 802
QR_STATUS.SUCCESS // 803
QR_TIMING.POLL_INTERVAL_MS // 2_000
QR_TIMING.SUCCESS_CLOSE_DELAY_MS // 1_000
```
---