前端有架构吗?这可能是很多人心中的疑惑,因为在实际的业务开发中,我们很少为前端设计一个标准规范的代码结构,更多的关注点可能是工程化、目录层次、业务代码实现。今天我们看一个前端架构的模式,原作者叫它“CleanArchitecture(清洁架构)”,文章很长,而且讲的很详细,我看了半天,很有意思收获,为大家翻译,文章中融入了很多自己的思考,推荐大家阅读。https://dev.to/bespoyasov/clean-architecture-on-frontend-4311本文示例源码:https://github.com/bespoyasov/frontend-clean-architecture/首先,我们将简单介绍一下什么是干净架构(Cleanarchitecture),比如领域、用例和应用层的概念。然后就是如何把干净的架构应用到前端,是否值得。接下来,我们将使用干净架构的原则来设计一个商店应用程序,并从头开始实现它,看看它是否可以运行。这个应用程序将使用React作为它的UI框架,只是为了表明这种开发风格可以与React一起使用。您也可以选择任何其他UI库来实现它。代码中使用了一些TypeScript,只是为了展示如何使用类型和接口来描述实体。其实所有的代码都可以不用TypeScript实现,只是代码看起来表达力会差一些。架构和设计设计本质上是以一种可以将它们重新组合在一起的方式将事物分开......将事物分解成可以重新组合在一起的事物就是设计。—RichHickey《设计、重构和性能》系统设计其实就是对系统的拆解。最重要的是我们可以重新组装它们而无需花费太多时间。我同意以上观点,但我认为系统架构的另一个主要目标是系统的可扩展性。我们的应用程序的需求在不断变化。我们希望我们的程序很容易更新和修改以满足新的和不断变化的需求。干净的架构可以帮助我们实现这个目标。什么是干净的架构?干净的架构是一种根据应用程序域的相似程度来划分职责和功能的方法。领域是从现实世界中抽象出来的程序模型。能够反映现实世界和程序中数据的映射。例如,如果我们更新产品名称,将旧名称替换为新名称就是域名转移。干净架构的功能通常分为三层,如下图所示:作为转换该数据的代码。字段是区分程序的核心。您可以将域视为当我们从React迁移到Angular或更改某些用例时不会更改的部分。在商店应用程序中,字段是产品、订单、用户、购物车以及更新这些数据的方法。数据结构和它们之间的转换与外界隔离。外部事件调用触发域转换,但不决定它们的行为方式。例如:添加商品到购物车的功能不关心商品添加到购物车的方式:用户自己通过点击“购买”按钮添加,用户使用优惠券添加自动地。在这两种情况下,都会返回一个更新的购物车对象。应用层围绕域的是应用层,它描述了用例。例如,“添加到购物车”场景就是一个用例。它描述了单击按钮时应该执行的具体操作,充当一种“协调器”:向服务器发送请求;执行域转换;使用响应中的数据更新UI。此外,还有应用层的端口——描述了应用层如何与外界进行通信。通常一个端口就是一个接口(interface),一个行为契约。端口也可以被认为是现实世界和应用程序之间的“缓冲区”。输入端口会告诉我们应用程序应该如何接受外部输入,输出端口也会说明如何为外部通信做准备。适配器层的最外层包含了对外服务的适配器,我们使用适配器来对外部服务不兼容的API进行转换。适配器可以降低我们的代码与外部第三方服务之间的耦合度。适配器一般分为:驱动类型——向我们的应用程序发送消息;被动类型-接受我们的应用程序发送的消息。一般用户最常与drivenadapter交互,比如处理UI框架发送的点击事件就是drivenadapter。它与浏览器API一起将事件转换为我们的应用程序可以理解的信号。司机与我们的基础设施互动。在前端,大部分基础设施是后端服务器,但有时我们也可能会直接与一些其他服务交互,比如搜索引擎。注意离中心越远,代码的功能越“服务化”,离应用领域越远,这在我们后面决定一个模块在哪一层时非常重要。依赖规则三层架构有一个依赖规则:只有外层可以依赖内层。这意味着:域必须是独立的应用层可以依赖域最外层可以依赖任何东西当然一些特殊情况可能会违反这个规则,但最好不要滥用。例如,某些第三方库可能会在域中使用,即使不应该存在此类依赖项。看下面的代码会有这样的例子。不控制依赖关系方向的代码会变得非常复杂且难以维护。比如:循环依赖,模块A依赖B,B依赖C,C依赖A。可测试性差,哪怕是一小块功能都要对整个系统进行模拟。耦合度太高,模块之间的交互就会很脆弱。干净架构的优点分离域应用程序的所有核心功能都被拆分并维护在一个地方——域域中的功能是独立的,这意味着它更容易测试。模块的依赖越少,测试所需的基础设施就越少。单独的域也更容易根据业务的期望进行测试。这有助于新手更容易理解。此外,单独的域还可以更轻松地解决从需求到代码实现中出现的错误。应用程序的使用场景和用例是独立描述的。它决定了我们需要哪些第三方服务。我们让外部服务更适应我们的需求,这给了我们更多的空间来选择合适的第三方服务。比如现在我们调用的支付系统涨价了,我们可以快速更换。用例的代码也很扁平,易于测试,可扩展性强。我们将在后面的示例中看到这一点。可更换的第三方服务适配器允许轻松更换外部第三方服务。只要我们不改变接口,由哪个第三方服务实现接口并不重要。这样如果别人改了代码,不会直接影响到我们。适配器还可以减少应用程序运行时错误的传播。干净架构的成本架构首先是一种工具。与任何其他工具一样,干净的架构除了好处之外还会带来额外的成本。需要更多的时间首先,设计和实现需要更多的时间,因为直接调用第三方服务总是比编写适配器更容易。我们一开始很难把模块的所有交互和需求都想清楚。当我们设计的时候,我们需要关注可能发生的变化,所以我们需要考虑更多的可扩展性。有时是多余的一般来说,干净的架构并不适合所有场景,有时甚至是有害的。如果本身就是一个小项目,你就得按照cleanarchitecture来设计,这样会大大增加进入的门槛。上手难度更大。完全按照cleanarchitecture来设计和实现,新手上手会比较困难,因为他必须先了解应用程序是如何工作的。代码量的增加是前端会议特有的问题,干净的架构会增加最终打包产品的体积。产品越大,浏览器下载和解释它的时间就越长,所以必须控制代码量,适当削减代码:描述用例更简单;直接从适配器和域交互,绕过用例;代码拆分如何降低这些成本您可以通过适当地偷工减料并牺牲架构的“清洁度”来减少一些实现时间和代码大小。如果放弃某件事会带来更大的好处,我会毫不犹豫地放弃。因此,没有必要在所有方面都遵循干净架构的设计准则,只需遵循核心准则即可。AbstractingDomains抽象域可以帮助我们理解整体设计以及它们是如何工作的,也可以让其他开发人员更容易理解程序、实体以及它们之间的关系。即使我们直接跳过其他层,抽象的领域也更容易重构。因为他们的代码是集中封装在一个地方的,所以在需要的时候可以很容易地添加其他层。遵守依赖规则第二个不应放弃的规则是依赖规则,或者它们的依赖方向。外部服务需要适应内部,而不是相反。如果你尝试直接调用外部API,有问题,最好在有问题之前写一个适配器。商店应用的设计说完理论,我们就可以开始实践了。接下来,让我们实际设计一个商店应用程序。店铺会出售不同种类的cookies,用户可以选择自己想要购买的cookies,通过第三方支付服务进行支付。用户在首页可以看到所有的cookies,但是需要登录后才能购买。点击登录按钮跳转到登录页面。登录成功后,用户可以将cookie添加到购物车。将cookie添加到购物车后,用户就可以进行支付了。付款后,购物车将被清空并重新下单。首先,让我们定义和分层实体、用例和功能。设计领域编程是最重要的领域设计,它代表了实体到数据的转换。store的字段可能包括:各个实体的数据类型:user、cookie、shoppingcart、order;如果用OOP(面向对象思想)实现,那么还要设计生成实体的工厂和类;数据转换功能。域中的转换方法应该只依赖于域的规则,而不是其他任何东西。比如方法应该是这样的:计算总价的方法检测用户口味的方法检测商品是否在购物车中的方法设计应用层应用层包括用例,以及一个用户包括参与者、动作和结果。在商店应用中,我们可以区分为:一个产品购买场景;支付,调用第三方支付系统;与产品和订单的交互:更新、查询;根据角色访问不同的页面。我们通常使用主题领域来描述用例。例如,“采购”包括以下步骤:从购物车中查询商品并创建新订单;创建付款单;支付失败时通知用户;支付成功,清空购物车,显示订单。用例方法是描述场景的代码。此外,在应用层中还有端口——与外界通信的接口。设计适配器层在适配器层中,我们为外部服务声明适配器。适配器可以使我们的系统兼容各种不兼容的外部服务。在前端,适配器一般是一个UI框架和一个API请求到后端的模块。例如,在我们的商店程序中,我们将使用:用户界面;API请求模块;本地存储适配器;API返回给应用层适配器。对比MVC架构,有时候我们很难判断一些数据属于哪一层。这里我们可以和MVC架构做一个小小的对比:Model一般是一个领域实体。Controller一般与转换或应用层View关联,是一个驱动适配器。不完全相同,但非常相似。实现细节——域一旦我们确定了我们需要的实体,我们就可以开始定义它们的行为,这是我们项目的目录结构:src/|_domain/|_user.ts|_product.ts|_order.ts|_cart.ts|_application/|_addToCart.ts|_authenticate.ts|_orderProducts.ts|_ports.ts|_services/|_authAdapter.ts|_notificationAdapter.ts|_paymentAdapter.ts|_storageAdapter.ts|_api.ts|_store.tsx|_lib/|_ui/domain定义在domain目录下,applicationlayer定义在application目录下,adapters定义在service目录下。最后,我们还将讨论目录结构是否有其他替代方案。创建域实体我们在域中有4个实体:产品(product)用户(user)订单(order)购物车(shoppingcart),其中最重要的是user,在响应中,我们会保存用户信息,所以我们设计单独域中的用户类型,用户类型包括以下数据://domain/user.tsexporttypeUserName=string;exporttypeUser={id:UniqueId;姓名:用户名;电子邮件:电子邮件;偏好:成分[];allergies:Ingredient[];};用户可以把cookies放入购物车,我们也给购物车添加种类和cookies。//domain/product.tsexporttypeProductTitle=string;exporttypeProduct={id:UniqueId;标题:产品标题;价格:PriceCents;toppings:Ingredient[];};//domain/cart.tsimport{Product}from"./product";exporttypeCart={products:Product[];};支付成功后,会创建一个新的订单,我们来添加一个订单实体类型。//domain/order.ts—ConardLiexport类型OrderStatus="new"|“交付”|“完成”;导出类型Order={user:UniqueId;手推车:手推车;创建:日期时间字符串;状态:订单状态;total:PriceCents;};理解实体之间的关系这样设计实体类型的好处是我们可以检查他们的关系图是否符合实际:我们可以检查以下几点:参与者是用户吗?订单中是否有足够的信息?一些实体是否需要扩展,以后可扩展性就足够了。此外,在此阶段,类型可以帮助识别实体之间的兼容性和呼叫方向中的错误。如果一切如我们所料,我们就可以开始设计域转换了。创建数据转换我们刚刚设计的数据类型会发生各种各样的事情。我们可以将商品添加到购物车、清空购物车、更新商品和用户名等。让我们为这些数据转换创建相应的函数:例如,为了判断用户是否喜欢某种口味,我们可以创建两个函数://domain/user.tsexport函数hasAllergy(user:User,ingredient:Ingredient):boolean{returnuser.allergies.includes(ingredient);}exportfunctionhasPreference(user:User,ingredient:Ingredient):boolean{returnuser.preferences.includes(ingredient);}将商品添加到购物车并检查商品是否在购物车中://domain/cart.ts—ConardLiexportfunctionaddProduct(cart:Cart,product:Product):Cart{return{...cart,products:[...cart.products,product]};}exportfunctioncontains(cart:Cart,product:Product):boolean{returncart.products.some(({id})=>id===产品。ID);然后我们可以设计更多的功能,比如折扣,优惠券等)://domain/product.tsexportfunctiontotalPrice(products:Product[]):PriceCents{returnproducts.reduce((total,{price})=>total+price,0);}创建一个新订单并将其与相应的用户及其购物车相关联。//domain/order.tsexport函数createOrder(user:User,cart:Cart):Order{return{user:user.id,cart,created:newDate().toISOString(),status:"new",total:totalPrice(products),};}详细设计-共享内核您可能已经注意到我们在描述域类型时使用的一些类型。例如电子邮件、UniqueId或DateTimeString。这些实际上是类型别名://shared-kernel.d.tstypeEmail=string;typeUniqueId=string;typeDateTimeString=string;typePriceCents=number;我使用DateTimeString而不是string来更清楚地表明这个字符串是用来做什么的。这些类型越真实,就越容易排除故障。这些类型在shared-kernel.d.ts文件中定义。共享内核是指代码和数据的依赖关系不会增加模块之间的耦合。在实践中,共享核心可以这样解释:我们使用TypeScript,使用它的标准类型库,但我们不将它们视为依赖项。这是因为使用它们的模块不会相互影响并且可以保持解耦。并不是所有的代码都可以看作共享内核,主要原则是这样的代码必须处处与系统兼容。如果程序的一部分是用TypeScript编写的,而另一部分是用另一种语言编写的,则共享核心只能包含可在两种语言中工作的部分。在我们的例子中,整个应用程序都是用TypeScript编写的,因此将内置类型别名作为共享核心的一部分非常好。这种全局可用的类型不会增加模块之间的耦合,可以在程序的任何部分使用。实现细节——应用层我们已经完成了域的设计,现在我们可以设计应用层了。这一层会包括具体的用例设计,比如一个用例就是将商品加入购物车并支付的完整过程。用例涉及应用程序与外部服务的交互,与外部服务的交互是副作用。我们都知道没有副作用的调用或调试方法更容易,所以大多数领域函数都被实现为纯函数。为了结合没有副作用的纯函数和有副作用的交互,我们可以使用应用层作为有副作用的不纯上下文。非纯上下文纯数据转换非纯上下文纯数据转换带副作用是这样的代码组织:先执行一个副作用得到一些数据;然后对数据执行一个纯函数来处理数据;最后执行一个副作用,存储或传递这个结果。例如“将商品放入购物车”的用例:首先,从数据库中获取购物车的状态;然后调用购物车更新函数,传入要添加的商品信息;最后将更新后的购物车保存到数据库中。这个过程就像一个“三明治”:副作用,纯函数,副作用。所有主要逻辑处理都是调用纯函数进行数据转换,所有与外部的通信都隔离在一个命令式shell中。设计用例我们选择结账场景来进行用例设计,这个场景比较有代表性,因为它是异步的,并且与很多第三方服务交互。我们可以通过整个用例来思考我们想要表达什么。用户的购物车中有一些cookie。当用户点击购买按钮时:创建新订单;在第三方支付系统中支付;支付失败,通知用户;如果支付成功,将订单保存到服务器;本地存储保存订单数据并显示在页面上;在设计功能的时候,我们会把用户和购物车都作为参数,然后让这个方法来完成整个过程。输入OrderProducts=(user:User,cart:Cart)=>Promise
