# macOS-web 纯 Vue 重构手册
> **目标**:去除原生 JS 思路和实现,去掉 `window` 全局调用,将项目改造为纯 Vue 3 项目。
> **范围**:全量重构,不考虑成本。
---
## 目录
1. [现状诊断](#1-现状诊断)
2. [目标架构](#2-目标架构)
3. [前端架构设计](#3-前端架构设计)
4. [重构总路线](#4-重构总路线)
5. [阶段一:基础设施层](#5-阶段一基础设施层)
6. [阶段二:系统 UI 组件化](#6-阶段二系统-ui-组件化)
7. [阶段三:窗口管理器现代化](#7-阶段三窗口管理器现代化)
8. [阶段四:应用层优化](#8-阶段四应用层优化)
9. [阶段五:清理与收尾](#9-阶段五清理与收尾)
10. [附录:新旧对照表](#10-附录新旧对照表)
---
## 1. 现状诊断
### 1.1 双渲染系统并存(核心问题)
| 层 | 当前渲染方式 | 问题 |
|---|---|---|
| **系统 UI 层**(菜单栏/Dock/对话框/菜单/通知/Spotlight/控制中心/锁屏/屏保/启动界面) | `el()` 命令式 DOM 构建 + `$()` 直接 DOM 查询 | 非 Vue,绕过虚拟 DOM |
| **窗口 chrome 层**(traffic lights/标题栏/resize handles) | `el()` 命令式 DOM 构建 | 非 Vue |
| **应用层**(Finder/Calculator/Safari 等 33 个应用) | Vue 3 SFC + `h()`/`render()` 手动挂载 | 部分合理 |
| **容器层**(DesktopArea/MenuBarArea 等) | Vue 模板(仅作为 DOM 挂载锚点) | 合理 |
### 1.2 原生 JS 使用分布
| 模块 | 原生 API | 严重度 |
|---|---|---|
| `useSys.ts` (~800 行) | `$`, `$$`, `el`, `addEventListener`, `matchMedia`, `MutationObserver`, `requestAnimationFrame`, `setInterval`, `Image()`, `document.activeElement`, `getBoundingClientRect` | 🔴 最重度 |
| `useWM.ts` (~400 行) | `el()`, `addEventListener('pointermove/up')`, `document.getElementById`, `document.body.classList`, `setPointerCapture`, `getBoundingClientRect` | 🔴 重度 |
| `useUI.ts` (~200 行) | `document.createElement/addEventListener/body.append/remove` + `window.innerWidth/innerHeight` | 🔴 全部原生 |
| `useNotify.ts` (~200 行) | `$`, `$$`, `el`, `setTimeout`, `querySelector` | 🔴 重度 |
| `useSpotlight.ts` (~150 行) | `$`, `$$`, `el`, `addEventListener`, `navigator.clipboard`, `scrollIntoView` | 🔴 重度 |
| `useApps.ts` (~120 行) | `document.activeElement`, `document.execCommand`, `navigator.clipboard` | 🟡 中度 |
| `system/index.ts` (~160 行) | `$`, `$$`, `el`, `document.addEventListener`, 14 个 `window` 全局暴露 | 🔴 重度 |
| `utils/index.ts` (~100 行) | `document.querySelector/querySelectorAll/createElement` | 🔴 基础依赖 |
### 1.3 关键反模式
```mermaid
graph TD
A["window 全局暴露
14 个全局变量"] --> B["隐式依赖,模块封装破坏"]
C["懒引用 lazy ref
let _Sys: any = null"] --> D["循环依赖 workaround
类型不安全"]
E["el() 命令式 DOM
微型虚拟 DOM 替代品"] --> F["绕过 Vue 响应式
6+ 模块重度依赖"]
G["$('') 直接 DOM 查询"] --> H["绕过 Vue ref 系统"]
I["bus.on/emit"] --> J["类型不安全
非 Vue 标准模式"]
K["33 个副作用 import"] --> L["无懒加载
全量打包"]
```
### 1.4 核心循环依赖
```
useSys ←→ useWM ←→ useApps ←→ useFS
```
通过 `setSysRef()` / `setSysForNotify()` / `setAppsRef()` 等 lazy setter 打破。
---
## 2. 目标架构
### 2.1 总览
```mermaid
graph TD
subgraph "Vue 应用入口"
main.ts --> App.vue
end
subgraph "状态管理 Pinia"
settingsStore["settingsStore
(原 useSettings + useStore)"]
fsStore["fsStore
(原 useFS)"]
appStore["appStore
(原 useApps 注册表部分)"]
wmStore["wmStore
(原 useWM 状态部分)"]
notifyStore["notifyStore
(原 useNotify 数据部分)"]
sysStore["sysStore
(原 useSys 设置/电源部分)"]
end
subgraph "系统 UI 组件(纯 Vue SFC)"
MenuBar["<MenuBar />"]
Dock["<Dock />"]
DesktopIcons["<DesktopIcons />"]
Spotlight["<Spotlight />"]
NotificationCenter["<NotificationCenter />"]
ControlCenter["<ControlCenter />"]
LockScreen["<LockScreen />"]
Screensaver["<Screensaver />"]
BootScreen["<BootScreen />"]
ContextMenu["<ContextMenu />"]
DialogLayer["<DialogLayer />"]
end
subgraph "窗口系统"
WindowLayer["<WindowLayer />"]
WindowFrame["<WindowFrame />
(chrome: 标题栏/traffic lights/resize)"]
end
subgraph "应用层(懒加载)"
AppLoader["<AppLoader />
defineAsyncComponent"]
Finder["Finder.vue"]
Safari["Safari.vue"]
TxtEdit["...33 个应用"]
end
subgraph "组合式函数(纯逻辑,无 DOM)"
useKeyboard["useKeyboard(全局快捷键)"]
useIdleWatch["useIdleWatch(屏保计时)"]
useTheme["useTheme(暗色模式/壁纸)"]
useVolume["useVolume(音量控制)"]
useEventBus["useEventBus(mitt 替代 bus)"]
end
App.vue --> MenuBar
App.vue --> Dock
App.vue --> DesktopIcons
App.vue --> WindowLayer
App.vue --> Spotlight
App.vue --> NotificationCenter
App.vue --> ControlCenter
App.vue --> LockScreen
App.vue --> Screensaver
App.vue --> BootScreen
App.vue --> ContextMenu
App.vue --> DialogLayer
WindowLayer --> WindowFrame
WindowLayer --> AppLoader
```
### 2.2 核心原则
1. **一切 UI 皆 Vue 组件**:不再使用 `el()` 创建任何 DOM 元素
2. **状态归 Pinia**:跨模块共享状态走 Pinia store,不挂 `window`
3. **通信走 provide/inject + mitt**:父子用 provide/inject,跨层级用 mitt(类型安全的事件总线)
4. **无直接 DOM 查询**:用 Vue `ref` / `template ref` 替代 `$()` / `$$()`
5. **应用懒加载**:`defineAsyncComponent` + 动态 import
6. **仅保留必要的原生 API**:`pointer events` 拖拽、`localStorage`、`navigator.clipboard` 等不可避免的部分,封装在专用 composable 中
---
## 3. 前端架构设计
### 3.1 组件层级树
```mermaid
graph TD
subgraph "App.vue(根组件)"
direction TB
A[App.vue]
end
subgraph "Layer 1:背景层 z-0"
B1["<OverlayBrightness />"]
B2["<OverlayNightShift />"]
B3["<DesktopArea />"]
end
subgraph "Layer 2:内容层 z-1~99"
C1["<WindowLayer />"]
end
subgraph "Layer 3:系统 UI 层 z-100~499"
D1["<MenuBar />"]
D2["<Dock />"]
end
subgraph "Layer 4:浮层 z-500~899"
E1["<Spotlight />"]
E2["<ControlCenter />"]
E3["<NotificationCenter />"]
E4["<ContextMenu />"]
E5["<DialogLayer />"]
E6["<BannerContainer />"]
end
subgraph "Layer 5:全屏覆盖层 z-900+"
F1["<LockScreen />"]
F2["<Screensaver />"]
F3["<BootScreen />"]
F4["<PowerOff />"]
end
A --> B1 & B2 & B3
A --> C1
A --> D1 & D2
A --> E1 & E2 & E3 & E4 & E5 & E6
A --> F1 & F2 & F3 & F4
C1 --> C1A["<WindowFrame /> ×N"]
C1A --> C1A1["<TrafficLights />"]
C1A --> C1A2["<AppLoader />"]
C1A2 --> C1A2A["应用 SFC
(Finder/Safari/...)"]
```
**z-index 分层规范**:
| 层 | z-index 范围 | 组件 | 说明 |
|---|---|---|---|
| 背景层 | 0 | OverlayBrightness, OverlayNightShift, DesktopArea | 静态背景,无交互 |
| 内容层 | 1–99 | WindowLayer → WindowFrame ×N | 窗口动态分配 z-index |
| 系统 UI 层 | 100–499 | MenuBar(100), Dock(200) | 始终在窗口之上 |
| 浮层 | 500–899 | Spotlight(500), ControlCenter(510), NotificationCenter(510), ContextMenu(600), DialogLayer(700), BannerContainer(800) | 临时弹出内容 |
| 全屏覆盖层 | 900+ | LockScreen(900), Screensaver(950), BootScreen(999), PowerOff(998) | 独占式全屏状态 |
### 3.2 数据流架构
```mermaid
flowchart LR
subgraph "数据源"
LS[localStorage]
UA[用户操作]
SYS[系统事件
resize/keyboard/idle]
end
subgraph "Pinia Stores(单一数据源)"
direction TB
SS["settingsStore
设置/外观/音量/壁纸"]
FS["fsStore
虚拟文件系统"]
WS["wmStore
窗口状态"]
AS["appStore
应用注册表"]
NS["notifyStore
通知数据"]
US["uiStore
菜单/对话框状态"]
end
subgraph "Composables(纯逻辑)"
UK[useKeyboard]
UI[useIdleWatch]
UT[useTheme]
UD[useDrag / useResize]
UC[useClipboard]
end
subgraph "Vue 组件(视图层)"
direction TB
MB["<MenuBar />"]
DK["<Dock />"]
SP["<Spotlight />"]
CC["<ControlCenter />"]
NC["<NotificationCenter />"]
WF["<WindowFrame />"]
APPS["33 个应用组件"]
end
subgraph "跨组件通信"
MITT["mitt 事件总线
(wm:focus, fs:changed,
volume:changed, apps:ready)"]
PI["provide / inject
(窗口级上下文)"]
end
LS -->|"watch 持久化"| SS
LS --> FS
LS --> NS
UA -->|"@click / v-model"| SS
UA --> WS
UA --> AS
UA --> US
SYS --> UK
SYS --> UI
SYS --> UT
SS -->|"reactive state"| MB
SS --> DK
SS --> CC
SS --> NC
SS --> WF
SS --> APPS
WS -->|"reactive state"| WF
WS --> DK
FS -->|"reactive state"| SP
FS --> APPS
NS -->|"reactive state"| NC
NS --> DK
AS -->|"registry lookup"| SP
AS --> WF
AS --> DK
US -->|"reactive state"| MB
US --> SP
UK -->|"快捷键路由"| SS
UK --> WS
UK --> AS
UI -->|"空闲超时"| SS
UT -->|"外观变更"| SS
MITT -->|"事件订阅"| WS
MITT --> FS
MITT --> DK
MITT --> APPS
PI -->|"窗口上下文"| APPS
```
**核心数据流原则**:
1. **单向数据流**:`用户操作 → Store 更新 → 组件响应式重渲染`
2. **Store 是唯一真相源**:所有共享状态必须位于 Pinia store 中
3. **组件不直接修改其他组件的状态**:通过 store action 或事件总线
4. **事件总线仅用于通知,不用于状态传递**:`emitter.emit('fs:changed')` 是"文件变了,你们自己去看",而非"文件变了,这是新数据"
### 3.3 路由与懒加载策略
本项目是**桌面模拟器**,不使用 vue-router(无需 URL 路由)。应用加载使用以下策略:
```typescript
// src/apps/loaders.ts
import { defineAsyncComponent, type AsyncComponentLoader } from 'vue'
/**
* 应用懒加载映射表
*
* 策略:
* - 核心应用(Finder)→ 立即加载(Eager)
* - 系统应用(前 7 个)→ 空闲时预加载(Idle Preload)
* - 其他应用 → 按需加载(Lazy)
*/
// 加载策略枚举
enum LoadStrategy {
Eager, // 立即加载
IdlePreload, // requestIdleCallback 预加载
Lazy, // 首次打开时加载
}
export const appLoaders: Record = {
// === 核心应用(Eager:启动即加载) ===
finder: {
loader: () => import('./finder/Finder.vue'),
strategy: LoadStrategy.Eager,
},
// === 系统应用(IdlePreload:空闲时后台加载) ===
settings: {
loader: () => import('./settings/Settings.vue'),
strategy: LoadStrategy.IdlePreload,
},
safari: {
loader: () => import('./safari/Safari.vue'),
strategy: LoadStrategy.IdlePreload,
},
mail: {
loader: () => import('./mail/Mail.vue'),
strategy: LoadStrategy.IdlePreload,
},
messages: {
loader: () => import('./messages/Messages.vue'),
strategy: LoadStrategy.IdlePreload,
},
music: {
loader: () => import('./music/Music.vue'),
strategy: LoadStrategy.IdlePreload,
},
photos: {
loader: () => import('./photos/Photos.vue'),
strategy: LoadStrategy.IdlePreload,
},
notes: {
loader: () => import('./notes/Notes.vue'),
strategy: LoadStrategy.IdlePreload,
},
// === 其他应用(Lazy:按需加载) ===
calculator: {
loader: () => import('./calculator/Calculator.vue'),
strategy: LoadStrategy.Lazy,
},
calendar: {
loader: () => import('./calendar/Calendar.vue'),
strategy: LoadStrategy.Lazy,
},
// ... 其余 24 个应用
}
/**
* 在 appStore.init() 之后调用,利用浏览器空闲时间预加载常用应用
*/
export function initPreload() {
const idleLoaders = Object.values(appLoaders)
.filter(a => a.strategy === LoadStrategy.IdlePreload)
idleLoaders.forEach(({ loader }) => {
requestIdleCallback(() => {
// 触发异步加载但不等待结果
;(loader as () => Promise)()
}, { timeout: 3000 })
})
}
```
**加载状态处理**:
```vue
正在加载 {{ win.title }}…
```
### 3.4 状态管理设计
#### 3.4.1 Store 职责矩阵
| Store | 拥有的状态 | 不拥有的状态 | 持久化 |
|---|---|---|---|
| `settingsStore` | 用户设置、外观、音量、壁纸、登录项 | 窗口状态、通知数据 | ✅ localStorage |
| `fsStore` | 虚拟文件系统树、当前目录 | 应用注册信息 | ✅ localStorage |
| `wmStore` | 窗口列表、activeWin、z-index、cascade | 窗口 DOM 元素 | ❌ 仅会话 |
| `appStore` | 应用注册表、打开队列 | 应用内部状态 | ❌ 仅会话 |
| `notifyStore` | 通知列表、已读状态 | 通知 DOM、横幅定时器 | ✅ localStorage |
| `uiStore` | 菜单/对话框显隐、位置、回调 | 菜单/对话框 DOM | ❌ 仅会话 |
#### 3.4.2 Store 间依赖规则
```
settingsStore ← 无依赖(叶子 store)
fsStore ← 无依赖
notifyStore ← 依赖 settingsStore(权限检查)
appStore ← 依赖 wmStore(open 时创建窗口)
wmStore ← 依赖 settingsStore(可用区域计算)、appStore(窗口应用信息)
uiStore ← 无依赖
```
规则:**禁止 Store 间循环依赖**。如果 A 需要 B 的数据而 B 也需要 A 的数据,应提取到第三个 Store C 或使用事件总线解耦。
#### 3.4.3 Pinia 插件:持久化
```typescript
// src/stores/plugins/persist.ts
import type { PiniaPluginContext } from 'pinia'
const PERSISTED_STORES = ['settings', 'fs', 'notify']
export function persistPlugin({ store }: PiniaPluginContext) {
if (!PERSISTED_STORES.includes(store.$id)) return
// 初始化:从 localStorage 恢复
const saved = localStorage.getItem(`macos-web:${store.$id}`)
if (saved) {
try { store.$patch(JSON.parse(saved)) } catch { /* ignore */ }
}
// 自动持久化
store.$subscribe((_, state) => {
localStorage.setItem(`macos-web:${store.$id}`, JSON.stringify(state))
}, { detached: true })
}
```
### 3.5 事件通信设计
#### 3.5.1 通信方式选型
| 场景 | 方式 | 示例 |
|---|---|---|
| 父子组件通信 | `props` + `emits` | `` |
| 祖先→后代(跨层级) | `provide` / `inject` | 窗口级上下文(win 对象、clipboard 等) |
| 兄弟/任意组件(通知类) | `mitt` 事件总线 | `emitter.emit('fs:changed', { paths })` |
| 全局状态共享 | Pinia store | `settingsStore.settings.darkMode` |
| 浏览器级事件 | `composable` 封装 | `useKeyboard()` 监听 keydown |
#### 3.5.2 mitt 事件契约
```typescript
// src/composables/useEventBus.ts
import mitt from 'mitt'
// ⚠️ 所有事件必须在此声明类型,保证端到端类型安全
type Events = {
// 窗口事件
'wm:focus': { winId: string; appId: string }
'wm:changed': void
'wm:closed': { winId: string }
// 文件系统事件
'fs:changed': { paths: string[] }
'trash:changed': void
// 系统事件
'apps:ready': void
'volume:changed': number
'unlocked': void
'locked': void
// 应用间事件
'spotlight:open': void
'spotlight:close': void
}
export const emitter = mitt()
```
#### 3.5.3 provide / inject 设计
```typescript
// 窗口级上下文 —— 由 WindowFrame 提供,所有应用组件注入
// src/composables/useWindowContext.ts
import { provide, inject, type InjectionKey, type Ref } from 'vue'
interface WindowContext {
winId: string
appId: string
clipboard: Ref // 应用内剪贴板
isActive: Ref // 窗口是否聚焦
closeWindow: () => void
setTitle: (title: string) => void
}
export const WIN_CTX_KEY: InjectionKey = Symbol('winCtx')
export function provideWindowContext(ctx: WindowContext) {
provide(WIN_CTX_KEY, ctx)
}
export function useWindowContext(): WindowContext {
const ctx = inject(WIN_CTX_KEY)
if (!ctx) throw new Error('useWindowContext() must be used inside a WindowFrame')
return ctx
}
```
### 3.6 组件分类与职责
```mermaid
quadrantChart
title 组件分类矩阵
x-axis "有状态" --> "无状态"
y-axis "有 DOM" --> "无 DOM"
quadrant-1 "容器组件"
quadrant-2 "展示组件"
quadrant-3 "逻辑 composable"
quadrant-4 "纯工具函数"
"WindowFrame": [0.2, 0.85]
"MenuBar": [0.25, 0.9]
"Dock": [0.3, 0.9]
"Spotlight": [0.25, 0.85]
"ControlCenter": [0.2, 0.85]
"Finder": [0.35, 0.8]
"TrafficLights": [0.85, 0.55]
"DockIcon": [0.75, 0.6]
"Banner": [0.9, 0.5]
"CCToggle": [0.85, 0.55]
"useDrag": [0.95, 0.15]
"useKeyboard": [0.9, 0.1]
"useTheme": [0.85, 0.1]
"useClipboard": [0.95, 0.05]
"emitter": [0.98, 0.02]
"stdMenus": [0.98, 0.02]
```
| 分类 | 特征 | 示例 | 规则 |
|---|---|---|---|
| **容器组件** | 连接 Store,管理子组件状态,有 DOM 输出 | `WindowFrame`, `MenuBar`, `Dock`, `Spotlight` | 可访问 Pinia store,可 emit 事件 |
| **展示组件** | 纯 props 输入 + emits 输出,无 Store 依赖 | `TrafficLights`, `DockIcon`, `Banner`, `CCToggle` | 禁止直接访问 Pinia store |
| **逻辑 composable** | 封装有状态逻辑,无 DOM 输出 | `useDrag`, `useKeyboard`, `useTheme`, `useClipboard` | 可访问 Pinia store,不输出 DOM |
| **纯工具函数** | 纯函数,无副作用,无状态 | `stdMenus`, `fmtDateCN`, `clamp` | 禁止访问任何 store 或 DOM |
### 3.7 目录结构设计
```
src/
├── main.ts # 入口:createApp + Pinia + 全局样式
├── App.vue # 根布局组件
│
├── stores/ # Pinia 状态管理
│ ├── index.ts # createPinia + 插件注册
│ ├── plugins/
│ │ └── persist.ts # 持久化插件
│ ├── settings.ts # useSettingsStore
│ ├── fs.ts # useFSStore
│ ├── wm.ts # useWMStore
│ ├── apps.ts # useAppStore
│ ├── notify.ts # useNotifyStore
│ └── ui.ts # useUIStore
│
├── composables/ # 纯逻辑组合式函数(无 DOM)
│ ├── useEventBus.ts # mitt 实例 + 类型定义
│ ├── useWindowContext.ts # provide/inject 窗口上下文
│ ├── useKeyboard.ts # 全局快捷键
│ ├── useIdleWatch.ts # 空闲计时 & 屏保触发
│ ├── useTheme.ts # 暗色模式 & 壁纸
│ ├── useVolume.ts # 音量 & 媒体注册
│ ├── useDrag.ts # 窗口拖拽(pointer events 封装)
│ ├── useResize.ts # 窗口缩放(pointer events 封装)
│ ├── useClipboard.ts # 剪贴板操作封装
│ ├── useClickOutside.ts # 外部点击检测
│ ├── useStdMenus.ts # 标准菜单模板(纯函数)
│ └── useDockMag.ts # Dock 放大效果
│
├── components/ # Vue 组件
│ ├── system/ # 系统 UI 组件
│ │ ├── MenuBar.vue # 菜单栏(容器)
│ │ ├── MenuBarItem.vue # 菜单栏单项(展示)
│ │ ├── Dock.vue # 程序坞(容器)
│ │ ├── DockIcon.vue # 程序坞图标(展示)
│ │ ├── DesktopIcons.vue # 桌面图标区(容器)
│ │ ├── DesktopIcon.vue # 单个桌面图标(展示)
│ │ ├── Spotlight.vue # 聚焦搜索(容器)
│ │ ├── SpotlightItem.vue # 搜索结果项(展示)
│ │ ├── ControlCenter.vue # 控制中心(容器)
│ │ ├── CCToggle.vue # 控制中心开关(展示)
│ │ ├── CCSlider.vue # 控制中心滑块(展示)
│ │ ├── NotificationCenter.vue # 通知中心(容器)
│ │ ├── BannerContainer.vue # 横幅容器(容器)
│ │ ├── Banner.vue # 单个横幅(展示)
│ │ ├── LockScreen.vue # 锁屏界面(容器)
│ │ ├── Screensaver.vue # 屏保(容器)
│ │ ├── BootScreen.vue # 启动动画(展示)
│ │ └── PowerOff.vue # 关机提示(展示)
│ ├── wm/ # 窗口管理组件
│ │ ├── WindowLayer.vue # 窗口层容器(容器)
│ │ ├── WindowFrame.vue # 窗口框架(容器)
│ │ ├── TrafficLights.vue # 红绿灯按钮(展示)
│ │ └── AppLoader.vue # 应用加载器(容器)
│ └── ui/ # 通用 UI 组件
│ ├── ContextMenu.vue # 右键/下拉菜单(容器)
│ ├── ContextMenuItem.vue # 菜单项(展示)
│ ├── DialogLayer.vue # 对话框层(容器)
│ ├── AlertDialog.vue # 警告框(展示)
│ ├── ConfirmDialog.vue # 确认框(展示)
│ └── PromptDialog.vue # 输入框(展示)
│
├── apps/ # 应用层
│ ├── loaders.ts # 懒加载映射 + 预加载策略
│ ├── finder/
│ │ ├── index.ts # AppDefinition(纯数据)
│ │ └── Finder.vue # Finder SFC
│ ├── safari/
│ │ ├── index.ts
│ │ └── Safari.vue
│ └── ...(其余 31 个应用,结构相同)
│
├── styles/ # 样式(不变)
│ ├── base.css
│ ├── desktop.css
│ ├── window.css
│ ├── apps.css
│ └── apps2.css
│
├── types/ # 全局类型定义
│ ├── app.ts # AppDefinition, MenuItem
│ ├── window.ts # WinState, Rect
│ ├── fs.ts # FSNode, FSEntry
│ └── settings.ts # Settings, Wallpaper
│
└── utils/ # 纯工具函数(无 DOM)
├── format.ts # fmtDateCN, fmtTime, fmtBytes
├── math.ts # clamp
├── misc.ts # uid, debounce, esc
└── index.ts # 统一导出
```
### 3.8 关键技术决策
| 决策点 | 选择 | 理由 |
|---|---|---|
| 路由方案 | **不使用 vue-router** | 桌面模拟器无 URL 路由需求,应用切换由 `wmStore` 窗口管理 + `AppLoader` 动态组件实现 |
| 状态管理 | **Pinia**(非 Vuex) | Vue 3 官方推荐,完整 TS 支持,模块化设计 |
| 跨组件通信 | **mitt + provide/inject** | mitt 用于全局通知,provide/inject 用于窗口级上下文,避免过度使用全局事件 |
| CSS 方案 | **纯 CSS(不变)** | 项目样式已成熟,无需引入 Tailwind/UnoCSS,减少重构范围 |
| 构建工具 | **Vite(不变)** | 当前已使用 Vite,保留配置 |
| 测试框架 | **Vitest(不变)** | 当前已使用 Vitest |
| 窗口拖拽/缩放 | **原生 Pointer Events + composable 封装** | 这是性能关键路径,Vue 事件系统不适合高频 pointermove |
| 持久化 | **Pinia watch + localStorage** | 简单可靠,无需引入额外的持久化库 |
| 应用加载 | **defineAsyncComponent + 分级策略** | Eager/IdlePreload/Lazy 三级策略兼顾首屏速度与体验 |
| 类型安全 | **全局禁止 `any`**(eslint rule) | 强制所有接口、store、事件使用精确类型 |
### 3.9 性能策略
```mermaid
flowchart TB
subgraph "首屏加载"
A1["仅加载 Eager 应用(Finder)"]
A2["系统 UI 组件同步渲染"]
A3["requestIdleCallback 预加载 7 个常用应用"]
end
subgraph "窗口操作"
B1["打开应用 → 检查是否已加载"]
B2["未加载 → Suspense + loading 占位"]
B3["已加载 → 立即显示 <component :is=>"]
end
subgraph "关闭优化"
C1["关闭窗口 → 组件 v-if 卸载"]
C2["已加载的异步 chunk 保留在浏览器缓存"]
C3["再次打开 → 不重新下载 JS"]
end
subgraph "内存管理"
D1["窗口关闭 → clearInterval/timeout"]
D2["onUnmounted → 移除事件监听"]
D3["Pinia store 按需持久化,不存储 DOM 引用"]
end
A1 --> A2 --> A3
B1 --> B2
B1 --> B3
C1 --> C2 --> C3
```
---
## 4. 重构总路线
```
Phase 1: 基础设施层 Phase 2: 系统 UI 组件化 Phase 3: WM 现代化
───────────────────── ──────────────────────── ────────────────
Pinia stores MenuBar → SFC WindowFrame → SFC
mitt 事件总线 Dock → SFC TrafficLights → SFC
消除 window 全局暴露 DesktopIcons → SFC 拖拽/缩放 composable
消除 lazy ref Spotlight → SFC resize handles → SFC
NotificationCenter → SFC
ControlCenter → SFC Phase 5: 清理
LockScreen → SFC ───────────
Screensaver → SFC 删除 utils/index.ts
BootScreen → SFC 删除 system/index.ts
ContextMenu → SFC 删除 el() 函数
Dialog/Alert/Confirm → SFC 删除 $/$$
全局类型检查
Phase 4: 应用层优化
─────────────────────
defineAsyncComponent
移除 (window as any).Sys
移除 h()/render() 手动挂载
stdMenus 纯函数化
```
---
## 5. 阶段一:基础设施层
### 4.1 建立 Pinia Stores
#### 4.1.1 `settingsStore` — 替代 useSettings + useStore
```typescript
// src/stores/settings.ts
import { defineStore } from 'pinia'
import { reactive, watch } from 'vue'
export const WALLPAPERS = [ /* 原数据 */ ]
export const DEFAULT_SETTINGS = { /* 原数据 */ }
export const useSettingsStore = defineStore('settings', () => {
const settings = reactive(structuredClone(DEFAULT_SETTINGS))
// 持久化 watch(替代 store.set/load)
watch(settings, (v) => {
localStorage.setItem('macos-web:settings', JSON.stringify(v))
}, { deep: true })
// 计算属性
const isDark = computed(() => { /* 原 Sys.isDark() */ })
const wallpaperSrc = computed(() => { /* 原 Sys.wallpaperSrc() */ })
// 方法
function applyAppearance() { /* 操作 document.body.classList */ }
function applyWallpaper() { /* ... */ }
return { settings, isDark, wallpaperSrc, applyAppearance, applyWallpaper }
})
```
**改动要点**:
- `store.get/set` → Pinia `$state` + watch 持久化
- `Sys.settings.xxx` → `settingsStore.settings.xxx`
- `Sys.isDark()` → `settingsStore.isDark` (computed)
- `Sys.save()` → 自动 watch 持久化
#### 4.1.2 `fsStore` — 替代 useFS
```typescript
// src/stores/fs.ts
export const useFSStore = defineStore('fs', () => {
const root = ref(null)
const HOME = '/Users/guest'
const TRASH = computed(() => HOME + '/.Trash')
function init() { /* 原 fs.init() */ }
function list(path: string) { /* ... */ }
function node(path: string) { /* ... */ }
function mkdir(path: string) { /* ... */ }
function rename(path: string, newName: string) { /* ... */ }
function trash(path: string) { /* ... */ }
function emptyTrash() { /* ... */ }
// 用 mitt 替代 bus.emit('fs:changed')
function notifyChange(paths: string[]) {
emitter.emit('fs:changed', { paths })
}
return { root, HOME, TRASH, init, list, node, mkdir, rename, trash, emptyTrash }
})
```
**改动要点**:
- `fs.node(path)` → `fsStore.node(path)`,响应式数据
- `bus.emit('fs:changed')` → `emitter.emit('fs:changed')`(mitt)
- 文件系统树用 `ref` / `reactive` 保持响应式
#### 4.1.3 `wmStore` — 替代 useWM 的状态部分
```typescript
// src/stores/wm.ts
export const useWMStore = defineStore('wm', () => {
const windows = reactive([])
const activeWin = ref(null)
const zTop = ref(100)
const cascadeCount = ref(0)
function addWindow(win: WinState) { windows.push(win) }
function removeWindow(id: string) { /* splice */ }
function focus(win: WinState) { /* 更新 z-index + activeWin */ }
return { windows, activeWin, zTop, cascadeCount, addWindow, removeWindow, focus }
})
```
**改动要点**:
- WM `windows`/`activeWin`/`zTop` → Pinia store
- 窗口 DOM 创建逻辑分离到 `` 组件
#### 4.1.4 `appStore` — 替代 useApps 注册表
```typescript
// src/stores/apps.ts
export const useAppStore = defineStore('apps', () => {
const registry = reactive>({})
function register(def: AppDefinition) { registry[def.id] = def }
function get(id: string) { return registry[id] }
// open 方法:不再手动调用 wm.openWindow + h() + render()
// 而是设置一个"待打开"信号,由 组件消费
const pendingOpen = ref<{ id: string; args?: any } | null>(null)
function open(id: string, args?: any) {
pendingOpen.value = { id, args }
}
return { registry, register, get, pendingOpen, open }
})
```
#### 4.1.5 `notifyStore` — 替代 useNotify 数据部分
```typescript
// src/stores/notify.ts
export const useNotifyStore = defineStore('notify', () => {
const notifications = ref([])
function send(n: Notification) { /* push + 持久化 */ }
function markRead(id: string) { /* ... */ }
function remove(id: string) { /* ... */ }
function clearAll() { /* ... */ }
function badgeCount(appId: string) { /* computed */ }
return { notifications, send, markRead, remove, clearAll, badgeCount }
})
```
### 4.2 引入 mitt 替代 useBus
```bash
pnpm add mitt
```
```typescript
// src/composables/useEventBus.ts
import mitt from 'mitt'
type Events = {
'wm:focus': WinState
'wm:changed': void
'fs:changed': { paths: string[] }
'apps:ready': void
'volume:changed': number
'trash:changed': void
// ... 所有事件类型
}
export const emitter = mitt()
```
**替代映射**:
| 原 bus 用法 | 新 mitt 用法 |
|---|---|
| `bus.on('wm:focus', fn)` | `emitter.on('wm:focus', fn)` |
| `bus.emit('wm:focus', win)` | `emitter.emit('wm:focus', win)` |
| `bus.off('wm:focus', fn)` | `emitter.off('wm:focus', fn)` |
### 4.3 消除 `window` 全局暴露
**删除 `system/index.ts` 中的所有 `(window as any)` 赋值**。
替代方案:
- 模块间引用 → Pinia store / provide-inject
- App 组件引用 → 通过 `useAppStore().get(id)` 获取
- 工具函数引用 → 直接 import
### 4.4 消除 lazy ref
**删除所有 `let _Xxx: any = null` + `setXxxRef()` 模式**。
原懒引用的用途和替代方案:
| 原用途 | 替代 |
|---|---|
| `setSysRef(wm)` — WM 需要 Sys.settings | WM 直接 import `useSettingsStore` |
| `setSysForNotify(sys)` — Notify 需要 Sys.settings | Notify 直接 import `useSettingsStore` |
| `setSysForSpotlight(sys)` — Spotlight 需要 Sys | Spotlight 直接 import 对应 store |
| `setAppsRef(apps)` — FS 需要 Apps 注册表 | FS 直接 import `useAppStore` |
| `setAppStoreRef(a)` — Sys 需要 AppStore | 延迟访问 `useAppStore().get('appstore')` |
解决循环依赖的根本方法:**拆分模块**。例如 `useSys.ts` 应该拆分为:
- `stores/settings.ts` — 设置管理
- `composables/useKeyboard.ts` — 全局快捷键
- `composables/useIdleWatch.ts` — 空闲计时/屏保
- `composables/useTheme.ts` — 外观/壁纸应用
- `` / `` / `` 等 Vue 组件
---
## 6. 阶段二:系统 UI 组件化
### 5.1 `` — 替代 Sys.buildMenubar()
**原实现**:`useSys.ts` 中 `buildMenubar()` 用 `el()` 构建整个菜单栏 DOM,操作 `#menubar-left` / `#menubar-right`。
**目标实现**:
```vue
```
**改动要点**:
- `el()` 构建 DOM → Vue 模板循环
- `(this as any)._mbClock` → `ref` + `setInterval`
- `addEventListener('click')` → `@click`
- 菜单栏左侧应用菜单 → 根据 `wmStore.activeWin` 计算
### 5.2 `` — 替代 Sys.renderDock()
```vue
```
**改动要点**:
- `dock.innerHTML = ''` + `el()` 循环构建 → `v-for` 组件
- `ic.addEventListener('click')` → `@click` emit
- `ic.addEventListener('contextmenu')` → `@contextmenu` emit
- Dock 放大动画用 CSS + 少量 pointer 事件(封装 composable)
- `updateDockDots()` → 基于 `wmStore.windows` 的 computed
### 5.3 `` — 替代 Sys.renderDesktopIcons()
```vue
```
**改动要点**:
- 每个桌面图标是一个独立的 `` SFC
- 拖拽移动用 pointer events(保留原生,封装在 composable 中)
- 双击/右键 → emit 到父组件处理
### 5.4 `` / `` — 替代 useUI
这是最关键的变革之一:**将动态创建的弹出菜单和对话框改为 Vue 组件 + Teleport**。
```vue
```
**全局菜单/对话框管理**:
```typescript
// src/stores/ui.ts
export const useUIStore = defineStore('ui', () => {
// 弹出菜单
const menuVisible = ref(false)
const menuItems = ref