# 資料流與元件分層優化策略 > 分析標的:`~/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` | `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` prop 驅動。 - `PageResolver` 處理 `Promise` 的 loading / error 狀態。 - `Page.svelte` 用 type guard 分發到對應的 page component:`isProductPage(page)` → ``。 - **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 drilling:Jet、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 | `` 或 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 ``` - 若頁面資料支援 async setup 或 `usePageDriver` 回傳 Promise,可用 Vue `` 達到與 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/`) - **職責**: 1. 呼叫 `useXxxPage()` 取得 `pageModel`。 2. 將 `pageModel` 與事件處理器傳給對應的 `PageXxx.vue`。 3. 處理 route param 解析(僅限轉換,不含業務邏輯)。 - **目標行數**:< 80 行。 - **禁止**:大量模板、dialog 定義、form 欄位、直接操作 store。 ```vue ``` #### Layer 3: Page Component(`src/components/pages/`) - **職責**:組裝完整頁面的 section 順序、處理 page-level slot override、分發頁面級事件。 - **命名**:一律 `Page` 前綴。 - **對齊**:App Store 的 `ProductPage.svelte`、`TodayPage.svelte`、`DefaultPage.svelte`。 ```vue ``` #### Layer 4: Section / Shelf(`src/components/sections/`) - **職責**:決定「這一區的佈局方式」(水平捲軸、網格、列表、摺疊面板)。 - **禁止**:知道上層 page 的業務邏輯、不直接呼叫 API。 - **對齊**:App Store 的 `Shelf.svelte`、`ShelfItemLayout.svelte`。 ```vue ``` #### Layer 5: Item / Atom(`src/components/items/`) - **職責**:純粹呈現單一資料單位。 - **禁止**:知道自己是水平捲軸還是網格、不管理任何狀態。 - **對齊**:App Store 的 `BrickItem.svelte`、`LargeLockupItem.svelte`。 ```vue ``` ### 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 └── SectionFormPanel(dialog shell) ├── MntDialogCard(外殼:標題、toolbar、actions) └── ItemFormFieldGroup(內容:欄位) ``` - `SectionFormPanel` 管理 dialog 的開關、mode、loading、saving。 - `ItemFormFieldGroup` 純粹呈現欄位,不知道自己在 dialog 裡。 - View 中不再出現 ``、``、`` 的具體定義。 --- ## 五、重構優先順序與遷移路徑 ### 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:遷移最厚的 view(SingleRecord.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 driver,command 用 command composable。 - [ ] 這個元件只服務單一 domain 嗎?→ 是:留在 `components/items/` 或 `components/sections/` 的 domain 子目錄;否:才進 `base/`。 - [ ] 這個抽象降低了理解成本嗎?→ 否:不要抽。 --- *本文件取代 `docs/frontend-layering.md` 與 `src/components/README.md` 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。*