И тут в голову закрадываются плохие мысли, а может 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
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.
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:
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)
Kanumowa
16.01.2017 20:39А если кнопка меняет свое состояние (Enabled/Disabled) страница перезагружается?
voooka
16.01.2017 20:47Не совсем понял вопрос. Если про workbench, то страница перезагружается только, если меняется код, ресурсы (но это можно еще настроить). Если мы из кода биндим attribute на какое-то свойство (через ObservedValue например) и его меняем, страница не перезагружается. Это SPA и перезагружать его можно тогда, когда этого захочется самому.
smartkrio
17.01.2017 13:04Статья хорошая, 2 проекта у себя в компании завели на scala.js, идеально подходит для построения прототипов фронтенда под скаловский бэк.
vba
18.01.2017 14:49-1По мне так Scala.js выглядит довольно таки неуклюже по сравнению с элегантностью Elm и другими функциональными языками доступными для веба. Какова ее ниша? Я например не представляю как вот это можно использовать с React или Angular.
solver
18.01.2017 18:05Очень просто можно использовать.
Во первых, сам по себе язык хороший и достаточно мощный. Заметьте, не идеальный.
Во вторых, изучив язык ты можешь его использовать и на фронте и на беке. И даже бэк может быть например Node.js.
Единовая кодобаза и все плюсы проистекающие из этого.
Т.е. это не язык для обычной web разработки, как-то странички, информационные сайтики и т.д.
Это для, если так можно выразиться, web приложений. Более сложных чем обычные информационные сайтики.
И в этом оно очень удобно. Хотя и может конечно использоватья для них, если команда хочет.
Мне вот наоборот не понятно, зачем изучать «вещи в себе», такие как: cofe script, type script, elm и т.д. и т.п.
Это одноразовые вещи, эти знания больше нигде не применить. И тут уже не важна мифическая «элегантность языка», если для каждой новой задачи тебе надо учить новый язык, с новой инфраструктурой и со своими заморочками. Тем более, что эти одноразовые вещи довольно быстро умирают, т.к. более мейнстримовые языки впитывают идеи из них. Либо через развитие языка, либо через библиотеки.
В этом плане мне больше нравится подход Scala/Scala.js или Clojure/Clojurescript. Более прагматичный подход в целом. Когда инвестированное время не пропадает зря ради мифической «элегантности».vba
18.01.2017 18:21Ну следует заметить что такая вещь в себе как TypeScript навряд ли покинет сцену скоро. В том то и дело что Clojurescript и другие порты scheme-like выглядят намного элегантней и более востребованным для node.js, чем та же scala. Зачем кому-то писать на scala под node.js, когда есть акка, play!, и прочие плюшки.
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, кто-то выберет что-то другое.
Но главное, чтобы не было зоопарка.vba
18.01.2017 19:00Нет я вас прекрасно понял, но у нас подходы разные. Я исхожу из того что один инструмент для одной определенной сферы. Зачем язык которые умеет запрашивать БД, делать кофе и его можно перевести без труда на перфокарты. Согласен с вами что когда у вас 35 разных языков это уже перебор, но когда один язык для клиентской части, один для бэкенда, один для скриптинга около DevOps-ых задач это уже не так уж и много. Не вижу смысла "притягивать за уши" бэкенд язык к вебу.
Как не крути у вас все равно будет как минимум 2 языка в команде, один системный другой для всего остального, если вы конечно не пишите на python.
senia
Инфраструктуры, кстати, уже не так мало. Есть как совместимые scala библиотеки, так и множество биндингов JS библиотек.
Я пробовал биндинг ReactJs — остались положительные впечатления.
AlexPu
Правильно ли японимаю, что scala.js можно использовать для разработки с React.js без танцев с бубном?
А как насчет Angular?
я так например жду, когда scala.js дозреет… Хотя и не слежу пристально…
senia
На сейте есть список фасадов.
Я использовал scalajs-react + diode.
Для первого angular есть фасад. Я его года 2 назад пробовал использовать — понравилась типизация $scope. Но глубоко не копал.
Для второго ангулара фасад в разработке: angulate2.
AlexPu
Да… надо будет посмотреть — спасибо