使用 Elm 使您的 Web 前端可靠
已发表: 2022-03-11您有多少次尝试调试您的 Web 前端,却发现自己陷入了处理复杂事件链的代码中?
您是否尝试过重构 UI 代码,以处理使用 jQuery、Backbone.js 或其他流行的 JavaScript 框架构建的大量组件?
关于这些场景最痛苦的事情之一是试图遵循多个不确定的事件序列并预测和修复所有这些行为。 简直是噩梦!
我一直在寻找方法来摆脱 Web 前端开发的地狱般的一面。 Backbone.js 在这方面为我提供了很好的效果,它为 Web 前端提供了他们长期以来一直缺少的结构。 但考虑到做一些最琐碎的事情所需的冗长,结果并没有变得更好。
然后我遇到了榆树。
Elm 是一种基于 Haskell 编程语言的静态类型函数式语言,但规范更简单。 编译器(也使用 Haskell 构建)解析 Elm 代码并将其编译为 JavaScript。
Elm 最初是为前端开发而构建的,但软件工程师也找到了将它用于服务器端编程的方法。
本文概述了 Elm 如何改变我们对 Web 前端开发的看法,并介绍了这种函数式编程语言的基础知识。 在本教程中,我们将使用 Elm 开发一个简单的类似购物车的应用程序。
为什么选择榆树?
Elm 承诺了很多优势,其中大部分对于实现干净的 Web 前端架构非常有用。 Elm 提供了比其他流行框架(甚至 React.js)更好的 HTML 渲染性能优势。 此外,Elm 允许开发人员编写代码,在实践中,这些代码不会产生大多数困扰动态类型语言(如 JavaScript)的运行时异常。
编译器自动推断类型并发出友好的错误,使开发人员在运行前意识到任何潜在的问题。
NoRedInk 拥有 36,000 行 Elm,经过一年多的生产,仍然没有产生一个运行时异常。 [资源]
您无需转换整个现有的 JavaScript 应用程序就可以试用 Elm。 通过它与 JavaScript 的卓越互操作性,您甚至可以只取现有应用程序的一小部分,并在 Elm 中重写它。
Elm 还拥有出色的文档,不仅可以为您提供关于它所提供的功能的全面描述,还可以为您提供遵循 Elm 架构构建 Web 前端的正确指南——这对于模块化、代码重用和测试非常有用.
让我们做一个简单的购物车
让我们从一段很短的 Elm 代码开始:
import List exposing (..) cart = [] item product quantity = { product = product, qty = quantity } product name price = { name = name, price = price } add cart product = if isEmpty (filter (\item -> item.product == product) cart) then append cart [item product 1] else cart subtotal cart = -- we want to calculate cart subtotal sum (map (\item -> item.product.price * toFloat item.qty) cart)
任何以
--
开头的文本都是 Elm 中的注释。
在这里,我们将购物车定义为项目列表,其中每个项目都是具有两个值(它对应的产品和数量)的记录。 每个产品都是一个有名称和价格的记录。
将产品添加到购物车涉及检查该商品是否已存在于购物车中。
如果是这样,我们什么也不做; 否则,我们将产品作为新商品添加到购物车中。 我们通过过滤列表、将每个项目与产品匹配并检查结果过滤列表是否为空来检查产品是否已存在于购物车中。
为了计算小计,我们遍历购物车中的商品,找到相应的产品数量和价格,然后将它们加起来。
这与购物车及其相关功能一样简约。 我们将从这段代码开始,逐步改进它,使其成为一个完整的 Web 组件,或者 Elm 术语中的一个程序。
让我们首先在程序中为各种标识符添加类型。 Elm 能够自行推断类型,但为了充分利用 Elm 及其编译器,建议明确指出类型。
module Cart1 exposing ( Cart, Item, Product , add, subtotal , itemSubtotal ) -- This is module and its API definition {-| We build an easy shopping cart. @docs Cart, Item, Product, add, subtotal, itemSubtotal -} import List exposing (..) -- we need list manipulation functions {-| Cart is a list of items. -} type alias Cart = List Item {-| Item is a record of product and quantity. -} type alias Item = { product : Product, qty : Int } {-| Product is a record with name and price -} type alias Product = { name : String, price : Float } {-| We want to add stuff to a cart. This is a function definition, it takes a cart, a product to add and returns new cart -} add : Cart -> Product -> Cart {-| This is an implementation of the 'add' function. Just append product item to the cart if there is no such product in the cart listed. Do nothing if the product exists. -} add cart product = if isEmpty (filter (\item -> item.product == product) cart) then append cart [Item product 1] else cart {-| I need to calculate cart subtotal. The function takes a cart and returns float. -} subtotal : Cart -> Float {-| It's easy -- just sum subtotal of all items. -} subtotal cart = sum (map itemSubtotal cart) {-| Item subtotal takes item and return the subtotal float. -} itemSubtotal : Item -> Float {-| Subtotal is based on product's price and quantity. -} itemSubtotal item = item.product.price * toFloat item.qty
使用类型注释,编译器现在可以捕获否则会导致运行时异常的问题。
然而,Elm 并没有就此止步。 Elm 架构通过一个简单的模式引导开发人员构建他们的 Web 前端,并且它通过大多数开发人员已经熟悉的概念来实现:
- 模型:模型保存程序的状态。
- 视图:视图是状态的可视化表示。
- 更新:更新是改变状态的一种方式。
如果您将处理更新的代码部分视为控制器,那么您将拥有与良好的旧模型-视图-控制器 (MVC) 范例非常相似的东西。
由于 Elm 是纯函数式编程语言,所有数据都是不可变的,这意味着模型无法更改。 相反,我们可以在前一个模型的基础上创建一个新模型,我们通过更新函数来完成。
为什么这么厉害?
使用不可变数据,函数不再有副作用。 这开辟了一个充满可能性的世界,包括 Elm 的时间旅行调试器,我们将在稍后讨论。
每次模型中的更改需要视图中的更改时都会渲染视图,并且对于模型中的相同数据,我们将始终得到相同的结果——这与纯函数总是为相同的输入参数。
从主函数开始
让我们继续为我们的购物车应用程序实现 HTML 视图。
如果你熟悉 React,我相信你会喜欢的:Elm 有一个包只用于定义 HTML 元素。 这允许您使用 Elm 实现您的视图,而不必依赖外部模板语言。
HTML 元素的包装器在Html
包下可用:
import Html exposing (Html, button, table, caption, thead, tbody, tfoot, tr, td, th, text, section, p, h1)
所有 Elm 程序都从执行 main 函数开始:
type alias Stock = List Product type alias Model = { cart : Cart, stock : Stock } main = Html.beginnerProgram { model = Model [] [ Product "Bicycle" 100.50 , Product "Rocket" 15.36 , Product "Biscuit" 21.15 ] , view = view , update = update }
在这里,main 函数用一些模型、一个视图和一个更新函数来初始化一个 Elm 程序。 我们已经定义了几种产品及其价格。 为简单起见,我们假设我们有无限数量的产品。
一个简单的更新函数
更新功能是我们的应用程序得以实现的地方。
它接收一条消息并根据消息的内容更新状态。 我们将其定义为一个接受两个参数(一条消息和当前模型)并返回一个新模型的函数:
type Msg = Add Product update : Msg -> Model -> Model update msg model = case msg of Add product -> { model | cart = add model.cart product }
现在,我们正在处理消息为Add product
的单个案例,我们在其中使用product
调用cart
的add
方法。
更新函数会随着 Elm 程序复杂性的增加而增加。
实现视图功能
接下来,我们定义购物车的视图。
视图是将模型转换为 HTML 表示的函数。 但是,它不仅仅是静态 HTML。 HTML 生成器能够根据各种用户交互和事件将消息发送回应用程序。
view : Model -> Html Msg view model = section [style [("margin", "10px")]] [ stockView model.stock , cartView model.cart ] stockView : Stock -> Html Msg stockView stock = table [] [ caption [] [ h1 [] [ text "Stock" ] ] , thead [] [ tr [] [ th [align "left", width 100] [ text "Name" ] , th [align "right", width 100] [ text "Price" ] , th [width 100] [] ] ] , tbody [] (map stockProductView stock) ] stockProductView : Product -> Html Msg stockProductView product = tr [] [ td [] [ text product.name ] , td [align "right"] [ text ("\t$" ++ toString product.price) ] , td [] [ button [ onClick (Add product) ] [ text "Add to Cart" ] ] ]
Html
包为所有常用元素提供了包装器,作为具有熟悉名称的函数(例如,函数section
生成一个<section>
元素)。
style
函数是Html.Attributes
包的一部分,它生成一个对象,该对象可以传递给section
函数来设置结果元素的 style 属性。
最好将视图拆分为单独的函数以获得更好的可重用性。
为了简单起见,我们将 CSS 和一些布局属性直接嵌入到我们的视图代码中。 但是,存在一些库来简化从 Elm 代码中设置 HTML 元素样式的过程。
请注意片段末尾附近的button
,以及我们如何将消息Add product
关联到按钮的单击事件。
Elm 负责生成所有必要的代码,以将回调函数与实际事件绑定,并使用相关参数生成和调用更新函数。
最后,我们需要实现视图的最后一点:
cartView : Cart -> Html Msg cartView cart = if isEmpty cart then p [] [ text "Cart is empty" ] else table [] [ caption [] [ h1 [] [ text "Cart" ]] , thead [] [ tr [] [ th [ align "left", width 100 ] [ text "Name" ] , th [ align "right", width 100 ] [ text "Price" ] , th [ align "center", width 50 ] [ text "Qty" ] , th [ align "right", width 100 ] [ text "Subtotal" ] ] ] , tbody [] (map cartProductView cart) , tfoot [] [ tr [style [("font-weight", "bold")]] [ td [ align "right", colspan 4 ] [ text ("$" ++ toString (subtotal cart)) ] ] ] ] cartProductView : Item -> Html Msg cartProductView item = tr [] [ td [] [ text item.product.name ] , td [ align "right" ] [ text ("$" ++ toString item.product.price) ] , td [ align "center" ] [ text (toString item.qty) ] , td [ align "right" ] [ text ("$" ++ toString (itemSubtotal item)) ] ]
在这里,我们定义了视图的另一部分,用于渲染购物车的内容。 尽管视图函数不发出任何消息,但它仍然需要具有返回类型Html Msg
才能成为视图。
该视图不仅列出了购物车的内容,还根据购物车的内容计算和呈现小计。
你可以在这里找到这个 Elm 程序的完整代码。
如果你现在运行 Elm 程序,你会看到如下内容:
它是怎么运行的?
我们的程序从main
函数的一个相当空的状态开始 - 一个带有一些硬编码产品的空购物车。
每次单击“添加到购物车”按钮时,都会向更新功能发送一条消息,该功能随后会相应地更新购物车并创建一个新模型。 每当模型更新时,Elm 都会调用视图函数来重新生成 HTML 树。
由于 Elm 使用类似于 React 的 Virtual DOM 方法,因此仅在必要时才对 UI 进行更改,从而确保快速的性能。
不仅仅是类型检查器
Elm 是静态类型的,但编译器可以检查的不仅仅是类型。
让我们更改我们的Msg
类型,看看编译器对此有何反应:
type Msg = Add Product | ChangeQty Product String
我们定义了另一种消息 - 会改变购物车中产品数量的消息。 但是,尝试再次运行程序而不在更新函数中处理此消息将发出以下错误:

迈向更实用的购物车
请注意,在上一节中,我们使用字符串作为数量值的类型。 这是因为该值将来自一个字符串类型的<input>
元素。
让我们在Cart
模块中添加一个新函数changeQty
。 最好将实现保留在模块中,以便以后在需要时能够在不更改模块 API 的情况下更改它。
{-| Change quantity of the product in the cart. Look at the result of the function. It uses Result type. The Result type has two parameters: for bad and for good result. So the result will be Error "msg" or a Cart with updated product quantity. -} changeQty : Cart -> Product -> Int -> Result String Cart {-| If the quantity parameter is zero the product will be removed completely from the cart. If the quantity parameter is greater then zero the quantity of the product will be updated. Otherwise (qty < 0) the error will be returned. -} changeQty cart product qty = if qty == 0 then Ok (filter (\item -> item.product /= product) cart) else if qty > 0 then Result.Ok (map (\item -> if item.product == product then { item | qty = qty } else item) cart) else Result.Err ("Wrong negative quantity used: " ++ (toString qty))
我们不应该对函数的使用方式做出任何假设。 我们可以确信参数qty
将包含一个Int
但值可以是任何值。 因此,我们检查该值并在它无效时报告错误。
我们也相应地更新了我们的update
函数:
update msg model = case msg of Add product -> { model | cart = add model.cart product } ChangeQty product str -> case toInt str of Ok qty -> case changeQty model.cart product qty of Ok cart -> { model | cart = cart } Err msg -> model -- do nothing, the wrong input Err msg -> model -- do nothing, the wrong quantity
在使用之前,我们将消息中的字符串quantity
参数转换为数字。 如果字符串包含无效数字,我们会将其报告为错误。
在这里,当发生错误时,我们保持模型不变。 或者,我们可以更新模型,将错误报告为视图中的消息,供用户查看:
type alias Model = { cart : Cart, stock : Stock, error : Maybe String } main = Html.beginnerProgram { model = Model [] -- empty cart [ Product "Bicycle" 100.50 -- stock , Product "Rocket" 15.36 , Product "Bisquit" 21.15 ] Nothing -- error (no error at beginning) , view = view , update = update } update msg model = case msg of Add product -> { model | cart = add model.cart product } ChangeQty product str -> case toInt str of Ok qty -> case changeQty model.cart product qty of Ok cart -> { model | cart = cart, error = Nothing } Err msg -> { model | error = Just msg } Err msg -> { model | error = Just msg }
我们在模型中使用Maybe String
类型作为错误属性。 Maybe 是另一种可以保存Nothing
或特定类型值的类型。
更新视图函数后如下:
view model = section [style [("margin", "10px")]] [ stockView model.stock , cartView model.cart , errorView model.error ] errorView : Maybe String -> Html Msg errorView error = case error of Just msg -> p [style [("color", "red")]] [ text msg ] Nothing -> p [] []
你应该看到这个:
尝试输入非数字值(例如“1a”)将导致错误消息,如上面的屏幕截图所示。
包装世界
Elm 有自己的开源软件包存储库。 使用 Elm 的包管理器,利用这个包池变得轻而易举。 尽管存储库的大小无法与 Python 或 PHP 等其他一些成熟的编程语言相比,但 Elm 社区每天都在努力实现更多的包。
请注意我们视图中呈现的价格小数位是如何不一致的?
让我们用存储库中更好的东西替换我们对toString
的天真使用:numeric-elm。
cartProductView item = tr [] [ td [] [ text item.product.name ] , td [ align "right" ] [ text (formatPrice item.product.price) ] , td [ align "center" ] [ input [ value (toString item.qty) , onInput (ChangeQty item.product) , size 3 --, type' "number" ] [] ] , td [ align "right" ] [ text (formatPrice (itemSubtotal item)) ] ] formatPrice : Float -> String formatPrice price = format "$0,0.00" price
我们在这里使用来自 Numeral 包的format
函数。 这将以我们通常格式化货币的方式格式化数字:
100.5 -> $100.50 15.36 -> $15.36 21.15 -> $21.15
自动文档生成
将包发布到 Elm 存储库时,会根据代码中的注释自动生成文档。 您可以通过在此处查看我们的购物车模块的文档来查看它的实际效果。 所有这些都是从这个文件中看到的评论生成的:Cart.elm。
真正的前端调试器
最明显的问题由编译器本身检测和报告。 但是,没有应用程序可以避免逻辑错误。
由于 Elm 中的所有数据都是不可变的,并且一切都通过传递给更新函数的消息发生,因此 Elm 程序的整个流程可以表示为一系列模型更改。 对于调试器来说,Elm 就像一个回合制策略游戏。 这允许调试器执行一些非常了不起的壮举,例如穿越时空。 它可以通过在程序生命周期内发生的各种模型更改之间跳转来在程序流程中来回移动。
您可以在此处了解有关调试器的更多信息。
与后端交互
所以,你说,我们已经制造了一个不错的玩具,但榆树可以用来做一些严肃的事情吗? 绝对地。
让我们将购物车前端与一些异步后端连接起来。 为了让它变得有趣,我们将实现一些特别的东西。 假设我们要实时检查所有购物车及其内容。 在现实生活中,我们可以使用这种方法为我们的在线商店或市场带来一些额外的营销/销售功能,或者向用户提出一些建议,或者估计所需的库存资源等等。
因此,我们将购物车存储在客户端,并让服务器实时了解每个购物车。
为了简单起见,我们将使用 Python 实现我们的后端。 您可以在此处找到后端的完整代码。
它是一个简单的 Web 服务器,它使用 WebSocket 并跟踪内存中购物车的内容。 为简单起见,我们将在同一页面上呈现其他所有人的购物车。 这可以很容易地在单独的页面中实现,甚至可以作为单独的 Elm 程序来实现。 目前,每个用户都可以看到其他用户购物车的摘要。
后端到位后,我们现在需要更新我们的 Elm 应用程序以向服务器发送和接收购物车更新。 我们将使用 JSON 来编码我们的有效负载,Elm 对此有很好的支持。
CartEncoder.elm
我们将实现一个编码器来将我们的 Elm 数据模型转换为 JSON 字符串表示。 为此,我们需要使用 Json.Encode 库。
module CartEncoder exposing (cart) import Cart2 exposing (Cart, Item, Product) import List exposing (map) import Json.Encode exposing (..) product : Product -> Value product product = object [ ("name", string product.name) , ("price", float product.price) ] item : Item -> Value item item = object [ ("product", product item.product) , ("qty", int item.qty) ] cart : Cart -> Value cart cart = list (map item cart)
该库提供了一些函数(例如string
、 int
、 float
、 object
等),它们接受 Elm 对象并将它们转换为 JSON 编码的字符串。
购物车解码器.elm
实现解码器有点棘手,因为所有 Elm 数据都有类型,我们需要定义需要将哪些 JSON 值转换为哪种类型:
module CartDecoder exposing (cart) import Cart2 exposing (Cart, Item, Product) -- decoding for Cart import Json.Decode exposing (..) -- will decode cart from string cart : Decoder (Cart) cart = list item -- decoder for cart is a list of items item : Decoder (Item) item = object2 Item -- decoder for item is an object with two properties: ("product" := product) -- 1) "product" of product ("qty" := int) -- 2) "qty" of int product : Decoder (Product) product = object2 Product -- decoder for product also an object with two properties: ("name" := string) -- 1) "name" ("price" := float) -- 2) "price"
更新榆树应用程序
由于最终的 Elm 代码有点长,你可以在这里找到它。 以下是对前端应用程序所做更改的摘要:
我们已经用一个函数包装了我们原来的update
函数,该函数在每次更新购物车时将购物车内容的更改发送到后端:
updateOnServer msg model = let (newModel, have_to_send) = update msg model in case have_to_send of True -> -- send updated cart to server (!) newModel [ WebSocket.send server (encode 0 (CartEncoder.cart newModel.cart)) ] False -> -- do nothing (newModel, Cmd.none)
我们还添加了额外的消息类型ConsumerCarts String
来接收来自服务器的更新并相应地更新本地模型。
该视图已更新为使用consumersCartsView
购物车视图功能呈现其他人购物车的摘要。
已建立 WebSocket 连接以订阅后端以侦听他人购物车的更改。
subscriptions : Model -> Sub Msg subscriptions model = WebSocket.listen server ConsumerCarts server = "ws://127.0.0.1:8765"
我们还更新了我们的主要功能。 我们现在使用带有额外init
和subscriptions
参数的Html.program
。 init
指定程序的初始模型, subscription
指定订阅列表。
订阅是我们告诉 Elm 监听特定频道的变化并将这些消息转发到update
函数的一种方式。
main = Html.program { init = init , view = view , update = updateOnServer , subscriptions = subscriptions } init = ( Model [] -- empty cart [ Product "Bicycle" 100.50 -- stock , Product "Rocket" 15.36 , Product "Bisquit" 21.15 ] Nothing -- error (no error at beginning) [] -- consumer carts list is empty , Cmd.none)
最后,我们处理了解码从服务器接收到的 ConsumerCarts 消息的方式。 这确保了我们从外部来源接收的数据不会破坏应用程序。
ConsumerCarts message -> case decodeString (Json.Decode.list CartDecoder.cart) message of Ok carts -> ({ model | consumer_carts = carts }, False) Err msg -> ({ model | error = Just msg, consumer_carts = [] }, False)
保持你的前端理智
榆树不一样。 它需要开发人员以不同的方式思考。
任何来自 JavaScript 和类似语言领域的人都会发现自己正在尝试学习 Elm 的做事方式。
但归根结底,Elm 提供了其他框架(即使是最流行的框架)常常难以做到的东西。 也就是说,它提供了一种构建健壮的前端应用程序的方法,而无需纠结于大量冗长的代码。
Elm 还通过将智能编译器与强大的调试器相结合,消除了 JavaScript 带来的许多困难。
Elm 是前端开发人员长期以来一直向往的东西。 既然您已经看到了它的实际效果,请自己试一试,并通过在 Elm 中构建您的下一个 Web 项目来获得好处。