可扩展的前端#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

相关文章
相关标签/搜索