Сделайте свой веб-интерфейс надежным с помощью Elm

Опубликовано: 2022-03-11

Сколько раз вы пытались отладить свой веб-интерфейс и оказывались запутанными в коде, связанном со сложными цепочками событий?

Вы когда-нибудь пытались провести рефакторинг кода пользовательского интерфейса, содержащего множество компонентов, созданных с помощью jQuery, Backbone.js или других популярных фреймворков JavaScript?

Одна из самых болезненных вещей в этих сценариях — это попытки следовать многочисленным неопределенным последовательностям событий, а также предвидеть и исправлять все эти варианты поведения. Просто кошмар!

Я всегда искал способы избежать этого адского аспекта разработки веб-интерфейса. Backbone.js хорошо сработал для меня в этом отношении, предоставив веб-интерфейсам структуру, которой им не хватало долгое время. Но учитывая многословие, которое требуется для выполнения некоторых из самых тривиальных вещей, это оказалось не намного лучше.

Сделайте свой веб-интерфейс надежным с помощью Elm

Потом я встретил Элма.

Elm — это статически типизированный функциональный язык, основанный на языке программирования Haskell, но с более простой спецификацией. Компилятор (также созданный с использованием Haskell) анализирует код Elm и компилирует его в JavaScript.

Изначально Elm создавался для фронтенд-разработки, но инженеры-программисты нашли способы использовать его и для серверного программирования.

В этой статье представлен обзор того, как Elm может изменить наши представления о разработке веб-интерфейса, а также введение в основы этого функционального языка программирования. В этом руководстве мы разработаем простое приложение, похожее на корзину для покупок, с помощью Elm.

Почему Эльм?

Elm обещает множество преимуществ, большинство из которых чрезвычайно полезны для создания чистой веб-интерфейсной архитектуры. Elm предлагает лучшие преимущества производительности рендеринга HTML по сравнению с другими популярными фреймворками (даже React.js). Более того, Elm позволяет разработчикам писать код, который на практике не приводит к большинству исключений во время выполнения, характерных для языков с динамической типизацией, таких как JavaScript.

Компилятор автоматически определяет типы и выдает удобные ошибки, информируя разработчика о любой потенциальной проблеме до выполнения.

NoRedInk содержит 36 000 строк Elm и, несмотря на более чем год работы, до сих пор не создал ни одного исключения во время выполнения. [Источник]

Вам не нужно конвертировать все существующее приложение JavaScript только для того, чтобы попробовать Elm. Благодаря превосходной совместимости с JavaScript вы даже можете взять небольшую часть существующего приложения и переписать его на Elm.

У Elm также есть отличная документация, которая не только дает вам подробное описание того, что он может предложить, но также дает вам правильное руководство по созданию веб-интерфейса в соответствии с архитектурой Elm — то, что отлично подходит для модульности, повторного использования кода и тестирования. .

Давайте сделаем простую тележку

Давайте начнем с очень короткого фрагмента кода 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.

Здесь мы определяем корзину как список товаров, где каждый товар представляет собой запись с двумя значениями (товар, которому он соответствует, и количество). Каждый товар представляет собой запись с названием и ценой.

Добавление товара в корзину подразумевает проверку наличия товара в корзине.

Если это так, мы ничего не делаем; в противном случае мы добавляем товар в корзину как новый товар. Мы проверяем, существует ли продукт в корзине, фильтруя список, сопоставляя каждый элемент с продуктом и проверяя, является ли полученный отфильтрованный список пустым.

Чтобы рассчитать промежуточный итог, мы перебираем товары в корзине, находим соответствующее количество и цену продукта и суммируем все это.

Это настолько минималистично, насколько может быть тележка и связанные с ней функции. Мы начнем с этого кода и будем улучшать его шаг за шагом, превращая его в полноценный веб-компонент или программу в терминах 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 помогает разработчикам использовать простой шаблон для структурирования своих веб-интерфейсов, и делает это с помощью концепций, с которыми большинство разработчиков уже знакомы:

  • Модель: модели хранят состояние программы.
  • Представление: представление — это визуальное представление состояния.
  • Обновление: Обновление — это способ изменить состояние.

Если вы думаете о части вашего кода, связанной с обновлениями , как о контроллере, то у вас есть что-то очень похожее на старую добрую парадигму Model-View-Controller (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 начинаются с выполнения основной функции:

 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 », где мы вызываем метод add в cart с product .

Функция обновления будет расширяться по мере роста сложности программы 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 , чтобы установить атрибут стиля для результирующего элемента.

Лучше разделить представление на отдельные функции для лучшего повторного использования.

Для простоты мы встроили CSS и некоторые атрибуты макета непосредственно в код представления. Однако существуют библиотеки, которые упрощают процесс стилизации HTML-элементов из кода Elm.

Обратите внимание на 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 использует подход Virtual DOM, аналогичный подходу React, изменения в пользовательском интерфейсе выполняются только при необходимости, что обеспечивает быструю работу.

Не просто проверка типов

Elm статически типизирован, но компилятор может проверять гораздо больше, чем просто типы.

Давайте внесем изменения в наш тип Msg и посмотрим, как на это отреагирует компилятор:

 type Msg = Add Product | ChangeQty Product String

Мы определили еще один тип сообщения — что-то, что изменит количество товара в корзине. Однако попытка запустить программу снова без обработки этого сообщения в функции обновления приведет к следующей ошибке:

На пути к более функциональной тележке

Обратите внимание, что в предыдущем разделе мы использовали строку в качестве типа для значения количества. Это связано с тем, что значение будет поступать из элемента <input> , который будет иметь строковый тип.

Давайте добавим новую функцию changeQty в модуль Cart . Всегда лучше хранить реализацию внутри модуля, чтобы иметь возможность изменить ее позже, если потребуется, без изменения 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 чем-то лучшим из репозитория: 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

Здесь мы используем функцию format из пакета Numeral. Это отформатирует числа так, как мы обычно форматируем валюты:

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

Автоматическое создание документации

При публикации пакета в репозиторий Elm документация создается автоматически на основе комментариев в коде. Вы можете увидеть его в действии, ознакомившись с документацией для нашего модуля Cart здесь. Все они были сгенерированы из комментариев в этом файле: 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)

Библиотека предоставляет некоторые функции (такие как string , int , float , object и т. д.), которые берут объекты 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 немного длиннее, вы можете найти его здесь. Вот краткое изложение изменений, которые были внесены во внешнее приложение:

Мы обернули нашу исходную функцию 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"

Мы также обновили нашу основную функцию. Теперь мы используем Html.program с дополнительными параметрами init и subscriptions . 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.