可擴展的前端#2--常見模式(譯)

譯者:這篇文章是在 medium 講解前端架構分層系列的第二篇文章,分層和以前翻譯的文章相似,相對普通項目多出來兩層,領域層(從業務抽象出來的領域實體)應用層(實現業務邏輯的用例)。另外在編程範式上,相對面對對象,做者更傾向於採用函數式,讀者可根據項目特色選擇適合本身的方式。前端

原文連接 blog.codeminer42.com/scalable-fr…react

文章首發於個人博客 github.com/mcuking/blo…git

這篇博客是《可擴展的前端》系列的一部分,你能夠看到其餘部分: #1 — Architecture#3 — The State Layergithub

讓咱們繼續討論前端可擴展性!在上一篇文章中,咱們僅在概念上討論了前端應用程序中的架構基礎。如今,咱們將動手操做實際代碼。算法

常見模式

如第一篇文章所述,咱們如何實現架構?與咱們過去的作法有什麼不一樣?咱們如何將全部這些與依賴注入結合起來?數據庫

無論你使用哪一個庫來抽象 view 或管理 state,前端應用程序中都有重複出現的模式。在這裏,咱們將討論其中的一部分,所以請繫緊安全帶!編程

譯者解讀:結合上篇文章分紅的四層:application 層、domain 層、 infrastructure 層、view 層。下面講解的內容中:用例屬於 application 層的核心概念,實體/值對象/聚合屬於 domain 層核心概念,Repositories 屬於 infrastructure 核心概念。redux

用例(Use Case)

咱們選擇用例做爲第一種模式,由於在架構方面,它們是咱們與軟件進行交互的方式。用例說明了咱們的應用程序的頂層功能;它是咱們功能的祕訣;application 層的主要模塊。他們定義了應用程序自己。後端

用例一般也稱爲 interactors,它們負責在其餘層之間執行交互。它們:設計模式

  • 由 view 層調用,

  • 應用它們的算法,

  • 使 domain 和 infrastructure 層交互而無需關心它們在內部的工做方式,而且,

  • 將結果狀態返回到 view 層。結果狀態用來代表用例是成功仍是失敗,緣由是內部錯誤、失敗的驗證、前提條件等。

知道結果狀態頗有用,由於它有助於肯定要爲結果發出什麼 action,從而容許 UI 中包含更豐富的消息,以便用戶知道故障下出了什麼問題。可是有一個重要的細節:結果狀態的邏輯應該在用例以內,而不是 view 層--由於知道這一點不是 view 層的責任。這意味着 view 層不該從用例中接收通用錯誤對象,而應使用 if 語句來找出失敗的緣由--例如檢查 error.message 屬性或 instanceof 以查詢錯誤的類。

這給咱們帶來了一個棘手的事實:從用例返回 promise 可能不是最佳的設計決策,由於 promise 只有兩個可能的結果:成功或失敗,這就要求咱們在條件語句來發現 catch() 語句中失敗的緣由。是否意味着咱們應該跳過軟件中的 promise?不!徹底能夠從其餘部分返回 promise,例如 actions、repositories、services。克服此限制的一種簡單方法是對用例的每種可能結果狀態進行回調。

用例的另外一個重要特徵是,即便在只有單個入口點的前端,它們也應該來遵循分層之間的邊界,不用知道哪一個入口點在調用它們。這意味着咱們不該該修改用例內的瀏覽器全局變量,特定 DOM 的值或任何其餘低級對象。例如:咱們不該該將<input />元素的實例做爲參數,而後再讀取其值;view 層應該是負責提取該值並將其傳遞給用例。

沒有什麼比一個例子更清楚地表達一個概念了:

createUser.js

export default ({ validateUser, userRepository }) => async (
  userData,
  { onSuccess, onError, onValidationError }
) => {
  if (!validateUser(userData)) {
    return onValidationError(new Error('Invalid user'));
  }

  try {
    const user = await userRepository.add(userData);
    onSuccess(user);
  } catch (error) {
    onError(error);
  }
};
複製代碼

userAction.js

const createUserAction = userData => (dispatch, getState, container) => {
  container.createUser(userData, {
    // notice that we don't add conditionals to emit any of these actions
    onSuccess: user => dispatch(createUserSuccessAction(user)),
    onError: error => dispatch(createUserErrorAction(error)),
    onValidationError: error => dispatch(createUserValidationErrorAction(error))
  });
};
複製代碼

本示例使用 Redux 和 Redux-Thunk。容器將做爲 thunk 的第三個參數注入。

請注意,在 userAction 中,咱們不會對 createUser 用例的響應進行任何斷言;咱們相信用例將爲每一個結果調用正確的回調。另外,即便 userData 對象中的值來自 HTML 輸入,用例對此也不瞭解。它僅接收提取的數據並將其轉發。

就是這樣!用例不能作的更多。你能看到如今測試它們有多容易嗎?咱們能夠簡單地注入所需功能的模擬依賴項,並測試咱們的用例是否針對每種狀況調用了正確的回調。

實體、值對象和聚合(Entities, value objects, and aggregates)

實體是咱們 domain 層的核心:它們表明了咱們軟件所處理的概念。假設咱們正在構建博客引擎應用程序,在這種狀況下,若是咱們的引擎容許,咱們可能會有一個 User 實體,Article 實體,甚至還有 Comment 實體。所以,實體只是保存數據和這些概念的行爲的對象,而不用考慮技術實現。實體不該被視爲 Active Record 設計模式的模型或實現;他們對數據庫、AJAX 或持久數據一無所知。它們只是表明概念和圍繞該概念的業務規則。

所以,若是咱們博客引擎的用戶在評論有關暴力的文章時有年齡限制,咱們會有一個 user.isMajor()方法,該方法將在 article.canBeCommentedBy(user)內部調用,以某種方式將年齡分類規則保留在 user 對象內,並將年齡限制規則保留在 article 對象內。AddCommentToArticle 用例是將用戶實例傳遞給 article.canBeCommentedBy,而用例則是在它們之間執行 interaction 的地方。

有一種方法能夠識別代碼庫中某物是否爲實體:若是一個對象表明一個 domain 概念而且它具備標識符屬性(例如,id 或文檔編號),則它是一個實體。此標識符的存在很重要,由於它是區分實體和值對象的緣由。

儘管實體具備標識符屬性,但值對象的身份由其全部屬性的值組合而成。混亂?考慮一個顏色對象。當用對象表示顏色時,咱們一般不給該對象一個 ID。咱們給它提供紅色,綠色和藍色的值,這三個屬性結合在一塊兒能夠識別該對象。如今,若是咱們更改紅色屬性的值,咱們能夠說它表明了另外一種顏色,可是用 id 標識的用戶卻不會發生一樣的狀況。若是咱們更改用戶的 name 屬性的值但保留相同的 ID,則表示它仍然是同一用戶,對嗎?

在本節的開頭,咱們說過在實體中使用方法以及給定實體的業務規則和行爲是很廣泛的。可是在前端,將業務規則做爲實體對象的方法並不老是很好。考慮一下函數式編程:咱們沒有實例方法,或者 this, 可變性--這是一種使用普通 JavaScript 對象而不是自定義類的實例的,很好兼容單向數據流的範例。那麼在使用函數式編程時,實體中具備方法是否有意義?固然沒有。那麼咱們如何建立具備此類限制的實體?咱們採用函數式方式!

咱們將不使用帶有 user.isMajor() 實例方法的 User 類,而是使用一個名爲 User 的模塊,該模塊導出 isMajor(user) 函數,該函數會返回具備用戶屬性的對象,就像 User 類的 this。該參數沒必要是特定類的實例,只要它具備與用戶相同的屬性便可。這很重要:屬性(用戶實體的預期參數)應以某種方式形式化。你能夠在具備工廠功能的純 JavaScript 中進行操做,也可使用 Flow 或 TypeScript 更明確地進行操做。

爲了更容易理解,咱們看下先後對比。

使用類實現的實體

// User.js

export default class User {
  static LEGAL_AGE = 21;

  constructor({ id, age }) {
    this.id = id;
    this.age = age;
  }

  isMajor() {
    return this.age >= User.LEGAL_AGE;
  }
}

// usage
import User from './User.js';

const user = new User({ id: 42, age: 21 });
user.isMajor(); // true

// if spread, loses the reference for the class
const user2 = { ...user, age: 20 };
user2.isMajor(); // Error: user2.isMajor is not a function
複製代碼

使用函數實現的實體

// User.js

const LEGAL_AGE = 21;

export const isMajor = user => {
  return user.age >= LEGAL_AGE;
};

// this is a user factory
export const create = userAttributes => ({
  id: userAttributes.id,
  age: userAttributes.age
});

// usage
import * as User from './User.js';

const user = User.create({ id: 42, age: 21 });
User.isMajor(user); // true

// no problem if it's spread
const user2 = { ...user, age: 20 };
User.isMajor(user2); // false
複製代碼

當與 Redux 之類的狀態管理器打交道時,越容易支持 immutable(不變性)就越好,所以沒法展開對象來進行淺拷貝並非一件好事。使用函數式方式會強制解耦,而且咱們能夠展開對象。

全部這些規則都適用於值對象,但它們還有另外一個重要性:它們有助於減小實體的膨脹。一般,實體中有不少彼此不直接相關的屬性,這可能代表咱們能夠提取其中一些屬性給值對象。舉例來講,假設咱們有一個椅子實體,其屬性有 id,cushionType,cushionColor,legsCount,legsColor 和 legsMaterial。注意到 cushionType 和 cushionColor 與 legsCount,legsColor 和 legsMaterial 不相關,所以在提取了一些值對象以後,咱們的椅子將減小爲三個屬性:id,cushion 和 legs。如今,咱們能夠繼續爲 cushion 和 legs 添加屬性,而不會使椅子變得更繁冗。

提取鍵值對以前

提取鍵值對以後

可是,僅從實體中提取值對象並不老是足夠的。你會發現,一般會有與次要實體相關聯的實體,其中主要概念由第一個實體表示,依賴於這些次要實體做爲一個總體,而僅存在這些次要實體是沒有意義的。如今你的腦海中確定會有些混亂,因此讓咱們清除一下。

想一下購物車。購物車能夠由購物車實體表示,該實體將由訂單項組成,而訂單項又是實體,由於它們具備本身的 ID。訂單項只能經過主要實體購物車對象進行交互。想知道特定產品是否在購物車內?調用 cart.hasProduct(product) 方法,而不是像 cart.lineItems.find(...) 那樣直接訪問 lineItems 屬性。對象之間的這種關係稱爲聚合,給定聚合的主要實體(在本例中爲 cart 對象)稱爲聚合根。表明聚合及其全部組件概念的實體只能經過購物車進行訪問,但聚合內部的實體從外部引用對象是能夠的。咱們甚至能夠說,在單個實體可以表明整個概念的狀況下,該實體也是由單個實體及其值對象(若是有)組成的聚合。所以,當咱們說「聚合」時,從如今開始,你必須將其解釋爲適當的聚合和單一實體聚合。

外部沒法訪問聚合的內部實體,可是次要實體能夠從聚合外部訪問事物,例如 products。

在咱們的代碼庫中具備明肯定義的實體,集合和值對象,並以領域專家如何引用它們來命名可能很是有價值(無雙關語)。所以,在將代碼丟到其餘地方以前,請始終注意是否可使用它們來抽象一些東西。另外,請務必瞭解實體和聚合,由於它對下一種模式頗有用!

Repositories

你是否注意到咱們尚未談論持久化呢?考慮這一點很重要,由於它會強制執行咱們從一開始就講過的話:持久化是實現細節,是次要關注點。只要在軟件中將負責處理的部分合理地封裝而且不影響其他代碼,將內容持久化到哪裏就沒什麼關係。在大多數基於分層的架構中,這就是 repository 的職責,該 repository 位於 infrastructure 層內。

Repositories 是用於持久化和讀取實體的對象,所以它們應實現使它們感受像集合的方法。若是你有 article 對象並但願保留它,則可能有一個帶有 add(article) 方法的 ArticleRepository,該方法將文章做爲參數,將其保留在某個地方,而後返回帶有附加的僅保留屬性(如 id)的文章副本。

我說過咱們會有一個 ArticleRepository,可是咱們如何持久化其餘對象呢?咱們是否應該使用其餘 repository 來持久存儲用戶?咱們應該有多少個 repository,它們應該有多少顆粒度?冷靜下來,規則並不難掌握。你還記得聚合嗎?那是咱們切入的地方。根據經驗通常是爲代碼庫的每一個聚合提供一個 repository。咱們也能夠爲次要實體建立 repository,但僅在須要時才能夠。

好吧,好吧,這聽起來很像後端談話。那麼,repository 在前端作什麼?咱們那裏沒有數據庫!這就是要注意的問題:中止將 repository 與數據庫相關聯。repository 與整個持久性有關,而不只僅是數據庫。在前端,repository 處理數據源,例如 HTTP API,LocalStorage,IndexedDB 等。在上一個示例中,咱們的 ArticleRepository.add 方法將 Article 實體做爲輸入,將其轉換爲 API 指望的 JSON 格式,對 API 進行 AJAX 調用,而後將 JSON 響應映射回 Article 實體的實例。

很高興注意到,例如,若是 API 仍在開發中,咱們能夠經過實現一個名爲 LocalStorageArticleRepository 的 ArticleRepository 來模擬它,該 ArticleRepository 與 LocalStorage 而不是與 API 交互。當 API 準備就緒時,咱們而後建立另外一個稱爲 AjaxArticleRepository 的實現,從而替換 LocalStorage 實現--只要它們都共享相同的接口,並注入通用名稱便可,而不須要展現底層技術,例如 articleRepository。

咱們在這裏使用「接口」一詞來表示對象應實現的一組方法和屬性,所以請不要將其與圖形用戶界面(也稱爲 GUI)混淆。若是你使用的是純 JavaScript,則接口僅是概念性的;它們是虛構的,由於該語言不支持接口的顯式聲明,可是若是你使用的是 TypeScript 或 Flow,則它們能夠是顯性的。

Services

這是最後一種模式,不是偶然。正是在這裏,由於它應該被視爲「最後的資源」。若是你沒法將概念適用於上述任何一種模式,則只有在那時才考慮建立服務。在代碼庫中,任何可重用的代碼被拋出到所謂的「服務對象」中是很廣泛的,它不過是一堆沒有封裝概念的可重用邏輯。始終要意識到這一點,不要讓這種狀況在你的代碼庫中發生,而且要避免建立服務而不是用例的衝動,由於它們不是一回事。

簡而言之:服務是一個對象,它實現了領域對象中不適合的過程。例如,支付網關。

讓咱們想象一下,咱們正在創建一個電子商務,而且須要與支付網關的外部 API 交互以獲取購買的受權令牌。付款網關不是一個領域概念,所以很是適合 PaymentService。向其中添加不會透露技術細節的方法,例如 API 響應的格式,而後你將擁有一個通用對象,能夠很好地封裝你的軟件和支付網關之間的交互。

就是這樣,這裏不是祕密。嘗試使你的領域概念適應上述模式,若是它們不起做用,則僅考慮提供服務。它對代碼庫的全部層都很重要!

文件組織

許多開發人員誤解了架構和文件組織之間的區別,認爲後者定義了應用程序的架構。甚至擁有良好的文件組織,應用程序就能夠很好地擴展,這徹底是一種誤導。即便是最完美的文件組織,你仍然可能在代碼庫中遇到性能和可維護性問題,所以這是本文的最後主題。讓咱們揭開文件組織的神祕面紗,以及如何將其與架構結合使用以實現可讀且可維護的項目結構。

基本上,文件組織是你從視覺上分離應用程序各部分的方式,而架構是從概念上分離應用程序的方式。你能夠很好地保持相同的架構,而且在文件組織方案時仍然能夠有多個選擇。可是,最好是組織文件以反映架構的各個層次,並幫助代碼庫的讀者,以便他們僅查看文件樹便可瞭解會發生什麼。

沒有完美的文件組織,所以請根據你的喜愛和需求進行明智的選擇。可是,有兩種方法對突出本文討論的層特別有用。讓咱們看看它們中的每個。

第一個是最簡單的,它包括將 src 文件夾的根分爲幾層,而後是架構的概念。例如:

.
|-- src
|  |-- app
|  |  |-- user
|  |  |  |-- CreateUser.js
|  |  |-- article
|  |  |  |-- GetArticle.js
|  |-- domain
|  |  |-- user
|  |  |  |-- index.js
|  |-- infra
|  |  |-- common
|  |  |  |-- httpService.js
|  |  |-- user
|  |  |  |-- UserRepository.js
|  |  |-- article
|  |  |  |-- ArticleRepository.js
|  |-- store
|  |  |-- index.js
|  |  |-- user
|  |  |  |-- index.js
|  |-- view
|  |  |-- ui
|  |  |  |-- Button.js
|  |  |  |-- Input.js
|  |  |-- user
|  |  |  |-- CreateUserPage.js
|  |  |  |-- UserForm.js
|  |  |-- article
|  |  |  |-- ArticlePage.js
|  |  |  |-- Article.js
複製代碼

當這種文件組織與 React 和 Redux 配合使用時,一般會看到諸如 components, containers, reducers, actions 等文件夾。咱們傾向於更進一步,將類似的職責分組在同一文件夾中。例如,咱們的 components 和 containers 都將在 view 文件夾中,而 actions 和 reducers 將在 store 文件夾中,由於它們遵循將出於相同緣由而改變的事物收集在一塊兒的規則。如下是該文件組織的立場:

  • 你不該該經過文件夾來反映技術角色,例如「controllers」,「components」,「helpers」等;

  • 實體位於 domain/ 文件夾中,其中「 concept」是實體所在的集合的名稱,並經過 domain / /index.js 文件導出;

  • 只要不會引發耦合,就能夠在同一層的概念之間導入文件。

咱們的第二個選擇包括按功能分隔 src 文件夾的根。假設咱們正在處理文章和用戶;在這種狀況下,咱們將有兩個功能文件夾來組織它們,而後是第三個文件夾,用於處理諸如通用 Button 組件之類的常見事物,甚至是僅用於 UI 組件的功能文件夾:

.
|-- src
|  |-- common
|  |  |-- infra
|  |  |  |-- httpService.js
|  |  |-- view
|  |  |  |-- Button.js
|  |  |  |-- Input.js
|  |-- article
|  |  |-- app
|  |  |  |-- GetArticle.js
|  |  |-- domain
|  |  |  |-- Article.js
|  |  |-- infra
|  |  |  |-- ArticleRepository.js
|  |  |-- store
|  |  |  |-- index.js
|  |  |-- view
|  |  |  |-- ArticlePage.js
|  |  |  |-- ArticleForm.js
|  |-- user
|  |  |-- app
|  |  |  |-- CreateUser.js
|  |  |-- domain
|  |  |  |-- User.js
|  |  |-- infra
|  |  |  |-- UserRepository.js
|  |  |-- store
|  |  |  |-- index.js
|  |  |-- view
|  |  |  |-- UserPage.js
|  |  |  |-- UserForm.js
複製代碼

該組織的立場與第一個組織的立場基本相同。對於這兩種狀況,你都應將 dependencies container 保留在 src 文件夾的根目錄中。

一樣,這些選項可能沒法知足你的需求,可能不是你理想的文件組織方式。所以,請花一些時間來移動文件和文件夾,直到得到能夠更輕鬆地找到所需工件爲止。這是找出最適合大家團隊的最佳方法。請注意,僅將代碼分紅文件夾不會使你的應用程序更易於維護!你必須保持相同的心態,同時在代碼中分離職責。

接下來

哇!不少內容,對不對?不要緊,咱們在這裏談到了不少模式,因此不要一口氣讀懂全部這些內容。隨時從新閱讀並檢查該系列的第一篇文章和咱們的示例,直到你對體系結構及其實現的輪廓感到更滿意爲止。

在下一篇文章中,咱們還將討論實際示例,但將徹底集中在狀態管理上。

若是你想看到此架構的實際實現,請查看此示例博客引擎應用程序的代碼,點擊查看。請記住,沒有什麼是一成不變的,在之後的文章中,咱們還會討論一些模式。

推薦閱讀連接

Mark Seemann — Functional architecture — The pits of success

Scott Wlaschin — Functional Design Patterns

相關文章
相關標籤/搜索