了解Elm中的UI组件

175 阅读17分钟

由于Humio网络客户端是用Elm构建的,我想分享一下这些年来我们在Elm方面的一些经验。特别是与UI组件一起工作--这是新的Elm开发者常见的症结所在。

我将介绍的主要内容。

  • Elm中组件的一些架构考虑和权衡

  • 在Elm中建立描述和比较组件的词汇表

这是基于我们在Elm上学到的最佳实践和见解,有许多开发人员在一个大型的应用程序上工作。这篇文章假设你对Elm语言1和Elm架构有基本了解。

让我们深入了解一下吧!

什么是组件?

与其他许多UI系统不同,Elm没有任何 "组件 "的概念。因此,虽然我们很容易就Web组件的含义达成一致,例如,Elm让我们决定 "组件 "的实际含义。我想使用的定义是,一个组件是 "一个可视化的构建块"。换句话说,一个组件是一块可重复使用的用户界面。这是我们的图形和用户体验设计师对组件的看法,对我们Humio的开发者来说,这也是很有效的。

然后,我们将讨论在Elm中实现组件的不同方法。在这篇文章中,我想重点介绍两个阵营。

  1. 有状态组件

  2. 无状态组件

这些组件之所以重要,是因为Elm是一种 "纯 "功能语言,所有的状态都必须非常明确地处理。如果一个组件有状态,它也必须有一堆机器来维护这个状态,这对开发者来说意味着大量的工作。但是,能够保持和更新状态也使得有状态的组件能够封装 "行为"。我所说的 "行为 "是指组件可以有自己的响应,比如用户点击一个按钮或从服务器上获取数据。

相比之下,无状态组件的编写和使用通常要简单得多,因为它们不能响应任何东西;它们只能进行渲染。当然,我们的应用程序仍然需要响应用户的互动,无状态组件将这一责任推给它们的父代,而不是自己去处理它。

在Humio,我们更喜欢使用无状态组件而不是有状态组件。我们的经验是,在大多数情况下,使用无状态组件可以使工作更少,代码更好。然而,权衡无状态或有状态之间的权衡是很重要的,因为有状态组件有时仍然是正确的选择。

让我们看看它们的作用。

有状态的组件是模板磁铁

我们先来看看在Elm中成为一个有状态组件意味着什么。举个例子,假设我们有一个复选框的有状态组件,它可以跟踪它是否被选中。对于一个有状态的组件,它必须有一个存储状态的Model ,它还需要一个update 函数来更新Model ,等等。所以我们要创建一个Checkbox 模块,大致包含这样的代码。

module Checkbox exposing (Model, initUnchecked, update, view)

type alias Model = 
    { isChecked : Bool }

-- Our component can trigger a single message
type Msg = CheckboxClicked

initUnchecked : Model
initUnchecked =
    { isChecked = False }

update : Msg -> Model -> Model
update msg model =
    case msg of
        CheckboxClicked ->
            { isChecked = not model.isChecked }
            
view : Model -> Html Msg
view model =
    Html.input 
        [ Attr.type_ "checkbox"
        , Attr.checked model.isChecked
        , Events.onClick CheckboxClicked
        ]
        []

榆树

这本身看起来是合理的。该组件被很好地封装起来,API反映了一个完整的Elm应用程序的情况,这是一个很好的对称性。让我们试着使用它。

假设我们需要用户在一些地方接受我们的条款和条件,同时接受营销通信。我们将在一个名为AcceptTermsAndConditions 的模块中为其制作一个组件。在这种情况下,我们的组件将只是一个围绕两个复选框的薄的包装。

module AcceptTermsAndConditions exposing (Model, initUnchecked, update, view)

type alias Model = 
    { acceptTermsAndConditionsCheckbox : Checkbox.Model
    , acceptMarketingCheckbox : Checkbox.Model 
    }

type Msg
    -- Since each `Checkbox` can generate messages,
    -- we create wrapper messages so we can distinguish
    -- which checkbox actually triggered a given message.
    = TermsAndConditionCheckboxMsg Checkbox.Msg
    | MarketingCheckboxMsg Checkbox.Msg

initUnchecked : Model
initUnchecked =
    { acceptTermsAndConditionsCheckbox = Checkbox.initUnchecked
    , acceptMarketingCheckbox = Checkbox.initUnchecked
    }

update : Msg -> Model -> Model
update msg model =
    case msg of
        -- We must explicitly update the right `Checkbox` model,
        -- depending on which `Checkbox` generated a message.
        TermsAndConditionCheckboxMsg subMsg ->
            { model | acceptTermsAndConditionsCheckbox = 
                Checkbox.update subMsg model.acceptTermsAndConditionsCheckbox
            }
            
        MarketingCheckboxMsg subMsg ->
            { model | acceptMarketingCheckbox = 
                Checkbox.update subMsg model.acceptMarketingCheckbox
            }
            
view : Model -> Html Msg
view model =
    Html.div []
        -- The `Checkbox` component can trigger messages via its HTML,
        -- so we use `Html.map` to wrap those messages with our wrappers.
        [ Checkbox.view acceptTermsAndConditionsCheckbox
            |> Html.map TermsAndConditionCheckboxMsg
        , Checkbox.view acceptMarketingCheckbox
            |> Html.map MarketingCheckboxMsg
        ]

榆树

正如你所看到的,为了有两个复选框,有很多模板代码。原因是:当我们的复选框组件触发一个消息时,该消息必须从组件树的根部向下传递到组件,而组件应该处理该消息。也就是说,Checkbox.Msg 需要到达Checkbox.update 。而为了让消息到达那里,根和目标之间的每个组件都必须明确地将消息传递给树上的下一个组件。

换句话说,如果一个有状态的组件A 被用于组件B ,而B 被用于组件C ,那么。

  • B 需要一个消息类型来包住来自的消息A

  • C 需要一个消息类型来包装来自的消息B

这也意味着当一个有状态的组件在其他组件中使用时,其有状态性实际上会传播到其所有的新祖先。但如果里面有一个有状态的组件,现在它们就必须有了。而当一个父类必须改变成为有状态的时候,任何其他已经使用它的地方也必须改变,这样工作就容易膨胀。

而这只是处理进入组件的通信。我们还需要组件的通信。

在我们的例子中,假设接受条款和条件是启用 "提交 "按钮的先决条件,所以我们需要验证这个复选框是否被选中。为了让父类检查组件的状态,我们需要一个Checkbox.isChecked 函数,AcceptTermsAndConditions 可以调用,然后我们需要一个AcceptTermsAndConditions.areTermsAccepted function它的父类可以调用,以此类推,以便在父类组件的链条上传递状态。

长话短说,有状态的组件本身似乎是相当无辜的,但它迫使它的祖先有很多的模板。如果它蔓延开来,就会真正影响你的开发速度,这是因为模板本身需要花费时间来编写和维护,而且还因为它给周围的所有代码增加了噪音和摩擦。不过,重要的是要注意,你需要的模板代码的数量取决于一个组件有多少个祖先。

我们会回到这一点上,但让我们先看看CheckboxAcceptTermsAndConditions 作为无状态组件的样子。

无状态的拯救!

让我们看看我们以前的组件是如何实现无状态的,以作比较。首先是复选框。

module Checkbox exposing (view)

view : Bool -> msg -> Html msg
view isChecked onClick =
    Html.input 
        [ Attr.type_ "checkbox"
        , Attr.checked isChecked
        , Events.onClick onClick
        ]
        []

榆树

然后是AcceptTermsAndConditions

module AcceptTermsAndConditions exposing (view)

view : 
    { isTermsAndConditionsChecked : Bool
    , isMarketingChecked : Bool
    , onTermsAndConditionsClicked : msg
    , onMarketingClicked : msg
    } 
    -> Html msg
view params =
    Html.div []
        [ Checkbox.view 
            params.isTermsAndConditionsChecked
            params.onTermsAndConditionsClicked
        , Checkbox.view 
            params.isMarketingChecked
            params.onMarketingClicked
        ]

榆树

这就是了!正如我之前所指出的,我们现在把大部分所需的工作推给了父级,而不是用一个有状态组件来封装它。这在表面上看起来很糟糕,但正如我们所看到的,有状态的组件需要从其所有的祖先那里获得大量的工作。因此,相比较而言,在这种情况下,将状态管理推送给父类,对于每个参与的组件来说,实际上是减少了工作。它还允许父类以任何有意义的方式对状态进行建模。例如,不是所有的复选框都需要自己的布尔值。

为了了解我的意思,让我们在AcceptTermsAndConditions 组件上再添加一个复选框。我们希望更多的人收到我们的营销邮件,所以我们要添加一个 "接受所有 "的复选框,当点击时,它将选中或取消所有的复选框。此外,如果用户手动勾选其他复选框,"接受所有 "将自动变为勾选。

让我们来扩展我们的无状态组件。

module AcceptTermsAndConditions exposing (view)

view : 
    { isTermsAndConditionsChecked : Bool
    , isMarketingChecked : Bool
    -- New message
    , onAcceptAllClicked : msg
    -- Old messages
    , onTermsAndConditionsClicked : msg
    , onMarketingClicked : msg
    } 
    -> Html msg
view params =
    Html.div []
        -- New checkbox first
        [ Checkbox.view
            (params.isTermsAndConditionsChecked && params.isMarketingChecked)
            params.onAcceptAllClicked
        -- Old checkboxes below
        , Checkbox.view 
            params.isTermsAndConditionsChecked
            params.onTermsAndConditionsClicked
        , Checkbox.view 
            params.isMarketingChecked
            params.onMarketingClicked
        ]

榆树

我们正在传入一个新的消息,但没有新的状态。相反,"接受所有 "复选框的状态可以从现有的状态中导出。

但如果Checkbox 是有状态的,"Accept all "就会有自己的布尔值,当你点击任何一个复选框时都需要更新。这就增加了复杂性和不同状态之间不同步的风险。无状态设计避免了这一点,因为我们可以 "塑造 "任何现有的状态来呈现复选框,在我们的例子中,我们通过结合其他复选框的布尔值来做到这一点。

然而,虽然无状态组件对于避免不一致的状态很有好处,但它们也带来了另一种错误的风险:不一致的行为。

在我们使用AcceptTermsAndConditions 的每一个地方,一些父类现在必须实现当onAcceptAllClicked 消息被触发时应该发生什么。特别是,如果 "接受条款和条件 "被选中,而当用户点击 "接受所有 "时,"接受营销 "没有被选中,怎么办?我们是选择还是取消选择一切?如果我们在多个地方使用AcceptTermsAndConditions ,很容易想象,不同的实现onAcceptAllClicked ,会变得不一致。

我们可以通过创建一个函数来帮助自己,在我们响应onAcceptAllClicked 消息的任何地方调用这个函数,以一致地翻转布尔值。这个函数将很容易得到良好的测试覆盖。而且它可以减少我们的错误风险。现在唯一的风险是有人把这个函数挂错了或者没有使用这个函数。

我们可以尝试对这个设计进行迭代以进一步降低风险。但不管我们怎么做,我们都必须不断地权衡利弊,因为如果不以某种方式否定无状态的某些好处,我们就无法完全消除不一致行为的风险。

何时使用有状态组件

但是,也许AcceptTermsAndConditions ,那么应该是一个有状态的组件而不是无状态的?什么时候无状态组件的错误空间会大到不再值得使用这些好处?

要想知道该走哪条路,最好是在第一次迭代时就把一个给定的组件建成无状态的。无状态组件清楚地揭示了该组件的接口是什么:该组件可以触发哪些消息,需要哪些状态?当这个接口开始出现时,我们可以评估让不同的父类重新实现这个接口是否合适,或者这样做是否会带来太大的错误风险。

我的经验法则是,如果这三个标准中至少有两个得到满足,那么一个有状态的组件就可能有意义。

  1. 该组件可以触发许多信息。

  2. 该组件的更新需要复杂的逻辑。

  3. 该组件在很多地方都有使用。

对于AcceptTermsAndConditions ,我们只有三条消息,这并不是什么大问题。但我们已经看到,更新逻辑的复杂性足以让不一致的情况潜入。因此,这几乎可以归结为该组件被用于多少地方,因为每一次新的使用都会增加错误的风险。

这些标准并不单独存在。如前所述,它还归结为该组件在整个树上有多少个祖先。如果我们在页面的根部使用一个有状态的组件,那么模板就非常容易管理。但是,如果这个组件被用在组件分支的深处,并且我们必须让一长串的组件适应有状态,那么我们可能要避免这样做。

回到AcceptTermsAndConditions ,我们将不得不想象它的用途。虽然它看起来不会被用在很多地方,而且在树中也不是很深,所以我可能会保持它的无状态。然后我肯定会写一些函数flipBooleans ,在收到onAcceptAllClicked 消息时调用,以保持行为一致。这种方式的bug风险相当低,而且在这种特殊情况下,这种bug可能造成的损害也非常有限。

状态处理的例子

为了获得更多的灵感,这里有一些关于我们如何在Humio的不同组件中选择权衡的例子。

第一个例子是模态对话框,我们经常把它写成成熟的有状态组件。因为它们通常是与页面的其他部分隔离的,给它们一个独立的模块,有自己的逻辑和渲染,通常效果很好。如果它们是适度复杂的,它们也可以很容易地结束与相当多的信息,使有状态的设计更加自然。模态对话框通常也是孩子与父母之间交流的一个很好的用例,因为你通常需要父母知道用户何时选择关闭对话框。在这种情况下,我们喜欢使用OutMsg模式

状态处理对于表单验证也是非常重要的(毫不奇怪)。表单需要肮脏的跟踪,例如,表单是否被提交,等等。但我们也不希望表单的个别部分带有自己的状态(比如我们在Checkbox ,就看到了)。我们的解决方案是有一个共同的FormState 类型(在一个同名的模块中),包含脏跟踪等内容,同时有自己的更新函数来管理状态,它必须在表单使用的地方被连接起来。

重要的部分是,FormState 模块不负责渲染表单。相反,我们有所有现有的无状态组件(文本字段、复选框等),并有函数来 "塑造 "FormState ,由这些组件来渲染,并让上述组件触发FormState 消息。这意味着我们的渲染代码仍然是无状态的,并且看起来和我们其他的渲染代码很像,使我们可以灵活地组成表单元素,而不需要大量的模板。

最后,有一个组件状态的例子,我们没有在Elm中实现,以保持我们组件的 "无状态":例如,下拉菜单中键盘导航所需的状态。在这种情况下,我们需要跟踪哪些元素被关注,以便在用户用方向键向下导航时能够触发一条信息,例如。

为了在Elm中实现这一点,我们的许多核心组件本身需要是有状态的,或者页面需要重新实现许多消息以正确地触发导航。这两种选择都会变得不方便,或者留下太多不一致的空间。

最后,我们选择了实现一个网络组件来做这些工作,并用该网络组件来包装所需的Elm组件。这种方法也省去了我们在Elm中追踪焦点的工作,因为我们的Web组件可以完全访问DOM,而DOM已经是哪个元素拥有焦点的明确真理来源。

展望组件之外

到目前为止,我们主要研究了单个组件,以及它们的设计如何影响它们自己和它们在组件树中的祖先。但是,选择有状态或无状态的组件也有超出这个范围的影响:也就是对页面的总体结构的影响。

这里需要记住的两个关键事实是。

  1. 在Elm中,无状态组件是更自然的选择,因为有状态的组件使用起来可能非常笨拙。

  2. 无状态组件将状态推送给它们的父类。

自然的结果是,越来越多的状态被推送到组件之外,聚集在页面的根部附近。反过来,这实际上使构建需要与几块状态交互的新功能变得更加容易。

举个例子,假设我们的 "接受所有 "复选框并没有像我们希望的那样让人们注册营销邮件。我们将删除它,并尝试使用聊天机器人弹出窗口。当用户选中营销复选框时,机器人可能会开始唱歌或放烟花。

当然,我们不能假设我们的AcceptTermsAndConditions 组件会放在机器人组件的旁边。它们可能很容易地被放在组件树的不同分支中。这意味着我们可能要把状态从AcceptTermsAndConditions 组件传送到某个共同的祖先,然后再传送到机器人。但这只是在这些组件是有状态的情况下。如果AcceptTermsAndConditions 和机器人组件是无状态的,而且它们各自的状态已经在一起了,那么就不需要再编写接线代码了。

我们的经验是,虽然许多组件在屏幕上渲染时看起来是孤立的,但它们可以很容易地与它们所感知的边界之外的状态进行交互。而使用无状态组件作为最常见的组件类型,往往意味着我们的组件更自然地符合这一现实。

相比之下,这也是谨慎使用有状态组件的一个原因。有状态的组件会让人感觉很有诱惑力,因为它们的封装是一种设置边界的方式,这让我们更容易对孤立的组件进行推理。但是,我们可以很容易地创造出边界,而这些边界更多的是一种阻碍,而不是帮助。

综上所述

这篇文章探讨了无状态组件的好处,以及Elm如何迫使我们使用它们而不是有状态组件。当我们倾向于此时,它会引导我们编写易于组合和以新方式应用的组件,这非常好。主要的缺点是,我们可以更容易地在同一组件的不同用途中引入不一致的行为。

当你来自于组件是一个定义明确的概念的系统时,Elm施加的压力可能是非常微妙的。而对于许多开发者来说,创建有状态组件的本能仍然很强。

我希望这篇文章能让大家明白一些道理。总结一下,这里有几条经验法则。

  1. 倾向于无状态组件
  2. 让页面本身拥有尽可能多的状态
  3. 有状态的组件在尽可能靠近组件树根的地方使用时效果最好。

如果你想了解关于创建更复杂的无状态组件的建议,我推荐你观看Brian Hicks的精彩演讲。在Humio,我们对使用构建器模式相当满意,正如该视频中所探讨的。

感谢我的同事对这篇文章的反馈。如果没有它,这将是一个非常不同的帖子!

  1. 在写这篇文章的时候,Elm的版本是0.19.1。
  2. 我们的两个阵营与 "呈现 "和 "容器 "组件或 "哑巴 "和 "智能 "组件非常相似,你可能从React和Angular中知道这些。
  3. 这也是一个"着色"的例子,尽管是强制改变 "颜色 "的组件而不是函数。