63 KiB
macOS-web 纯 Vue 重构手册
目标:去除原生 JS 思路和实现,去掉
window全局调用,将项目改造为纯 Vue 3 项目。 范围:全量重构,不考虑成本。
目录
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 关键反模式
graph TD
A["window 全局暴露<br/>14 个全局变量"] --> B["隐式依赖,模块封装破坏"]
C["懒引用 lazy ref<br/>let _Sys: any = null"] --> D["循环依赖 workaround<br/>类型不安全"]
E["el() 命令式 DOM<br/>微型虚拟 DOM 替代品"] --> F["绕过 Vue 响应式<br/>6+ 模块重度依赖"]
G["$('') 直接 DOM 查询"] --> H["绕过 Vue ref 系统"]
I["bus.on/emit"] --> J["类型不安全<br/>非 Vue 标准模式"]
K["33 个副作用 import"] --> L["无懒加载<br/>全量打包"]
1.4 核心循环依赖
useSys ←→ useWM ←→ useApps ←→ useFS
通过 setSysRef() / setSysForNotify() / setAppsRef() 等 lazy setter 打破。
2. 目标架构
2.1 总览
graph TD
subgraph "Vue 应用入口"
main.ts --> App.vue
end
subgraph "状态管理 Pinia"
settingsStore["settingsStore<br/>(原 useSettings + useStore)"]
fsStore["fsStore<br/>(原 useFS)"]
appStore["appStore<br/>(原 useApps 注册表部分)"]
wmStore["wmStore<br/>(原 useWM 状态部分)"]
notifyStore["notifyStore<br/>(原 useNotify 数据部分)"]
sysStore["sysStore<br/>(原 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 /><br/>(chrome: 标题栏/traffic lights/resize)"]
end
subgraph "应用层(懒加载)"
AppLoader["<AppLoader /><br/>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 核心原则
- 一切 UI 皆 Vue 组件:不再使用
el()创建任何 DOM 元素 - 状态归 Pinia:跨模块共享状态走 Pinia store,不挂
window - 通信走 provide/inject + mitt:父子用 provide/inject,跨层级用 mitt(类型安全的事件总线)
- 无直接 DOM 查询:用 Vue
ref/template ref替代$()/$$() - 应用懒加载:
defineAsyncComponent+ 动态 import - 仅保留必要的原生 API:
pointer events拖拽、localStorage、navigator.clipboard等不可避免的部分,封装在专用 composable 中
3. 前端架构设计
3.1 组件层级树
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<br/>(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 数据流架构
flowchart LR
subgraph "数据源"
LS[localStorage]
UA[用户操作]
SYS[系统事件<br/>resize/keyboard/idle]
end
subgraph "Pinia Stores(单一数据源)"
direction TB
SS["settingsStore<br/>设置/外观/音量/壁纸"]
FS["fsStore<br/>虚拟文件系统"]
WS["wmStore<br/>窗口状态"]
AS["appStore<br/>应用注册表"]
NS["notifyStore<br/>通知数据"]
US["uiStore<br/>菜单/对话框状态"]
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 事件总线<br/>(wm:focus, fs:changed,<br/>volume:changed, apps:ready)"]
PI["provide / inject<br/>(窗口级上下文)"]
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
核心数据流原则:
- 单向数据流:
用户操作 → Store 更新 → 组件响应式重渲染 - Store 是唯一真相源:所有共享状态必须位于 Pinia store 中
- 组件不直接修改其他组件的状态:通过 store action 或事件总线
- 事件总线仅用于通知,不用于状态传递:
emitter.emit('fs:changed')是"文件变了,你们自己去看",而非"文件变了,这是新数据"
3.3 路由与懒加载策略
本项目是桌面模拟器,不使用 vue-router(无需 URL 路由)。应用加载使用以下策略:
// 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<string, {
loader: AsyncComponentLoader
strategy: LoadStrategy
}> = {
// === 核心应用(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<any>)()
}, { timeout: 3000 })
})
}
加载状态处理:
<!-- src/components/wm/AppLoader.vue -->
<template>
<Suspense>
<component :is="asyncComp" v-if="asyncComp" :win="win" />
<template #fallback>
<div class="app-loading">
<img :src="win.icon" alt="" class="app-loading-icon" />
<div class="app-loading-text">正在加载 {{ win.title }}…</div>
</div>
</template>
</Suspense>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineAsyncComponent } from 'vue'
import { appLoaders } from '@/apps/loaders'
const props = defineProps<{ win: WinState }>()
const asyncComp = computed(() => {
const entry = appLoaders[props.win.appId]
if (!entry) return null
return defineAsyncComponent({
loader: entry.loader,
loadingComponent: { /* 加载中占位 */ },
errorComponent: { /* 加载失败占位 */ },
delay: 200, // 200ms 后才显示 loading
timeout: 10000, // 10s 超时
})
})
</script>
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 插件:持久化
// 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 |
<WindowFrame :win="w" @close="onClose" /> |
| 祖先→后代(跨层级) | provide / inject |
窗口级上下文(win 对象、clipboard 等) |
| 兄弟/任意组件(通知类) | mitt 事件总线 |
emitter.emit('fs:changed', { paths }) |
| 全局状态共享 | Pinia store | settingsStore.settings.darkMode |
| 浏览器级事件 | composable 封装 |
useKeyboard() 监听 keydown |
3.5.2 mitt 事件契约
// 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<Events>()
3.5.3 provide / inject 设计
// 窗口级上下文 —— 由 WindowFrame 提供,所有应用组件注入
// src/composables/useWindowContext.ts
import { provide, inject, type InjectionKey, type Ref } from 'vue'
interface WindowContext {
winId: string
appId: string
clipboard: Ref<string> // 应用内剪贴板
isActive: Ref<boolean> // 窗口是否聚焦
closeWindow: () => void
setTitle: (title: string) => void
}
export const WIN_CTX_KEY: InjectionKey<WindowContext> = 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 组件分类与职责
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 性能策略
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
// 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.xxxSys.isDark()→settingsStore.isDark(computed)Sys.save()→ 自动 watch 持久化
4.1.2 fsStore — 替代 useFS
// src/stores/fs.ts
export const useFSStore = defineStore('fs', () => {
const root = ref<FSNode | null>(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 的状态部分
// src/stores/wm.ts
export const useWMStore = defineStore('wm', () => {
const windows = reactive<WinState[]>([])
const activeWin = ref<WinState | null>(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 创建逻辑分离到
<WindowFrame>组件
4.1.4 appStore — 替代 useApps 注册表
// src/stores/apps.ts
export const useAppStore = defineStore('apps', () => {
const registry = reactive<Record<string, AppDefinition>>({})
function register(def: AppDefinition) { registry[def.id] = def }
function get(id: string) { return registry[id] }
// open 方法:不再手动调用 wm.openWindow + h() + render()
// 而是设置一个"待打开"信号,由 <AppLoader> 组件消费
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 数据部分
// src/stores/notify.ts
export const useNotifyStore = defineStore('notify', () => {
const notifications = ref<Notification[]>([])
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
pnpm add mitt
// 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<Events>()
替代映射:
| 原 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— 外观/壁纸应用<MenuBar>/<Dock>/<LockScreen>等 Vue 组件
6. 阶段二:系统 UI 组件化
5.1 <MenuBar> — 替代 Sys.buildMenubar()
原实现:useSys.ts 中 buildMenubar() 用 el() 构建整个菜单栏 DOM,操作 #menubar-left / #menubar-right。
目标实现:
<!-- src/components/system/MenuBar.vue -->
<template>
<header id="menubar" :class="{ hidden: !unlocked, autohide: fullscreenWin }">
<div id="menubar-left">
<!-- Apple 菜单 -->
<MenuBarItem
is-apple
@click="openAppleMenu"
/>
<!-- 当前应用名 + 应用菜单 -->
<MenuBarItem
v-for="menu in appMenus"
:key="menu.label"
:label="menu.label"
:items="menu.items"
@click="toggleMenu(menu)"
/>
</div>
<div id="menubar-right">
<MenuBarItem icon="battery" @click="toggleControlCenter" />
<MenuBarItem icon="wifi" @click="toggleControlCenter" />
<MenuBarItem icon="spotlight" @click="spotlightStore.toggle()" />
<MenuBarItem icon="control-center" @click="toggleControlCenter" />
<MenuBarItem
id="mb-clock"
:label="clockText"
@click="toggleNotificationCenter"
/>
</div>
</header>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import { useAppStore } from '@/stores/apps'
import { useWMStore } from '@/stores/wm'
import MenuBarItem from './MenuBarItem.vue'
const settingsStore = useSettingsStore()
const appStore = useAppStore()
const wmStore = useWMStore()
const clockText = ref('')
let clockTimer: ReturnType<typeof setInterval>
function tickClock() {
clockText.value = fmtMenuClock(new Date(), settingsStore.settings.h24)
}
onMounted(() => {
tickClock()
clockTimer = setInterval(tickClock, 1000)
})
onUnmounted(() => clearInterval(clockTimer))
</script>
改动要点:
el()构建 DOM → Vue 模板循环(this as any)._mbClock→ref+setIntervaladdEventListener('click')→@click- 菜单栏左侧应用菜单 → 根据
wmStore.activeWin计算
5.2 <Dock> — 替代 Sys.renderDock()
<!-- src/components/system/Dock.vue -->
<template>
<div id="dock-hotzone" :class="dockPosition" />
<nav id="dock" :class="dockClasses" @contextmenu="onBgContextMenu">
<DockIcon
v-for="appId in dockItems"
:key="appId"
:app="appStore.get(appId)"
:badge="notifyStore.badgeCount(appId)"
:running="wmStore.windowsForApp(appId).length > 0"
@click="onDockClick(appId)"
@contextmenu="onIconContextMenu($event, appId)"
/>
<div class="dock-sep" />
<DockIcon
app-id="__trash"
:icon="trashIcon"
name="废纸篓"
@click="appStore.open('finder', { path: fsStore.TRASH })"
@contextmenu="onTrashContextMenu"
/>
</nav>
</template>
改动要点:
dock.innerHTML = ''+el()循环构建 →v-for组件ic.addEventListener('click')→@clickemitic.addEventListener('contextmenu')→@contextmenuemit- Dock 放大动画用 CSS + 少量 pointer 事件(封装 composable)
updateDockDots()→ 基于wmStore.windows的 computed
5.3 <DesktopIcons> — 替代 Sys.renderDesktopIcons()
<!-- src/components/system/DesktopIcons.vue -->
<template>
<div id="desktop-icons">
<DesktopIcon
v-for="item in desktopItems"
:key="item.path"
:item="item"
:position="positions[item.name]"
@dblclick="appStore.openPath(item.path)"
@contextmenu="onIconContextMenu($event, item)"
/>
</div>
</template>
改动要点:
- 每个桌面图标是一个独立的
<DesktopIcon>SFC - 拖拽移动用 pointer events(保留原生,封装在 composable 中)
- 双击/右键 → emit 到父组件处理
5.4 <ContextMenu> / <DialogLayer> — 替代 useUI
这是最关键的变革之一:将动态创建的弹出菜单和对话框改为 Vue 组件 + Teleport。
<!-- src/components/system/ContextMenu.vue -->
<template>
<Teleport to="body">
<div
v-if="visible"
class="menu-pop"
:style="{ left: x + 'px', top: y + 'px' }"
role="menu"
>
<template v-for="item in items" :key="item.label">
<div v-if="item.sep" class="menu-sep" />
<div
v-else
class="menu-item"
:class="{ disabled: item.disabled, hover: hovered === item.label }"
@click="onClick(item)"
@pointerenter="hovered = item.label; onHover(item)"
>
<span class="mi-check">{{ item.checked ? '✓' : '' }}</span>
<span class="mi-label">{{ item.label }}</span>
<span v-if="item.key" class="mi-key">{{ item.key }}</span>
<span v-if="item.submenu" class="mi-sub">▶</span>
</div>
</template>
</div>
</Teleport>
</template>
全局菜单/对话框管理:
// src/stores/ui.ts
export const useUIStore = defineStore('ui', () => {
// 弹出菜单
const menuVisible = ref(false)
const menuItems = ref<MenuItem[]>([])
const menuPosition = ref({ x: 0, y: 0 })
const menuOnClose = ref<(() => void) | null>(null)
function showMenu(items: MenuItem[], x: number, y: number, onClose?: () => void) {
closeMenu()
menuItems.value = items
menuPosition.value = { x, y }
menuVisible.value = true
menuOnClose.value = onClose ?? null
}
function closeMenu() {
menuOnClose.value?.()
menuVisible.value = false
}
// 对话框
const dialogVisible = ref(false)
const dialogConfig = ref({ /* title, msg, buttons, ... */ })
let dialogResolve: ((v: any) => void) | null = null
function showDialog(config: DialogConfig): Promise<any> {
return new Promise(resolve => {
dialogConfig.value = config
dialogVisible.value = true
dialogResolve = resolve
})
}
return { menuVisible, menuItems, menuPosition, showMenu, closeMenu,
dialogVisible, dialogConfig, showDialog, /* ... */ }
})
改动要点:
ui.menu(items, x, y)→uiStore.showMenu(items, x, y)ui.dialog({...})→uiStore.showDialog({...}),返回 Promise 不变ui.alert/confirm/prompt→ 基于showDialog的语法糖- 菜单嵌套(submenu)用递归组件
- 全局
pointerdown关闭菜单 → 在<ContextMenu>组件内部用onClickOutside
5.5 <Spotlight> — 替代 useSpotlight
<!-- src/components/system/Spotlight.vue -->
<template>
<Teleport to="#spotlight-anchor">
<div v-if="visible" id="spotlight" role="dialog" @click.self="close">
<div id="spotlight-box">
<div id="spotlight-input-row">
<svg><!-- 搜索图标 --></svg>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="聚焦搜索"
@keydown="onKeydown"
/>
</div>
<div id="spotlight-results">
<SpotlightItem
v-for="(item, idx) in results"
:key="idx"
:item="item"
:selected="idx === selectedIdx"
@click="item.action(); close()"
@hover="selectedIdx = idx"
/>
</div>
</div>
</div>
</Teleport>
</template>
改动要点:
- HTML 结构在模板中定义(当前已在
SpotlightArea.vue中有静态模板,但内容仍由 JS 动态填充) input.oninput→v-model+watchinput.onkeydown→@keydownrender()中的el()构建 → computedresults+v-forscrollIntoView→nextTick+ Vue 方式滚动
5.6 <NotificationCenter> / <BannerContainer> — 替代 useNotify 渲染
<!-- src/components/system/BannerContainer.vue -->
<template>
<div id="banner-container">
<TransitionGroup name="banner">
<Banner
v-for="notif in activeBanners"
:key="notif.id"
:notif="notif"
@click="onBannerClick(notif)"
/>
</TransitionGroup>
</div>
</template>
改动要点:
el()创建横幅 DOM →<Banner>SFCsetTimeout自动消失 →watch+setTimeout,配合<TransitionGroup>- 通知中心
nc.innerHTML = ''重建 → computed 列表 +v-for
5.7 <LockScreen> / <Screensaver> / <BootScreen> / <PowerOff>
这些容器组件当前已有 Vue 模板(如 LockScreenArea.vue),但内部内容(锁屏按钮、屏保幻灯片等)全由 el() 动态构建。需改为:
<LockScreen>模板中包含完整的 HTML 结构,用v-if/v-show控制显示<Screensaver>中的时钟/幻灯片 → 用计算属性 +v-if选择类型<BootScreen>中的进度条动画 → CSS transition +v-show
5.8 <ControlCenter> — 替代 Sys.renderControlCenter()
<!-- src/components/system/ControlCenter.vue -->
<template>
<Teleport to="#control-center-anchor">
<div v-if="visible" id="control-center">
<div class="cc-card">
<CCToggle icon="wifi" label="Wi-Fi" v-model="settings.wifi" />
<CCToggle icon="bluetooth" label="蓝牙" v-model="settings.bluetooth" />
<CCToggle icon="airdrop" label="隔空投送" v-model="settings.airdrop" />
</div>
<div class="cc-card span2">
<div class="cc-title">显示器</div>
<CCSlider v-model="settings.brightness" :min="0.2" :max="1" />
</div>
<!-- ... -->
</div>
</Teleport>
</template>
改动要点:
ccToggle()函数生成 DOM →<CCToggle>SFC,用v-model双向绑定cc.innerHTML = ''重建 → 响应式数据驱动- 外点击关闭 →
onClickOutside指令/composable
7. 阶段三:窗口管理器现代化
6.1 <WindowFrame> — 替代 useWM 的窗口 chrome 构建
原实现:openWindow() 中用 el() 创建 traffic lights、标题栏、body、resize handles,全部命令式。
目标实现:
<!-- src/components/wm/WindowFrame.vue -->
<template>
<div
ref="winEl"
class="window"
:class="[win.state, { inactive: !isActive, opening: isNew }]"
:style="winStyle"
@pointerdown="onFocus"
>
<!-- 标题栏 -->
<div class="win-titlebar" @pointerdown="onDragStart" @dblclick="onToggleZoom">
<TrafficLights
:win="win"
@close="$emit('close', win.id)"
@minimize="$emit('minimize', win.id)"
@maximize="$emit('toggleFullscreen', win.id)"
/>
<div class="win-title">
<img :src="win.icon" alt="" />
<span class="t">{{ win.title }}</span>
</div>
</div>
<!-- 应用内容区域 -->
<div ref="bodyEl" class="win-body">
<slot />
</div>
<!-- Resize Handles -->
<template v-if="!win.noResize">
<div
v-for="dir in resizeDirections"
:key="dir"
:class="`rz ${dir}`"
@pointerdown.stop="onResizeStart($event, dir)"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import TrafficLights from './TrafficLights.vue'
import { useDrag } from '@/composables/useDrag'
import { useResize } from '@/composables/useResize'
const props = defineProps<{
win: WinState
isActive: boolean
}>()
const emit = defineEmits(['close', 'minimize', 'toggleFullscreen', 'focus'])
const winEl = ref<HTMLElement>()
const bodyEl = ref<HTMLElement>()
// 拖拽逻辑封装为 composable
const { onDragStart } = useDrag(winEl, props.win)
// 缩放逻辑封装为 composable
const { onResizeStart } = useResize(winEl, props.win)
const winStyle = computed(() => ({
left: props.win.rect.x + 'px',
top: props.win.rect.y + 'px',
width: props.win.rect.w + 'px',
height: props.win.rect.h + 'px',
zIndex: props.win.zIndex,
}))
function onFocus() {
emit('focus', props.win.id)
}
// 暴露 body 元素给父组件(供应用挂载)
defineExpose({ bodyEl })
</script>
6.2 <TrafficLights> — 独立组件
<!-- src/components/wm/TrafficLights.vue -->
<template>
<div class="traffic-lights">
<button class="tl close" aria-label="关闭" @click.stop="$emit('close')">
<svg viewBox="0 0 8 8"><!-- X --></svg>
</button>
<button class="tl min" aria-label="最小化" @click.stop="$emit('minimize')">
<svg viewBox="0 0 8 8"><!-- — --></svg>
</button>
<button class="tl max" aria-label="全屏" @click.stop="$emit('maximize')">
<svg viewBox="0 0 8 8"><!-- □ --></svg>
</button>
</div>
</template>
6.3 <WindowLayer> — 容器组件
<!-- src/components/wm/WindowLayer.vue -->
<template>
<div id="window-layer">
<WindowFrame
v-for="win in sortedWindows"
:key="win.id"
:win="win"
:is-active="win.id === wmStore.activeWin?.id"
@focus="wmStore.focus(win.id)"
@close="wmStore.close(win.id)"
@minimize="wmStore.minimize(win.id)"
@toggle-fullscreen="wmStore.toggleFullscreen(win.id)"
>
<!-- 动态加载应用组件 -->
<AppLoader :win="win" />
</WindowFrame>
</div>
</template>
6.4 拖拽/缩放 composable
拖拽和缩放必须用原生 pointer events(这是性能关键路径,不可用 Vue 事件替代),但封装为纯 composable:
// src/composables/useDrag.ts
export function useDrag(elRef: Ref<HTMLElement | undefined>, win: WinState) {
function onDragStart(e: PointerEvent) {
if (win.state === 'fullscreen') return
const el = elRef.value!
el.setPointerCapture(e.pointerId)
const startX = e.clientX
const startY = e.clientY
const origX = win.rect.x
const origY = win.rect.y
function onMove(e: PointerEvent) {
win.rect.x = origX + e.clientX - startX
win.rect.y = origY + e.clientY - startY
}
function onUp() {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
}
return { onDragStart }
}
// src/composables/useResize.ts
export function useResize(elRef: Ref<HTMLElement | undefined>, win: WinState) {
function onResizeStart(e: PointerEvent, dir: string) {
// 类似 useDrag,但修改 width/height/x/y
}
return { onResizeStart }
}
6.5 窗口关闭流程现代化
原 close() 方法的 Promise + confirm 流程保持不变,但 closePromise 管理移到 wmStore:
// stores/wm.ts
async function close(winId: string): Promise<string> {
const win = windows.find(w => w.id === winId)
if (!win) return 'alreadyClosed'
// 如果有确认回调
if (win.confirmClose) {
return new Promise(resolve => {
win.confirmClose!(
() => { doClose(win); resolve('closed') },
() => resolve('cancelled'),
)
})
}
doClose(win)
return 'closed'
}
8. 阶段四:应用层优化
7.1 <AppLoader> — 替代手动 h() + render()
原实现:每个 app 的 index.ts 中:
render(win) {
const vnode = h(XxxComponent, { win })
render(vnode, win.body)
}
目标实现:用 defineAsyncComponent + <component :is=""> 动态加载:
<!-- src/components/wm/AppLoader.vue -->
<template>
<component
v-if="appComponent"
:is="appComponent"
:win="win"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/stores/apps'
const props = defineProps<{ win: WinState }>()
const appStore = useAppStore()
// 动态懒加载应用组件
const appComponent = computed(() => {
const appId = props.win.appId
const loader = appLoaders[appId]
return loader || null
})
</script>
应用懒加载映射表:
// src/apps/loaders.ts
import { defineAsyncComponent } from 'vue'
export const appLoaders: Record<string, any> = {
finder: defineAsyncComponent(() => import('./finder/Finder.vue')),
safari: defineAsyncComponent(() => import('./safari/Safari.vue')),
calculator: defineAsyncComponent(() => import('./calculator/Calculator.vue')),
settings: defineAsyncComponent(() => import('./settings/Settings.vue')),
// ... 33 个应用
}
7.2 应用注册简化
原 index.ts:
export const FinderApp = { id:'finder', ..., render(win) { ... } }
Apps.register(FinderApp)
改为纯数据定义:
// src/apps/finder/index.ts
import type { AppDefinition } from '@/stores/apps'
export const finderDef: AppDefinition = {
id: 'finder',
name: '访达',
icon: '/assets/icons/finder.png',
w: 800, h: 550,
minW: 480, minH: 300,
singleton: true,
menus(win) { return stdMenus('finder', { /* ... */ }) },
// 不再需要 render 方法!
}
// src/stores/apps.ts
import { finderDef } from '@/apps/finder'
// 在 store 初始化时注册
function init() {
register(finderDef)
register(safariDef)
// ... 33 个应用
}
关键变化:每个 app 不再包含 render(win) 方法,而是由 <AppLoader> 根据 appId 查找对应的异步组件来渲染。
7.3 移除应用中的 (window as any)
当前多个应用访问 (window as any).Sys / (window as any).Notify 等:
| 文件 | 全局引用 | 替代方案 |
|---|---|---|
finder/Finder.vue |
(window as any).__finderClipboard |
Pinia store 或 provide/inject |
safari/Safari.vue |
window.open(url, '_blank') |
允许(无替代方案) |
calculator/Calculator.vue |
props.win.el?.addEventListener |
用 @keydown 在模板中处理 |
| 多个应用 | bus.on('fs:changed') |
emitter.on('fs:changed') |
| 多个应用 | ui.alert/confirm/prompt |
uiStore.showDialog() |
| 多个应用 | wm.setTitle(props.win, ...) |
wmStore.setTitle(winId, title) |
| 多个应用 | props.win.appState = st |
通过 win.data 或 provide |
7.4 stdMenus() 纯函数化
原实现使用 document.activeElement / document.execCommand:
// 原:直接访问 document
const editBase = () => {
const a = document.activeElement as any
const isText = a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable)
// ...
}
改进:接受 isTextFocused: boolean 参数,由调用方传入:
// src/composables/useStdMenus.ts
export function stdMenus(appDef: AppDef, options: StdMenuOptions = {}) {
return function (win: WinState | null) {
return [
{
label: '编辑',
items: () => [
{ label: '剪切', key: '⌘X', action: () => clipboard.cut() },
{ label: '拷贝', key: '⌘C', action: () => clipboard.copy() },
{ label: '粘贴', key: '⌘V', action: () => clipboard.paste() },
// ...
]
},
// ...
]
}
}
剪贴板操作封装为独立的 useClipboard composable(内部使用 navigator.clipboard API)。
9. 阶段五:清理与收尾
8.1 删除文件
删除:
src/utils/index.ts — el/$/$$ 函数,完全由 Vue 替代
src/system/index.ts — 启动序列逻辑分散到各初始化的 store/composable
src/composables/useBus.ts — 由 mitt 替代
src/composables/useStore.ts — 由 Pinia 持久化插件替代
src/composables/useUI.ts — 由 <ContextMenu>/<DialogLayer> + uiStore 替代
src/composables/useSys.ts — 拆分为多个 store + composable + Vue 组件
8.2 重构后的目录结构
src/
├── main.ts
├── App.vue
├── stores/ # Pinia stores(新增)
│ ├── settings.ts # 设置管理
│ ├── fs.ts # 虚拟文件系统
│ ├── wm.ts # 窗口管理器状态
│ ├── apps.ts # 应用注册表
│ ├── notify.ts # 通知数据
│ └── ui.ts # 菜单/对话框状态
├── composables/ # 纯逻辑 composable(无 DOM)
│ ├── useKeyboard.ts # 全局快捷键
│ ├── useIdleWatch.ts # 空闲计时/屏保
│ ├── useTheme.ts # 外观/壁纸
│ ├── useVolume.ts # 音量/媒体控制
│ ├── useDrag.ts # 窗口拖拽(pointer events 封装)
│ ├── useResize.ts # 窗口缩放(pointer events 封装)
│ ├── useClipboard.ts # 剪贴板操作封装
│ ├── useStdMenus.ts # 标准菜单模板(纯函数)
│ ├── useEventBus.ts # mitt 事件总线实例
│ └── useClickOutside.ts # 外点击检测
├── components/
│ ├── 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
│ │ ├── Banner.vue
│ │ ├── BannerContainer.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/ # 应用层(基本不变,去掉 render 方法)
│ ├── loaders.ts # 懒加载映射表(新增)
│ ├── finder/
│ │ ├── index.ts # 仅数据定义(AppDefinition)
│ │ └── Finder.vue
│ ├── calculator/
│ │ ├── index.ts
│ │ └── Calculator.vue
│ └── ...(33 个应用)
├── styles/ # 不变
└── types/ # 类型定义(从各模块提取)
├── app.ts
├── window.ts
├── fs.ts
└── settings.ts
8.3 启动流程重构
原:system/index.ts → initSystem() → 手动串联 fs.init → Sys.init → Notify.init → Sys.renderDock → initDesktop → boot
新:在 App.vue 的 onMounted 中:
// App.vue
onMounted(async () => {
// 1. 初始化 stores
const fsStore = useFSStore()
await fsStore.init()
const settingsStore = useSettingsStore()
settingsStore.init()
const appStore = useAppStore()
appStore.init() // 注册所有 33 个应用
const notifyStore = useNotifyStore()
notifyStore.init()
// 2. 初始化全局 composable
useKeyboard()
useIdleWatch()
useTheme()
// 3. 触发启动动画
const sysStore = useSysStore()
sysStore.boot()
// 4. 通知应用就绪
emitter.emit('apps:ready')
})
8.4 测试适配
测试文件(tests/cases/ 下 47 个)需要相应更新:
(window as any).Sys/(window as any).WM→ 通过 Pinia store 操作(window as any).FS→useFSStore()(window as any).Notify→useNotifyStore()(window as any).Apps→useAppStore()bus.on/emit→emitter.on/emit
建议在 tests/setup.ts 中创建一个测试辅助工具,提供简化的 store 访问。
10. 附录:新旧对照表
9.1 文件映射
| 原文件 | 重构后 | 说明 |
|---|---|---|
src/utils/index.ts |
删除 | el()/$/$$ 由 Vue 模板/ref 替代;格式化函数移入 src/utils/format.ts |
src/system/index.ts |
删除 | 启动逻辑分散到 store 初始化和 App.vue |
src/composables/useBus.ts |
删除 | 由 src/composables/useEventBus.ts (mitt) 替代 |
src/composables/useStore.ts |
删除 | 由 Pinia 持久化 watch 替代 |
src/composables/useUI.ts |
src/stores/ui.ts + src/components/ui/*.vue |
拆为状态 store + Vue 组件 |
src/composables/useSys.ts |
拆分为 7 个模块 | 见下一节 |
src/composables/useWM.ts |
src/stores/wm.ts + src/components/wm/*.vue + src/composables/useDrag.ts + src/composables/useResize.ts |
状态/UI/拖拽分离 |
src/composables/useNotify.ts |
src/stores/notify.ts + src/components/system/Banner.vue + src/components/system/NotificationCenter.vue |
数据与视图分离 |
src/composables/useSpotlight.ts |
src/stores/spotlight.ts + src/components/system/Spotlight.vue |
数据与视图分离 |
src/composables/useApps.ts |
src/stores/apps.ts + src/composables/useStdMenus.ts |
注册表 + 菜单模板分离 |
src/composables/useFS.ts |
src/stores/fs.ts |
直接迁移到 Pinia |
src/composables/useSettings.ts |
src/stores/settings.ts |
合并 useStore 到 Pinia |
9.2 useSys.ts 拆分
| 原负责内容 | 目标位置 |
|---|---|
settings / save / set / applyAll / isDark / wallpaperSrc / applyAppearance / applyWallpaper / applyBrightness / applyNightShift / applyVolume / registerMedia |
src/stores/settings.ts |
boot / showLock / closeOverlays / powerOff / restart / logout / sleep / forceQuitDialog |
src/stores/sys.ts |
globalKeys |
src/composables/useKeyboard.ts |
initIdleWatch / resetIdle / showScreensaver / hideScreensaver |
src/composables/useIdleWatch.ts |
buildMenubar / wifiSvg / tickClock / openAppleMenu / setActiveApp |
src/components/system/MenuBar.vue |
dockPinned / dockItems / renderDock / effDockSize / layoutDock / dockPeek / dockHide / dockResetMag / initDockMag / updateDockDots / dockClick / dockIconMenu / dockBgMenu |
src/components/system/Dock.vue + src/composables/useDockMag.ts |
toggleControlCenter / ccToggle / renderControlCenter |
src/components/system/ControlCenter.vue |
toggleNotificationCenter |
src/components/system/MenuBar.vue(时钟点击) |
resetAll |
src/stores/settings.ts |
9.3 API 对照
| 原 API | 新 API |
|---|---|
Sys.settings.xxx |
settingsStore.settings.xxx |
Sys.isDark() |
settingsStore.isDark (computed) |
Sys.set('key', val) |
settingsStore.settings.key = val |
Sys.renderDesktopIcons() |
emitter.emit('fs:changed') → 自动更新 |
fs.node(path) |
fsStore.node(path) |
fs.list(path) |
fsStore.list(path) |
bus.emit('fs:changed', data) |
emitter.emit('fs:changed', data) |
ui.menu(items, x, y) |
uiStore.showMenu(items, x, y) |
ui.dialog({...}) |
uiStore.showDialog({...}) |
ui.alert(title, msg) |
uiStore.alert(title, msg) |
ui.confirm(title, msg) |
uiStore.confirm(title, msg) |
wm.openWindow(opts) |
wmStore.openWindow(opts) |
wm.close(win) |
wmStore.close(winId) |
wm.focus(win) |
wmStore.focus(winId) |
wm.activeWin.value |
wmStore.activeWin |
Apps.open('finder', args) |
appStore.open('finder', args) |
Apps.get('finder') |
appStore.get('finder') |
Apps.register(def) |
appStore.register(def) |
Notify.send({...}) |
notifyStore.send({...}) |
Notify.badgeCount('mail') |
notifyStore.badgeCount('mail') |
Spotlight.toggle() |
spotlightStore.toggle() |
9.4 保留的原生 API
以下原生 API 是必须保留的(封装在专用 composable 中):
| API | 用途 | 封装位置 |
|---|---|---|
PointerEvent (pointerdown/move/up/capture) |
窗口拖拽、缩放、桌面图标拖拽 | useDrag.ts / useResize.ts |
localStorage |
持久化(由 Pinia watch 自动处理) | 各 store 内部 |
matchMedia('prefers-color-scheme: dark') |
系统暗色模式检测 | useTheme.ts |
navigator.clipboard |
剪贴板操作 | useClipboard.ts |
window.open(url, '_blank') |
Safari 外链打开 | useSafari.ts |
requestAnimationFrame |
启动动画 | <BootScreen> 组件内 |
Image() |
壁纸预加载 | useTheme.ts |
11. 执行建议
10.1 执行顺序
- 安装依赖:
pnpm add pinia mitt - 阶段一:创建所有 Pinia stores + mitt 事件总线,确保与原功能等价(此时还依赖
el()等旧 API) - 阶段二:逐个将系统 UI 组件化,每完成一个就用新组件替换旧的
el()渲染,保持可运行 - 阶段三:窗口管理器重构
- 阶段四:应用层优化 + lazy load
- 阶段五:清理删除旧文件,更新测试
10.2 关键原则
- 每步保持可运行:不要一次性改完所有东西再测试;每个组件替换后立即验证
- 先测试后重构:在重构前确保现有 47 个测试用例全部通过,重构后逐批更新测试
- 类型安全优先:所有新增模块用完整 TypeScript 类型定义,消除
any - CSS 不变:现有
styles/*.css无需修改,CSS 类名保持不变
10.3 预估工作量
| 阶段 | 描述 | 预估 |
|---|---|---|
| 阶段一 | Pinia stores + mitt | 1-2 天 |
| 阶段二 | 系统 UI 组件化(MenuBar/Dock/Spotlight/CC/通知/锁屏/屏保/启动/对话框/右键菜单 — 共 10+ 个组件) | 3-5 天 |
| 阶段三 | WM 重构(WindowFrame/TrafficLights/AppLoader + drag/resize composable) | 1-2 天 |
| 阶段四 | 33 个应用适配(去 render 方法、去 window 引用、lazy load) | 2-3 天 |
| 阶段五 | 清理 + 测试更新 | 1-2 天 |
| 总计 | 8-14 天 |