Haga que su front-end web sea confiable con Elm

Publicado: 2022-03-11

¿Cuántas veces ha intentado depurar su front-end web y se ha encontrado enredado en un código que trata con complejas cadenas de eventos?

¿Alguna vez trató de refactorizar el código para una interfaz de usuario que maneja muchos componentes creados con jQuery, Backbone.js u otros marcos de JavaScript populares?

Una de las cosas más dolorosas de estos escenarios es tratar de seguir las múltiples secuencias indeterminadas de eventos y anticipar y corregir todos estos comportamientos. ¡Simplemente una pesadilla!

Siempre he buscado formas de escapar de este aspecto infernal del desarrollo web front-end. Backbone.js funcionó bien para mí en este sentido al proporcionar a los front-end web la estructura que les ha faltado durante mucho tiempo. Pero dada la verbosidad que requiere para hacer algunas de las cosas más triviales, no resultó ser mucho mejor.

Haga que su front-end web sea confiable con Elm

Entonces conocí a Olmo.

Elm es un lenguaje funcional tipado estáticamente basado en el lenguaje de programación Haskell, pero con una especificación más simple. El compilador (también creado con Haskell) analiza el código de Elm y lo compila en JavaScript.

Elm se creó originalmente para el desarrollo front-end, pero los ingenieros de software también han encontrado formas de usarlo para la programación del lado del servidor.

Este artículo proporciona una descripción general de cómo Elm puede cambiar la forma en que pensamos sobre el desarrollo web front-end y una introducción a los conceptos básicos de este lenguaje de programación funcional. En este tutorial, desarrollaremos una aplicación simple similar a un carrito de compras usando Elm.

¿Por qué Olmo?

Elm promete muchas ventajas, la mayoría de las cuales son extremadamente útiles para lograr una arquitectura front-end web limpia. Elm ofrece mejores ventajas de rendimiento de representación HTML sobre otros marcos populares (incluso React.js). Además, Elm permite a los desarrolladores escribir código que, en la práctica, no produce la mayoría de las excepciones de tiempo de ejecución que plagan los lenguajes de escritura dinámica como JavaScript.

El compilador infiere tipos automáticamente y emite errores amigables, lo que hace que el desarrollador sea consciente de cualquier problema potencial antes del tiempo de ejecución.

NoRedInk tiene 36 000 líneas de Elm y, después de más de un año en producción, todavía no ha producido una sola excepción de tiempo de ejecución. [Fuente]

No necesita convertir toda su aplicación JavaScript existente solo para poder probar Elm. A través de su excelente interoperabilidad con JavaScript, incluso puede tomar solo una pequeña parte de su aplicación existente y reescribirla en Elm.

Elm también tiene una excelente documentación que no solo le brinda una descripción detallada de lo que tiene para ofrecer, sino que también le brinda una guía adecuada para crear un front-end web siguiendo The Elm Architecture, algo que es excelente para modularidad, reutilización de código y pruebas. .

Hagamos un carrito simple

Comencemos con un fragmento muy breve del código de 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)

Cualquier texto precedido por -- es un comentario en Elm.

Aquí estamos definiendo un carrito como una lista de artículos, donde cada artículo es un registro con dos valores (el producto al que corresponde y la cantidad). Cada producto es un registro con un nombre y un precio.

Agregar un producto al carrito implica verificar si el artículo ya existe en el carrito.

Si lo hace, no hacemos nada; de lo contrario, agregamos el producto al carrito como un artículo nuevo. Comprobamos si el producto ya existe en el carrito filtrando la lista, emparejando cada artículo con el producto y comprobando si la lista filtrada resultante está vacía.

Para calcular el subtotal, iteramos sobre los artículos en el carrito, encontrando la cantidad y el precio del producto correspondiente, y resumiéndolo todo.

Esto es tan minimalista como un carro y sus funciones relacionadas pueden llegar a ser. Comenzaremos con este código y lo mejoraremos paso a paso para convertirlo en un componente web completo, o un programa en términos de Elm.

Comencemos agregando tipos a los diversos identificadores en nuestro programa. Elm es capaz de inferir tipos por sí mismo, pero para aprovechar al máximo Elm y su compilador, se recomienda que los tipos se indiquen explícitamente.

 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

Con las anotaciones de tipo, el compilador ahora puede detectar problemas que, de otro modo, habrían resultado en excepciones de tiempo de ejecución.

Sin embargo, Elm no se detiene ahí. Elm Architecture guía a los desarrolladores a través de un patrón simple para estructurar sus interfaces web, y lo hace a través de conceptos con los que la mayoría de los desarrolladores ya están familiarizados:

  • Modelo: Los modelos mantienen el estado del programa.
  • Ver: Ver es una representación visual del estado.
  • Actualizar: Actualizar es una forma de cambiar el estado.

Si piensa en la parte de su código que se ocupa de las actualizaciones como el controlador, entonces tiene algo muy similar al viejo paradigma Modelo-Vista-Controlador (MVC).

Dado que Elm es un lenguaje de programación funcional puro, todos los datos son inmutables, lo que significa que el modelo no se puede cambiar. En su lugar, podemos crear un nuevo modelo basado en el anterior, lo que hacemos a través de funciones de actualización.

¿Por qué es tan genial?

Con datos inmutables, las funciones ya no pueden tener efectos secundarios. Esto abre un mundo de posibilidades, incluido el depurador de viajes en el tiempo de Elm, del que hablaremos en breve.

Las vistas se representan cada vez que un cambio en el modelo requiere un cambio en la vista, y siempre tendremos el mismo resultado para los mismos datos en el modelo, de la misma manera que una función pura siempre devuelve el mismo resultado para el mismos argumentos de entrada.

Comience con la función principal

Avancemos e implementemos la vista HTML para nuestra aplicación de carrito.

Si está familiarizado con React, esto es algo que seguramente apreciará: Elm tiene un paquete solo para definir elementos HTML. Esto le permite implementar su vista usando Elm, sin tener que depender de lenguajes de plantillas externos.

Los contenedores para los elementos HTML están disponibles en el paquete Html :

 import Html exposing (Html, button, table, caption, thead, tbody, tfoot, tr, td, th, text, section, p, h1)

Todos los programas de Elm comienzan ejecutando la función principal:

 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 }

Aquí, la función principal inicializa un programa Elm con algunos modelos, una vista y una función de actualización. Hemos definido algunos tipos de productos y sus precios. Para simplificar, asumimos que tenemos un número ilimitado de productos.

Una función de actualización simple

La función de actualización es donde nuestra aplicación cobra vida.

Toma un mensaje y actualiza el estado según el contenido del mensaje. Lo definimos como una función que toma dos parámetros (un mensaje y el modelo actual) y devuelve un nuevo modelo:

 type Msg = Add Product update : Msg -> Model -> Model update msg model = case msg of Add product -> { model | cart = add model.cart product }

Por ahora, estamos manejando un solo caso cuando el mensaje es Add product , donde llamamos al método add on cart con el product .

La función de actualización crecerá a medida que crezca la complejidad del programa Elm.

Implementando la función de vista

A continuación, definimos la vista de nuestro carrito.

La vista es una función que traduce el modelo a su representación HTML. Sin embargo, no es solo HTML estático. El generador de HTML es capaz de devolver mensajes a la aplicación en función de diversas interacciones y eventos del usuario.

 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" ] ] ]

El paquete Html proporciona envoltorios para todos los elementos de uso común como funciones con nombres familiares (por ejemplo, la section de función genera un elemento <section> ).

La función de style , parte del paquete Html.Attributes , genera un objeto que se puede pasar a la función de section para establecer el atributo de estilo en el elemento resultante.

Es mejor dividir la vista en funciones separadas para una mejor reutilización.

Para simplificar las cosas, hemos incorporado CSS y algunos atributos de diseño directamente en nuestro código de vista. Sin embargo, existen bibliotecas que agilizan el proceso de diseñar sus elementos HTML desde el código Elm.

Observe el button cerca del final del fragmento y cómo hemos asociado el mensaje Add product al evento de clic del botón.

Elm se encarga de generar todo el código necesario para vincular una función de devolución de llamada con el evento real y generar y llamar a la función de actualización con los parámetros relevantes.

Finalmente, necesitamos implementar el último bit de nuestra vista:

 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)) ] ]

Aquí hemos definido la otra parte de nuestra vista donde representamos el contenido de nuestro carrito. Aunque la función de vista no emite ningún mensaje, todavía necesita tener el tipo de devolución Html Msg para calificar como una vista.

La vista no solo enumera el contenido del carrito, sino que también calcula y representa el subtotal en función del contenido del carrito.

Puede encontrar el código completo para este programa Elm aquí.

Si tuviera que ejecutar el programa Elm ahora, vería algo como esto:

¿Cómo funciona todo?

Nuestro programa comienza con un estado bastante vacío de la función main : un carrito vacío con algunos productos codificados.

Cada vez que se hace clic en el botón "Agregar al carrito", se envía un mensaje a la función de actualización, que luego actualiza el carrito en consecuencia y crea un nuevo modelo. Cada vez que se actualiza el modelo, Elm invoca las funciones de vista para regenerar el árbol HTML.

Dado que Elm utiliza un enfoque DOM virtual, similar al de React, los cambios en la interfaz de usuario se realizan solo cuando es necesario, lo que garantiza un rendimiento rápido.

No es solo un verificador de tipos

Elm tiene tipado estático, pero el compilador puede verificar mucho más que solo tipos.

Hagamos un cambio en nuestro tipo de Msg y veamos cómo reacciona el compilador a eso:

 type Msg = Add Product | ChangeQty Product String

Hemos definido otro tipo de mensaje: algo que cambiaría la cantidad de un producto en el carrito. Sin embargo, intentar ejecutar el programa nuevamente sin manejar este mensaje en la función de actualización generará el siguiente error:

Hacia un carrito más funcional

Tenga en cuenta que en la sección anterior usamos una cadena como tipo para el valor de la cantidad. Esto se debe a que el valor provendrá de un elemento <input> que será de tipo cadena.

Agreguemos una nueva función changeQty al módulo Cart . Siempre es mejor mantener la implementación dentro del módulo para poder cambiarla más tarde si es necesario sin cambiar la API del módulo.

 {-| 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))

No debemos hacer suposiciones sobre cómo se usará la función. Podemos estar seguros de que el parámetro qty contendrá un Int pero el valor puede ser cualquier cosa. Por lo tanto, verificamos el valor e informamos un error cuando no es válido.

También actualizamos nuestra función update en consecuencia:

 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

Convertimos el parámetro de quantity de cadena del mensaje a un número antes de usarlo. En caso de que la cadena contenga un número no válido, lo informamos como un error.

Aquí, mantenemos el modelo sin cambios cuando ocurre un error. Alternativamente, podríamos simplemente actualizar el modelo para informar el error como un mensaje en la vista para que el usuario lo vea:

 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 }

Usamos el tipo Maybe String para el atributo de error en nuestro modelo. Quizás es otro tipo que puede contener Nothing o un valor de un tipo específico.

Después de actualizar la función de vista de la siguiente manera:

 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 [] []

Deberías ver esto:

Intentar ingresar un valor no numérico (por ejemplo, "1a") generaría un mensaje de error como se muestra en la captura de pantalla anterior.

Mundo de paquetes

Elm tiene su propio repositorio de paquetes de código abierto. Con el administrador de paquetes para Elm, es muy fácil aprovechar este grupo de paquetes. Aunque el tamaño del repositorio no es comparable con otros lenguajes de programación maduros como Python o PHP, la comunidad de Elm está trabajando arduamente para implementar más paquetes todos los días.

¿Observe cómo los lugares decimales en los precios presentados en nuestra opinión son inconsistentes?

Reemplacemos nuestro uso ingenuo de toString con algo mejor del repositorio: numeral-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

Estamos usando la función de format del paquete Numeral aquí. Esto daría formato a los números de la forma en que normalmente formateamos las monedas:

 100.5 -> $100.50 15.36 -> $15.36 21.15 -> $21.15

Generación Automática de Documentación

Al publicar un paquete en el repositorio de Elm, la documentación se genera automáticamente en función de los comentarios del código. Puede verlo en acción consultando la documentación de nuestro módulo Cart aquí. Todos estos fueron generados a partir de los comentarios vistos en este archivo: Cart.elm.

Un verdadero depurador para front-end

La mayoría de los problemas obvios son detectados e informados por el propio compilador. Sin embargo, ninguna aplicación está a salvo de errores lógicos.

Dado que todos los datos en Elm son inmutables y todo sucede a través de mensajes que se pasan a la función de actualización, todo el flujo de un programa de Elm se puede representar como una serie de cambios en el modelo. Para el depurador, Elm es como un juego de estrategia por turnos. Esto permite que el depurador realice algunas hazañas realmente sorprendentes, como viajar en el tiempo. Puede avanzar y retroceder a través del flujo de un programa saltando entre varios cambios de modelo que ocurrieron durante la vida útil de un programa.

Puede obtener más información sobre el depurador aquí.

Interactuando con un back-end

Entonces, dices, hemos construido un buen juguete, pero ¿se puede usar Elm para algo serio? Absolutamente.

Conectemos el front-end de nuestro carrito con un back-end asíncrono. Para hacerlo interesante, implementaremos algo especial. Digamos que queremos inspeccionar todos los carritos y su contenido en tiempo real. En la vida real, podríamos usar este enfoque para aportar algunas capacidades adicionales de marketing/ventas a nuestra tienda o mercado en línea, o hacer algunas sugerencias al usuario, o estimar los recursos de stock necesarios, y mucho más.

Entonces, almacenamos el carrito en el lado del cliente y también informamos al servidor sobre cada carrito en tiempo real.

Para mantener las cosas simples, implementaremos nuestro back-end usando Python. Puede encontrar el código completo para el back-end aquí.

Es un servidor web simple que utiliza un WebSocket y realiza un seguimiento del contenido del carrito en la memoria. Para simplificar las cosas, mostraremos el carrito de todos los demás en la misma página. Esto se puede implementar fácilmente en una página separada o incluso como un programa Elm separado. Por ahora, cada usuario podrá ver el resumen de los carritos de otros usuarios.

Con el back-end en su lugar, ahora necesitaremos actualizar nuestra aplicación Elm para enviar y recibir actualizaciones del carrito en el servidor. Usaremos JSON para codificar nuestras cargas útiles, para lo cual Elm tiene un soporte excelente.

CartEncoder.elm

Implementaremos un codificador para convertir nuestro modelo de datos Elm en una representación de cadena JSON. Para eso, necesitamos usar la biblioteca 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)

La biblioteca proporciona algunas funciones (como string , int , float , object , etc.) que toman objetos de Elm y los convierten en cadenas codificadas con JSON.

CartDecoder.elm

Implementar el decodificador es un poco más complicado ya que todos los datos de Elm tienen tipos y necesitamos definir qué valor JSON debe convertirse a qué tipo:

 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"

Aplicación Elm actualizada

Como el código final de Elm es un poco más largo, puede encontrarlo aquí. Aquí hay un resumen de los cambios que se han realizado en la aplicación frontal:

Hemos envuelto nuestra función de update original con una función que envía cambios al contenido del carrito al back-end cada vez que se actualiza el carrito:

 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)

También hemos agregado un tipo de mensaje adicional de ConsumerCarts String para recibir actualizaciones del servidor y actualizar el modelo local en consecuencia.

La vista se ha actualizado para mostrar el resumen de los carritos de los demás mediante la función consumersCartsView .

Se ha establecido una conexión WebSocket para suscribirse al back-end para escuchar los cambios en los carros de los demás.

 subscriptions : Model -> Sub Msg subscriptions model = WebSocket.listen server ConsumerCarts server = "ws://127.0.0.1:8765"

También hemos actualizado nuestra función principal. Ahora usamos Html.program con parámetros adicionales de init y subscriptions . init especifica el modelo inicial del programa y subscription especifica una lista de suscripciones.

Una suscripción es una forma de decirle a Elm que escuche los cambios en canales específicos y reenvíe esos mensajes a la función de 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)

Finalmente, hemos manejado la forma en que decodificamos el mensaje de ConsumerCarts que recibimos del servidor. Esto asegura que los datos que recibimos de una fuente externa no dañarán la aplicación.

 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)

Mantenga sus front-ends sanos

El olmo es diferente. Requiere que el desarrollador piense diferente.

Cualquiera que venga del ámbito de JavaScript y lenguajes similares se encontrará tratando de aprender la forma de hacer las cosas de Elm.

Sin embargo, en última instancia, Elm ofrece algo que otros marcos, incluso los más populares, a menudo luchan por hacer. Es decir, proporciona un medio para crear aplicaciones front-end sólidas sin enredarse en un código enorme y detallado.

Elm también abstrae muchas de las dificultades que plantea JavaScript al combinar un compilador inteligente con un potente depurador.

Elm es lo que los desarrolladores front-end han estado anhelando durante tanto tiempo. Ahora que lo ha visto en acción, pruébelo usted mismo y aproveche los beneficios creando su próximo proyecto web en Elm.