引子
继 Scalable Frontend 1 — Architecture Fundamentals 第二篇。
原文:Scalable Frontend #2 — Common Patterns
- Origin
- My GitHub
注释
模式应该很好的适应,就像玩积木。
让咱们持续前端可扩展性的探讨!在上一篇文章中,咱们探讨了前端应用程序的架构根底,但仅限于概念。当初咱们要用理论的代码亲自实际一下。
常见模式(Common patterns)
咱们如何实现第一篇文章中提到的架构?和咱们以前做的相比有什么不同?咱们如何将所有这些与依赖注入联合起来?
无论你应用哪个库来形象视图或治理状态,在前端应用程序中有一些重复呈现的模式。当初咱们将要谈谈其中的一些,所以系好安全带,筹备开车了!
用例(Use cases)
咱们抉择 用例 作为第一种模式,因为在架构方面,它们是咱们与软件交互的形式。用例在一个高层次上讲述咱们的应用程序做了什么;它们是咱们个性的配方;是应用层的次要单元。它们定义应用程序自身。
用例通常也称为 互动者,它负责执行与其它层之间的交互。他们:
- 被输出层调用,
- 利用它们的算法,
- 使定义域层和基础设施层交互,不用关怀它们外部的工作形式,并且,
- 将后果状态返回给输出层。后果状态表明用例是胜利还是因为外部谬误、验证失败、前置条件等等而失败。
理解后果状态是很有用的,因为它有助于确定要为后果响应什么操作,从而容许 UI 中有更丰盛的信息,这样用户就能够晓得在失败状况下产生了什么谬误。但这里有一个重要细节:后果状态的逻辑应该在用例外部,而不是输出层——因为这个不是输出层的责任。这意味着输出层 不 应该接管从用例传递来的通用谬误对象,并求助于应用 if
语句来找出失败的起因,比方查看 error.message
属性或应用 instanceof
查问谬误的类。
这让咱们碰到一个辣手的事实:从用例中返回 promise 可能不是最佳的设计决策,因为 promise 只有两种可能的后果:胜利和失败,须要咱们借助 catch()
语句找到失败的起因。这是否意味着在软件中咱们应该疏忽 promise?不!只有输出层对此无所不知,就齐全能够从咱们代码的其它局部返回 promise,比方操作、存储库和服务。克服这个限度的一个简略办法是,对用例的每个可能后果状态提供一个回调。
用例的另一个重要特色是,它们应该遵循层与层之间的边界:不晓得什么入口点在调用它们,即便在只有一个入口点的前端也是如此。这意味着咱们不应该在用例中接触浏览器全局变量、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;咱们给它 red
、green
和 blue
的值,正是这三个属性的组合标识了这个对象。如果咱们扭转 red
属性的值,咱们当初能够说它代表另一种色彩,但用 id 标识的用户不会产生这样的状况。如果咱们批改 name
属性的值,但放弃雷同的 id,咱们认为依然是同一个用户,对吧?
在本节的结尾,咱们说过在实体中蕴含业务规定和行为的办法是很常见的。但在前端,将业务规定作为实体对象的办法并不总是行得通。想想函数式编程:咱们没有实例办法,或者 this
,或者可变性——应用一般的 JavaScript 对象代替自定义类的实例,这是能够很好地解决单向数据流的榜样。当应用函数式编程时,实体中蕴含办法还有意义吗?当然没有。那么咱们该如何创立具备这类限度的实体呢?咱们通过函数的形式。
咱们将有个 User 模块导出命名为 isMajor(user)
, 代替 User
类实例办法 user.isMajor()
,它承受一个具备用户属性的对象,并将其视为来自 User
类的 this
。参数不须要是特定类的实例,只有它具备与用户雷同的属性。这一点很重要:属性(User
实体的预期参数)应该以某种形式格式化。你能够应用纯 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 这样的状态管理器时,你能够更容易反对不变性,因而不能通过创立浅拷贝扩大对象并不是一件坏事。应用函数办法将强制解耦,并且咱们依然可能扩大对象。
所有这些规定都实用于值对象,但它们还有另一个重要作用:它们有助于使咱们的实体不那么臃肿。在实体中有许多属性彼此不间接相干是很常见的,这可能是咱们可能将其中一些属性提取到值对象的一个迹象。例如,假如咱们有个 Chair
实体,领有属性 id
、cushionType
、cushionColor
、legsCount
、legsColor
、legsMaterial
。留神到 cushionType
、cushionColor
和 legsCount
、legsColor
、legsMaterial
没有关联,因而在提取一些值对象后,咱们的椅子将简化为三个属性:id
、cushion
和 legs
。当初咱们能够持续为 cushion
和 legs
增加属性,而不会使 Chair
更加臃肿。
但仅仅从实体中提取值对象并不总是足够的。你会留神到,往往会有与主要实体关联,又代表了次要概念的次要实体,作为一个 整体 次要实体依赖这些主要实体,而这些主要实体独自存在是没有意义的。当初你脑子里必定有些凌乱,所以让咱们把它弄清楚。
想想购物车。购物车能够用 Cart
实体来示意,由 lineItems
组成,lineItems
也是实体,因为它们有本人的 id。lineItems
只能通过主实体 cart 对象进行交互。想晓得给定的产品是否在购物车内吗?调用 cart.hasProduct(product)
办法,而不是相似 cart.lineItems.find(...)
间接查找 lineItems
的属性。这种对象之间的关系称之为 聚合 (aggregate)。提供聚合的次要实体(在这个例子中指 cart 对象)称为 聚合根(aggregate root)。示意聚合概念的实体及其所有组件只能通过 cart 拜访,但聚合中的实体能够从内部援用对象。咱们甚至能够说,在单个实体独自可能示意整个概念的状况下,该实体也是由单个实体及其值对象(如果有的话)组成的聚合。因而,当咱们说“聚合”时,从现在起,你必须把它了解为适当的聚合或繁多实体聚合。
无奈从内部拜访聚合的外部实体,但主要实体能够拜访聚合内部的货色,比方产品
在咱们的代码库中定义好实体、聚合和值对象,并以定义域层的里手如何援用它们来命名,这是十分有价值的(没有其它意思)。所以在把代码扔到其它中央之前,肯定要留神是否能够用它们形象出一些货色。另外,肯定要了解实体和聚合,因为它对下一个模式很有用!
存储库(Repositories)
你留神到咱们还没谈到长久化吗?思考它很重要,因为它强调了咱们从一开始就谈到的内容:长久化是一个实现细节,一个主要的关注点。只有负责解决这些内容的局部被正当地封装并且不影响你的其余代码,那么你能够将这些内容长久化到软件中的任何中央。在大多数分层的架构中,这是存储库的责任,存储库位于基础设施层中。
存储库是用于长久化和读取实体的对象,因而它们应该执行使它们 看起来像 汇合的办法。如果你有一个 article
对象并且想要长久化它,那么你可能会有一个 ArticleRepository
,它有一个 add(article)
办法,该办法将文章作为一个参数,把文章长久化到 某个中央,而后返回一个带有长久化只读属性(例如 id)的文章正本。
我说过咱们会有一个 ArticleRepository
,但咱们如何长久化其它对象?咱们是否应该有一个不同的存储库来长久化用户?咱们应该有多少个存储库,它们的粒度应该有多大?沉着点,规定并不难把握。你还记得聚合吗?那是咱们界定的中央。教训法令是代码库的每个聚合都有一个对应存储库。咱们还能够为主要实体创立存储库,但仅在必要时。
好吧,好吧,听起来很像在议论后端。存储库在前端做什么?咱们那里没有数据库!这里的要害是:进行将存储库与数据库相关联。存储库是对于整体的长久化,而不仅仅是对于数据库。在前端,存储库解决来自 HTTP APIs、LocalStorage、IndexedDB 等等数据源。在上一个示例中,咱们的 ArticleRepository#add
办法将一个 Article
实体作为输出,将其转换为 API 冀望的 JSON 格局,对 API 进行 AJAX 调用,而后将 JSON 响应映射回 Article
实体的实例。
留神到这些很好,例如,如果 API 还在开发中,咱们能够通过实现一个名为 LocalStorageArticleRepository
的 ArticleRepository
来模仿它,它与 LocalStorage 通信而不是 API。当 API 筹备好后,咱们创立另一个名为 AjaxArticleRepository
的实现,替换 LocalStorage
——只有它们共享同一个 接口,并且注入一个不会裸露底层技术的通用名称,比方 articleRepository
。
咱们在这里应用的术语 interface,示意一个对象应该实现的办法和属性集,所以不要把它与图形用户界面(又称 GUIs)混同。如果你应用的是原生 JavaScript,那么接口将只是概念性的;它们将是虚构的,因为该语言不反对接口的显式申明,然而如果你应用的是 TypeScript 或 Flow,它们是能够的。
服务(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 配合时,常常会看到 components
、containers
、reducers
、actions
等等这样文件夹。咱们偏向更进一步,在同一个文件夹中对相似的职责进行分组。例如,咱们的组件和容器都将放入 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 应用程序的代码。请记住没有什么是变化无穷的,在接下来的文章中,咱们还会探讨一些模式。
举荐链接
- Mark Seemann — Functional architecture — The pits of success
- Scott Wlaschin — Functional Design Patterns
参考资料
- Scalable Frontend #2 — Common Patterns