可扩展前端介绍1—架构基础第二部分。原文:ScalableFrontend#2—CommonPatternsOriginMyGitHub文本模式应该很好地适应,就像玩积木一样。让我们继续前端可扩展性的讨论!在上一篇文章中,我们讨论了前端应用程序的架构基础知识,但只是概念上的。现在我们要用实际代码亲自尝试一下。通用模式(Commonpatterns)我们如何实现第一篇文章中提到的架构呢?它与我们之前所做的有何不同?我们如何将所有这些与依赖注入结合起来?无论您使用哪个库来抽象视图或管理状态,前端应用程序中都有一些重复出现的模式。现在我们要谈谈其中的一些,所以系好安全带,准备开车吧!用例(Usecases)我们选择用例作为第一个模式,因为在架构方面,它们是我们与软件交互的方式。用例在较高层次上说明我们的应用程序做什么;它们是我们功能的秘诀;应用层的主要单元。他们定义应用程序本身。用例,通常也称为交互器,负责执行与其他层的交互。它们:由输入层调用,应用它们的算法,使域和基础设施层交互而不关心它们的内部工作,并将结果状态返回到输入层。结果状态指示用例是由于内部错误、验证失败、先决条件等原因而成功还是失败。了解结果的状态非常有用,因为它有助于确定对结果响应的操作,允许在UI中提供更丰富的信息,以便用户知道在发生故障时出了什么问题。但这里有一个重要的细节:结果状态的逻辑应该在用例内部,而不是输入层——因为这不是输入层的责任。这意味着输入层不应该接收从用例传递的通用错误对象,并求助于使用if语句来找出它失败的原因,例如检查error.message属性或使用instanceof查询错误的类.这给我们带来了一个棘手的事实:从用例中返回一个承诺可能不是最好的设计决策,因为承诺只有两种可能的结果:成功和失败,我们需要借助catch()语句。这是否意味着我们应该忽略软件中的承诺?不!只要输入层对此一无所知,就可以从我们代码的其他部分返回承诺,例如操作、存储库和服务。克服此限制的一种简单方法是为用例的每个可能结果状态提供回调。用例的另一个重要特征是它们应该遵守层到层的边界:不知道调用它们的入口点是什么,即使在只有一个入口点的前端也是如此。这意味着我们不应该在我们的用例中触及浏览器全局变量、DOM特定值或任何其他低级对象。例如:我们不应该接收一个元素的实例作为参数,然后读取它的值;输入层应负责提取此值并将其传递给用例。没有什么比一个例子更清楚了:exportdefault({validateUser,userRepository})=>async(userData,{onSuccess,onError,onValidationError})=>{if(!validateUser(userData)){returnonValidationError(newError('Invalid用户'));}try{constuser=awaituserRepository.add(userData);onSuccess(用户);}catch(错误){onError(错误);}};constcreateUserAction=(userData)=>(dispatch,getState,container)=>{container.createUser(userData,{//注意我们没有添加条件来发出任何这些动作onSuccess:(user)=>dispatch(createUserSuccessAction(user)),onError:(error)=>dispatch(createUserErrorAction(error)),onValidationError:(error)=>dispatch(createUserValidationErrorAction(error))});};请注意,在userAction中,我们不会对createUser用例的响应做出任何断言;我们相信用例会为每个结果调用正确的回调。此外,即使userData对象中的值来自HTML输入,用例对此一无所知。它只是获取提取的数据并转发它。就是这样!用例不应该做更多的事情。你能看出现在测试它们是多么容易吗?我们只是注入我们想要的模拟依赖项并测试是否为每种情况调用了正确的回调。实体、价值对象和聚合实体是我们定义域层的核心:它们代表我们的软件处理的概念。假设我们正在构建一个博客引擎应用程序;在这种情况下,我们可能有一个User实体、一个Article实体,如果引擎允许的话,甚至还有一个Comment实体。实体只是保存这些概念的数据和行为的对象,与技术无关。实体不应被视为ActiveRecord设计模式的模型或实现;他们对数据库、AJAX或持久性一无所知。它们只是代表概念和围绕它的业务规则。例如,如果我们的博客引擎的用户在评论一篇关于暴力的文章时有年龄限制,我们将有一个user.isMajor()方法,该方法将在article.canBeCommentedBy(user)中以这样一种方式调用:年龄分类规则保存在用户对象中,年龄限制规则保存在文章对象中。AddCommentToArticle用例会将用户实例传递给article.canBeCommentedBy,并且正是这个用例执行它们之间的交互。有一种方法可以在您的代码库中识别什么是实体:如果一个对象表示一个领域概念,并且它具有标识符属性(例如id、slug或文档编号),那么它就是一个实体。这种身份的存在很重要,因为它是实体与值对象的区别。虽然实体具有标识符属性,但值对象的标识由其所有属性的值组成。想不通?想象一个彩色物体。当一个对象代表一种颜色时,我们通常不给对象一个id;我们赋予它红色、绿色和蓝色的值,它是这三个属性的组合来标识对象。如果我们更改红色属性的值,我们现在可以说它代表不同的颜色,但对于由id标识的用户来说,这不会发生。如果我们修改name属性的值,但保持相同的id,我们认为它仍然是同一个用户,对吧?在本节的开头,我们说过在实体中包含业务规则和行为的方法是很常见的。但是在前端,将业务规则作为实体对象的方法并不总是有效。想想函数式编程:我们没有实例方法、this或可变性——使用纯JavaScript对象而不是自定义类的实例是一个很好的例子,说明如何很好地处理单向数据流。使用函数式编程时,在实体中使用方法是否仍然有意义?当然不是。那么我们如何创建具有此类限制的实体呢?我们传递函数的方式。我们将有一个名为isMajor(user)的User模块导出,替换User类实例方法user.isMajor(),它获取一个具有用户属性的对象并将其视为来自User类的对象。参数不需要是特定类的实例,只要它具有与用户相同的属性即可。这很重要:属性(用户实体的预期参数)应该以某种方式格式化。您可以使用纯JavaScript工厂函数来完成,或者更明确地使用Flow或TypeScript。让我们看一下前后对比,以便更容易理解。//User.jsexport默认类User{staticLEGAL_AGE=21;constructor({id,age}){this.id=id;这个。年龄=年龄;}isMajor(){returnthis.age>=User.LEGAL_AGE;}}//usageimportUserfrom'./User.js';constuser=newUser({id:42,age:21});user.isMajor();//true//如果传播,则丢失类的引用constuser2={...user,age:20};user2.isMajor();//错误:user2.isMajor不是一个函数//User.jsconstLEGAL_AGE=21;exportconstisMajor=(user)=>{returnuser.age>=LEGAL_AGE;};//这是一个用户factoryexportconstcreate=(userAttributes)=>({id:userAttributes.id,age:userAttributes.age});//usageimport*asUserfrom'./User.js';constuser=User.create({id:42,age:21});User.isMajor(用户);//true//如果它是spreadconstuser2={...user,age:20};User.isMajor(user2);//false当处理像Redux这样的状态管理器时,你可以更容易地支持不变性,所以不能通过创建浅拷贝来扩展对象并不是一件好事。使用函数式方法将强制解耦,并且我们仍然能够扩展该对象。所有这些规则都适用于值对象,但它们也有另一个重要目的:它们有助于使我们的实体不那么臃肿。在实体中有许多彼此不直接相关的属性是很常见的,这可能表明我们能够将其中一些属性提取到值对象中。例如,假设我们有一个具有属性id、cushionType、cushionColor、legsCount、legsColor、legsMaterial的Chair实体。注意,cushionType、cushionColor和legsCount、legsColor、legsMaterial没有关联,所以在提取一些值对象后,我们的椅子将简化为三个属性:id、cushion和legs。现在我们可以继续为坐垫和腿添加属性,而不会使椅子变得更加臃肿。但仅从实体中提取值对象并不总是足够的。您会注意到,通常存在与次要实体相关联的主要实体,这些次要实体代表主要概念,主要实体作为一个整体所依赖,并且它本身没有意义。你脑子里现在肯定有些混乱,所以让我们把它弄清楚。想想购物车。购物车可以由Cart实体表示,由lineItems组成,它们也是实体,因为它们有自己的id。lineItems只能通过主实体购物车对象进行交互。想知道给定的产品是否在购物车中?调用cart.hasProduct(product)方法,而不是像cart.lineItems.find(...)那样直接查找lineItems属性。对象之间的这种关系称为聚合。提供聚合的主要实体(在本例中为购物车对象)称为聚合根。表示聚合概念的实体及其所有组件只能通过购物车访问,但聚合内的实体可以从外部引用对象。我们甚至可以说实体也是由单个实体及其值对象(如果有的话)组成的集合,在单个实体单独能够表示整个概念的情况下。所以当我们说“聚合”的时候,从现在开始你必须把它理解为适当的聚合或单一实体的聚合。聚合内部的实体不能从外部访问,但是次级实体可以访问聚合外部的东西,例如产品实体、聚合和值对象在我们的代码库中定义良好,并根据定义域层的专家引用它们的方式命名,这个很有价值(没有别的意思)。因此,在将代码扔到别处之前,一定要检查是否可以使用它们来抽象某些东西。另外,一定要了解实体和聚合,因为它对下一个模式很有用!存储库您是否注意到我们还没有讨论持久性?考虑它很重要,因为它强调了我们从一开始就谈到的内容:持久性是一个实现细节,是次要问题。只要负责处理它的部分被正确封装并且不影响您的代码的其余部分,您就可以在软件中的任何地方保留这些内容。在大多数分层架构中,这是位于基础设施层的存储库的责任。存储库是用于持久化和读取实体的对象,因此它们应该实现使它们看起来像集合的方法。如果你有一个文章对象并且你想持久化它,你可能有一个ArticleRepository,它有一个add(article)方法,该方法将文章作为参数,将文章持久化在某个地方,并返回一个具有持久读取的文章副本-仅限属性(例如id)。我说过我们会有一个ArticleRepository,但是我们如何持久化其他对象呢?我们应该为持久用户提供不同的存储库吗?我们应该拥有多少个存储库以及它们的粒度如何?冷静点,规则并不难掌握。你还记得聚合吗?这就是我们定义的地方。根据经验,代码库的每个聚合都有一个相应的存储库。我们也可以为二级实体创建存储库,但仅限于必要时。好吧,好吧,这听起来很像是在谈论后端。存储库在前端做什么?我们那里没有数据库!这里的关键是:停止将存储库与数据库相关联。存储库通常与持久性有关,而不仅仅是数据库。在前端,存储库处理来自HTTPAPI、LocalStorage、IndexedDB等的数据源。在前面的示例中,我们的ArticleRepository#add方法将Article实体作为输入,将其转换为API期望的JSON格式,对API进行AJAX调用,然后将JSON响应映射回Article实体的实例。值得注意的是,例如,如果API仍在开发中,我们可以通过实现一个名为LocalStorageArticleRepository的ArticleRepository来模拟它,它与LocalStorage而不是API通信。当API准备就绪时,我们创建另一个名为AjaxArticleRepository的实现,替换LocalStorage-只要它们共享相同的接口并注入不暴露底层技术的通用名称,如articleRepository。我们在这里使用术语接口来表示对象应该实现的一组方法和属性,所以不要将它与图形用户界面(又名GUI)混淆。如果你使用普通的JavaScript,接口将只是概念性的;它们将是虚构的,因为该语言不支持接口的显式声明,但如果您使用的是TypeScript或Flow,它们可以。服务(Services)这不是最后一个模式。它在这里是因为它应该被视为“最后的手段”。当您无法将某个概念融入之前的任何模式时,您应该考虑创建服务。任何一段可重用的基础代码被扔进所谓的“服务对象”是很常见的,它只是一堆可重用的逻辑,没有封装的概念。始终意识到这一点,不要让这种情况发生在您的代码库中,并抵制创建服务而不是用例的冲动,因为它们不是一回事。简单的说:服务器对象执行的程序不适合定义域的对象。例如,支付网关。假设我们正在构建一个电子商务,我们需要与支付网关的外部API进行通信以获得购买授权令牌。支付网关不是领域概念,因此它非常适合PaymentService。向其中添加不泄露技术细节的方法,例如API响应的格式,您将拥有一个封装良好的通用对象,用于软件和支付网关之间的通信。就是这样,没有秘密。尝试将您的领域概念与上述模式相匹配,如果它们都不起作用,那么只考虑使用服务。它包含代码库的所有层!文件组织许多开发人员误解了体系结构和文件组织之间的区别,认为后者定义了应用程序的体系结构。或者认为有了良好的组织,应用程序就能很好地扩展,这是完全误导的。即使拥有最完美的文件组织,您的代码库仍然会存在性能和可维护性问题,因此这是本文的最后一个主题。让我们来解释什么是真正的组织,以及如何将它与模式结合使用以实现可读和可维护的项目结构。基本上,组织是您如何在视觉上分离应用程序的各个部分,而体系结构是您在概念上分离应用程序的方式。在选择组织方案时,您可以保持相同的体系结构,并且仍然有多种选择。不过,通过组织文件以反映架构的层,让代码库的读者受益是一个好主意,这样他们只需查看文件树就可以了解正在发生的事情。没有完美的文件组织,因此请根据您的品味和需求明智地选择。这里有两种方法对于突出显示本文中讨论的层特别有用。让我们一一看看。第一种方式最简单,就是以src文件夹为根目录,然后根据你的架构概念划分层级。例如:.|--src||--应用|||--用户||||--创建用户.js|||--文章||||--GetArticle.js||--域名|||--用户||||--索引.js||--下文|||--普通||||--httpService.js|||--用户||||--用户库.js|||--文章||||--文章库.js||--店铺|||--索引.js|||--用户||||--索引.js||--查看|||--用户界面||||--按钮.js||||--输入.js|||--用户||||--创建用户页面.js||||--用户窗体.js|||--文章||||--ArticlePage.js||||--Article.js将此组织与React和Redux一起使用时,您会经常看到组件、容器、reducer、actions等文件夹。我们倾向于更进一步,将相似的职责分组在同一个文件夹中。例如,我们的组件和容器都将进入view文件夹,而actions和reducers将进入store文件夹,因为它们遵循将因相同原因而改变的事物分组的规则。以下是对这种组织方式的一些看法:你不应该有反映技术角色的文件夹,例如“控制器”、“组件”、“助手”等;实体位于domain/
