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

22 KiB
Raw Blame History

資料流與元件分層優化策略

分析標的:~/code/apps.apple.com-mainApple App Store Web — Svelte + Jet 架構) 適用目標:/home/carl/git/skt-vuetify-templatesVue 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
  • 整個應用由單一 page prop 驅動。
  • PageResolver 處理 Promise<Page> 的 loading / error 狀態。
  • Page.svelte 用 type guard 分發到對應的 page componentisProductPage(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 選擇 HorizontalShelfGrid
  • 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 drillingJet、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 渲染完全聲明式,只接收 pageisFirstPage 兩個 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 usePageDriverpageModel → 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 Shellsrc/shell/

  • 職責layout 切換、全域 overlaysnackbar、dialog)、頁籤容器、事件總線橋接。
  • 禁止:頁面專屬業務流程、頁面資料組裝、特定 dialog 內容。
  • 對齊App Store 的 App.svelte + browser.ts 的 overlay 部分。

Layer 2: Page Driversrc/views/

  • 職責
    1. 呼叫 useXxxPage() 取得 pageModel
    2. pageModel 與事件處理器傳給對應的 PageXxx.vue
    3. 處理 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 Componentsrc/components/pages/

  • 職責:組裝完整頁面的 section 順序、處理 page-level slot override、分發頁面級事件。
  • 命名:一律 Page 前綴。
  • 對齊App Store 的 ProductPage.svelteTodayPage.svelteDefaultPage.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 / Shelfsrc/components/sections/

  • 職責:決定「這一區的佈局方式」(水平捲軸、網格、列表、摺疊面板)。
  • 禁止:知道上層 page 的業務邏輯、不直接呼叫 API。
  • 對齊App Store 的 Shelf.svelteShelfItemLayout.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 / Atomsrc/components/items/

  • 職責:純粹呈現單一資料單位。
  • 禁止:知道自己是水平捲軸還是網格、不管理任何狀態。
  • 對齊App Store 的 BrickItem.svelteLargeLockupItem.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
        └── 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.vueAppTabs.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.vueItemFormField.vue[]
  4. 將 CRUD 流程抽出到 composables/commands/useStudentCrudCommands.ts
  5. SingleRecord.vue 目標縮減到 < 80 行。

Phase 3:推廣到所有 maintenance 頁面

  1. EditableGrid.vueMasterDetailA/B/C.vue 依同樣模式重構。
  2. 建立通用的 useCrudPageDriver()useCrudCommands(),減少重複。

Phase 4:非 maintenance 頁面統一

  1. Home.vueSettings.vueFncPage.vue 套用 Page Driver + Page Component 模式。
  2. App.vue 最終只保留 layout 切換與 GlobalOverlays 掛載。

六、命名規範總結

層級 目錄 檔名前綴/範例
App Shell src/shell/ AppShell.vueGlobalOverlays.vue
Page Driver src/views/ SingleRecord.vueroute view,不改名)
Page Component src/components/pages/ PageStudentMaintenance.vue
Section / Shelf src/components/sections/ SectionDataTable.vueSectionSearchPanel.vue
Item / Atom src/components/items/ ItemStudentRow.vueItemFormField.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.tspage.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.mdsrc/components/README.md 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。