前端領域的 “乾淨架構”
大家好,我是 ConardLi
,前端有架構嗎?這可能是很多人心裏的疑惑,因爲在實際業務開發裏我們很少爲前端去設計標準規範的代碼架構,可能更多的去關注的是工程化、目錄層級、以及業務代碼的實現。
今天我們來看一種前端架構的模式,原作者稱它爲 “乾淨架構(Clean Architecture
)”,文章很長,講的也很詳細,我花了很長時間去讀完了它,看完很有收穫,翻譯給大家,文中也融入了很多我自己的思考,推薦大家看完。
-
https://dev.to/bespoyasov/clean-architecture-on-frontend-4311
-
本文中示例的源碼:
https://github.com/bespoyasov/frontend-clean-architecture/
首先,我們會簡單介紹一下什麼是乾淨架構(Clean architecture
),比如領域、用例和應用層這些概念。然後就是怎麼把乾淨架構應用於前端,以及值不值得這麼做。
接下來,我們會用乾淨架構的原則來設計一個商店應用,並從頭實現一下,看看它能不能運行起來。
這個應用將使用 React
作爲它的 UI 框架,這只是爲了表明這種開發方式是可以和 React
一起使用的。你也可以選擇其他任何一種 UI 庫去實現它。
代碼中會用到一些 TypeScript
,這只是爲了展示怎麼使用類型和接口來描述實體。其實所有的代碼都可以不用 TypeScript
實現,只是代碼不會看起來那麼富有表現力。
架構和設計
設計本質上就是以一種可以將它們重新組合在一起的方式將事物拆開…… 將事物拆分成可以重新組合的事物,這就是設計。— Rich Hickey《設計、重構和性能》
系統設計其實就是系統的拆分,最重要的是我們可以在不耗費太多時間的情況下重新把它們組起來。
我同意上面這個觀點,但我認爲系統架構的另一個主要目標是系統的可擴展性。我們應用的需求是不斷變化的。我們希望我們的程序可以非常易於更新和修改以滿足持續變化的新需求。乾淨的架構就可以幫助我們實現這一目標。
什麼是乾淨的架構?
乾淨架構是一種根據應用程序的領域(domain
)的相似程度來拆分職責和功能的方法。
領域(domain
)是由真實世界抽象而來的程序模型。可以反映現實世界和程序中數據的映射。比如,如果我們更新了一個產品的名稱,用新名稱來替換舊名稱就是領域轉換。
乾淨架構的功能通常被分爲三層,我們可以看下面這張圖:
領域層
在中心的是領域層,這裏會描述應用程序主題區域的實體和數據,以及轉換該數據的代碼。領域是區分不同程序的核心。
你可以把領域理解爲當我們從 React
遷移到 Angular
,或者改變某些用例的時候不會變的那一部分。在商店這個應用中,領域就是產品、訂單、用戶、購物車以及更新這些數據的方法。
數據結構和他們之間的轉化與外部世界是相互隔離的。外部的事件調用會觸發領域的轉換,但是並不會決定他們如何運行。
比如:將商品添加到購物車的功能並不關心商品添加到購物車的方式:
-
用戶自己通過點擊 “購買” 按鈕添加
-
用戶使用了優惠券自動添加。
在這兩種情況下,都會返回一個更新之後的購物車對象。
應用層
圍在領域外面的是應用層,這一層描述了用例。
例如,“添加到購物車” 這個場景就是一個用例。它描述了單擊按鈕後應執行的具體操作,像是一種 “協調者”:
-
向服務器發送一個請求;
-
執行領域轉換;
-
使用響應的數據更新 UI。
此外,在應用層中還有端口 — 它描述了應用層如何和外部通信。通常一個端口就是一個接口(interface
),一個行爲契約。
端口也可以被認爲是一個現實世界和應用程序之間的 “緩衝區”。輸入端口會告訴我們應用要如何接受外部的輸入,同樣輸出端口會說明如何與外部通信做好準備。
適配器層
最外層包含了外部服務的適配器,我們通過適配器來轉換外部服務的不兼容 API
。
適配器可以降低我們的代碼和外部第三方服務的耦合,適配器一般分爲:
-
驅動型 - 向我們的應用發消息;
-
被動型 - 接受我們的應用所發送的消息。
一般用戶最常和驅動型適配器進行交互,例如,處理 UI 框架發送的點擊事件就是一個驅動型適配器。它與瀏覽器 API
一起將事件轉換爲我們的應用程序可以理解的信號。
驅動型會和我們的基礎設施交互。在前端,大部分的基礎設施就是後端服務器,但有時我們也可能會直接與其他的一些服務交互,例如搜索引擎。
注意,離中心越遠,代碼的功能就越 “面向服務”,離應用的領域就越遠,這在後面我們要決定一個模塊是哪一層的時候是非常重要的。
依賴規則
三層架構有一個依賴規則:只有外層可以依賴內層。這意味着:
-
領域必須獨立
-
應用層可以依賴領域
-
最外層可以依賴任何東西
當然有些特殊的情況可能會違反這個規則,但最好不要濫用它。例如,在領域中也有可能會用到一些第三方庫,即使不應該存在這樣的依賴關係。下面看代碼時會有這樣一個例子。
不控制依賴方向的代碼可能會變得非常複雜和難以維護。比如:
-
循環依賴,模塊 A 依賴於 B,B 依賴於 C,C 依賴於 A。
-
可測試性差,即使測試一小塊功能也不得不模擬整個系統。
-
耦合度太高,因此模塊之間的交互會很脆弱。
乾淨架構的優勢
獨立領域
所有應用的核心功能都被拆分並統一維護在一個地方—領域
領域中的功能是獨立的,這意味着它更容易測試。模塊的依賴越少,測試所需的基礎設施就越少。
獨立的領域也更容易根據業務的期望進行測試。這有助於讓新手理解起來更容易。此外,獨立的域也讓從需求到代碼實現中出現的錯誤更容易排除。
獨立用例
應用的使用場景和用例都是獨立描述的。它決定了我們所需要哪些第三方服務。我們讓外部服務更適應我們的需求,這讓我們有更多的空間可以選擇合適的第三方服務。比如,現在我們調用的支付系統漲價了,我們可以很快的換掉它。
用例的代碼也是扁平的,並且容易測試,擴展性強。我們會在後面的示例中看到這一點。
可替換的第三方服務
適配器讓外部第三方服務更容易替換。只要我們不改接口,那麼實現這個接口的是哪個第三方服務都沒關係。
這樣如果其他人改動了代碼,不會直接影響我們。適配器也會減少應用運行時錯誤的傳播。
實現乾淨架構的成本
架構首先是一種工具。像任何其他工具一樣,乾淨的架構除了好處之外還會帶來額外的成本。
需要更多時間
首先是時間,設計、實現都需要更多的時間,因爲直接調用第三方服務總是比寫適配器簡單。
我們很難在一開始就把模塊所有的交互和需求都想的很明白,我們設計的時候需要時刻留意哪些地方可能發生變化,所以要考慮更多的可擴展性。
有時會顯得多餘
一般來說,乾淨架構並不適用於所有場景、甚至有的時候是有害的。如果本身就是一個很小的項目,你還要按照乾淨架構進行設計,這會大大增加上手門檻。
上手更困難
完全按照乾淨架構進行設計和實現會讓新手上手更加困難,因爲他首先要了解清楚應用是怎麼運行起來的。
代碼量增加
這是前端會特有的一個問題,乾淨架構會增加最終打包的產物體積。產物越大,瀏覽器下載和解釋的時間越長,所以代碼量一定要把控好,適當刪減代碼:
-
將用例描述的得更簡單一些;
-
直接從適配器和領域交互,繞過用例;
-
進行代碼拆分
如何降低這些成本
你可以通過適當的偷工減料和犧牲架構的 “乾淨度” 來減少一些實現時間和代碼量。如果捨棄一些東西會獲得更大的收益,我會毫不猶豫的去做。
所以,不必在所有方面走遵守乾淨架構的設計準則,把核心準則遵守好即可。
抽象領域
對領域的抽象可以幫助我們理解整體的設計,以及它們是怎麼工作的,同時也會讓其他開發人員更容易理解程序、實體以及它們之間的關係。
即使我們直接跳過其他層,抽象的領域也更加容易重構。因爲它們的代碼是集中封裝在一個地方的,其他層需要的時候可以方便添加。
遵守依賴規則
第二條不應該放棄的規則是依賴規則,或者說是它們的依賴方向。外部的服務需要適配內部,而不是反方向的。
如果你嘗試直接去調用一個外部 API,這就是有問題的,最好在還沒出問題之前寫個適配器。
商店應用的設計
說完了理論,我們就可以開始實踐了,下面我們來實際設計一個商店應用的。
商店會出售不同種類的餅乾,用戶可以自己選擇要購買的餅乾,並通過三方支付服務進行付款。
用戶可以在首頁看到所有餅乾,但是隻有登錄後才能購買,點擊登錄按鈕可以跳轉到登錄頁。
登錄成功後,用戶就可以把餅乾加進購物車了。
把餅乾加進購物車後,用戶就可以付款了。付款後,購物車會清空,併產生一個新的訂單。
首先,我們來對實體、用例和功能進行定義,並對它們進行分層。
設計領域
程序設計中最重要的就是領域設計,它們表示了實體到數據的轉換。
商店的領域可能包括:
-
每個實體的數據類型:用戶、餅乾、購物車和訂單;
-
如果你是用 OOP(面向對象思想)實現的,那麼也要設計生成實體的工廠和類;
-
數據轉換的函數。
領域中的轉換方法應該只依賴於領域的規則,而不依賴於其他任何東西。比如方法應該是這樣的:
-
計算總價的方法
-
檢測用戶口味的方法
-
檢測商品是否在購物車的方法
設計應用層
應用層包含用例,一個用包含一個參與者、一個動作和一個結果。
在商店應用裏,我們可以這樣區分:
-
一個產品購買場景;
-
支付,調用第三方支付系統;
-
與產品和訂單的交互:更新、查詢;
-
根據角色訪問不同頁面。
我們一般都是用主題領域來描述用例,比如 “購買” 包括下面的步驟:
-
從購物車中查詢商品並創建新訂單;
-
創建支付訂單;
-
支付失敗時通知用戶;
-
支付成功,清空購物車,顯示訂單。
用例方法就是描述這個場景的代碼。
此外,在應用層中還有端口—用於與外界通信的接口。
設計適配器層
在適配器層,我們爲外部服務聲明適配器。適配器可以爲我們的系統兼容各種不兼容的外部服務。
在前端,適配器一般是 UI 框架和對後端的 API 請求模塊。比如在我們的商店程序中會用到:
-
用戶界面;
-
API 請求模塊;
-
本地存儲的適配器;
-
API 返回到應用層的適配器。
對比 MVC 架構
有時我們很難判斷某些數據屬於哪一層,這裏可以和 MVC 架構做個小對比:
-
Model 一般都是領域實體
-
Controller 一般是與轉換或者應用層
-
View 是驅動適配器
這些概念雖然在細節上不太相同,但是非常相似。
實現細節—領域
一旦我們確定了我們需要哪些實體,我們就可以開始定義它們的行爲了,下面就是我們項目的目錄結構:
src/
|_domain/
|_user.ts
|_product.ts
|_order.ts
|_cart.ts
|_application/
|_addToCart.ts
|_authenticate.ts
|_orderProducts.ts
|_ports.ts
|_services/
|_authAdapter.ts
|_notificationAdapter.ts
|_paymentAdapter.ts
|_storageAdapter.ts
|_api.ts
|_store.tsx
|_lib/
|_ui/
領域都定義在 domain
目錄下,應用層定義在 application
目錄下,適配器都定義在 service
目錄下。最後我們還會討論目錄結構是否會有其他的替代方案。
創建領域實體
我們在領域中有 4 個實體:
-
product(產品)
-
user(用戶)
-
order(訂單)
-
cart(購物車)
其中最重要的就是 user
,在回話中,我們會把用戶信息存起來,所以我們單獨在領域中設計一個用戶類型,用戶類型包括以下數據:
// domain/user.ts
export type UserName = string;
export type User = {
id: UniqueId;
name: UserName;
email: Email;
preferences: Ingredient[];
allergies: Ingredient[];
};
用戶可以把餅乾放進購物車,我們也給購物車和餅乾加上類型。
// domain/product.ts
export type ProductTitle = string;
export type Product = {
id: UniqueId;
title: ProductTitle;
price: PriceCents;
toppings: Ingredient[];
};
// domain/cart.ts
import { Product } from "./product";
export type Cart = {
products: Product[];
};
付款成功後,將創建一個新訂單,我們再來添加一個訂單實體類型。
// domain/order.ts — ConardLi
export type OrderStatus = "new" | "delivery" | "completed";
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
理解實體之間的關係
以這種方式設計實體類型的好處是我們可以檢查它們的關係圖是否和符合實際情況:
我們可以檢查以下幾點:
-
參與者是否是一個用戶
-
訂單裏是否有足夠的信息
-
有些實體是否需要擴展
-
在未來是否有足夠的可擴展性
此外,在這個階段,類型可以幫助識別實體之間的兼容性和調用方向的錯誤。
如果一切都符合我們預期的,我們就可以開始設計領域轉換了。
創建數據轉換
我們剛剛設計的這些類型數據會發生各種各樣的事情。我們可以添加商品到購物車、清空購物車、更新商品和用戶名等。下面我們分別來爲這些數據轉換創建對應的函數:
比如,爲了判斷某個用戶是喜歡還是厭惡某個口味,我們可以創建兩個函數:
// domain/user.ts
export function hasAllergy(user: User, ingredient: Ingredient): boolean {
return user.allergies.includes(ingredient);
}
export function hasPreference(user: User, ingredient: Ingredient): boolean {
return user.preferences.includes(ingredient);
}
將商品添加到購物車並檢查商品是否在購物車中:
// domain/cart.ts — ConardLi
export function addProduct(cart: Cart, product: Product): Cart {
return { ...cart, products: [...cart.products, product] };
}
export function contains(cart: Cart, product: Product): boolean {
return cart.products.some(({ id }) => id === product.id);
}
下面是計算總價(如果需要的話我們還可以設計更多的功能,比如配打折、優惠券等場景):
// domain/product.ts
export function totalPrice(products: Product[]): PriceCents {
return products.reduce((total, { price }) => total + price, 0);
}
創建新訂單,並和對應用戶以及他的購物車建立關聯。
// domain/order.ts
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
詳細設計—共享內核
你可能已經注意到我們在描述領域類型的時候使用的一些類型。例如 Email
,UniqueId
或 DateTimeString
。這些其實都是類型別名:
// shared-kernel.d.ts
type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;
我用 DateTimeString
代替 string
來更清晰的表明這個字符串是用來做什麼的。這些類型越貼近實際,就更容易排查問題。
這些類型都定義在 shared-kernel.d.ts
文件裏。共享內核指的是一些代碼和數據,對他們的依賴不會增加模塊之間的耦合度。
在實踐中,共享內核可以這樣解釋:我們用到 TypeScript
,使用它的標準類型庫,但我們不會把它們看作是一個依賴項。這是因爲使用它們的模塊互相不會產生影響並且可以保持解耦。
並不是所有代碼都可以被看作是共享內核,最主要的原則是這樣的代碼必須和系統處處都是兼容的。如果程序的一部分是用 TypeScript
編寫的,而另一部分是用另一種語言編寫的,共享核心只可以包含兩種語言都可以工作的部分。
在我們的例子中,整個應用程序都是用 TypeScript
編寫的,所以內置類型的別名完全可以當做共享內核的一部分。這種全局都可用的類型不會增加模塊之間的耦合,並且在程序的任何部分都可以使用到。
實現細節—應用層
我們已經完成了領域的設計,下面可以設計應用層了。
這一層會包含具體的用例設計,比如一個用例是將商品添加到購物車並支付的完整過程。
用例會涉及應用和外部服務的交互,與外部服務的交互都是副作用。我們都知道調用或者調試沒有副作用的方法會更簡單一些,所以大部分領域函數都實現爲成純函數了。
爲了將無副作用的純函數和與有副作用的交互結合起來,我們可以將應用層用作有副作用的非純上下文。
非純上下文純數據轉換
一個包含副作用的非純上下文和純數據轉換是這樣一種代碼組織方式:
-
首先執行一個副作用來獲取一些數據;
-
然後對數據執行純函數進行數據處理;
-
最後再執行一個副作用,存儲或傳遞這個結果。
比如,“將商品放入購物車” 這個用例:
-
首先,從數據庫裏獲取購物車的狀態;
-
然後調用購物車更新函數,把要添加的商品信息傳進去;
-
最後將更新的購物車保存到數據庫中。
這個過程就像一個 “三明治”:副作用、純函數、副作用。所有主要的邏輯處理都在調用純函數進行數據轉換上,所有與外部的通信都隔離在一個命令式的外殼中。
設計用例
我們選擇結賬這個場景來做用例設計,它更具代表性,因爲它是異步的,而且會與很多第三方服務進行交互。
我們可以想一想,通過整個用例我們要表達什麼。用戶的購物車裏有一些餅乾,當用戶點擊購買按鈕的時候:
-
要創建一個新訂單;
-
在第三方支付系統中支付;
-
如果支付失敗,通知用戶;
-
如果支付成功,將訂單保存在服務器上;
-
在本地存儲保存訂單數據,並在頁面上顯示;
設計函數的時候,我們會把用戶和購物車都作爲參數,然後讓這個方法完成整個過程。
type OrderProducts = (user: User, cart: Cart) => Promise<void>;
當然,理想情況下,用例不應該接收兩個單獨的參數,而是接收一個封裝後的對象,爲了精簡代碼,我們先這樣處理。
編寫應用層的接口
我們再來仔細看看用例的步驟:訂單創建本身就是一個領域函數,其他一切操作我們都要調用外部服務。
我們要牢記,外部方法永遠要適配我們的需求。所以,在應用層,我們不僅要描述用例本身,也要定義調用外部服務的通信方式—端口。
想一想我們可能會用到的服務:
-
第三方支付服務;
-
通知用戶事件和錯誤的服務;
-
將數據保存到本地存儲的服務。
注意,我們現在討論的是這些服務的 interface
,而不是它們的具體實現。在這個階段,描述必要的行爲對我們來說很重要,因爲這是我們在描述場景時在應用層所依賴的行爲。
如何實現現在不是重點,我們可以在最後再考慮調用哪些外部服務,這樣代碼才能儘量保證低耦合。
另外還要注意,我們按功能拆分接口。與支付相關的一切都在同一個模塊中,與存儲相關的都在另一個模塊中。這樣更容易確保不的同第三方服務的功能不會混在一起。
支付系統接口
我們這個商店應用只是個小 Demo
,所以支付系統會很簡單。它會有一個 tryPay
方法,這個方法將接受需要支付的金額,然後返回一個布爾值來表明支付的結果。
// application/ports.ts — ConardLi
export interface PaymentService {
tryPay(amount: PriceCents): Promise<boolean>;
}
一般來說,付款的處理是在服務端。但我們只是簡單演示一下,所以在前端就直接處理了。後面我們也會調用一些簡單的 API,而不是直接和支付系統進行通信。
通知服務接口
如果出現一些問題,我們必須通知到用戶。
我們可以用各種不同的方式通知用戶。比如使用 UI,發送郵件,甚至可以讓用戶的手機振動。
一般來說,通知服務最好也抽象出來,這樣我們現在就不用考慮實現了。
給用戶發送一條通知:
// application/ports.ts
export interface NotificationService {
notify(message: string): void;
}
本地存儲接口
我們會將新的訂單保存在本地的存儲庫中。
這個存儲可以是任何東西:Redux、MobX、任何存儲都可以。存儲庫可以在不同實體上進行拆分,也可以是整個應用程序的數據都維護在一起。不過現在都不重要,因爲這些都是實現細節。
我習慣的做法是爲每個實體都創建一個單獨的存儲接口:一個單獨的接口存儲用戶數據,一個存儲購物車,一個存儲訂單:
// application/ports.ts — ConardLi
export interface OrdersStorageService {
orders: Order[];
updateOrders(orders: Order[]): void;
}
用例方法
下面我們來看看能不能用現有的領域方法和剛剛建的接口來構建一個用例。腳本將包含如下步驟:
-
驗證數據;
-
創建訂單;
-
支付訂單;
-
通知問題;
-
保存結果。
首先,我們聲明出來我們要調用的服務的模塊。TypeScript
會提示我們沒有給出接口的實現,先不要管他。
// application/orderProducts.ts — ConardLi
const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};
現在我們可以像使用真正的服務一樣使用這些模塊。我們可以訪問他們的字段,調用他們的方法。這在把用例轉換爲代碼的時候非常有用。
現在,我們創建一個名爲 orderProducts
的方法來創建一個訂單:
// application/orderProducts.ts — ConardLi
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
}
這裏,我們把接口當作是行爲的約定。也就是說以模塊示例會真正執行我們期望的操作:
// application/orderProducts.ts — ConardLi
//...
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
// Try to pay for the order;
// Notify the user if something is wrong:
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify("Oops! 🤷");
// Save the result and clear the cart:
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
注意,用例不會直接調用第三方服務。它依賴於接口中描述的行爲,所以只要接口保持不變,我們就不需要關心哪個模塊來實現它以及如何實現它,這樣的模塊就是可替換的。
實現細節—適配器層
我們已經把用例 “翻譯” 成 TypeScript
了,現在我們來檢查一下現實是否符合我們的需求。
通常情況下是不會的,所以我們要通過封裝適配器來調用第三方服務。
添加 UI 和用例
首先,第一個適配器就是一個 UI 框架。它把瀏覽器的 API 與我們的應用程序連接起來。在訂單創建的這個場景,就是 “結帳” 按鈕和點擊事件的處理方法,這裏會調用具體用例的功能。
// ui/components/Buy.tsx — ConardLi
export function Buy() {
// Get access to the use case in the component:
const { orderProducts } = useOrderProducts();
async function handleSubmit(e: React.FormEvent) {
setLoading(true);
e.preventDefault();
// Call the use case function:
await orderProducts(user!, cart);
setLoading(false);
}
return (
<section>
<h2>Checkout</h2>
<form onSubmit={handleSubmit}>{/* ... */}</form>
</section>
);
}
我們可以通過一個 Hook
來封裝用例,建議把所有的服務都封裝到裏面,最後返回用例的方法:
// application/orderProducts.ts — ConardLi
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
async function orderProducts(user: User, cookies: Cookie[]) {
// …
}
return { orderProducts };
}
我們使用 hook
來作爲一個依賴注入。首先我們使用 useNotifier,usePayment,useOrdersStorage
這幾個 hook
來獲取服務的實例,然後我們用函數 useOrderProducts
創建一個閉包,讓他們可以在 orderProducts
函數中被調用。
另外需要注意的是,用例函數和其他的代碼是分離的,這樣對測試更加友好。
支付服務實現
用例使用 PaymentService
接口,我們先來實現一下。
對於付款操作,我們依然使用一個假的 API 。同樣的,我們現在還是沒必要編寫全部的服務,我們可以之後再實現,現在最重要的是實現指定的行爲:
// services/paymentAdapter.ts — ConardLi
import { fakeApi } from "./api";
import { PaymentService } from "../application/ports";
export function usePayment(): PaymentService {
return {
tryPay(amount: PriceCents) {
return fakeApi(true);
},
};
}
fakeApi
這個函數會在 450
毫秒後觸發的超時,模擬來自服務器的延遲響應,它返回我們傳入的參數。
// services/api.ts — ConardLi
export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
return new Promise((res) => setTimeout(() => res(response), 450));
}
通知服務實現
我們就簡單使用 alert
來實現通知,因爲代碼是解耦的,以後再來重寫這個服務也不成問題。
// services/notificationAdapter.ts — ConardLi
import { NotificationService } from "../application/ports";
export function useNotifier(): NotificationService {
return {
notify: (message: string) => window.alert(message),
};
}
本地存儲實現
我們就通過 React.Context
和 Hooks
來實現本地存儲。
我們創建一個新的 context
,然後把它傳給 provider
,然後導出讓其他的模塊可以通過 Hooks
使用。
// store.tsx — ConardLi
const StoreContext = React.createContext<any>({});
export const useStore = () => useContext(StoreContext);
export const Provider: React.FC = ({ children }) => {
// ...Other entities...
const [orders, setOrders] = useState([]);
const value = {
// ...
orders,
updateOrders: setOrders,
};
return (
<StoreContext.Provider value={value}>{children}</StoreContext.Provider>
);
};
我們可以給每一個功能點都實現一個 Hook
。這樣我們就不會破壞服務接口和存儲,至少在接口的角度來說他們是分離的。
// services/storageAdapter.ts
export function useOrdersStorage(): OrdersStorageService {
return useStore();
}
此外,這種方法還可以使我們能夠爲每個商店定製額外的優化:創建選擇器、緩存等。
驗證數據流程圖
現在讓我們驗證一下用戶是怎麼和應用程序通信的。
用戶與 UI 層交互,但是 UI 只能通過端口訪問服務接口。也就是說,我們可以隨時替換 UI。
用例是在應用層處理的,它可以準確地告訴我們需要哪些外部服務。所有主要的邏輯和數據都封裝在領域中。
所有外部服務都隱藏在基礎設施中,並且遵守我們的規範。如果我們需要更改發送消息的服務,只需要修改發送消息服務的適配器。
這樣的方案讓代碼更方便替換、更容易測試、擴展性更強,以適應不斷變化的需求。
有什麼可以改進的
上面介紹的這些已經可以讓你開始並初步瞭解乾淨的架構了,但是我想指出上面我爲了讓示例更簡單做的一些偷工減料的事情。
讀完下面的內容,大家可以理解 “沒有偷工減料” 的乾淨架構是什麼樣子的。
使用對象而不是數字來表示價格
你可能已經注意到我用一個數字來描述價格,這不是一個好習慣。
// shared-kernel.d.ts
type PriceCents = number;
數字只能表示數量,不能表示貨幣,沒有貨幣的價格是沒有意義的。理想情況下,價格應該是具有兩個字段的對象:價值和貨幣。
type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;
type Price = {
value: AmountCents;
currency: Currency;
};
這樣就能解決存儲貨幣的問題了,並可以省去大量的存儲和處理貨幣的精力。在示例中我沒有這麼做是爲了讓這個例子儘量簡單。在真實的情況裏,價格的結構定義會更加接近上面的寫法。
另外,值得一提的是價格的單位,比如美元的最小單位是美分。以這種方式顯示價格讓我可以避免考慮浮點數計算的問題。
按功能拆分代碼,而不是按層
代碼可以 “按功能” 拆分到文件夾中,而不是 “按層”,一塊功能就是下面餅圖的一部分。
這種結構更清晰,因爲它可以讓你分別部署不同的功能點:
注意跨組件使用
如果我們正在討論將系統拆分爲組件,就不得不考慮跨組件代碼使用的問題。我們再來看看創建訂單的代碼:
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
這個函數用到了從另一個 Product
模塊引入的 totalPrice
方法。這樣使用本身沒有什麼問題,但是如果我們要考慮把代碼拆分到獨立的功能的時候,我們不能直接訪問其他模塊的代碼。
使用 ts-brand ,而不是類型別名
在共享內核的編寫中,我使用了類型別名。它們很容易實現,但缺點是 TypeScript
沒有監控並強制執行它們的機制。
這看起來也不是個問題:你是用 string
類型去替代 DateTimeString
也不會怎麼樣,代碼還是會編譯成功。但是,這樣會讓代碼變得脆弱、可讀性也很差,因爲這樣你可以用任意的字符串,導致錯誤的可能性會增加。
有一種方法可以讓 TypeScript
理解我們想要一個特定的類型 — ts-brand
(https://github.com/kourge/ts-brand
)。它可以準確的跟蹤類型的使用方式,但會使代碼更復雜一些。
注意領域中可能的依賴
接下來的問題是我們在 createOrder
函數的領域中創建了一個日期:
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
// Вот эта строка:
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
new Date().toISOString()
這樣的函數可能會被重複調用很多次,我們可以把它封裝到一個 hleper
裏面:
// lib/datetime.ts — ConardLi
export function currentDatetime(): DateTimeString {
return new Date().toISOString();
}
然後在領域中調用它:
// domain/order.ts
import { currentDatetime } from "../lib/datetime";
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
created: currentDatetime(),
status: "new",
total: totalPrice(products),
};
}
但是領域的原則是不能依賴其他任何東西,所以 createOrder
函數最好是所有數據都從外面傳進來,日期可以作爲最後一個參數:
// domain/order.ts — ConardLi
export function createOrder(
user: User,
cart: Cart,
created: DateTimeString
): Order {
return {
user: user.id,
products,
created,
status: "new",
total: totalPrice(products),
};
}
這樣我們就不會破壞依賴規則,即使創建日期也需要依賴第三方庫:
function someUserCase() {
// Use the `dateTimeSource` adapter,
// to get the current date in the desired format:
const createdOn = dateTimeSource.currentDatetime();
// Pass already created date to the domain function:
createOrder(user, cart, createdOn);
}
這會讓領域保持獨立,也使測試更容易。
在前面的示例中,我不這樣做有兩個原因:它會分散我們的重點,如果它只使用語言本身的特性,我認爲依賴你自己的 Helper
沒有任何問題。這樣的 Helper
甚至可以被視爲共享內核,因爲它們只會減少代碼的重複度。
注意購物車與訂單的關係
在這個小例子中,Order
會包含 Cart
, 因爲購物車只表示 Product
列表:
export type Cart = {
products: Product[];
};
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
如果購物車有其他的和訂單沒有關聯的屬性,可能會出問題,所以直接用 ProductList
會更合理:
type ProductList = Product[];
type Cart = {
products: ProductList;
};
type Order = {
user: UniqueId;
products: ProductList;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
讓用例更方便測試
用例也有很多要討論的地方。比如,orderProducts
函數很難獨立於 React
來測試,這不太好。理想情況下,測試不應該消耗太多的成本。
問題的根本原因我們使用 Hooks
來實現了用例:
// application/orderProducts.ts — ConardLi
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify("Oops! 🤷");
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
return { orderProducts };
}
在規範的實現中,用例方法可以放在 Hooks
的外面,服務通過參數或者使用依賴注入傳入用例:
type Dependencies = {
notifier?: NotificationService;
payment?: PaymentService;
orderStorage?: OrderStorageService;
};
async function orderProducts(
user: User,
cart: Cart,
dependencies: Dependencies = defaultDependencies
) {
const { notifier, payment, orderStorage } = dependencies;
// ...
}
然後 Hooks
的代碼就可以當做一個適配器,只有用例會留在應用層。orderProdeucts
方法很容易就可以被測試了。
function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
return (user: User, cart: Cart) =>
orderProducts(user, cart, {
notifier,
payment,
orderStorage,
});
}
配置自動依賴注入
在應用層,我們是手動將依賴注入服務的:
export function useOrderProducts() {
// Here we use hooks to get the instances of each service,
// which will be used inside the orderProducts use case:
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
// ...Inside the use case we use those services.
}
return { orderProducts };
}
當然還有更好的做法,依賴注入可以自動完成。我們前面已經通過最後一個參數實現了最簡單的注入版本,下面可以進一步配置自動依賴注入。
在這個特定的應用程序中,我認爲設置依賴注入沒有多大意義。它會分散我們的注意力並使代碼過於複雜。在使用了 React
和 hooks
的情況下,我們可以將它們用作 “容器”,返回指定接口的實現。是的,雖然還是手動實現的,但它不會增加上手門檻,並且對於新手開發人員來說閱讀速度更快。
實際項目中的情況可能更復雜
文章中的示例是經過精簡的而且需求也比較簡單。很明顯,我們實際開發中比這個例子要複雜的多。所以我還想談談實際開發中使用乾淨架構時可能出現的常見問題。
分支業務邏輯
最重要的問題是我們對需求的實際場景研究不夠深入。想象一下,一家商店有一個產品、一個打折產品和一種已經註銷的產品。我們怎麼準確描述這些實體?
是不是應該有一個可擴展的 “基礎” 實體呢?這個實體究竟應該怎麼擴展?應該有額外的字段嗎?這些實體是否應該互斥?
可能有太多的問題和太多的答案,如果只是假設,我們不可能考慮到所有的情況。
具體解決方法還要視具體情況而定,我只能推薦幾個我的經驗。
不建議使用繼承,即使它看起來可 “擴展”。
複製粘貼的代碼並不一定都不好,有時候甚至能發揮更大的作用。創建兩個幾乎相同的實體,觀察它們在現實中的行爲。在某些時候,它們的行爲可能區別很大,有時候也可能只有一兩個字段的區別。合併兩個非常相似的實體比寫大量的的檢查要容易很多。
如果你一定要擴展一些內容的話。。
記住協變、逆變和不變,這樣你就不會多出一些意想不到的工作。
在不同的實體和可擴展之間選擇,推薦使用類似於 BEM
中的塊和修飾符概念來幫助你思考,如果我在 BEM
的上下文中考慮它,它可以幫助我確定我是否有一個單獨的實體或代碼的 “修飾符擴展”。
BEM - Block Element Modfier(塊元素編輯器)是一個很有用的方法,它可以幫助你創建出可以複用的前端組件和前端代碼。
相互依賴的用例
第二個問題是用例相關的,通過一個用例的事件觸發另一個用例。
我知道並且對我有幫助的處理這個問題的唯一方法是將用例分解爲更小的原子用例。它們將更容易組合在一起。
通常,出現這個問題是編程中另外一個大問題的結果。這就是實體組合。
最後
在本文裏,我們介紹了前端的 “乾淨架構”。
這不是一個黃金標準,而是一個在很多的項目、規範和語言上積累的經驗彙總。
我發現它是一種非常方便的方案,可以幫助你解耦你的代碼。讓層、模塊和服務儘量獨立。不僅可以獨立發佈、部署,還可以讓你從一個項目遷移另一個項目的時候也更加容易。
你理想下的前端架構是什麼樣的呢?
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/4bB_B5JTOrev8mqfG7K2cw