在过去的10年里,我的大部分工作都涉及到编写通常被称为巫师的东西。
向导本质上是一个多步骤的过程,它引导用户完成特定的工作流程。例如,如果要在计算机上安装新应用程序,向导可能会引导您完成以下过程:
大多数Web应用程序都提供类似的功能。如果用户需要为应用程序输入大量数据来运行一系列计算,那么您当然可以只向用户提供一个大的Web表单。然而,在一定的大小下,单一的Web表单可能会令人望而生畏,并提供不太理想的用户体验。在这里,改善用户体验的标准方法是将网页拆分成几个单独的页面。这是另一个向导的例子。
我尝试过用许多不同的Web技术编写向导,到目前为止,Elm已经证明自己是最健壮、最轻松的,特别是当不可避免地要改变一些条件逻辑来满足各种业务流程的可变需求时。
对于任何小型ELM应用程序,项目结构都很简单。1,000行ELM应用程序没有理由不能保存在一个文件中。事实上,这确实是每个ELM应用程序都应该开始其生命的方式。从具有常见样板和以下内容的单个文件开始:
然而,如果您的Web表单足够复杂,足以被分成多个单独的页面,那么您的应用程序自然不会由少数几行代码组成。经验较少的ELM程序员普遍担心的一个问题是,所有消息的一个大SUM类型维护起来会变得不方便。同样,对于所有应用程序状态都有一个较浅的大记录,或者使用一个大的更新函数来匹配一个大的MSG类型的所有构造函数。这就是不必要的复杂性开始膨胀的地方,因为程序员添加了聪明的抽象和误导(通常涉及Html.map和Cmd.map),为应用程序的每个逻辑子部分添加单独的更新函数(通常带有明显笨拙的类型签名),以及朝着封装和所谓的Clean Code方向模糊地挥手。
我认为,这种误导几乎不是你想要的。我进一步认为,如果你的背景是维护复杂的反应/角度应用程序,那么这一点尤其适用于你,在这种情况下,虚构的复杂性是现状,而这种误导只是你已经变得麻木不仁的东西。
因此,如果要避免Html.map和Cmd.map的组合,我们如何才能在不牺牲开发人员人机工程学的情况下扩展ELM应用程序呢?简而言之,可以使用的诀窍是:
让我们来看看这些想法的更具体的应用。作为一个例子,我们可以对一个人申请银行贷款的过程进行建模。
银行会问申请者一大堆问题,我们可以把这些问题分成三类:
这将建议使用三步向导或三页网页表单。开始将我们的应用程序拆分成三个更小的部分的合理位置是在我们的msg类型中。
对我们的应用程序应该支持的消息进行建模的天真方法是使用一个大的SUM类型,该类型可能如下所示:
类型页面=个人信息页面|贷款目的页面|财务详细信息页面类型消息--系统范围的消息=无操作|设置页面页面--等…。--个人信息|SetFirstName String|SetLastName String|SetAddressLine1 String--…。个人信息页面的更多消息--贷款目的|设置购买项目类别|设置购买项目估计值--…。贷款目的页面的更多消息--财务信息|SetMonthlyIncomeBeforTax|SetMonthlyRentPayment--…。有关申请人财务详细信息的更多信息。
这确实是可行的,但在某些情况下,支持大量构造函数会变得很麻烦。当然,“大”的值取决于程序员的个人品味和/或痛楚。为了减轻这一痛苦,人们通常将消息组提取到他们自己的单独的SUM类型中,这随后迫使他们编写返回顶级消息类型以外的类型的更新函数。
拆分这些构造函数组的方法是首先将它们嵌套在msg类型中,如下所示:
输入PersonalInformationMsg=SetFirstName字符串|SetLastName字符串|SetAddressLine1字符串--等。输入LoanPurposeMsg--等…。键入FinancialDetailsMsg--ETC…。类型msg=NoOp|SetPage Page|PersonalInformationMsg PersonalInformationMsg|LoanPurposeMsg LoanPurposeMsg|FinancialDetailsMsg FinancialDetailsMsg
新消息类型可以与顶级消息类型位于同一文件中。也可以将它们解压缩到不同的文件中。这是你的选择。
下一件要处理的事情是我们的更新功能,因为它需要反映我们的味精类型。
我见过有人提倡特定于页面的更新函数,该函数采用特定于页面的模型,并返回该特定于页面的模型的元组和特定于页面的Cmd MSG等效物。这通常是您看到Cmd.map潜入的地方。这些函数最终几乎不可避免地需要来自顶级应用程序范围状态的某些东西,因此您经常会看到一些类似下面这样的类型签名:
这已经太复杂了,而且这种方法实际上并没有给你带来任何好处。
简单得多的方法是让每个嵌套的更新函数获取特定于页面的消息(整个应用程序状态),并返回该状态的相同类型以及顶级消息类型,如下所示:
更新个人信息:个人信息消息-&>;型号-&>(型号,命令消息)更新个人信息消息型号=设置名a-&>的案例消息--…。设置姓氏a->;--…。设置地址行1 a->;--…。--ETC…。更新:MSG-&>;Model-&>(Model,Cmd MSG)UPDATE MSG model=case msg of NoOp-&>(model,Cmd.one)SetPage page->;({model|page=page},Cmd.one)PersonalInformationMsg subMsg->;updatePersonalInformation subMsg model LoanPurposeMsg subMsg->;updatePersonalInformation subMsg model LoanPurposeMsg subMsg->;updatePersonalInformation subMsg model LoanPurposeMsg subMsg;upupPages。
当然,我们更新函数的全部目的是提升模型的状态,而模型的结构也可能会膨胀并变得笨重,这就是我们接下来要分析的内容。
在项目开始时,我们所有的个人状态都可能存在于模型的顶层,这通常被表示为记录。也许是这样的:
类型别名型号={PAGE:PAGE,名字:字符串,姓氏:字符串,地址行1:字符串--…。更多个人信息字段,PurchaseItemCategory:ItemCategory,PurchaseItemEstimatedValue:Money--…。更多贷款目的字段…。--…。以及财务细节和系统范围的状态等…。}。
就像我们之前提到的项目的部分一样,随着它的发展,这也可能会变得有点混乱。应用程序范围的数据和页面特定的数据混合在一起,感觉有点随意。幸运的是,对这些字段进行分组和提取通常相当直观。我们可以先将州中特定于页面的部分分组在一起,然后再进一步分组,直到不再感觉混乱。
类型别名地址={行1:字符串,行2:字符串,城市:字符串,邮政编码:字符串--…。}类型别名个人信息={名字:字符串,姓氏:字符串,地址:地址--…。}类型别名LoanPurpose={PurchaseItemCategory:ItemCategory,PurchaseItemEstimatedValue:Money--…。}类型别名财务明细=--…。类型别名Model={page:page,PersonalInformation:PersonalInformation,LoanPurpose:LoanPurpose,FinancialDetails:FinancialDetails}。
然而,现在的问题是,当我们希望更新深度嵌套的字段时,我们需要编写所有代码来展开每个级别,直到达到所需的深度。以另一种方式说明,假设我们想要更新申请者地址的第一行。
检索此字段的值不成问题,因为我们可以使用ELM的点语法简洁地将我们带到那里,如下所示:
然而,我们在这里不能做的是以类似的方式更新该字段,也就是说,Elm不允许我们写这样的内容:
--这也赢得了作品{model.PersonalInformation.Address|line1=newLine1}--这也赢得了作品{model|PersonalInformation.Address.line1=newLine1}。
在此记录中解开并随后更新该字段的天真方法是编写如下代码:
更新个人信息:个人信息消息-&>;型号-&>(型号,命令消息)更新个人信息消息型号=设置名字_-&>;的案例消息--…。设置姓氏_->;--…。SetAddressLine1 newLine1->;let PersonalInformation=Model.PersonalInformation Address=PersonalInformation.Address newAddress={Address|Line1=newLine1}newPersonalInformation={PersonalInformation|Address=newAddress}in({Model|PersonalInformation=newPersonalInformation},Cmd.None)SetAddressLine2_-&>;({Model|PersonalInformation=newPersonalInformation},Cmd.None)SetAddressLine2_->;
更新一个字段需要14行代码。这个例子不仅有点让人费解,还需要想象一下这个更新函数在考虑到地址记录中的大约五个其他字段时会是什么样子!坦率地说,这是相当糟糕的。这里的诀窍是不要盯着窗外,考虑用ClojureScript重写所有内容。相反,我们要做的是写一大堆镜头。
从概念上讲,我们需要的镜头功能相当简单。我们需要一个功能来弥合信息体系结构的每一层之间的差距,然后我们只需要将这些功能粘合在一起。
在上图中,橙色箭头表示我们想要的各个镜头,以便在数据结构的不同级别之间移动。蓝色箭头是我们要在更新PersonalInformation功能中使用的镜头,我们将三个较小的镜头组合在一起就得到了这个较大的镜头。
我们可以使用一个名为elm-moncle的方便的库来编写这些函数,它们看起来如下所示:
Import Monocle.ComposeImport Monocle.Lens曝光(镜头)--镜头APersonalDetailsL:镜头型号PersonalDetailsPersonalDetailsL=镜头.PersonalDetailsL=镜头.PersonalDetails(\b a->;{a|PersonalDetailsAddressL:镜头PersonalDetailsAddressL=镜头.AddressL:镜头PersonalDetailsAddressL=镜头.address(\b a->;{a|。
您几乎可以忽略这些镜头的实现,因为它们大多只是模型的每个级别之间的机械转换。相反,最好是阅读类型签名,它清楚地显示了第一个镜头让你从模特到个人详细信息,第二个镜头让你从个人详细信息到地址,第三个镜头让我们更深一层,第四个镜头结合了前三个,从我们的顶级模特一直到代表申请者地址第一行的字符串。
诚然,写出所有这些镜头有点乏味,而且大部分都是样板--这让我想知道,这些镜头是不是不能以某种方式生成;这是改天再研究的话题。不过,一旦我们有了这些镜头,我们就可以彻底清理我们的更新功能了。
更新个人信息:个人信息消息-&>;型号-&>(型号,命令消息)更新个人信息消息型号=设置名字_-&>;的案例消息--…。设置姓氏_->;--…。SetAddressLine1 newLine1->;(PersonalDetailsAddressLine1L.set newLine1 model,Cmd.None)SetAddressLine1_->;--…。
这要优雅得多,而且这个API的性质意味着它在一次更新多个字段时工作得更好。例如,您可以编写类似以下设计的代码:
Flip:(a->;b-&>;c)->;b->;a->;cflip f b a=f a bupdateExampleFields:ExampleMsg->;Model->;(Model,Cmd MSG)updateExampleFields msg model=case msg of SetManyModelFields Foo bar Baz Spam鸡蛋->;model|>。
简而言之,在这一点上,我会同情那些过去批评榆树的人,因为他们中一些最直言不讳的支持者毫无帮助,令人沮丧。
与此相关的是,我也认为镜片是一个巨大的错误,如果有一种方法可以让这种语言无法实现镜片库,我会支持它。(不幸的是,不能排除这种可能性。)。
我很抱歉,但是冒昧地拒绝使用一种强大的技术来处理模式中的数据结构,而这些数据结构已经有机地存在于其中,甚至没有费心去证明这种解雇是合理的,或者提供了一种替代技术,这是非常糟糕的。
快速前进,现在我们的模型已经得到了令人满意的讨论,我们可以开始解决这个谜题的最后部分了,它将我们的模型呈现在页面上。
与嵌套模型更新函数应该返回顶级消息类型(而不是特定于页面的消息类型)相同,视图函数也应该返回顶级消息类型。
那么这里的问题是:如果视图函数在其类型签名中声明它在HTML上下文中返回顶级消息类型,我们如何将页面特定的消息发送到运行时?事实证明,这很简单。我们可以只使用函数组合将不同的构造函数连接在一起。
如果我们考虑一个视图函数,该函数像以前一样提供用于修改申请者地址第一行的输入,我们可能会这样写它:
AddressLine1Input:Model->;HTML MsgressLine1Input model=Input[type_";text";,value model.PersonalDetails.Address.line1,onInput(PersonalInformationMsg<;<;SetAddressLine1)][]。
真的就是这样。我很高兴地在许多ELM项目中使用了上面描述的所有技术,每个项目跨越数千行代码。
在ELM应用程序不断增长的过程中,您还可以做一些其他的事情来更好地管理它们。例如,在Riskbook,我们使用elm-bridge从Haskell后端生成ELM类型、JSON编码器和JSON解码器。这对我们非常有效,我会推荐它,尽管这超出了本文的范围。