本文概述
你尝试调试Web前端多少次, 发现自己陷入处理复杂事件链的代码中了?
你是否曾经尝试过为UI重构代码, 以处理使用jQuery, Backbone.js或其他流行的JavaScript框架构建的许多组件?
这些场景中最痛苦的事情之一就是试图遵循事件的多个不确定序列, 并预测并修复所有这些行为。简直是一场噩梦!
我一直在寻找方法来摆脱网络前端开发这一令人讨厌的方面。 Backbone.js在这方面很适合我, 因为它为Web前端提供了很长一段时间以来一直缺少的结构。但是考虑到它的冗长程度, 它需要做一些最琐碎的事情, 但事实证明并没有更好。
然后我遇到了Elm。
Elm是基于Haskell编程语言的静态类型化功能语言, 但具有更简单的规范。该编译器(也使用Haskell构建)解析Elm代码并将其编译为JavaScript。
Elm最初是为前端开发而构建的, 但是软件工程师已经找到了将其用于服务器端编程的方法。
本文概述了Elm如何改变我们对Web前端开发的看法, 并对该功能编程语言的基础进行了介绍。在本教程中, 我们将使用Elm开发一个类似购物车的简单应用程序。
为什么是Elm?
Elm承诺有很多优点, 其中大多数对于实现干净的Web前端体系结构都非常有用。与其他流行框架(甚至是React.js)相比, Elm提供了更好的HTML渲染性能优势。此外, Elm允许开发人员编写代码, 而实际上, 这些代码不会产生困扰动态类型语言(如JavaScript)的大多数运行时异常。
编译器自动推断类型并发出友好错误, 使开发人员在运行时意识到任何潜在的问题。
NoRedInk有36, 000行Elm, 经过一年多的生产, 仍然没有产生任何运行时异常。 [资源]
你无需仅转换整个现有JavaScript应用程序即可试用Elm。通过其与JavaScript的出色互操作性, 你甚至可以只使用现有应用程序的一小部分, 然后将其重写为Elm。
Elm还具有出色的文档, 不仅为你提供了详尽的说明, 而且还为你提供了遵循Elm Architecture构建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
}
在这里, 主要功能使用某些模型, 视图和更新功能来初始化Elm程序。我们定义了几种产品及其价格。为简单起见, 我们假设我们有无限数量的产品。
一个简单的更新功能
更新功能是我们的应用程序栩栩如生的地方。
它接收一条消息并根据消息的内容更新状态。我们将其定义为具有两个参数(消息和当前模型)并返回新模型的函数:
type Msg = Add Product
update : Msg -> Model -> Model
update msg model =
case msg of
Add product ->
{ model | cart = add model.cart product }
目前, 当消息为”添加产品”时, 我们正在处理一种情况, 我们在购物车中使用该产品调用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>元素)。
样式函数是Html.Attributes包的一部分, 它生成一个对象, 该对象可以传递给section函数, 以在结果元素上设置样式属性。
最好将视图拆分为单独的功能, 以提高可重用性。
为了简单起见, 我们将CSS和一些布局属性直接嵌入到我们的视图代码中。但是, 存在可以简化Elm代码中HTML元素样式设置过程的库。
请注意代码段结尾附近的按钮, 以及我们如何将消息添加产品添加到按钮的click事件中。
Elm负责生成所有必要的代码, 以将回调函数与实际事件绑定在一起, 并使用相关参数生成和调用update函数。
最后, 我们需要实现我们观点的最后一点:
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)) ]
]
在这里, 我们定义了视图的另一部分, 用于渲染购物车中的内容。尽管view函数不会发出任何消息, 但仍需要具有返回类型Html Msg才能成为视图。
该视图不仅列出购物车的内容, 还根据购物车的内容计算和渲染小计。
你可以在此处找到此Elm程序的完整代码。
如果你现在要运行Elm程序, 你将看到以下内容:
它是怎么运行的?
我们的程序从主要功能的一个相当空的状态开始-一个装有一些硬编码产品的空购物车。
每次单击”添加到购物车”按钮时, 都会向更新功能发送一条消息, 更新功能随后会相应地更新购物车并创建新模型。每当更新模型时, Elm都会调用视图函数以重新生成HTML树。
由于Elm使用类似于React的Virtual DOM方法, 因此仅在必要时才执行对UI的更改, 以确保出色的性能。
不只是类型检查器
Elm是静态类型的, 但是编译器可以检查的不仅仅是类型。
让我们更改Msg类型, 看看编译器对此有何反应:
type Msg = Add Product | ChangeQty Product String
我们定义了另一种消息-可能会改变购物车中产品数量的消息。但是, 尝试在更新功能中未处理此消息的情况下再次运行该程序将发出以下错误:
迈向功能更强的购物车
请注意, 在上一节中, 我们使用字符串作为数量值的类型。这是因为该值将来自<input>元素, 该元素的类型为string。
让我们向购物车模块添加一个新功能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 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
在使用之前, 我们将字符串数量参数从消息转换为数字。如果字符串包含无效数字, 我们将其报告为错误。
在这里, 当发生错误时, 我们保持模型不变。另外, 我们可以通过更新模型的方式将错误报告为视图中的消息, 以供用户查看:
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类型用作模型中的error属性。也许是另一种类型, 可以不包含任何内容或特定类型的值。
更新视图功能后, 如下所示:
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
我们在这里使用”数字”包中的格式功能。这将以我们通常格式化货币的方式格式化数字:
100.5 -> $100.50
15.36 -> $15.36
21.15 -> $21.15
自动文档生成
将软件包发布到Elm存储库时, 将根据代码中的注释自动生成文档。你可以在此处查看我们的购物车模块的文档, 以查看实际运行情况。所有这些都是根据此文件中的注释生成的:Cart.elm。
真正的前端调试器
编译器本身会检测并报告最明显的问题。但是, 没有应用程序可以避免逻辑错误。
由于Elm中的所有数据都是不可变的, 并且所有事情都是通过传递给更新功能的消息发生的, 因此Elm程序的整个流程可以表示为一系列模型更改。对于调试员而言, Elm就像是回合制策略游戏。这使调试器可以执行一些非常出色的壮举, 例如穿越时空。通过在程序生命周期内发生的各种模型更改之间跳转, 它可以在程序流程中来回移动。
你可以在此处了解有关调试器的更多信息。
与后端交互
所以, 你说, 我们制造了一个不错的玩具, 但是Elm可以用于严肃的事情吗?绝对。
让我们将购物车前端与一些异步后端连接起来。为了使它有趣, 我们将实现一些特殊的东西。假设我们要实时检查所有购物车及其内容。在现实生活中, 我们可以使用这种方法为我们的网上商店或市场带来一些额外的营销/销售功能, 或者向用户提出一些建议, 或者估计所需的库存资源等等。
因此, 我们将购物车存储在客户端, 并让服务器实时了解每个购物车。
为了简单起见, 我们将使用Python实现后端。你可以在此处找到后端的完整代码。
这是一个简单的网络服务器, 它使用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)
该库提供了一些函数(例如字符串, 整数, 浮点数, 对象等), 这些函数采用Elm对象并将其转换为JSON编码的字符串。
CartDecoder.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应用程序
由于最终的Elm代码要长一些, 因此你可以在这里找到它。这是对前端应用程序所做的更改的摘要:
我们将原始的更新功能包装在一起, 该功能在每次更新购物车时将购物车内容的更改发送到后端:
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"
我们还更新了主要功能。现在, 我们将Html.program与其他init和subscriptions参数一起使用。 init指定程序的初始模型, subscription指定订阅列表。
订阅是一种让我们告诉Elm侦听特定频道上的更改并将这些消息转发到更新功能的方法。
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)
保持前端稳定
Elm是不同的。它要求开发人员以不同的方式思考。
来自JavaScript和类似语言领域的任何人都会发现自己正在尝试学习Elm的做事方式。
最终, Elm提供了其他框架(甚至是最受欢迎的框架)通常很难做到的东西。也就是说, 它提供了一种构建健壮的前端应用程序的方法, 而不会陷入繁琐的冗长代码中。
Elm还通过结合使用智能编译器和功能强大的调试器来消除JavaScript带来的许多困难。
长期以来, Elm一直是前端开发人员所渴望的。现在你已经看到了它的实际应用, 可以自己尝试一下, 并通过在Elm中构建下一个Web项目来获得收益。