Давайте представим, что нужно для нашего любимого Scala backend сервиса (например, который весь на Akka), сделать небольшой frontend. Для внутренних нужд, не переживая за совместимость браузеров, и без дизайна, чтоб совсем простенький: пару табличек, пару формочек, по сокетам что-то обновлялось, моргало, так, по мелочи. И вот начинаешь думать что там в js мире. Angular? Angular 2? React? Vue? jQuery? Или еще что-нибудь? А может просто на ваниле сделать и не переживать? Но руки уже не лежат к JavaScript, не помнят его совсем. То точку с запятой не поставишь, то кавычки не те, то return забыл, то в коллекции нет твоих любимых методов. Понятно что для такой штуки можно и тяп-ляп сделать, но не хочется, совсем не хочется. Начинаешь писать, но все равно что-то не то.

И тут в голову закрадываются плохие мысли, а может Scala.js? Ты их отгоняешь, но не отпускает.

А почему бы и нет?

Некоторые могут задаться вопросом, что это вообще такое? Если коротко, то это библиотека с биндингом на объекты браузера, dom-элементы и компилятор, который берет ваш Scala код и компилирует в JavaScript, также можно делать shared классы между jvm и js, например, case class с encoder/decoder json. Звучит страшно, прям как C++, который можно скомпилировать в JavaScript (хотя это неплохо так работает в связке с WebGL).

И так, с чего бы нам начать? Подключим же его!

// plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.14")
addSbtPlugin("com.lihaoyi" % "workbench" % "0.3.0")

// build.sbt
enablePlugins(ScalaJSPlugin, WorkbenchPlugin)

Добавим index-dev.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Example</title>
    <link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body class="loading">
    <div>Loading...</div>
    <script type="text/javascript" src="../ui-fastopt.js"></script>
    <script type="text/javascript" src="/workbench.js"></script>
    <script>
      example.WebApp().main();
    </script>
</body>
</html>

Заметили workbench? Этот плагин позволяет автоматически компилировать и обновлять страницу, когда вы что-то поменяли. Очень удобно.

Теперь пришло время добавить точку входа example.WebApp

WebApp
import scala.scalajs.js.JSApp
import scala.scalajs.js.annotation.JSExport
import org.scalajs.dom
import org.scalajs.dom.Event
import org.scalajs.dom.raw.HTMLElement

@JSExport
object WebApp extends JSApp {

  @JSExport
  override def main(): Unit = {
    dom.document.addEventListener("DOMContentLoaded", (_: Event) ? {
      dom.document.body.outerHTML = "<body></body>"
      bootstrap(dom.document.body)
    })
  }

  def bootstrap(root: HTMLElement): Unit = {
    println("loaded")
  }
}


В сети есть достаточно подробный мануал Hans-on Scala.js, поэтому особенно задерживаться не будем. Наш проект загружается, обновляется, пришло время писать код. Но так как у нас тут ванила js, в общем-то, особо не разгуляешься. Крайне не удобно создавать dom элементы через document.createElement(«div») и, вообще, работать с ними. Для как раз такого рода вещей есть как минимум пару готовых решений, и да, опять web frameworks… Мы не ищем легких путей, нам не хочется разбираться с большими монстрами и тащить их за собой, мы хотим легкое, маленькое приложение. Давайте сделаем все сами.

Нам потребуется какое-то более привычное и удобное представление dom, хочется биндинг простой, а еще можно немного скопировать принцип и подход из ScalaFX, чтобы попривычнее. Для начала, давайте сделаем простой ObservedList и ObservedValue.

EventListener, ObservedList, ObservedValue
class EventListener[T] {
  private var list: mutable.ListBuffer[T ? Unit] = mutable.ListBuffer.empty
  def bind(f: T ? Unit): Unit = list += f
  def unbind(f: T ? Unit): Unit = list -= f
  def emit(a: T): Unit = list.foreach(f ? f(a))
}

class ObservedList[T] {
  import ObservedList._
  private var list: mutable.ListBuffer[T] = mutable.ListBuffer.empty
  val onChange: EventListener[(ObservedList[T], Seq[Change[T]])] = new EventListener
  def +=(a: T): Unit = {
    list += a
    onChange.emit(this, Seq(Add(a)))
  }
  def -=(a: T): Unit = {
    if (list.contains(a)) {
      list -= a
      onChange.emit(this, Seq(Remove(a)))
    }
  }
  def ++=(a: Seq[T]): Unit = {
    list ++= a
    onChange.emit(this, a.map(Add(_)))
  }
  def :=(a: T): Unit = this := Seq(a)
  def :=(a: Seq[T]): Unit = {
    val toAdd = a.filter(el ? !list.contains(el))
    val toRemove = list.filter(el ? !a.contains(el))
    toRemove.foreach(el ? list -= el)
    toAdd.foreach(el ? list += el)
    onChange.emit(this, toAdd.map(Add(_)) ++ toRemove.map(Remove(_)))
  }
  def values: Seq[T] = list
}
object ObservedList {
  sealed trait Change[T]
  final case class Add[T](e: T) extends Change[T]
  final case class Remove[T](e: T) extends Change[T]
}

class ObservedValue[T](default: T, valid: (T) ? Boolean = (_: T) ? true) {
  private var _value: T = default
  private var _valid = valid(default)
  val onChange: EventListener[T] = new EventListener
  val onValidChange: EventListener[Boolean] = new EventListener
  def isValid: Boolean = _valid
  def :=(a: T): Unit = {
    if (_value != a) {
      _valid = valid(a)
      onValidChange.emit(_valid)
      _value = a
      onChange.emit(a)
    }
  }
  def value: T = _value
  def ==>(p: ObservedValue[T]): Unit = {
    onChange.bind(d ? p := d)
  }
  def <==(p: ObservedValue[T]): Unit = {
    p.onChange.bind(d ? this := d)
  }
  def <==>(p: ObservedValue[T]): Unit = {
    onChange.bind(d ? p := d)
    p.onChange.bind(d ? this := d)
  }
}
object ObservedValue {
  implicit def str2prop(s: String): ObservedValue[String] = new ObservedValue(s)
  implicit def int2prop(s: Int): ObservedValue[Int] = new ObservedValue(s)
  implicit def long2prop(s: Long): ObservedValue[Long] = new ObservedValue(s)
  implicit def double2prop(s: Double): ObservedValue[Double] = new ObservedValue(s)
  implicit def bool2prop(s: Boolean): ObservedValue[Boolean] = new ObservedValue(s)

  def attribute[T](el: Element, name: String, default: T)(implicit convert: String ? T, unConvert: T ? String): ObservedValue[T] = {
    val defValue = if (el.hasAttribute(name)) convert(el.getAttribute(name)) else convert("")
    val res = new ObservedValue[T](defValue)
    res.onChange.bind(v ? el.setAttribute(name, unConvert(v)))
    res
  }
}


Теперь пришло время сделать базовый класс для всех dom-элементов:

abstract class Node(tagName: String) {
  protected val dom: Element = document.createElement(tagName)
  val className: ObservedList[String] = new ObservedList
  val id: ObservedValue[String] = ObservedValue.attribute(dom, "id", "")(s ? s, s ? s)
  val text: ObservedValue[String] = new ObservedValue[String]("")
  text.onChange.bind(s ? dom.textContent = s)

  className.onChange.bind { case (_, changes) ?
    changes.foreach {
      case ObservedList.Add(n) ? dom.classList.add(n)
      case ObservedList.Remove(n) ? dom.classList.remove(n)
    }
  }
}
object Node {
  implicit def node2raw(n: Node): Element = n.dom
}

И немного ui компонентов, вроде Pane, Input, Button:

Pane, Input, Button
class Pane extends Node("div") {
  val children: ObservedList[Node] = new ObservedList
  children.onChange.bind { case (_, changes) ?
    changes.foreach {
      case ObservedList.Add(n) ? dom.appendChild(n)
      case ObservedList.Remove(n) ? dom.removeChild(n)
    }
  }
}
class Button extends Node("button") {
  val style = new ObservedValue[ButtonStyle.Value](ButtonStyle.Default)

  style.onChange.bind { v ?
    val styleClasses = ButtonStyle.values.map(_.toString)
    className.values.foreach { c ?
      if (styleClasses.contains(c)) className -= c
    }
    if (v != ButtonStyle.Default) className += v.toString
  }
}
class Input extends Node("input") {
  val value: ObservedValue[String] = new ObservedValue("", isValid)
  val inputType: ObservedValue[InputType.Value] = ObservedValue.attribute(dom, "type", InputType.Text)(s ? InputType.values.find(_.toString == s).getOrElse(InputType.Text), s ? s.toString)

  Seq("change", "keydown", "keypress", "keyup", "mousedown", "click", "mouseup").foreach { e ?
    dom.addEventListener(e, (_: Event) ? value := dom.asInstanceOf[HTMLInputElement].value)
  }
  value.onChange.bind(s ? dom.asInstanceOf[HTMLInputElement].value = s)

  value.onValidChange.bind(onValidChange)
  onValidChange(value.isValid)

  private def onValidChange(b: Boolean): Unit = if (b) {
    className -= "invalid"
  } else {
    className += "invalid"
  }

  def isValid(s: String): Boolean = true
}


После этого можно сделать свою первую страничку:

class LoginController() extends Pane {
  className += "wnd"
  val email = new ObservedValue[String]("")
  val password = new ObservedValue[String]("")
  children := Seq(
    new Pane {
      className += "inputs"
      children := Seq(
        new Span {
          text := "Email"
        },
        new Input {
          value <==> email
          inputType := InputType.Email
          override def isValid(s: String): Boolean = validators.isEmail(s)
        }
      )
    },
    new Pane {
      className += "inputs"
      children := Seq(
        new Span {
          text := "Password"
        },
        new Input {
          value <==> password
          inputType := InputType.Password
          override def isValid(s: String): Boolean = validators.minLength(6)(s)
        }
      )
    },
    new Pane {
      className += "buttons"
      children := Seq(
        new Button {
          text := "Login"
          style := ButtonStyle.Primary
        },
        new Button {
          text := "Register"
        }
      )
    }
  )
}

И последний штрих:

def bootstrap(root: HTMLElement): Unit = root.appendChild(new LoginController())

Что получилось в итоге


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

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

PS. В коде могут быть огрехи, использовать на свой страх и риск. Всем добра!
Поделиться с друзьями
-->

Комментарии (13)


  1. senia
    16.01.2017 19:21

    Инфраструктуры, кстати, уже не так мало. Есть как совместимые scala библиотеки, так и множество биндингов JS библиотек.
    Я пробовал биндинг ReactJs — остались положительные впечатления.


    1. AlexPu
      17.01.2017 12:12

      Правильно ли японимаю, что scala.js можно использовать для разработки с React.js без танцев с бубном?
      А как насчет Angular?

      я так например жду, когда scala.js дозреет… Хотя и не слежу пристально…


      1. senia
        17.01.2017 12:49

        На сейте есть список фасадов.
        Я использовал scalajs-react + diode.

        Для первого angular есть фасад. Я его года 2 назад пробовал использовать — понравилась типизация $scope. Но глубоко не копал.
        Для второго ангулара фасад в разработке: angulate2.


        1. AlexPu
          17.01.2017 14:39

          Да… надо будет посмотреть — спасибо


  1. Kanumowa
    16.01.2017 20:39

    А если кнопка меняет свое состояние (Enabled/Disabled) страница перезагружается?


    1. voooka
      16.01.2017 20:47

      Не совсем понял вопрос. Если про workbench, то страница перезагружается только, если меняется код, ресурсы (но это можно еще настроить). Если мы из кода биндим attribute на какое-то свойство (через ObservedValue например) и его меняем, страница не перезагружается. Это SPA и перезагружать его можно тогда, когда этого захочется самому.


  1. Kanumowa
    16.01.2017 20:53

    Спасибо, это именно и спрашивал)


  1. smartkrio
    17.01.2017 13:04

    Статья хорошая, 2 проекта у себя в компании завели на scala.js, идеально подходит для построения прототипов фронтенда под скаловский бэк.


  1. vba
    18.01.2017 14:49
    -1

    По мне так Scala.js выглядит довольно таки неуклюже по сравнению с элегантностью Elm и другими функциональными языками доступными для веба. Какова ее ниша? Я например не представляю как вот это можно использовать с React или Angular.


    1. solver
      18.01.2017 18:05

      Очень просто можно использовать.
      Во первых, сам по себе язык хороший и достаточно мощный. Заметьте, не идеальный.
      Во вторых, изучив язык ты можешь его использовать и на фронте и на беке. И даже бэк может быть например Node.js.
      Единовая кодобаза и все плюсы проистекающие из этого.
      Т.е. это не язык для обычной web разработки, как-то странички, информационные сайтики и т.д.
      Это для, если так можно выразиться, web приложений. Более сложных чем обычные информационные сайтики.
      И в этом оно очень удобно. Хотя и может конечно использоватья для них, если команда хочет.

      Мне вот наоборот не понятно, зачем изучать «вещи в себе», такие как: cofe script, type script, elm и т.д. и т.п.
      Это одноразовые вещи, эти знания больше нигде не применить. И тут уже не важна мифическая «элегантность языка», если для каждой новой задачи тебе надо учить новый язык, с новой инфраструктурой и со своими заморочками. Тем более, что эти одноразовые вещи довольно быстро умирают, т.к. более мейнстримовые языки впитывают идеи из них. Либо через развитие языка, либо через библиотеки.
      В этом плане мне больше нравится подход Scala/Scala.js или Clojure/Clojurescript. Более прагматичный подход в целом. Когда инвестированное время не пропадает зря ради мифической «элегантности».


      1. vba
        18.01.2017 18:21

        Ну следует заметить что такая вещь в себе как TypeScript навряд ли покинет сцену скоро. В том то и дело что Clojurescript и другие порты scheme-like выглядят намного элегантней и более востребованным для node.js, чем та же scala. Зачем кому-то писать на scala под node.js, когда есть акка, play!, и прочие плюшки.


        1. solver
          18.01.2017 18:44

          > TypeScript навряд ли покинет сцену скоро
          Согласен. Но от этого больше смысла в его изучении я не вижу).

          >намного элегантней и более востребованным для node.js
          Вы не поняли суть моего высказывания. В целом логика не верная, на мой взгляд.
          Изучать язык «для Node.js» или для «декларативного создания графических интерфейсов» это не очень хорошая затея. Надо изучать хороший, мощный язык, который позволяет не думать в духе «Мне надо изучить язык X для платформы Y». А просто позволит реализовать необходимое. Scala и Clojure это умеют. Можно писать удобно и для бека (JVM, Node.JS) и для фронта (Web).
          Если команда знает Scala, она просто не меня язык подберет себе библиотеки для бека и фронта. Если знает Clojure то же самое. Будет единая кодовая база, единый опыт разработки. Можно развиваться в подходах, методиках, и оттачивать свое мастерство именно как разработчик.
          А когда у тебя вот тут у нас Elm, тут Go, тут Java и постоянно надо изучать новый язык, выходит хрень какая-то. Все по верхам изучается, на уровне копипасты с SO).

          В общем я за подход «один мощный язык — много библиотек реализующих разные подходы», а не «много языков и много библиотек реализующих подходы». Имеется ввиду язык для команды или проекта. Кто-то хочет типизации, возьмет Scala, кто-то за LISP и возьмет Clojure, кто-то выберет что-то другое.
          Но главное, чтобы не было зоопарка.


          1. vba
            18.01.2017 19:00

            Нет я вас прекрасно понял, но у нас подходы разные. Я исхожу из того что один инструмент для одной определенной сферы. Зачем язык которые умеет запрашивать БД, делать кофе и его можно перевести без труда на перфокарты. Согласен с вами что когда у вас 35 разных языков это уже перебор, но когда один язык для клиентской части, один для бэкенда, один для скриптинга около DevOps-ых задач это уже не так уж и много. Не вижу смысла "притягивать за уши" бэкенд язык к вебу.


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