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

в 15:53, , рубрики: bike, javascript, scala, scala.js

Давайте представим, что нужно для нашего любимого 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())

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

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

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

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

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

Автор: voooka

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js