Files
skt-vuetify-templates/docs/architecture-strategy.md
T
2026-05-18 16:56:21 +08:00

525 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 資料流與元件分層優化策略
> 分析標的:`~/code/apps.apple.com-main`Apple App Store Web — Svelte + Jet 架構)
> 適用目標:`/home/carl/git/skt-vuetify-templates`Vue 3 + Vuetify + Pinia
> 原則:不強制遷移既有程式碼,但所有新增功能與重構以本文件為最高準則。
---
## 一、Apple App Store 專案的核心架構特徵
### 1.1 單一業務邏輯門面(Jet Facade)
```
browser.ts → bootstrap → Jet.load → runtime + objectGraph
UI 層僅透過 jet.dispatch(intent) / jet.perform(action) 溝通
```
- **Jet** 封裝所有業務邏輯:路由、資料取得、動作分發、metrics。
- UI 層**不直接**呼叫 API、不直接操作 storage、不直接操作 history。
- 所有外部依賴(fetch、storage、locale、user)統一注入 `Dependencies`,再組裝成 `ObjectGraph`
### 1.2 Intent / Action 分離(查詢與命令)
| 類型 | 職責 | 回傳值 | 例子 |
|------|------|--------|------|
| **Intent** | 取得頁面資料(Query | `Promise<Page>` | `RouteUrlIntent` → 回傳 `ProductPage` |
| **Action** | 執行副作用(Command | `'performed' \| 'unsupported'` | `FlowAction` → 導航到新頁面 |
- `FlowAction` 是主要導航機制:內含 `destination Intent` + `pageUrl` + `presentationContext`
- Action handler 註冊採用**型別註冊制**:`jet.onAction('flowAction', handler)`
### 1.3 Page Model 驅動 UI(資料驅動)
```ts
// App.svelte 的介面極簡
export let page: Promise<Page> | Page
```
- 整個應用由單一 `page` prop 驅動。
- `PageResolver` 處理 `Promise<Page>` 的 loading / error 狀態。
- `Page.svelte` 用 type guard 分發到對應的 page component`isProductPage(page)``<ProductPage>`
- **Page 是 union type**,不是 route-based 的硬編碼映射。
### 1.4 Shelf / Item 分層(容器與內容分離)
```
Page (TodayPage / ProductPage / ...)
└── Shelf[] (水平捲軸 / 網格)
└── ShelfItemLayout (佈局抽象:HorizontalShelf or Grid)
└── Item (BrickItem / LargeLockupItem / ...)
```
- **Shelf** = 容器邏輯:決定是水平捲軸還是網格、rowsPerColumn、邊框。
- **ShelfItemLayout** = 佈局中介:根據 `isHorizontal` 選擇 `HorizontalShelf``Grid`
- **Item** = 純粹內容渲染:只關心單一資料單位的呈現,不知道自己是水平還是網格。
- **FallbackShelf** = 優雅的降級策略:遇到未實作的 shelf 類型顯示 placeholder,不 crash。
### 1.5 Svelte Context 作為跨層依賴注入
```ts
// bootstrap.ts
context.set('jet', jet)
context.set('i18n', i18nStore)
// 深層元件
const jet = getJet() // 從 Svelte Context 取得
const i18n = getI18n() // 從 Svelte Context 取得
```
- 避免 props drillingJet、i18n、accessibility layout、today-card layout 都透過 context 傳遞。
- Context 在啟動時注入,生命周期與應用一致,不是用來傳遞 UI 狀態的。
### 1.6 命令式外殼 + 聲明式 UI
```ts
// browser.ts(命令式啟動層)
const app = new App({ target: container, context, hydrate: true })
registerActionHandlers({
jet,
updateApp: (props) => app.$set(props), // 橋接命令式 → 聲明式
})
```
- 導航、歷史管理、scroll 復原由命令式的 action handler 處理。
- UI 渲染完全聲明式,只接收 `page``isFirstPage` 兩個 prop。
---
## 二、我們專案的現況診斷
### 2.1 App.vue 過度臃腫(~590 行)
| 職責 | 行數 | 應屬層級 |
|------|------|----------|
| Layout 切換 | ~20 | App Shell |
| Tabs 管理 | ~80 | Page Driver |
| Breadcrumb 組裝 | ~40 | Layout |
| Favorites 管理 | ~60 | Store |
| Search Dialog | ~80 | App Shell / Widget |
| Message Dialog | ~60 | App Shell / Widget |
| Snackbar | ~10 | Global Overlay |
| Logout / Force logout | ~30 | Auth Flow |
| HTTP Toast | ~20 | Service Layer |
- **問題**App.vue 同時承擔 App Shell、Page Driver、Global Widget、Auth Flow 四種責任。
- **對比**App Store 的 `App.svelte` 只有 161 行,只負責 `Navigation + PageResolver + Footer`
### 2.2 Views 過厚(SingleRecord.vue ~830 行)
- 混雜:表格呈現、搜尋表單、dialog 模板、表單狀態、CRUD 流程、驗證邏輯、分頁、snackbar。
- **對比**App Store 的 `ProductPage.svelte` 只有 77 行,只負責「把 page 轉成 DefaultPageRequirements + 一個 slot override」。
### 2.3 缺乏統一的頁面資料門面
```
現況:
view → store → service(直接鏈式呼叫)
view 自己管理 loading / error / dialog visible
App Store
UI → jet.dispatch(intent) → runtime → controller → page model
UI 只接收 page model,不管理載入狀態
```
### 2.4 Dialog 狀態與模板內嵌於 View
- `SingleRecord.vue` 內含 5 個 `ConfirmDialog` 實例 + 1 個大 form overlay。
- 任何 dialog 更動都需要修改 view 檔案。
### 2.5 沒有容器/內容分離的 Section 層
- 表格、表單、搜尋區塊都是直接寫在 view 或 page component 中。
- 缺乏類似 `ShelfItemLayout` 的通用佈局抽象:「這一區是水平捲軸還是網格」應該由容器決定,裡面的內容元件不應該知道。
---
## 三、優化後的資料流策略
### 3.1 核心資料流(單向 + 集中閘道)
```
┌─────────────────────────────────────────────────────────────┐
│ App Shell │
│ (App.vue → Layout → Global Overlays: Snackbar/Dialogs) │
└──────────────────────────┬──────────────────────────────────┘
│ page model (reactive)
┌─────────────────────────────────────────────────────────────┐
│ Page Driver │
│ (views/*.vue — 極薄,只負責:組裝 page model / 事件轉發) │
└──────────────────────────┬──────────────────────────────────┘
│ props / emits
┌─────────────────────────────────────────────────────────────┐
│ Page Component │
│ (PageXxx.vue — 組裝完整頁面,決定 Section 順序與 override) │
└──────────────────────────┬──────────────────────────────────┘
│ section data
┌─────────────────────────────────────────────────────────────┐
│ Section / Shelf │
│ (決定佈局:水平/網格/列表;不關心內部呈現) │
└──────────────────────────┬──────────────────────────────────┘
│ item data
┌─────────────────────────────────────────────────────────────┐
│ Item / Atom │
│ (純粹內容呈現;透過 provide/inject 取得 domain context) │
└─────────────────────────────────────────────────────────────┘
橫向:
composables → 可重用流程(CRUD state machine、form validation、editable grid
stores → 跨頁共享狀態(auth、menu、favorites、messages
services → HTTP 閘道(只封裝 ky,不持有 UI 狀態)
```
### 3.2 Page Model 作為主要資料單位
- **新增 `src/models/page.ts`**:定義各頁面的統一介面。
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 usePageDriver() 取得 page model,傳給 Page component」。
- Page model 可以來自:
- store(已有快取)
- service(直接 API
- composable(組裝多個來源)
範例:
```ts
// src/composables/usePageDriver.ts
export function useStudentMaintenancePage() {
const studentStore = useStudentStore()
const { records, loading, error, load } = useCrudDriver({
store: studentStore,
loadAction: () => studentStore.fetchStudents(),
})
const pageModel = computed(() => ({
title: '單筆資料維護',
records: records.value,
loading: loading.value,
error: error.value,
}))
return { pageModel, load }
}
```
### 3.3 查詢(Query)與命令(Command)分離
| 類型 | 資料流 | 錯誤處理 | 狀態位置 |
|------|--------|----------|----------|
| **Query** | `usePageDriver``pageModel` → props | `<PageErrorBoundary>` 或 page-level fallback | composable 內部 ref |
| **Command** | `executeCommand()``await service.action()` → 重新載入 query | snackbar / dialog / field error | composable 內部 ref |
- Query 對應 App Store 的 **Intent**:取得資料、回傳 model。
- Command 對應 App Store 的 **Action**:執行副作用、不回傳 model、觸發 Query 重新整理。
### 3.4 Promise-based 頁面載入(可選進階)
```vue
<!-- PageResolver.vue 對齊 App Store PageResolver.svelte -->
<template>
<Suspense>
<template #default>
<component :is="pageComponent" :page="resolvedPage" />
</template>
<template #fallback>
<PageLoadingSpinner />
</template>
</Suspense>
</template>
```
- 若頁面資料支援 async setup 或 `usePageDriver` 回傳 Promise,可用 Vue `<Suspense>` 達到與 App Store `{#await page}` 類似的效果。
- **短期**:維持 reactive ref,但將 loading / error 統一封裝在 `usePageDriver`
- **長期**:當多數頁面都使用統一 driver 後,可考慮引入 Suspense。
### 3.5 全域狀態 vs 頁面狀態的邊界
| 狀態類型 | 存放位置 | 生命周期 |
|----------|----------|----------|
| 認證、選單、語言、主題 | `src/stores/*.ts` | 應用級 |
| 搜尋條件、分頁、dialog visible | `src/composables/useXxxPage.ts` | 頁面級(離開頁面可選保留或重置) |
| 表單 dirty / validation | `src/composables/useXxxForm.ts` | dialog / form 級 |
| 表格排序、過濾 | `src/composables/useXxxTable.ts` | 區塊級 |
---
## 四、優化後的元件分層策略
### 4.1 五層結構
```
src/
├── shell/ ← 新增:App Shell(原 App.vue 拆分)
│ ├── AppShell.vue ← layout 切換、全域 overlay 掛載點
│ ├── GlobalOverlays.vue ← snackbar、確認 dialog、toast
│ └── AppTabs.vue ← 頁籤(從 App.vue 抽出)
├── views/ ← 維持:Page Driver(極薄)
│ └── maint/
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + 掛載 PageMaintDriver
├── components/
│ ├── pages/ ← 新增:Page Component 層
│ │ ├── PageStudentMaintenance.vue
│ │ └── PageReport.vue
│ │
│ ├── sections/ ← 新增:Section / Shelf 層
│ │ ├── SectionSearchPanel.vue
│ │ ├── SectionDataTable.vue
│ │ └── SectionFormPanel.vue
│ │
│ ├── items/ ← 新增:Item / Atom 層(領域獨立)
│ │ ├── ItemStudentRow.vue
│ │ └── ItemFormField.vue
│ │
│ ├── layouts/ ← 維持:App Shell Layout
│ │ ├── MainLayout.vue
│ │ └── PlainLayout.vue
│ │
│ └── base/ ← 維持:真正跨頁共用
│ └── DraggableDialog.vue
├── composables/
│ ├── page-drivers/ ← 新增:頁面資料協調
│ │ └── useStudentMaintenancePage.ts
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
│ │ └── useStudentCrudCommands.ts
│ ├── forms/ ← 維持/重組:表單狀態機
│ │ └── useStudentForm.ts
│ └── layout/ ← 維持
├── models/ ← 新增:領域模型與 Page Union
│ ├── page.ts
│ └── student.ts
├── stores/ ← 維持:跨頁共享狀態
├── services/ ← 維持:HTTP 閘道
└── router/ ← 維持:路由與 meta
```
### 4.2 各層職責與規範
#### Layer 1: App Shell`src/shell/`
- **職責**layout 切換、全域 overlaysnackbar、dialog)、頁籤容器、事件總線橋接。
- **禁止**:頁面專屬業務流程、頁面資料組裝、特定 dialog 內容。
- **對齊**App Store 的 `App.svelte` + `browser.ts` 的 overlay 部分。
#### Layer 2: Page Driver`src/views/`
- **職責**
1. 呼叫 `useXxxPage()` 取得 `pageModel`
2.`pageModel` 與事件處理器傳給對應的 `PageXxx.vue`
3. 處理 route param 解析(僅限轉換,不含業務邏輯)。
- **目標行數**< 80 行。
- **禁止**:大量模板、dialog 定義、form 欄位、直接操作 store。
```vue
<!-- views/maint/SingleRecord.vue優化後 -->
<script setup lang="ts">
import { useStudentMaintenancePage } from '@/composables/page-drivers/useStudentMaintenancePage'
import PageStudentMaintenance from '@/components/pages/PageStudentMaintenance.vue'
const { pageModel, load } = useStudentMaintenancePage()
load()
</script>
<template>
<PageStudentMaintenance :page="pageModel" />
</template>
```
#### Layer 3: Page Component`src/components/pages/`
- **職責**:組裝完整頁面的 section 順序、處理 page-level slot override、分發頁面級事件。
- **命名**:一律 `Page` 前綴。
- **對齊**App Store 的 `ProductPage.svelte``TodayPage.svelte``DefaultPage.svelte`
```vue
<!-- components/pages/PageStudentMaintenance.vue -->
<template>
<PageMaintShell :title="page.title">
<template #search>
<SectionSearchPanel :fields="page.searchFields" @search="emit('search', $event)" />
</template>
<template #content>
<SectionDataTable :records="page.records" @edit="emit('edit', $event)" />
</template>
</PageMaintShell>
<!-- 頁面級 dialog 外掛內容再拆到 SectionFormPanel -->
<SectionFormPanel
v-model="formVisible"
:mode="formMode"
:record="activeRecord"
@save="emit('save', $event)"
/>
</template>
```
#### Layer 4: Section / Shelf`src/components/sections/`
- **職責**:決定「這一區的佈局方式」(水平捲軸、網格、列表、摺疊面板)。
- **禁止**:知道上層 page 的業務邏輯、不直接呼叫 API。
- **對齊**App Store 的 `Shelf.svelte``ShelfItemLayout.svelte`
```vue
<!-- SectionDataTable.vue -->
<template>
<v-data-table
:headers="resolvedHeaders"
:items="records"
fixed-header
>
<template v-for="slot in customSlots" :key="slot.key" #[slot.key]="{ item }">
<slot :name="slot.key" :item="item">
<!-- 預設 item 渲染 -->
<component :is="slot.itemComponent" :data="item" />
</slot>
</template>
</v-data-table>
</template>
```
#### Layer 5: Item / Atom`src/components/items/`
- **職責**:純粹呈現單一資料單位。
- **禁止**:知道自己是水平捲軸還是網格、不管理任何狀態。
- **對齊**App Store 的 `BrickItem.svelte``LargeLockupItem.svelte`
```vue
<!-- ItemStudentRow.vue -->
<template>
<div class="d-flex ga-2">
<v-chip size="small" :color="statusColor(data.status)">
{{ data.status }}
</v-chip>
<span>{{ data.name }}</span>
</div>
</template>
```
### 4.3 容器/內容分離的具體規範
| 場景 | 容器(Section | 內容(Item |
|------|-----------------|--------------|
| 資料表格 | `SectionDataTable`(決定 headers、分頁、排序) | `ItemStudentRow`(決定單列呈現) |
| 搜尋面板 | `SectionSearchPanel`(決定展開/收合、grid 佈局) | `ItemFormField`(單一輸入框呈現) |
| 圖文列表 | `SectionCardGrid`(決定欄數、gap、RWD | `ItemProductCard`(卡片內容) |
| 表單對話框 | `SectionFormPanel`(決定 dialog 外殼、actions | `ItemFormFieldGroup`(欄位群組) |
- **原則**:若同一組資料在不同頁面需要「水平捲軸 vs 網格」兩種呈現,只換 Section,不換 Item。
### 4.4 Provide / Inject 作為跨層依賴注入
對齊 App Store 的 `getJet()` / `getI18n()`,在 Vue 中建立明確的 inject API
```ts
// src/providers/page.ts
import { inject, provide } from 'vue'
import type { PageDriver } from '@/composables/page-drivers/types'
const PageDriverKey = Symbol('page-driver')
export function providePageDriver(driver: PageDriver) {
provide(PageDriverKey, driver)
}
export function usePageDriverInjected(): PageDriver {
const driver = inject(PageDriverKey)
if (!driver) throw new Error('usePageDriverInjected called without provider')
return driver
}
```
- **提供時機**Page Component (`PageXxx.vue`) 在掛載時 provide。
- **使用時機**:深層的 Item 元件需要觸發 page-level action 時 inject,避免 props drilling。
- **禁止**:用 provide/inject 傳遞會頻繁變動的 UI 狀態(如 `dialogVisible`)。這類狀態應透過 props + emits。
### 4.5 Dialog 外層化策略
將 dialog 從 view 中完全抽出,形成「Dialog Shell + Content Slot」模式:
```
views/xxx.vue
└── PageXxx.vue
├── SectionDataTable
└── SectionFormPaneldialog shell
├── MntDialogCard(外殼:標題、toolbar、actions
└── ItemFormFieldGroup(內容:欄位)
```
- `SectionFormPanel` 管理 dialog 的開關、mode、loading、saving。
- `ItemFormFieldGroup` 純粹呈現欄位,不知道自己在 dialog 裡。
- View 中不再出現 `<teleport>``<v-overlay>``<v-dialog>` 的具體定義。
---
## 五、重構優先順序與遷移路徑
### Phase 1:建立基礎設施(不動既有 view)
1. 新增 `src/models/`:定義領域模型與 Page union type。
2. 新增 `src/shell/`:從 `App.vue` 抽出 `GlobalOverlays.vue``AppTabs.vue`
3. 新增 `src/components/pages/`:建立第一個 `PageStudentMaintenance.vue`(可從 `PageMaint.vue` 擴展)。
4. 新增 `src/composables/page-drivers/`:建立 `useStudentMaintenancePage.ts`
### Phase 2:遷移最厚的 viewSingleRecord.vue
1.`SingleRecord.vue` 的模板抽出到 `PageStudentMaintenance.vue`
2. 將 dialog 抽出到 `SectionFormPanel.vue`
3. 將表單欄位抽出到 `ItemFormFieldGroup.vue``ItemFormField.vue[]`
4. 將 CRUD 流程抽出到 `composables/commands/useStudentCrudCommands.ts`
5. `SingleRecord.vue` 目標縮減到 < 80 行。
### Phase 3:推廣到所有 maintenance 頁面
1. `EditableGrid.vue``MasterDetailA/B/C.vue` 依同樣模式重構。
2. 建立通用的 `useCrudPageDriver()``useCrudCommands()`,減少重複。
### Phase 4:非 maintenance 頁面統一
1. `Home.vue``Settings.vue``FncPage.vue` 套用 Page Driver + Page Component 模式。
2. `App.vue` 最終只保留 layout 切換與 `GlobalOverlays` 掛載。
---
## 六、命名規範總結
| 層級 | 目錄 | 檔名前綴/範例 |
|------|------|---------------|
| App Shell | `src/shell/` | `AppShell.vue``GlobalOverlays.vue` |
| Page Driver | `src/views/` | `SingleRecord.vue`route view,不改名) |
| Page Component | `src/components/pages/` | `PageStudentMaintenance.vue` |
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue``SectionSearchPanel.vue` |
| Item / Atom | `src/components/items/` | `ItemStudentRow.vue``ItemFormField.vue` |
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
| Page Driver Composable | `src/composables/page-drivers/` | `useStudentMaintenancePage.ts` |
| Command Composable | `src/composables/commands/` | `useStudentCrudCommands.ts` |
| Form Composable | `src/composables/forms/` | `useStudentForm.ts` |
| Domain Store | `src/stores/` | `students.ts`(維持) |
| Service Module | `src/services/modules/` | `students.ts`(維持) |
| Domain Model | `src/models/` | `student.ts``page.ts` |
---
## 七、對齊檢查清單(新增/重構時使用)
- [ ] 這個 view 超過 100 行了嗎?→ 考慮抽出 Page Component。
- [ ] 這個元件知道自己是水平捲軸還是網格嗎?→ Item 層不該知道,移到 Section 層。
- [ ] 這個 dialog 定義在 view 裡嗎?→ 抽出到 SectionFormPanel。
- [ ] 這個元件直接呼叫 `studentStore.updateStudent()` 嗎?→ 改為觸發 command 或 emit event。
- [ ] 這個狀態需要跨頁共享嗎?→ 是:store;否:composable。
- [ ] 這個邏輯是「取得資料」還是「執行動作」?→ query 用 page drivercommand 用 command composable。
- [ ] 這個元件只服務單一 domain 嗎?→ 是:留在 `components/items/``components/sections/` 的 domain 子目錄;否:才進 `base/`
- [ ] 這個抽象降低了理解成本嗎?→ 否:不要抽。
---
*本文件取代 `docs/frontend-layering.md` 與 `src/components/README.md` 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。*