Skip to content

分層架構 ​

與 blueprint 的關係:這一頁講的分層,就是 blueprint config 裡的 architecture 區塊 ——
也是整套工程理念裡,唯一會被編成硬性護欄的部分:產生的 ESLint 設定與 inspect 檢測。
只要在 blueprint.config.mjs 宣告自己的分層,同一套機制就會開始把關。

核心只有一句:程式碼只能往下流。 兩種支援的拓樸都共享這套內部分層紀律:

  • layer-first:Layer → Unit;
  • module-first:一般模組採用 Module → Layer → Unit。

標準 Vue 與 React 預設設定預設是 layer-first;給了 modules 之後,
同一份預設設定會把這套理念投影成 module-first。
Module-first 只是增加外層領域相依圖,不會取代每個一般模組內的分層理念。

這套規則跟你用什麼框架無關,Vue、React 都通 —— 同一個單元,兩邊只是名字不同。

下面就是那個順序,箭頭是「可以 import 的方向」:

單向依賴流的理由 ​

  1. 每層只做一件事(hook 不匯入 component,所以它一直是可重用的邏輯單元)
  2. 資料歸屬一眼就看得出來,不用搜遍整個專案
  3. 重構起來安全:跨層搬檔案時,lint 會一次把所有非法呼叫點列出來
  4. 多加一條依賴邊,就等於改了 blueprint config —— 逼你在 review 時正面回答「這一層真的該依賴那一層嗎」

外層模組邊界 ​

Module-first 會在每個一般模組內重複使用下方的分層契約。模組 dependsOn 決定可以接觸 哪些其他模組,包含遞移相依;一次匯入必須同時符合外層模組圖與內部單向分層流。

選用的保留 app 模組不同:它代表 container 位置上的遞迴路由組合,不是一般領域模組, 也不重複內部分層。這項例外讓路由所有權保持明確,不會削弱其他功能模組的模組/分層模型。

在 module-first 預設設定裡,下方的 pages 與 containers 是位置,不是重複的分層:
路由組合交給保留的 app 模組,每個模組根目錄的檔案佔 container 的位置;
其餘分層則在每個一般模組內重複出現。

各層職責 ​

pages

  • 職責 — 頁面版型與 containers 的組裝;對應路由與 SEO
  • 禁止 — 放置商業邏輯、直接堆疊 components

containers

  • 職責 — 單一功能的組裝、商業邏輯與資料增刪查改;持有狀態、呼叫 service、驅動導覽

components

  • 職責 — 負責介面呈現,包含只有一處使用的功能私有元件;可呼叫 hook、持有暫時性的呈現狀態
  • 禁止 — 操作路由、直接呼叫 service、持有應用程式或使用案例的狀態、協調使用案例流程

hooks

  • 職責 — inject / useContext 只在此層;加工伺服器資料與共享狀態;狀態儲存庫(Pinia / Zustand)為本層私有物件
  • 禁止 — 對外暴露原始的狀態儲存庫

contexts

  • 職責 — provide / createContext 只在此層;對外提供 Context 與 Provider

services

  • 職責 — 網路存取原語;唯一可匯入 axios、唯一可呼叫 fetch / WebSocket 之處
  • 禁止 — 夾帶介面或商業邏輯(只回傳資料)

這套架構不設 stores、也不設 utils 這兩層。
每個狀態儲存庫都該有一個唯一擁有它的 hook 模組,這個 hook 就是它對外的介面;其他功能一律透過它讀取。
至於 utils/,它就是個沒有內聚性的雜物間:
任何「看起來很通用」的東西都會被丟進去、越長越大,最後變成所有模組都依賴的耦合點。
純函式該照「誰在用」來歸屬:
只有單一模組在用,就當那個模組的私有檔案;
跨模組要共用,就開一個有名字、依領域切分的獨立模組,讓它「掙到一個名字」。

所有權 —— owns ​

上表那句「唯一可匯入 axios」不是講好看的 —— 它會被編成規則。
一層宣告了它獨佔哪些原語,其他層就一律不准碰:

js
{ name: 'services', owns: ['axios', { global: 'fetch' }, { global: 'WebSocket' }] },
{ name: 'hooks',    owns: [{ package: 'vue', imports: ['inject'] }, 'pinia'] },
  • 純字串代表持有整個套件;
    { package, imports } 可以收窄到特定的具名匯入(vue 各層還是能匯入 —— 只有 inject 被擋)
  • { global } 代表持有全域物件(fetch、WebSocket)——
    全域物件沒有 import 敘述,所以這半邊是 lint(no-restricted-globals)在管、不是 inspect
  • 套件所有權有兩道防線:
    lint(no-restricted-imports)跟 inspect 的 package-ownership 檢測

功能資料夾 —— 一個 unit 一個資料夾 ​

components/
└─ Dropdown/
   ├─ index        ← 對外唯一入口(公開)
   ├─ Dropdown     ← 實作本體,檔名即 unit 名(不命名為 Component)
   ├─ hooks        ← 私有
   ├─ styles       ← 私有
   └─ types        ← 私有
  • index 是 unit 對外的門面,外面只認得這個入口
  • 私有子元件可以留在所屬單元內(例如 container 底下的 ProfileTab);
    負責呈現的單元也可以放在 components/,即使目前只有一處使用。
    是否重用決定要不要移到共用或公開位置,不決定它屬於哪一層
  • 實作檔用「unit 名」命名:
    要是全都叫 Component.tsx,編輯器的分頁跟快速開啟根本認不出誰是誰

components 跟 containers 要依職責區分:「它負責介面呈現,還是主導使用案例?」
像 MatchScoreCard 這類特定功能的顯示元件,即使只有一處使用,也仍然是 component。
使用領域名稱或只有一處使用,都不會讓它變成 container。
Component 可以持有暫時性的呈現狀態,例如區塊展開、滑鼠停留或選單開啟;
應用程式狀態、工作流程轉換、資料增刪查改與導覽,則屬於 container 位置的職責。
Containers 負責把 components 跟資料接起來;components 不主導 container 的使用案例。

在 module-first 中,功能私有的呈現元件放在所屬模組的 components 層, 不放在模組根目錄,也不需要公開給其他模組。 在 layer-first 中,components 同樣可以保留功能歸屬,不必透過共用入口匯出。