发布于 2026-01-06 2 阅读
0

用 Elm 创建一个待办事项应用

用 Elm 创建一个待办事项应用

我打算做一件完全不同的事情——我要公开学习一门新语言,然后一步一步地尝试把我学到的东西教给别人。我要学习的语言是榆树语。

在本系列的上一篇文章中,我们介绍了如何设置 Elm 开发环境,以及如何创建一个非常“Hello World”的计数器应用程序。

在本文中,我们将创建一个稍微高级一点的东西——可靠的旧待办事项应用程序

从模型开始

我认为 Elm 的很多方面都做得很好,但我最喜欢的一点是,它能让你思考如何对状态进行建模。既然我们要创建一个待办事项应用,那么从对待办事项进行建模入手就很有意义:

type alias Todo =
    { text : String
    , completed : Bool
    }
Enter fullscreen mode Exit fullscreen mode

也就是说,待办事项是一个记录(类似于 JavaScript 对象),其中包含描述性文本和已完成标志。

现在,我们不想处理单个待办事项,而是想处理一个待办事项列表。因此,我们的模型可能如下所示:

type alias Model = 
    List Todo
Enter fullscreen mode Exit fullscreen mode

Elm 中的AList是数组的链表实现,并且有完善的文档。当你编写类似 `[[[[[[[]]]]]` 的代码时,就会创建 A list = [1,2,3],所以它看起来正是我需要的。

不过,我们的模型仍然存在不足。为了添加待办事项,我们还需要跟踪“添加待办事项”输入框中的文本。因此,我们需要使用记录!

type alias Model = 
    { todos: List Todo
    , inputText: String
    }
Enter fullscreen mode Exit fullscreen mode

现在开始烹饪!

制定可采取的行动

我们已经创建了一个状态模型。接下来,我们需要创建一个列表中所有可能发生的操作。让我们创建一个类型来枚举所有这些可能性。

type Message 
    = AddTodo
    | RemoveTodo Int
    | ToggleTodo Int
    | ChangeInput String
Enter fullscreen mode Exit fullscreen mode

这里,我们创建了四个不同的操作——而且大多数操作都接受一个参数。例如,` AddTodomessage` 不接受任何参数,而 `remove` 则RemoveTodo接受要删除的索引作为参数,以此类推。我想这就是所谓的参数化自定义类型,不过别担心,这完全没问题😄。你只需要知道,消息类型后面的单词是第一个参数的类型。如果你在它后面添加了第二个类型,那就表示该消息需要两个参数,以此类推!

类型与类型别名

如果你仔细阅读了以上示例,你会注意到我们type alias在指定模型和type消息类型时都使用了 `<form>` 标签。这是为什么呢?

如果我理解FAQ没错的话,`a`type alias是特定类型的“快捷方式”,而 `a` 则是一个实际存在的独立类型。我认为你可以对类型进行模式匹配,但不能匹配类型别名。我们使用类型别名来指定类似 ` a` 和`b` 这样type的函数的类型updateview

编写业务逻辑

每次在我的待办事项应用中把这段逻辑称为“业务逻辑”时,我都会忍不住笑出声来,但我想它本来就是业务逻辑。不过,不管你怎么称呼它,我们都应该通过这个update方法来实现它。

如果你不记得上一篇文章的内容,你可以把这个方法理解为 Redux 应用的“reducer”。每当你在应用中触发一个 action 时,它都会被调用,接收旧的 state 模型,并期望你返回更新后 state 模型。

我们将使用表达式来处理每一种可能的消息case .. of——这是一种“模式匹配”的方法。我仍然称它为“高级 switch 语句”。在我们的应用程序中,它看起来像这样:

update : Message -> Model -> Model
update message model
    = case message of
        AddTodo -> 
            { model 
                | todos = addToList model.inputText model.todos
                , inputText = ""
            }
        RemoveTodo index -> 
            { model | todos = removeFromList index model.todos }
        ToggleTodo index ->
            { model | todos = toggleAtIndex index model.todos }
        ChangeInput input ->
            { model | inputText = input }
Enter fullscreen mode Exit fullscreen mode

这里做了一些简化,所以我们一步一步来分析。

处理 AddTodo

首先,我们处理AddTodo消息。我们使用{ model | something }语法复制现有模型,然后覆盖 右侧的所有字段|。在这个特定示例中,我们原本不需要这样做,因为我们更改了整个状态——但这样做可以使我们的模型在以后更容易扩展。

todos我们通过调用这个神秘函数来获取新值addToList,该函数需要传入输入文本和现有的待办事项列表。但是这个函数究竟是什么样的呢?

addToList : String -> List Todo -> List Todo
addToList input todos =
    todos ++ [{ text = input, completed = False }]
Enter fullscreen mode Exit fullscreen mode

addToList接受一个字符串输入文本和一个待办事项列表,并返回一个新的待办事项列表。我们使用运算符将​​包含新待办事项的列表追加到旧列表中++

在 JavaScript 中,这个函数看起来会是这样:

const addToList = input => todos => [
  ...todos, 
  { text: input, completed: true },
];
Enter fullscreen mode Exit fullscreen mode

我们也可以把这段代码内联,但我个人觉得提取函数看起来更简洁一些🤷‍♂️

处理 RemoveTodo

接下来要处理的消息是RemoveTodo。我们收到index一个参数,并将索引和现有列表都作为参数传递给removeAtIndex函数。代码如下:

removeFromList : Int -> List Todo -> List Todo
removeFromList index list =
    List.take index list ++ List.drop (index + 1) list
Enter fullscreen mode Exit fullscreen mode

这里,我们使用两个名为 `list`List.take和`list` 的列表函数List.drop来构造两个新列表——一个包含指定索引之前(但不包含索引本身)的所有元素,另一个包含指定索引之后的所有元素。最后,我们使用 `connect` 运算符将这两个列表连接起来++

我知道肯定还有更巧妙的办法,但这是我能想到的。🙈

处理 ToggleTodo

ToggleTodo它与前一个非常相似。它调用了一个toggleAtIndex函数,该函数如下所示:

toggleAtIndex : Int -> List Todo -> List Todo
toggleAtIndex indexToToggle list =
    List.indexedMap (\currentIndex todo -> 
        if currentIndex == indexToToggle then 
            { todo | completed = not todo.completed } 
        else 
            todo
    ) list
Enter fullscreen mode Exit fullscreen mode

这里,我们使用indexedMap列表函数遍历所有项目,并切换完成标志。请注意,我们向该indexedMap函数传递了一个匿名函数——匿名函数必须以反斜杠\(\) 开头。据说,之所以选择反斜杠,是因为它看起来像一个λ字符,并且它表示一个 lambda 函数。这可能不太容易理解,但这确实是一个方便记忆添加反斜杠的小技巧!😄

对应的 JavaScript 版本可能如下所示:

const toggleAtIndex = indexToToggle => todos => 
  todos.map((todo, currentIndex) => 
    indexToToggle === currentIndex 
      ? { ...todo, completed: !todo.completed } 
      : todo
  );
Enter fullscreen mode Exit fullscreen mode

处理变更输入

最后要处理的消息其实最简单。它ChangeInput接收更新后的输入作为参数,然后我们返回一个包含更新后inputText字段的模型作为响应。

实现视图

我们已经设计了一个完善的状态模型,列出了所有可能的操作,并实现了这些操作将如何改变模型。现在,剩下的就是把所有这些都显示在屏幕上了!

与上一篇文章中的反例一样,我们实现该view函数。它的代码如下:

view : Model -> Html Message
view model =
    Html.form [ onSubmit AddTodo ]
        [ h1 [] [ text "Todos in Elm" ]
        , input [ value model.inputText, onInput ChangeInput, placeholder "What do you want to do?" ] []
        , if List.isEmpty model.todos then
            p [] [ text "The list is clean 🧘‍♀️" ]
          else
            ol [] List.indexedMap viewTodo model.todos
        ]
Enter fullscreen mode Exit fullscreen mode

在这里,我们创建一个表单,其中包含一个h1标签、一个用于添加新待办事项的输入框和一个待办事项列表。如果您的列表中没有任何待办事项,我们会告知您目前已完成所有操作。

我们将“渲染待办事项”的逻辑提取到了一个单独的辅助函数中。我们使用之前用过的实用程序,viewTodo对每个待办事项调用该函数。它的代码如下:model.todosList.indexedMap

viewTodo : Int -> Todo -> Html Message
viewTodo index todo =
    li
        [ style "text-decoration"
            (if todo.completed then
                "line-through"
             else
                "none"
            )
        ]
        [ text todo.text
        , button [ type_ "button", onClick (ToggleTodoCompleted index) ] [ text "Toggle" ]
        , button [ type_ "button", onClick (RemoveTodo index) ] [ text "Delete" ]
        ]
Enter fullscreen mode Exit fullscreen mode

在这里,我们创建一个包含待办事项文本的列表项,以及用于切换和删除列表的按钮。这里有几点我想解释一下:

首先,请注意 Elm 中保留字属性会以_- 结尾,就像type_buttons 中的属性一样。

其次,请注意如何指定内联样式。虽然代码比较冗长,但如果觉得麻烦,可以重构大部分代码。目前这样就可以了。

说到属性,我想提醒大家注意一点:所有 HTML 属性都是函数!一开始我也有点惊讶,但一旦你理解了这一点,语法就容易理解多了!

新增功能:过滤器!

在典型的项目中,你不会编写全新的用户界面,而是在现有用户界面上添加新功能。所以,我们现在就来添加一个新功能。

我想筛选出已完成的任务和剩余的任务。我们先来创建过滤器的类型定义,包括它的所有可能状态。

type Filter
    = All
    | Completed
    | Remaining
Enter fullscreen mode Exit fullscreen mode

接下来,让我们向模型中添加一个字段!

type alias Model = 
    { todos: List Todo
    , inputText: String
    , filter: Filter
    }
Enter fullscreen mode Exit fullscreen mode

init函数提示我们没有为新filter值指定初始值,所以我们也来添加一下:

init =
    { todos = []
    , inputText = ""
    , filter = All
    }
Enter fullscreen mode Exit fullscreen mode

我们还需要为更改过滤器指定一条新消息!

type Message
    = AddTodo
    | RemoveTodo Int
    | ToggleTodo Int
    | ChangeInput String
    | ChangeFilter Filter
Enter fullscreen mode Exit fullscreen mode

现在我们的update函数报错说我们没有处理该Message类型所有可能的情况。它的实现方式与此非常相似ChangeInput

update : Message -> Model -> Model
update message model
    = case message of
        -- all the other cases are truncated for brevity
        ChangeFilter filter ->
            { model | filter = filter }
Enter fullscreen mode Exit fullscreen mode

最后,我们需要对用户界面稍作修改。首先,让我们创建一些函数来创建“选择筛选条件”的用户界面:

type alias RadioWithLabelProps =
    { filter : Filter
    , label : String
    , name : String
    , checked : Bool
    }


viewRadioWithLabel : RadioWithLabelProps -> Html Message
viewRadioWithLabel config =
    label []
        [ input 
            [ type_ "radio"
            , name config.name
            , checked config.checked
            , onClick (ChangeFilter config.filter) 
            ] []
        , text config.label 
        ]


viewSelectFilter : Filter -> Html Message
viewSelectFilter filter =
    fieldset []
        [ legend [] [ text "Current filter" ]
        , viewRadioWithLabel 
            { filter = All
            , name = "filter"
            , checked = filter == All
            , label = "All items" 
            }
        , viewRadioWithLabel 
            { filter = Completed
            , name = "filter"
            , checked = filter == Completed
            , label = "Completed items" 
            }
        , viewRadioWithLabel 
            { filter = Remaining
            , name = "filter"
            , checked = filter == Remaining
            , label = "Remaining items"
            }
        ]

Enter fullscreen mode Exit fullscreen mode

哇,信息量真大!我们一步一步来:

首先,我们来看一下这个viewSelectFilter函数。它接受当前筛选条件,并返回一个包含图例和三个我命名为“嵌套视图”的字段集viewRadioWithLabel。每个单选按钮都会接收一条包含四个不同参数的记录。

这个viewRadioWithLabel函数也很简单。它会渲染一个带有输入框的标签,以及标签文本。它会设置元素的正确属性<input />,并添加一个onClick触发ChangeFilter消息的事件监听器。

请注意,我们给viewRadioWithLabel函数传递的参数只有一条记录(带有其自身的类型别名),而不是柯里化四个不同的参数。我认为这让逻辑更容易理解——即使你不能用同样的方式进行部分应用。

最后,我们将该函数添加viewSelectFilter到主view函数中,并将实际的过滤应用于我们的列表!

view : Model -> Html Message
view model =
    Html.form [ onSubmit AddTodo ]
        [ h1 [] [ text "Todos in Elm" ]
        , input [ value model.inputText, onInput ChangeInput, placeholder "What do you want to do?" ] []
        , viewSelectFilter model.filter
        , if List.isEmpty model.todos then
            p [] [ text "The list is clean 🧘‍♀️" ]
          else
            ol [] model.todos
                |> List.filter (applyFilter model.filter)
                |> List.indexedMap viewTodo
        ]
Enter fullscreen mode Exit fullscreen mode

看看我们列出待办事项的地方发生了什么?我们使用了这个新的高级运算符|>,它可以让我们一次对列表应用多个函数。

我们再来看一下这个applyFilter函数——它非常简单明了。

applyCurrentFilter : Filter -> Todo -> Bool
applyCurrentFilter filter todo =
    case filter of
        All ->
            True

        Completed ->
            todo.completed

        Remaining ->
            not todo.completed
Enter fullscreen mode Exit fullscreen mode

就这样!我们的应用程序现在又增加了一项全新的功能!

那么我们学到了什么?

随着应用程序变得越来越复杂,Elm 的优势也开始显现。虽然需要处理的代码行数很多,但一旦熟悉了语法,一切都变得非常简单。

通过按需拆分视图函数,可以简化复杂用户界面的创建。它们在很多方面都类似于 React 组件,但设计上更加专业化,可重用性也更低。我喜欢这种方式!

在使用这款应用的过程中,我从 Elm Slack 社区的各位热心人士那里得到了很多帮助。我必须好好感谢这个社区——这里充满了爱、同情和乐于助人的人。感谢你们抽出时间为我这个完全的新手讲解了这么多功能概念!

接下来呢?我觉得是时候研究一下如何获取数据并以某种方式呈现出来了。我已经掌握了足够的语法知识,可以开始深入探索 Elm 中一些更精彩的部分了!

你从我的讲解中学到了什么吗?你有什么问题吗?或者有什么建议可以让我更简化一些内容?请告诉我,我会尽力回复。

文章来源:https://dev.to/selbekk/creating-a-todo-app-in-elm-i3o