可扩展的前端 -- 常见模式
(给前端大学加星标,提升前端技能.)
译者:唐江洪
译文:https://github.com/mcuking/blog/issues/60
作者:Talysson de Oliveira
https://blog.codeminer42.com/scalable-frontend-2-common-patterns-d2f28aef0714
@唐江洪,网易云音乐前端工程师,三年前端经验的杭漂,目前专注在前端工程方面研究不能自拔
正文从这开始~~
让我们继续讨论前端可扩展性!在可扩展的前端 -- 架构基础中,我们仅在概念上讨论了前端应用程序中的架构基础。现在,我们将动手操作实际代码。
常见模式
如可扩展的前端 -- 架构基础所述,我们如何实现架构?与我们过去的做法有什么不同?我们如何将所有这些与依赖注入结合起来?
不管你使用哪个库来抽象 view 或管理 state,前端应用程序中都有重复出现的模式。在这里,我们将讨论其中的一部分,因此请系紧安全带!
译者解读:结合上篇文章分成的四层:application 层、domain 层、 infrastructure 层、view 层。下面讲解的内容中:用例属于 application 层的核心概念,实体/值对象/聚合属于 domain 层核心概念,Repositories 属于 infrastructure 核心概念。
用例(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 的值或任何其他低级对象。例如:我们不应该将 />
元素的实例作为参数,然后再读取其值;view 层应该是负责提取该值并将其传递给用例。
没有什么比一个例子更清楚地表达一个概念了:
createUser.js
exportdefault({ validateUser, userRepository })=>async(
userData,
{ onSuccess, onError, onValidationError }
)=>{
if(!validateUser(userData)){
return onValidationError(newError('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
exportdefaultclassUser{
static LEGAL_AGE =21;
constructor({ id, age }){
this.id = id;
this.age = age;
}
isMajor(){
returnthis.age >=User.LEGAL_AGE;
}
}
// usage
importUserfrom'./User.js';
const user =newUser({ 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;
exportconst isMajor = user =>{
return user.age >= LEGAL_AGE;
};
// this is a user factory
exportconst create = userAttributes =>({
id: userAttributes.id,
age: userAttributes.age
});
// usage
import*asUserfrom'./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
分享前端好文,点亮 在看