【译】可扩展前端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 应用程序的代码。请记住没有什么是一成不变的,在接下来的文章中,咱们还会讨论一些模式。

推荐连接

参考资料

相关文章
相关标签/搜索