分層架構
與 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 的方向」:
單向依賴流的理由
- 每層只做一件事(hook 不匯入 component,所以它一直是可重用的邏輯單元)
- 資料歸屬一眼就看得出來,不用搜遍整個專案
- 重構起來安全:跨層搬檔案時,lint 會一次把所有非法呼叫點列出來
- 多加一條依賴邊,就等於改了 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」不是講好看的 —— 它會被編成規則。
一層宣告了它獨佔哪些原語,其他層就一律不准碰:
{ 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 同樣可以保留功能歸屬,不必透過共用入口匯出。
