前端应用架构、路由与页面组织
本文档描述 SpinningMomo 桌面端 Web 前端(web/ 子包)的整体应用架构:Vue 3 应用实例的创建与装配顺序、路由(vue-router)与守卫的接入时机、引导流程(Onboarding)重定向逻辑,以及 src/ 下按职责划分的页面与组件组织方式。所有内容均基于仓库实际源码。
目的与范围
本页覆盖:
web/src/main.ts中的应用启动序列(Pinia → RPC → i18n → 权限 → 路由 → Store → 挂载)- 路由模块
web/src/router/(路由表入口与守卫模块)的装配方式与守卫注册时机约束 - Onboarding 引导重定向(
flowVersion版本门控) web/src/的分层目录组织(core/、features/、components/、router/)- 根 monorepo(pnpm workspace)中与前端相关的构建脚本
有意留给兄弟页面:
- 设置(Settings)体系与外观(theme/background)实现细节 → 见 features/settings 相关页面
- 前端与 C++/后端之间的 RPC 通信协议 → 见 core/rpc 相关页面
- 访问等级(access level)判定逻辑 → 见 core/access 相关页面
- i18n 语言包与翻译机制 → 见 core/i18n 相关页面
- 后台任务订阅 → 见 core/tasks 相关页面
概述
web/ 是一个独立的 pnpm workspace 子包(根 package.json 通过 pnpm --filter web 驱动其开发与构建),承载 SpinningMomo 的桌面端界面。应用以 web/index.html 为 HTML 入口,由 web/src/main.ts 完成如下职责:
- 创建 Vue 应用并安装 Pinia——状态管理必须最先就绪,因为后续的权限探测与路由守卫都会读取 store 状态;
- 初始化与宿主进程的 RPC 通信(
initializeRPC); - 依次初始化 i18n(默认
zh-CN)与调用者访问等级,因为页面内容会依权限呈现差异; - 注册路由守卫与文档标题钩子,再安装 Router——顺序不可颠倒,
app.use(router)会立即触发首次导航; - 初始化 settings/task store,执行 Onboarding 版本门控重定向;
- 在挂载前应用主题与背景,避免首屏闪烁(FOUC);
- 最后挂载到
#app。
关键概念:
| 概念 | 说明 | 源码依据 |
|---|---|---|
| 初始化顺序约束 | 多个模块之间存在严格的先后依赖,源码内以中文注释显式声明 | main.ts L21、L35、L56 注释 |
| 路由守卫前置注册 | 守卫必须在 app.use(router) 之前注册,否则首次导航不会被拦截 | main.ts L35-L39 |
| Onboarding flowVersion | 通过 CURRENT_ONBOARDING_FLOW_VERSION 判断是否需要重新展示引导页 | main.ts L49-L54 |
@/ 路径别名 | 导入形如 @/core/rpc,指向 web/src/ 下的模块 | main.ts L6-L12 |
架构
分层架构图
上图中的每条依赖边均对应 web/src/main.ts 中一次真实的导入或调用:编号 1–9 的顺序即源码中的执行顺序。分层意图非常清晰——core/ 承载与宿主进程及横切关注点(RPC、权限、i18n、任务)相关的基础设施,features/ 承载业务特性(当前采样中可见 settings),components/ 承载可复用组件(layout/ 负责应用外壳,ui/ 负责基础原语),router/ 独立成目录以便路由表与守卫解耦。
目录组织
基于仓库文件清单,web/ 的页面与组件组织如下:
| 路径 | 角色 |
|---|---|
web/index.html | HTML 入口,提供 #app 挂载点 |
web/src/main.ts | 应用启动编排(本文档核心) |
web/src/App.vue | 根组件 |
web/src/router/ | index.ts(路由表与 router 实例)+ guards.ts(守卫与标题钩子) |
web/src/core/rpc · core/access · core/i18n · core/tasks/store | 横切基础设施(由 main.ts 导入证实) |
web/src/features/settings/ | 设置特性:store、types(含 CURRENT_ONBOARDING_FLOW_VERSION)、appearance |
web/src/components/layout/ | AppLayout.vue、AppHeader.vue、ContentArea.vue、GalleryDebugOverlay.vue、WindowResizeOverlay.vue,附 index.ts 桶文件 |
web/src/components/ui/ | 基础 UI 原语,按组件分目录(如 accordion/Accordion.vue、AccordionContent.vue、AccordionItem.vue、AccordionTrigger.vue、index.ts) |
web/src/components/AdbDeviceInput.vue | 共享业务输入组件(ADB 设备输入) |
web/src/assets/ | SVG 资源(momo-outline.svg、zongzi-momo.svg) |
web/public/ | 静态资源(logo_192x192.png,为 192×192 规格的应用标识) |
web/components.json | UI 组件生成器(shadcn 风格)的配置文件,与 components/ui/ 的目录约定相呼应 |
components/ui/accordion/ 采用「每个原语一个 SFC + index.ts 桶文件」的组织方式,components/layout/ 同样带 index.ts 桶文件——这种约定让消费方可以从目录导入(@/components/ui/accordion),而不必关心内部文件拆分,是 shadcn-vue 生态的典型布局。
说明:路由表的具体路由清单(除下文验证的
welcome外)定义于 router/index.ts,本次源码采样未展开其内容,故本文不罗列未经验证的路由条目。
核心流程:应用启动序列
启动序列的顺序并非随意排列,而是由模块间真实的数据依赖决定的。下面的时序图逐步还原 web/src/main.ts 的真实控制流:
启动代码(完整)
1import { createApp } from 'vue'
2import { watch } from 'vue'
3import { createPinia } from 'pinia'
4import router from './router'
5import { setupDocumentTitle, setupRouterGuards } from './router/guards'
6import { initializeRPC } from '@/core/rpc'
7import { initializeAccessLevel } from '@/core/access'
8import { initI18n } from '@/core/i18n'
9import { useSettingsStore } from '@/features/settings/store'
10import { CURRENT_ONBOARDING_FLOW_VERSION } from '@/features/settings/types'
11import { applyAppearanceToDocument } from '@/features/settings/appearance'
12import { useTaskStore } from '@/core/tasks/store'
13import './index.css'
14import App from './App.vue'
15
16// 创建 Pinia 实例
17const pinia = createPinia()
18
19const app = createApp(App)
20
21// Pinia 必须先安装,后续权限探测和路由守卫会读取其状态。
22app.use(pinia)
23
24// 初始化 RPC 通信
25initializeRPC()
26
27// 初始化应用
28;(async () => {
29 // 首先初始化 i18n(使用默认语言)
30 await initI18n('zh-CN')
31
32 // 先确定调用者访问等级,再初始化会根据权限显示不同内容的页面。
33 await initializeAccessLevel()
34
35 // 守卫必须在安装 Router 前注册;app.use(router) 会立即启动首次导航。
36 setupRouterGuards(router)
37 setupDocumentTitle(router)
38 app.use(router)
39 await router.isReady()
40
41 // 然后初始化 settings store,它会自动同步后端的语言设置
42 const settingsStore = useSettingsStore()
43 await settingsStore.init()
44
45 // 初始化后台任务订阅
46 const taskStore = useTaskStore()
47 await taskStore.initialize()
48
49 const onboarding = settingsStore.appSettings.app.onboarding
50 const needsOnboarding =
51 !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION
52 if (needsOnboarding && router.currentRoute.value.name !== 'welcome') {
53 await router.replace('/welcome')
54 }
55
56 // 在挂载前应用主题和背景,避免首屏闪烁
57 applyAppearanceToDocument(settingsStore.appSettings)
58
59 // 监听设置变化,实时同步外观
60 watch(
61 () => [
62 settingsStore.appSettings.ui.webTheme.mode,
63 settingsStore.appSettings.ui.webTheme.customCss,
64 settingsStore.appSettings.ui.webTheme.menuBlur,
65 settingsStore.appSettings.ui.background.type,
66 settingsStore.appSettings.ui.background.imageFileName,
67 settingsStore.appSettings.ui.background.backgroundBlurAmount,
68 settingsStore.appSettings.ui.background.backgroundOpacity,
69 settingsStore.appSettings.ui.background.overlayColors.join('|'),
70 settingsStore.appSettings.ui.background.primaryColor,
71 settingsStore.appSettings.ui.background.overlayOpacity,
72 settingsStore.appSettings.ui.background.surfaceOpacity,
73 ],
74 () => {
75 applyAppearanceToDocument(settingsStore.appSettings)
76 },
77 )
78
79 // 最后挂载应用
80 app.mount('#app')
81})()Source: main.ts
逐段解析:为什么是这个顺序
(1)Pinia 先于一切(L16-L22)。 源码注释直白地说明原因:「后续权限探测和路由守卫会读取其状态」。initializeAccessLevel() 与 setupRouterGuards() 都可能调用 useXxxStore(),而 Pinia store 的激活依赖于已安装的 pinia 实例;若顺序颠倒会在守卫阶段抛出 "getActivePinia was called with no active Pinia" 类错误。
(2)initializeRPC() 位于同步段(L24-L25)。 RPC 是 Web 前端与宿主进程之间的通信底座(详见兄弟页),它在任何异步初始化之前建立,使后续 initI18n / initializeAccessLevel / store 同步等操作可立即复用该通道。
((3)i18n 先于权限(L28-L33)。 initI18n('zh-CN') 以默认语言先行,保证即使后端语言设置尚未同步,界面也已具备可用文案;随后的 settingsStore.init()(L41-L43)「会自动同步后端的语言设置」再切换为用户偏好语言——这是「先可用、再精确」的渐进式初始化策略。
(4)守卫注册必须先于 app.use(router)(L35-L39)。 这是本模块最关键的时序约束。vue-router 在被安装时会立刻启动首次导航(首次路由解析发生在 install 期间),因此 setupRouterGuards(router) 与 setupDocumentTitle(router) 必须提前挂到 router 实例上,否则首次导航将绕过权限拦截。随后的 await router.isReady() 确保首次导航完成后再进入 store 初始化。
(5)Onboarding 版本门控(L49-L54)。 判定条件是「未完成」或「flowVersion 落后于当前代码所要求的 CURRENT_ONBOARDING_FLOW_VERSION」——后者意味着前端代码升级后引入了新的引导步骤,老用户也会被重新带回 /welcome。重定向使用 router.replace(非 push),避免引导页污染历史栈;同时检查 router.currentRoute.value.name !== 'welcome' 防止在已是 welcome 时重复替换。
(6)挂载前应用外观(L56-L57)。 applyAppearanceToDocument 在 app.mount('#app') 之前执行,注释明确动机是「避免首屏闪烁」:若先挂载再改主题,用户会先看到默认样式再跳变为自定义样式。
(7)外观响应式监听(L59-L77)。 通过 watch 监听 ui.webTheme 与 ui.background 下的具体字段(包括用 overlayColors.join('|') 把数组折叠为可比较字符串),任何变更都重新应用外观。注意该 watch 依赖数组中「字段级」的取值而非整个对象引用,从而实现细粒度触发。
用法示例
示例:守卫注册与 Router 安装的强制顺序
1 // 守卫必须在安装 Router 前注册;app.use(router) 会立即启动首次导航。
2 setupRouterGuards(router)
3 setupDocumentTitle(router)
4 app.use(router)
5 await router.isReady()Source: main.ts
这段四行代码浓缩了本架构中最重要的时序契约。任何在 app.use(router) 之后才注册的守卫,都无法拦截第一次路由解析——这是扩展路由(例如新增需要权限的页面)时最容易踩中的陷阱。
示例:Onboarding 门控重定向
1 const onboarding = settingsStore.appSettings.app.onboarding
2 const needsOnboarding =
3 !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION
4 if (needsOnboarding && router.currentRoute.value.name !== 'welcome') {
5 await router.replace('/welcome')
6 }Source: main.ts
两个布尔条件的组合实现了「首次安装」与「版本升级后需要补充引导」两种场景的统一处理;CURRENT_ONBOARDING_FLOW_VERSION 常量来自 features/settings/types。
示例:monorepo 中驱动前端的构建脚本
"build:web": "pnpm --filter web run build",
"dev:web": "pnpm --filter web run dev",
"format:web": "pnpm --filter web exec prettier --write .",Source: package.json
根包使用 pnpm --filter web 把命令转发给 web/ 子包,前端拥有独立的 web/package.json(例如 build 与 dev 脚本定义在子包内,本页不重复展开)。同时根级 lint-staged 对 web/**/*.{js,ts,vue,json,css,md} 执行 node scripts/format-web.js,保证前端文件在提交前被格式化。
API 参考(启动编排相关)
以下签名均取自 web/src/main.ts 的实际导入,参数与行为以其所属模块的实现为准(详见各兄弟页面):
| API | 来源模块 | 说明 |
|---|---|---|
createPinia(): Pinia | pinia | 创建 Pinia 实例;必须先 app.use(pinia) 才能安全使用任何 store |
initializeRPC(): void | @/core/rpc | 建立与宿主进程的 RPC 通信底座(同步调用,位于异步 IIFE 之前) |
initI18n(locale: string): Promise<void> | @/core/i18n | 以指定 locale(启动时固定 'zh-CN')初始化国际化 |
initializeAccessLevel(): Promise<void> | @/core/access | 探测调用者访问等级,供权限化页面与路由守卫消费 |
setupRouterGuards(router: Router): void | ./router/guards | 向 router 实例注册全局守卫;必须在 app.use(router) 之前调用 |
setupDocumentTitle(router: Router): void | ./router/guards | 注册文档标题钩子,随路由切换更新页面标题 |
useSettingsStore(): SettingsStore | @/features/settings/store | 设置 store;其 init() 会自动同步后端语言设置 |
useTaskStore(): TaskStore | @/core/tasks/store | 后台任务 store;initialize() 建立任务订阅 |
applyAppearanceToDocument(settings: AppSettings): void | @/features/settings/appearance | 将主题/背景设置写入 document,挂载前调用可避免首屏闪烁 |
失败模式、边界情况与并发
- 初始化顺序破坏(时序契约):若把
app.use(router)提前到setupRouterGuards之前,首次导航不会经过权限守卫——这是隐式契约,源码通过注释(L35)显式警示而非类型系统保证。 - Pinia 未安装即用 store:
initializeAccessLevel()/setupRouterGuards()内部读取 store 的前提是app.use(pinia)已执行(L21-L22 注释)。 - Onboarding 死循环风险:重定向前检查
router.currentRoute.value.name !== 'welcome'(L52),防止在 welcome 页上反复replace自身造成导航冗余;此外若守卫层也强制跳转 welcome,则两层需保持一致,否则可能出现循环重定向。 - 首屏闪烁(FOUC):
applyAppearanceToDocument必须先于app.mount(L56-L57),否则用户先看到默认主题再切换。 - 启动失败传播:整个异步初始化链包裹在一个 IIFE 中且逐层
await;任何一步抛出异常都会中断后续初始化并阻止挂载(app.mount不会执行),表现为白屏——排查时应从控制台的首个未捕获异常入手。 - 外观监听的细粒度依赖:watch 依赖数组逐字段取值(含
overlayColors.join('|')折叠数组,L59-L77),避免替换整个 settings 对象引用导致的过度触发。
性能与运维要点
- 关键路径串行化:
i18n → access → router ready → settings init → tasks init全部串行 await,启动时间为其总和。这是以启动耗时换取确定性(权限先于路由、语言先于渲染)的刻意取舍。 - 挂载前的外观应用消除了主题切换引起的重排/重绘闪烁,对感知性能有直接收益。
- 构建入口:
pnpm dev:web/pnpm build:web(根 package.json L15、L22)经--filter web转发至子包;pnpm format:web用 prettier 统一格式,lint-staged(L39-L41)在提交时对web/**生效。 - 静态资源约定:
web/public/logo_192x192.png位于 public 目录,构建时原样拷贝,通常作为 PWA/图标资源;web/components.json与components/ui/的目录结构配套,是 UI 原语代码生成的配置。
扩展点
- 新增页面:在
web/src/router/index.ts的路由表中登记,并遵循守卫对访问等级的约束(守卫逻辑在web/src/router/guards.ts);若页面需要登录/权限,务必确认守卫已覆盖新路由。 - 新增守卫:继续在
setupRouterGuards内追加,并保持「注册先于app.use(router)」的调用点不变(当前在 main.ts L36)。 - 新增引导步骤:提升
features/settings/types中的CURRENT_ONBOARDING_FLOW_VERSION,即可让存量用户重新进入/welcome完成新步骤——这是设计好的版本化引导机制。 - 新增基础组件:按
components/ui/<component>/目录 + 各 SFC +index.ts桶文件的既有约定添加(参照accordion/的结构)。 - 新增应用外壳区块:在
components/layout/(AppLayout/AppHeader/ContentArea/ 调试浮层 / 尺寸浮层)内扩展,并通过其index.ts桶文件导出。
相关链接
- 应用启动编排源码:web/src/main.ts
- 路由表:web/src/router/index.ts
- 路由守卫与标题钩子:web/src/router/guards.ts
- 根构建脚本:package.json
- HTML 入口:web/index.html
- 前端子包说明:web/README.md
兄弟页面提示:RPC 通信协议见 core/rpc 专题页;访问等级与权限见 core/access 专题页;设置体系与主题/背景实现见 features/settings 专题页;i18n 见 core/i18n 专题页;后台任务见 core/tasks 专题页。