- PVSM.RU - https://www.pvsm.ru -

Создание веб-приложений с помощью Scala.js и React — часть 1

Перевод статьи Pedro Palma Ramos "Building Web applications with Scala.js and React — Part 1 [1]"

Мне, как Scala-программисту, разрабатывающему веб-приложения, обычно неприятен переход от аккуратного, функционального и типобезопасного Scala бэкенда к фронтенду, написанному на JavaScript. К счастью, существует мощная и зрелая альтернатива нашему (не всегда) любимому стандартному языку для Web.

Scala.js [2] — это реализация Scala за авторством Sébastien Doeraene [3], которая компилирует код Scala в JavaScript, а не в байт-код JVM. Она поддерживает полную двустороннюю функциональную совместимость между Scala и JavaScript-кодом и, следовательно, позволяет разрабатывать фронтенд веб-приложения на Scala с использованием библиотек и фреймворков JavaScript. Она также способствует уменьшению дублирования кода по сравнению с обычным веб-приложением на Scala, поскольку позволяет повторно использовать на фронтэнде модели и бизнес-логику, разработанные для серверной части.

React [4], с другой стороны, представляет собой веб-фреймворк для создания пользовательских интерфейсов в JavaScript, разработанный и поддерживаемый Facebook и другими компаниями. Он способствует чистому разделению между обновлением состояния приложения в ответ на пользовательские события и визуализацией представлений на основе указанного состояния. Поэтому фреймворк React особенно подходит для функциональной парадигмы, которая используется при программировании на Scala.

Мы могли бы использовать React непосредственно со Scala.js, но, к счастью, David Barri [5] создал scalajs-react [6]: библиотеку Scala, которая предоставляет набор обёрток для React, чтобы сделать его типобезопасным и более удобным для использования в Scala.js. Она также определяет некоторые полезные абстракции, такие как класс Callback [7]: составное, повторяемое, побочное вычисление, которое должно выполняться фреймворком React.

Эта статья является первой частью туториала, описывающего, как мы создаём фронтенд веб-приложения с помощью scalajs-react на сайте e.near [8]. Она фокусируется на создании чистого проекта на Scala.js, а во второй части будут сочетаться и Scala.js, и «стандартный» код Scala для JVM. Я предполагаю, что вы являетесь опытным пользователем Scala и по крайней мере знакомы с HTML и основами Bootstrap [9]. Предыдущий опыт работы с JavaScript или фреймворком React не требуется.

Конечным результатом будет простое веб-приложение, использующее открытый API [10] Spotify, для поиска артистов и показа их альбомов и треков (которое вы можете посмотреть здесь [11]). Несмотря на простоту, этот пример должен дать вам представление о том, как разрабатывать веб-приложения в Scala.js React, включая реакцию на ввод пользователя, вызов REST API через Ajax и обновление отображения.

Код, фрагменты которого использованы в этой статье, целиком доступен по адресу https://github.com/enear/scalajs-react-guide-part1 [12].

Настройка

Быстрый способ начать работу с проектом Scala.js — клонировать с помощью GIT шаблон приложения [13], написанный Sébastien Doeraene.

Вам нужно будет добавить ссылку на scalajs-react в файл build.sbt:

libraryDependencies ++= Seq(
  "com.github.japgolly.scalajs-react" %%% "core" % "0.11.3"
)

jsDependencies ++= Seq(
  "org.webjars.bower" % "react" % "15.3.2" / "react-with-addons.js" minified "react-with-addons.min.js" commonJSName "React",
  "org.webjars.bower" % "react" % "15.3.2" / "react-dom.js" minified "react-dom.min.js" dependsOn "react-with-addons.js" commonJSName "ReactDOM",
  "org.webjars.bower" % "react" % "15.3.2" / "react-dom-server.js" minified  "react-dom-server.min.js" dependsOn "react-dom.js" commonJSName "ReactDOMServer"
)

Плагин Scala.js для SBT добавляет параметр jsDependencies. Он позволяет SBT управлять зависимостями JavaScript, используя WebJars. В последствии они компилируются в файл <project-name>-jsdeps.js.

Чтобы скомпилировать код, мы можем использовать команду fastOptJS (умеренная оптимизация — для разработки) или fullOptJS (полная оптимизация — для продакшена) внутри SBT. Создадутся артефакты <project-name>-fastopt/fullopt.js и <project-name>-launcher.js. Первый содержит наш скомпилированный код, а второй — скрипт, который просто вызывает наш основной метод.

Нам также понадобится HTML-файл с пустым тэгом <div>, куда React будет вставлять отрисованный контент.

<!DOCTYPE html>
<html>
<head>
  <title>Example Scala.js application</title>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
</head>
<body>

<div class="app-container" id="playground">
</div>

<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<script type="text/javascript" src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>

<script type="text/javascript" src="./target/scala-2.12/scala-js-react-guide-jsdeps.js"></script>
<script type="text/javascript" src="./target/scala-2.12/scala-js-react-guide-fastopt.js"></script>
<script type="text/javascript" src="./target/scala-2.12/scala-js-react-guide-launcher.js"></script>

</body>
</html>

Построение компонентов React

Точка входа для Scala.js определяется объектом, который наследует трейт JSApp. Это гарантирует, что объект и его основной метод будет экспортирован в JavaScript под их полными именами.

object App extends JSApp {

  @JSExport
  override def main(): Unit = {
    ReactDOM.render(TrackListingApp.component(), dom.document.getElementById("playground"))
  }
}

scalajs-react предоставляет класс Router [14] для управления несколькими компонентами React на одностраничном приложении, но это выходит за рамки данного туториала, поскольку наше приложение состоит только из одного компонента React, который мы будем отображать внутри тэга с идентификатором "playground".

object TrackListingApp {

  val component = ReactComponentB[Unit]("Spotify Track Listing")
    .initialState(TrackListingState.empty)
    .renderBackend[TrackListingOps]
    .build

Все компоненты React должны определять метод render, который возвращает HTML как функцию своих аргументов и/или состояния. Наш компонент не требует аргументов, поэтому используется параметр типа Unit, но он требует объект с состоянием типа TrackListingState. Мы делегируем отрисовку этого компонента классу TrackListingOps, где мы можем также описать методы, которые управляют состоянием компонента.

Состояние нашего приложения будет храниться так:

case class TrackListingState(
  artistInput: String,  // имя артиста
  albums: Seq[Album],   // список альбомов
  tracks: Seq[Track]    // список треков
)

object TrackListingState {
  val empty = TrackListingState("", Nil, Nil)
}

Классы Album и Track определены в следующем разделе.

На другие способы создания компонентов React вы можете посмотреть здесь [15].

Вызов REST API

Мы будем использовать три метода публичного API [16] Spotify:

Метод Точка входа Назначение Возвращаемое значение
GET /v1/search?type=artist Найти артиста artists
GET /v1/artists/{id}/albums Получить альбомы артиста albums*
GET /v1/albums/{id}/tracks Получить песни из альбома tracks*

Этот API возвращает объекты в формате JSON, и они могут быть разобраны с помощью JavaScript. Мы можем воспользоваться этим в Scala.js, определив типы фасадов, которые станут интерфейсом между моделями Scala и JavaScript. Для этого мы пометим трейты с помощью @js.native и унаследуем их от js.Object.

@js.native
trait SearchResults extends js.Object {
  def artists: ItemListing[Artist]
}

@js.native
trait ItemListing[T] extends js.Object {
  def items: js.Array[T]
}

@js.native
trait Artist extends js.Object {
  def id: String
  def name: String
}

@js.native
trait Album extends js.Object {
  def id: String
  def name: String
}

@js.native
trait Track extends js.Object {
  def id: String
  def name: String
  def track_number: Int
  def duration_ms: Int
  def preview_url: String
}

Наконец, мы можем асинхронно вызывать API Spotify с помощью объекта Ajax [17] Scala.js (который для удобства возвращает Future, таким образом гарантируя, что вы не запутаетесь во всех этих обратных вызовах [18]).

object SpotifyAPI {

  def fetchArtist(name: String): Future[Option[Artist]] = {
    Ajax.get(artistSearchURL(name)) map { xhr =>
      val searchResults = JSON.parse(xhr.responseText).asInstanceOf[SearchResults]
      searchResults.artists.items.headOption
    }
  }

  def fetchAlbums(artistId: String): Future[Seq[Album]] = {
    Ajax.get(albumsURL(artistId)) map { xhr =>
      val albumListing = JSON.parse(xhr.responseText).asInstanceOf[ItemListing[Album]]
      albumListing.items
    }
  }

  def fetchTracks(albumId: String): Future[Seq[Track]] = {
    Ajax.get(tracksURL(albumId)) map { xhr =>
      val trackListing = JSON.parse(xhr.responseText).asInstanceOf[ItemListing[Track]]
      trackListing.items
    }
  }

  def artistSearchURL(name: String) = s"https://api.spotify.com/v1/search?type=artist&q=${URIUtils.encodeURIComponent(name)}"
  def albumsURL(artistId: String) =   s"https://api.spotify.com/v1/artists/$artistId/albums?limit=50&market=PT&album_type=album"
  def tracksURL(albumId: String) =    s"https://api.spotify.com/v1/albums/$albumId/tracks?limit=50"
}

Для изучения дополнительных способов взаимодействия с кодом JavaScript вы можете обратиться к документации [19] Scala.js.

Отрисовка HTML-кода

Теперь мы определяем метод render класса TrackListingOps, как функцию от состояния:

class TrackListingOps($: BackendScope[Unit, TrackListingState]) {

  def render(s: TrackListingState) = {
    <.div(^.cls := "container",
      <.h1("Spotify Track Listing"),
      <.div(^.cls := "form-group",
        <.label(^.`for` := "artist", "Artist"),
        <.div(^.cls := "row", ^.id := "artist",
          <.div(^.cls := "col-xs-10",
            <.input(^.`type` := "text", ^.cls := "form-control",
              ^.value := s.artistInput, ^.onChange ==> updateArtistInput
            )
          ),
          <.div(^.cls := "col-xs-2",
            <.button(^.`type` := "button", ^.cls := "btn btn-primary custom-button-width",
              ^.onClick --> searchForArtist(s.artistInput),
              ^.disabled := s.artistInput.isEmpty,
              "Search"
            )
          )
        )
      ),
      <.div(^.cls := "form-group",
        <.label(^.`for` := "album", "Album"),
        <.select(^.cls := "form-control", ^.id := "album",
          ^.onChange ==> updateTracks,
          s.albums.map { album =>
            <.option(^.value := album.id, album.name)
          }
        )
      ),
      <.hr,
      <.ul(s.tracks map { track =>
        <.li(
          <.div(
            <.p(s"${track.track_number}. ${track.name} (${formatDuration(track.duration_ms)})"),
            <.audio(^.controls := true, ^.key := track.preview_url,
              <.source(^.src := track.preview_url)
            )
          )
        )
      })
    )
  }

Код может показаться сложным, особенно, если вы не знакомы с Bootstrap, но имейте в виду, что это не более, чем типизированный HTML. Теги и атрибуты записываются как методы объектов < и ^ соответственно (сначала нужно импортировать japgolly.scalajs.react.vdom.prefix_<^._).

Странные стрелки (--> и ==>) используются для привязки обработчиков событий, которые определены как обратные вызовы Callback [7]:

  • --> принимает простой аргумент Callback,
  • ==> принимает функцию (ReactEvent => Callback), что полезно, когда вам нужно обработать значение, которое было захвачено из вызванного события.

Вы можете обратиться к документации [20] по scalajs-react для более детального изучения того, как создать виртуальный DOM.

Реакция на события

Осталось только определить обработчики событий.

Давайте еще раз взглянем на определение класса TrackListingOps:

class TrackListingOps($: BackendScope[Unit, TrackListingState]) {

Аргумент конструктора $ предоставляет интерфейс для обновления состояния приложения с помощью методов setState и modState. Мы можем определить линзы для всех полей состояния для более краткой записи их обновления.

val artistInputState = $.zoom(_.artistInput)((s, x) => s.copy(artistInput = x))
val albumsState = $.zoom(_.albums)((s, x) => s.copy(albums = x))
val tracksState = $.zoom(_.tracks)((s, x) => s.copy(tracks = x))

Как вы помните, мы используем три обработчика событий:

  • updateArtistInput, когда изменяется имя артиста,
  • updateTracks, когда выбран новый альбом,
  • searchForArtist, когда нажата кнопка поиска.

Начнем с updateArtistInput:

def updateArtistInput(event: ReactEventI): Callback = {
  artistInputState.setState(event.target.value)
}

Методы setState и modState не выполняют обновление сразу, а возвращают соответствующий обратный вызов Callback, так что они здесь подходят.

Для метода updateTracks нам необходимо использовать асинхронный обратный вызов, так как мы должны загрузить список песен в альбоме. К счастью, мы можем преобразовать Future[Callback] в асинхронный Callback с помощью метода Callback.future:

def updateTracks(event: ReactEventI) = Callback.future {
  val albumId = event.target.asInstanceOf[HTMLSelectElement].value
  SpotifyAPI.fetchTracks(albumId) map { tracks => tracksState.setState(tracks) }
}

Наконец, давайте определим метод searchForArtist, который использует все три метода API и полностью обновляет состояние:

def searchForArtist(name: String) = Callback.future {
  for {
    artistOpt <- SpotifyAPI.fetchArtist(name)
    albums <- artistOpt map (artist => SpotifyAPI.fetchAlbums(artist.id)) getOrElse Future.successful(Nil)
    tracks <- albums.headOption map (album => SpotifyAPI.fetchTracks(album.id)) getOrElse Future.successful(Nil)
  } yield {
    artistOpt match {
      case None => Callback(window.alert("No artist found"))
      case Some(artist) => $.setState(TrackListingState(artist.name, albums, tracks))
    }
  }
}

Заключение

Раз вы дошли досюда, то теперь должны уметь моделировать фронтенд веб-приложения с использованием чисто функциональных конструкций в Scala.js. Если заинтересовались, обязательно изучите документацию по Scala.js [21] и по scalajs-react [22].

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

Автор: prokofyev

Источник [23]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/scala/249964

Ссылки в тексте:

[1] Building Web applications with Scala.js and React — Part 1: http://enear.github.io/2017/03/07/scalajs-react-part1/

[2] Scala.js: https://www.scala-js.org/

[3] Sébastien Doeraene: https://github.com/sjrd

[4] React: https://facebook.github.io/react/

[5] David Barri: https://github.com/japgolly

[6] scalajs-react: https://github.com/japgolly/scalajs-react

[7] Callback: https://github.com/japgolly/scalajs-react/blob/master/doc/CALLBACK.md

[8] e.near: http://www.enear.co/

[9] Bootstrap: http://getbootstrap.com/

[10] API: https://developer.spotify.com/web-api/

[11] здесь: http://enear.github.io/pages/spotify-webapp/

[12] https://github.com/enear/scalajs-react-guide-part1: https://github.com/enear/scalajs-react-guide-part1

[13] шаблон приложения: https://github.com/sjrd/scala-js-example-app

[14] Router: https://github.com/japgolly/scalajs-react/blob/master/doc/ROUTER.md

[15] здесь: https://github.com/japgolly/scalajs-react/blob/master/doc/USAGE.md#creating-components

[16] API: https://developer.spotify.com/web-api/endpoint-reference/

[17] Ajax: http://scala-js.github.io/scala-js-dom/#Extensions

[18] обратных вызовах: https://en.wiktionary.org/wiki/callback_hell

[19] документации: https://www.scala-js.org/doc/interoperability/

[20] документации: https://github.com/japgolly/scalajs-react/blob/master/doc/VDOM.md

[21] Scala.js: https://www.scala-js.org/doc/index.html

[22] scalajs-react: https://github.com/japgolly/scalajs-react/blob/master/doc/USAGE.md

[23] Источник: https://habrahabr.ru/post/324260/?utm_source=habrahabr&utm_medium=rss&utm_campaign=sandbox