使用 Elm 使您的 Web 前端可靠

已發表: 2022-03-11

您有多少次嘗試調試您的 Web 前端,卻發現自己陷入了處理複雜事件鏈的代碼中?

您是否嘗試過重構 UI 代碼,以處理使用 jQuery、Backbone.js 或其他流行的 JavaScript 框架構建的大量組件?

關於這些場景最痛苦的事情之一是試圖遵循多個不確定的事件序列並預測和修復所有這些行為。 簡直是噩夢!

我一直在尋找方法來擺脫 Web 前端開發的地獄般的一面。 Backbone.js 在這方面為我提供了很好的效果,它為 Web 前端提供了他們長期以來一直缺少的結構。 但考慮到做一些最瑣碎的事情所需的冗長,結果並沒有變得更好。

使用 Elm 使您的 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調用cartadd方法。

更新函數會隨著 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)

該庫提供了一些函數(例如stringintfloatobject等),它們接受 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"

我們還更新了我們的主要功能。 我們現在使用帶有額外initsubscriptions參數的Html.programinit指定程序的初始模型, 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 項目來獲得好處。