Pull to refresh

Scala.js легко и просто

Reading time7 min
Views20K
Давайте представим, что нужно для нашего любимого 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. В коде могут быть огрехи, использовать на свой страх и риск. Всем добра!
Tags:
Hubs:
+19
Comments13

Articles