Перевод статьи Pedro Palma Ramos "Building Web applications with Scala.js and React — Part 1"
Мне, как Scala-программисту, разрабатывающему веб-приложения, обычно неприятен переход от аккуратного, функционального и типобезопасного Scala бэкенда к фронтенду, написанному на JavaScript. К счастью, существует мощная и зрелая альтернатива нашему (не всегда) любимому стандартному языку для Web.
Scala.js — это реализация Scala за авторством Sébastien Doeraene, которая компилирует код Scala в JavaScript, а не в байт-код JVM. Она поддерживает полную двустороннюю функциональную совместимость между Scala и JavaScript-кодом и, следовательно, позволяет разрабатывать фронтенд веб-приложения на Scala с использованием библиотек и фреймворков JavaScript. Она также способствует уменьшению дублирования кода по сравнению с обычным веб-приложением на Scala, поскольку позволяет повторно использовать на фронтэнде модели и бизнес-логику, разработанные для серверной части.
React, с другой стороны, представляет собой веб-фреймворк для создания пользовательских интерфейсов в JavaScript, разработанный и поддерживаемый Facebook и другими компаниями. Он способствует чистому разделению между обновлением состояния приложения в ответ на пользовательские события и визуализацией представлений на основе указанного состояния. Поэтому фреймворк React особенно подходит для функциональной парадигмы, которая используется при программировании на Scala.
Мы могли бы использовать React непосредственно со Scala.js, но, к счастью, David Barri создал scalajs-react: библиотеку Scala, которая предоставляет набор обёрток для React, чтобы сделать его типобезопасным и более удобным для использования в Scala.js. Она также определяет некоторые полезные абстракции, такие как класс Callback: составное, повторяемое, побочное вычисление, которое должно выполняться фреймворком React.
Эта статья является первой частью туториала, описывающего, как мы создаём фронтенд веб-приложения с помощью scalajs-react на сайте e.near. Она фокусируется на создании чистого проекта на Scala.js, а во второй части будут сочетаться и Scala.js, и «стандартный» код Scala для JVM. Я предполагаю, что вы являетесь опытным пользователем Scala и по крайней мере знакомы с HTML и основами Bootstrap. Предыдущий опыт работы с JavaScript или фреймворком React не требуется.
Конечным результатом будет простое веб-приложение, использующее открытый API Spotify, для поиска артистов и показа их альбомов и треков (которое вы можете посмотреть здесь). Несмотря на простоту, этот пример должен дать вам представление о том, как разрабатывать веб-приложения в Scala.js React, включая реакцию на ввод пользователя, вызов REST API через Ajax и обновление отображения.
Код, фрагменты которого использованы в этой статье, целиком доступен по адресу https://github.com/enear/scalajs-react-guide-part1.
Настройка
Быстрый способ начать работу с проектом Scala.js — клонировать с помощью GIT шаблон приложения, написанный 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 для управления несколькими компонентами 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 вы можете посмотреть здесь.
Вызов REST API
Мы будем использовать три метода публичного API 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 Scala.js (который для удобства возвращает Future, таким образом гарантируя, что вы не запутаетесь во всех этих обратных вызовах).
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 вы можете обратиться к документации 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:
-->
принимает простой аргументCallback
,==>
принимает функцию(ReactEvent => Callback)
, что полезно, когда вам нужно обработать значение, которое было захвачено из вызванного события.
Вы можете обратиться к документации по 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 и по scalajs-react.
Ожидайте вторую часть туториала, которая будет посвящена созданию полноценного веб-приложения на Scala и тому, как можно переиспользовать модель данных и общую бизнес-логику в бэкенде и фронтенде.
Автор: prokofyev