【譯】可擴展前端2  —  常見模式

引子

Scalable Frontend 1  — Architecture Fundamentals 第二篇。html

原文:Scalable Frontend #2 — Common Patterns前端

正文

76-head

模式應該很好的適應,就像玩積木。react

讓咱們繼續前端可擴展性的討論!在上一篇文章中,咱們討論了前端應用程序的架構基礎,但僅限於概念。如今咱們要用實際的代碼親自實踐一下。git

常見模式(Common patterns)

咱們如何實現第一篇文章中提到的架構?和咱們之前作的相比有什麼不一樣?咱們如何將全部這些與依賴注入結合起來?github

不管你使用哪一個庫來抽象視圖或管理狀態,在前端應用程序中有一些反覆出現的模式。如今咱們將要談談其中的一些,因此係好安全帶,準備開車了!算法

用例(Use cases)

咱們選擇 用例 做爲第一種模式,由於在架構方面,它們是咱們與軟件交互的方式。用例在一個高層次上講述咱們的應用程序作了什麼;它們是咱們特性的配方;是應用層的主要單元。它們定義應用程序自己。typescript

用例一般也稱爲 互動者 ,它負責執行與其它層之間的交互。他們:數據庫

  • 被輸入層調用,
  • 應用它們的算法,
  • 使定義域層和基礎設施層交互,沒必要關心它們內部的工做方式,而且,
  • 將結果狀態返回給輸入層。結果狀態代表用例是成功仍是因爲內部錯誤、驗證失敗、前置條件等等而失敗。

瞭解結果狀態是頗有用的,由於它有助於肯定要爲結果響應什麼操做,從而容許 UI 中有更豐富的信息,這樣用戶就能夠知道在失敗狀況下發生了什麼錯誤。但這裏有一個重要細節:結果狀態的邏輯應該在用例內部,而不是輸入層——由於這個不是輸入層的責任。這意味着輸入層 應該接收從用例傳遞來的通用錯誤對象,並求助於使用 if 語句來找出失敗的緣由,好比檢查 error.message 屬性或使用 instanceof 查詢錯誤的類。編程

這讓咱們碰到一個棘手的事實:從用例中返回 promise 可能不是最佳的設計決策,由於 promise 只有兩種可能的結果:成功和失敗,須要咱們藉助 catch() 語句找到失敗的緣由。這是否意味着在軟件中咱們應該忽略 promise ?不!只要輸入層對此一無所知,就徹底能夠從咱們代碼的其它部分返回 promise ,好比操做、存儲庫和服務。克服這個限制的一個簡單方法是,對用例的每一個可能結果狀態提供一個回調。redux

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

沒有什麼能比舉例說明更清楚:

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);
  }
};
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))
  });
};

注意,在 userAction 中,咱們不會對 createUser 用例的響應作出任何斷言;咱們相信用例會爲每一個結果調用正確的回調。並且,即便 userData 對象中的值來自 HTML 輸入,用例對此一無所知。它只接收提取的數據並將其轉發。

就是這樣了!用例不該該作更多的事了。你能看出如今測試它們有多容易嗎?咱們只要注入咱們想要的模擬依賴項,並測試用例是否針對每種狀況調用了正確的回調。

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

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

舉個例子,若是咱們博客引擎的一個用戶在評論一篇關於暴力的文章時有年齡限制,咱們會有一個 user.isMajor() 方法將在 article.canBeCommentedBy(user) 內部調用,用這樣的方式把年齡分類規則保持在 user 對象內,年齡限制規則保持在 article 對象內。AddCommentToArticle 用例將把用戶實例傳遞給 article.canBeCommentedBy ,執行它們之間 交互 的將是這個用例。

有一個方法能夠識別你代碼庫中那些是一個實體:若是一個對象表示一個定義域概念,而且它有一個標識符 屬性(例如 id、slug 或文檔編號),那麼它就是一個實體。這個標識的存在很重要,由於它是實體與值對象的區別所在。

雖然實體具備標識符屬性,但值對象的標識由其全部屬性的值組合而成。想不明白?想象一個顏色對象。當用一個對象來表示一種顏色時,咱們一般不會給這個對象一個 id ;咱們給它 redgreenblue 的值,正是這三個屬性的組合標識了這個對象。若是咱們改變 red 屬性的值,咱們如今能夠說它表明另外一種顏色,但用 id 標識的用戶不會發生這樣的狀況。若是咱們修改 name 屬性的值,但保持相同的 id ,咱們認爲仍然是同一個用戶,對吧?

在本節的開頭,咱們說過在實體中包含業務規則和行爲的方法是很常見的。但在前端,將業務規則做爲實體對象的方法並不老是行得通。想一想函數式編程:咱們沒有實例方法,或者 this ,或者可變性——使用普通的 JavaScript 對象代替自定義類的實例,這是能夠很好地處理單向數據流的典範。當使用函數式編程時,實體中包含方法還有意義嗎?固然沒有。那麼咱們該如何建立具備這類限制的實體呢?咱們經過函數的方式。

咱們將有個 User 模塊導出命名爲 isMajor(user) ,代替 User 類實例方法 user.isMajor(),它接受一個具備用戶屬性的對象,並將其視爲來自 User 類的 this 。參數不須要是特定類的實例,只要它具備與用戶相同的屬性。這一點很重要:屬性( User 實體的預期參數)應該以某種方式格式化。你可使用純 JavaScript 工廠函數來實現,或者更明確地使用 FlowTypeScript

讓咱們來看一個先後對照,以便更容易理解。

// 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 這樣的狀態管理器時,你能夠更容易支持不變性,所以不能經過建立淺拷貝擴展對象並非一件好事。使用函數方法將強制解耦,而且咱們仍然可以擴展對象。

全部這些規則都適用於值對象,但它們還有另外一個重要做用:它們有助於使咱們的實體不那麼臃腫。在實體中有許多屬性彼此不直接相關是很常見的,這多是咱們可以將其中一些屬性提取到值對象的一個跡象。例如,假設咱們有個 Chair 實體,擁有屬性 idcushionTypecushionColorlegsCountlegsColorlegsMaterial 。注意到 cushionTypecushionColorlegsCountlegsColorlegsMaterial 沒有關聯,所以在提取一些值對象後,咱們的椅子將簡化爲三個屬性:idcushionlegs 。如今咱們能夠繼續爲 cushionlegs 添加屬性,而不會使 Chair 更加臃腫。

76-before

76-after

但僅僅從實體中提取值對象並不老是足夠的。你會注意到,每每會有與次要實體關聯,又表明了主要概念的主要實體,做爲一個 總體 主要實體依賴這些次要實體,而這些次要實體單獨存在是沒有意義的。如今你腦子裏確定有些混亂,因此讓咱們把它弄清楚。

想一想購物車。購物車能夠用 Cart 實體來表示,由 lineItems 組成,lineItems 也是實體,由於它們有本身的 id 。lineItems 只能經過主實體 cart 對象進行交互。想知道給定的產品是否在購物車內嗎?調用 cart.hasProduct(product) 方法,而不是相似 cart.lineItems.find(...) 直接查找 lineItems 的屬性。這種對象之間的關係稱之爲 聚合(aggregate)。提供聚合的主要實體(在這個例子中指 cart 對象)稱爲 聚合根(aggregate root)。表示聚合概念的實體及其全部組件只能經過 cart 訪問,但聚合中的實體能夠從外部引用對象。咱們甚至能夠說,在單個實體單獨可以表示整個概念的狀況下,該實體也是由單個實體及其值對象(若是有的話)組成的聚合。所以,當咱們說「聚合」時,從如今起,你必須把它理解爲適當的聚合或單一實體聚合。

76-aggregate

沒法從外部訪問聚合的內部實體,但次要實體能夠訪問聚合外部的東西,好比產品

在咱們的代碼庫中定義好實體、聚合和值對象,並以定義域層的行家如何引用它們來命名,這是很是有價值的(沒有其它意思)。因此在把代碼扔到其它地方以前,必定要注意是否能夠用它們抽象出一些東西。另外,必定要理解實體和聚合,由於它對下一個模式頗有用!

存儲庫(Repositories)

你注意到咱們還沒談到持久化嗎?考慮它很重要,由於它強調了咱們從一開始就談到的內容:持久化是一個實現細節,一個次要的關注點。只要負責處理這些內容的部分被合理地封裝而且不影響你的其他代碼,那麼你能夠將這些內容持久化到軟件中的任何地方。在大多數分層的架構中,這是存儲庫的責任,存儲庫位於基礎設施層中。

存儲庫是用於持久化和讀取實體的對象,所以它們應該執行使它們 看起來像 集合的方法。若是你有一個 article 對象而且想要持久化它,那麼你可能會有一個 ArticleRepository ,它有一個 add(article) 方法,該方法將文章做爲一個參數,把文章持久化到 某個地方 ,而後返回一個帶有持久化只讀屬性(例如 id )的文章副本。

我說過咱們會有一個 ArticleRepository ,但咱們如何持久化其它對象?咱們是否應該有一個不一樣的存儲庫來持久化用戶?咱們應該有多少個存儲庫,它們的粒度應該有多大?冷靜點,規則並不難掌握。你還記得聚合嗎?那是咱們界定的地方。經驗法則是代碼庫的每一個聚合都有一個對應存儲庫。咱們還能夠爲次要實體建立存儲庫,但僅在必要時。

好吧,好吧,聽起來很像在談論後端。存儲庫在前端作什麼?咱們那裏沒有數據庫!這裏的關鍵是:中止將存儲庫與數據庫相關聯。存儲庫是關於總體的持久化,而不只僅是關於數據庫。在前端,存儲庫處理來自 HTTP APIs、LocalStorage、IndexedDB 等等數據源。在上一個示例中,咱們的 ArticleRepository#add 方法將一個 Article 實體做爲輸入,將其轉換爲 API 指望的 JSON 格式,對 API 進行 AJAX 調用,而後將 JSON 響應映射回 Article 實體的實例。

注意到這些很好,例如,若是 API 還在開發中,咱們能夠經過實現一個名爲 LocalStorageArticleRepositoryArticleRepository 來模擬它,它與 LocalStorage 通訊而不是 API 。當 API 準備好後,咱們建立另外一個名爲 AjaxArticleRepository 的實現,替換 LocalStorage ——只要它們共享同一個 接口 ,而且注入一個不會暴露底層技術的通用名稱,好比 articleRepository

咱們在這裏使用的術語 interface ,表示一個對象應該實現的方法和屬性集,因此不要把它與圖形用戶界面(又稱 GUIs )混淆。若是你使用的是原生 JavaScript ,那麼接口將只是概念性的;它們將是虛構的,由於該語言不支持接口的顯式聲明,可是若是你使用的是 TypeScriptFlow ,它們是能夠的。

76-repository

服務(Services)

這個不是最後的模式。它之因此在這裏,由於它應該被視爲「最後的手段」。當你沒法將一個概念融入到前面的任何一種模式中時,那麼你就應該考慮建立一個服務。任何一段可重用基礎代碼被拋出到所謂的「服務對象」中是很常見的,這只是一堆沒有封裝概念的可重用邏輯。始終要意識到這一點,不要讓這種狀況發生在你的代碼庫中,而且抵制建立服務而不是用例的衝動,由於它們不是一回事。

簡單來講:服務對象執行的程序,不適合定義域的對象。例如,支付網關。

讓咱們想象一下,咱們正在構建一個電子商務,咱們須要與支付網關的外部 API 通訊,以獲取購買的受權令牌。支付網關不是一個定義域的概念,所以它很是適合 PaymentService 。向其中添加不會透露技術細節的方法,例如 API 響應的格式化,而後你就有了一個具備良好封裝的,用來進行軟件和支付網關之間通訊的通用對象。

就這些了,沒有什麼祕密。嘗試將你的定義域概念與上述模式相匹配,若是它們都不起做用,那麼只好考慮使用服務。它包含了代碼庫的全部層!

文件組織(File organization)

許多開發人員誤解了架構和文件組織之間的區別,認爲後者定義了應用程序的架構。或認爲有良好的組織,應用程序也能很好地擴展,這徹底是一種誤導。即便使用了最完美的文件組織,你的代碼庫仍然存在性能和可維護性問題,所以這是本文的最後一個主題。讓咱們解釋清楚組織究竟是什麼,以及如何將其與架構結合使用,以實現可讀和可維護的項目結構。

大致上,組織是你如何在視覺上分離應用程序的各個部分,而架構是如何在 概念 上分離應用程序。你徹底能夠保持相同的架構,而且在選擇組織方案時仍有多種選擇。不過,組織你的文件以反映架構的各個層,有利於代碼庫的讀者,這是個好主意,這樣他們只要經過查看文件樹就能夠了解發生了什麼。

沒有完美的文件組織,因此根據你的品味和須要明智地選擇。這裏有兩種方式對於突出本文中討論的層次特別有用。讓咱們逐一看看。

第一種方式是最簡單的,它以 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 配合時,常常會看到 componentscontainersreducersactions 等等這樣文件夾。咱們傾向更進一步,在同一個文件夾中對相似的職責進行分組。例如,咱們的組件和容器都將放入 view 文件夾中,actions 和 reducer 將放入 store 文件夾中,由於它們遵循了將因一樣緣由而改變的事情集中起來的規則。如下是這種組織方式的一些立場:

  • 你不該該有反映技術角色的文件夾,如「controllers」、「components」、「helpers」等;
  • 實體位於 domain/<concept> 文件夾,其中「concept」是實體所在聚合的名稱,並經過 domain/< concept>/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

這種組織方式的立場與第一個基本相同。對於這兩種狀況,你應該將依賴容器放在 src 文件夾的根目錄中。

再說一次,這些選項可能不適合你的需求,所以可能不是你理想的組織方式。因此,花時間嘗試移動文件和文件夾,直到你實現一個讓你更容易找到所需文件的方案。這是發現什麼更適合你的團隊的最佳方法。請注意,僅僅將代碼分離到文件夾並不能使應用程序更易於維護!你在代碼中分離責任時,須要保持相同的心態。

接下來

哇!至關多的內容,對吧?不要緊,咱們在這裏講了不少模式,因此不要強迫本身在一次閱讀中理解全部。請隨意從新閱讀和檢查本系列的第一篇文章和咱們的示例,直到你對架構及其實現的輪廓感到更清晰爲止。

在下一篇文章中,咱們還將討論一些實際的例子,但重點徹底放在狀態管理上。

若是你想看到此架構的真正實現,請查看 blog engine application 應用程序的代碼。請記住沒有什麼是一成不變的,在接下來的文章中,咱們還會討論一些模式。

推薦連接

參考資料

相關文章
相關標籤/搜索