22 KiB
22 KiB
資料流與元件分層優化策略
分析標的:
~/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(資料驅動)
// App.svelte 的介面極簡
export let page: Promise<Page> | Page
- 整個應用由單一
pageprop 驅動。 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 作為跨層依賴注入
// bootstrap.ts
context.set('jet', jet)
context.set('i18n', i18nStore)
// 深層元件
const jet = getJet() // 從 Svelte Context 取得
const i18n = getI18n() // 從 Svelte Context 取得
- 避免 props drilling:Jet、i18n、accessibility layout、today-card layout 都透過 context 傳遞。
- Context 在啟動時注入,生命周期與應用一致,不是用來傳遞 UI 狀態的。
1.6 命令式外殼 + 聲明式 UI
// 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(組裝多個來源)
範例:
// 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 頁面載入(可選進階)
<!-- 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 切換、全域 overlay(snackbar、dialog)、頁籤容器、事件總線橋接。
- 禁止:頁面專屬業務流程、頁面資料組裝、特定 dialog 內容。
- 對齊:App Store 的
App.svelte+browser.ts的 overlay 部分。
Layer 2: Page Driver(src/views/)
- 職責:
- 呼叫
useXxxPage()取得pageModel。 - 將
pageModel與事件處理器傳給對應的PageXxx.vue。 - 處理 route param 解析(僅限轉換,不含業務邏輯)。
- 呼叫
- 目標行數:< 80 行。
- 禁止:大量模板、dialog 定義、form 欄位、直接操作 store。
<!-- 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。
<!-- 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。
<!-- 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。
<!-- 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:
// 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
└── SectionFormPanel(dialog shell)
├── MntDialogCard(外殼:標題、toolbar、actions)
└── ItemFormFieldGroup(內容:欄位)
SectionFormPanel管理 dialog 的開關、mode、loading、saving。ItemFormFieldGroup純粹呈現欄位,不知道自己在 dialog 裡。- View 中不再出現
<teleport>、<v-overlay>、<v-dialog>的具體定義。
五、重構優先順序與遷移路徑
Phase 1:建立基礎設施(不動既有 view)
- 新增
src/models/:定義領域模型與 Page union type。 - 新增
src/shell/:從App.vue抽出GlobalOverlays.vue與AppTabs.vue。 - 新增
src/components/pages/:建立第一個PageStudentMaintenance.vue(可從PageMaint.vue擴展)。 - 新增
src/composables/page-drivers/:建立useStudentMaintenancePage.ts。
Phase 2:遷移最厚的 view(SingleRecord.vue)
- 將
SingleRecord.vue的模板抽出到PageStudentMaintenance.vue。 - 將 dialog 抽出到
SectionFormPanel.vue。 - 將表單欄位抽出到
ItemFormFieldGroup.vue或ItemFormField.vue[]。 - 將 CRUD 流程抽出到
composables/commands/useStudentCrudCommands.ts。 SingleRecord.vue目標縮減到 < 80 行。
Phase 3:推廣到所有 maintenance 頁面
EditableGrid.vue、MasterDetailA/B/C.vue依同樣模式重構。- 建立通用的
useCrudPageDriver()與useCrudCommands(),減少重複。
Phase 4:非 maintenance 頁面統一
Home.vue、Settings.vue、FncPage.vue套用 Page Driver + Page Component 模式。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 driver,command 用 command composable。
- 這個元件只服務單一 domain 嗎?→ 是:留在
components/items/或components/sections/的 domain 子目錄;否:才進base/。 - 這個抽象降低了理解成本嗎?→ 否:不要抽。
本文件取代 docs/frontend-layering.md 與 src/components/README.md 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。