- PVSM.RU - https://www.pvsm.ru -
Перевод статьи 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>
Точка входа для 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].
Мы будем использовать три метода публичного 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.
Теперь мы определяем метод 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/
[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
Нажмите здесь для печати.