Laminar ではじめる Scala.js フロントエンド
Laminar は Scala で書かれたリアクティブな宣言的 UI ライブラリです. Scala でコードをかいて Scala.js にコンパイルすることでブラウザで動作するフロントエンドアプリケーションをつくれます.
Scala.js と Laminar, Scalacss, Scalatag を使えば、JS を一切使わずに型安全に Scala でフロントエンドを構築することができます. TypeScript の狂気的な型(誉め言葉)や JSのゆるゆるな言語仕様に疲れたそこのあなた! Scala.js を使ってみませんか?
この記事では以下のデモサイトのコードをいくつか抜き出して Laminar の書き方をざっくり紹介します.
このデモでは Laminar と Laika という Scala 製のテキスト変換ツール(pandoc のように中間AST を挟むことで多様な入力形式に対応しています. )を使ってクライアントサイドでリアルタイムにマークダウンやReStructuredText をパースして表示しています.
※ Laika についてはまた別の記事で紹介するかもしれません. テキストの変換を活用して SSG としても使えるので JS や Ruby の SSG に辛くなってきた人は試してみましょう😊
さて、さっそく本題に入りましょう.
ソースコードはここにおいてあります. (スターしてくれると嬉しいなチラッ)
Laminar について
Laminar の基本的な構成要素は次の2つです.
- Airstream
- ReactiveHTMLElement
Airstream
Airstream はデータの流れを扱うための構成要素で、いわゆるリアクティブストリームのような機能を持ちます. 以下のように様々なコンビネータを使ってストリームを合成・変換できます.
var userInput1 = Var("")
Signal
.combine(
userInput1.signal.changes.debounce(600).toSignal(initial = ""),
userInput2.signal.changes.debounce(600).toSignal(initial = ""),
)
.changes
.flatMap { case (i1,i2) => API.send(i1,i2) }
.map {
case Left(value) => "err"
case Right(value) => value
}
Airstream は EventStream と Var に分けられます. EventStream は「順番に値が流れてくるかもしれない」概念を、Var は「順番に値が流れてくるかもしれない、かつ常に現在の値を持つ」概念を抽象化します. userInput1
は初期値を持つので Var
です. 一方で、userInput1
の更新イベント userInput1.signal.changes
は常に値があるとは限らない EventStream です.
他のRx系フレームワークでいう PublishSubject が EventStream, BehaviorSubject が Var に当たると考えてもいいかもしれません.
ReactiveHTMLElement
ReactiveHTMLElement は Airstream と組み合わせてデータの更新を反映して DOM を再レンダリングする機能を抽象化しています.
React のようにUI要素は UI=F(state)
のように関数として設計するとベターです.
UI は html ライクな dsl を使って記述することができます.
object TitleBar {
def apply(title: String): Node = {
div(
className := TitleBarStyle.titleBar.className.value,
h3(
className := TitleBarStyle.titleText.className.value,
title
)
)
}
}
syntax
ストリームの値に応じて変化する要素は以下のように -->
,<--
を使って書きます.
val nameVar = Var(initial = "world")
val rootElement = div(
label("Your name: "),
input(
onMountFocus,
placeholder := "Enter your name here",
inContext { thisNode => onInput.map(_ => thisNode.ref.value) --> nameVar }
),
span(
"Hello, ",
child.text <-- nameVar.signal.map(_.toUpperCase)
)
)
rootElement はテキストフィールドの入力イベントに応じて nameVar
を更新します.
inContext { thisNode => onInput.map(_ => thisNode.ref.value) --> nameVar }
そして、nameVar の変更を検知して span を再描画します.
span(
"Hello, ",
child.text <-- nameVar.signal.map(_.toUpperCase)
)
左右のタブを切り替える UI は以下のように書けます. map
,flatmap
,zip
など、Scala のコレクション API と同じ感覚で使うことができます.
object Togglable {
val hideLeft = Var(initial = false)
def apply(left: Node, right: Node) = div(
Row(hideLeft),
div(
className := TogglableStyle.togglable.className.value,
div(
cls.toggle(TogglableStyle.hide.className.value) <-- hideLeft.signal,
className := TogglableStyle.togglableContent.className.value,
left
),
div(
cls.toggle(TogglableStyle.hide.className.value) <-- hideLeft.signal.map(
!_
),
className := TogglableStyle.togglableContent.className.value,
right
)
)
)
}
よくあるユースケースに対応する関数、例えば値の変化に応じたクラスの toggle に対応した便利な syntax cls.toggle(...)
、が生えています.
cls.toggle(TogglableStyle.hide.className.value) <-- hideLeft.signal.map(
このようにデータの流れとUIの変更をきわめて宣言的に記述することができます.
composability
Laminar はシンプルなライブラリなので他のライブラリとの組み合わせを邪魔しません. Scala.js に対応しているライブラリならどんなライブラリでも使えます. Laika も Scala.js にコンパイルすることができるので全てのコードを Scala で統一できます. ビルドも極めてシンプルになります.
以下のように Laika の Transformer を定義しておけば、Stream.flatMap で Stream も Future も FlatMap できるので、Stream から流れてきた値を渡せます.
object Transformer {
def transform(
input: String,
format: MarkupFormat,
on: RunsOn,
out: OutputFormat
): Future[Either[ParserError, String]] = {
(input, format, on, out) match {
case (input, format, RunsOn.JS, OutputFormat.RenderedHTML) =>
Future.successful(Transformer.transformToRenderedHTML(format, input))
case (input, format, RunsOn.JS, OutputFormat.HTMLSource) =>
Future.successful(Transformer.transformToHTMLSource(format, input))
case (input, format, RunsOn.JS, OutputFormat.ResolvedAST) =>
Future.successful(Transformer.transformToResolvedAST(format, input))
case (input, format, RunsOn.JS, OutputFormat.UnresolvedAST) =>
Future.successful(Transformer.transformToUnresolvedAST(format, input))
case _ => Future.successful(Right("request to remote"))
}
}
}
Signal
.combine(
userInput.signal.changes.debounce(600).toSignal(initial = ""),
inputMode,
runsOn,
outputMode
)
.changes
.flatMap { case (i, f, o, out) => Transformer.transform(i, f, o, out) }
Stream でインターフェースが共通化されているので、この変換処理をバックエンドにリクエストしてレスポンスを持つような設計にしたとしてもコードはほとんど変更しないでいいのでうれしいですね(^ω^)
それ以外にも、例えば、RPC フレームワークを使ってバックエンドとの通信を隠ぺいするユースケースも考えられますね.
最近、日本語のレイアウトを最適化する budoux が話題になっていましたが、Scala で書けばフロントエンド(Scala.js)でもネイティブ(scala native)でも, サーバー(scala jvm)でも使えるのになぁ...と思ってしまいました. Write once, Run anywhere ですね(^ω^)(アレ、どこかで聞いたことがあったような...)
scalacss
私は scala に魂を売ってしまったので css が書けない体になってしまいましたがそれでも大丈夫. scalacss を使えば型安全にスタイルシートを書くことができます.
※ ↑にでてきた TogglableStyle.hide.className.value
などは scalacss の機能を使ってクラス名を生成しています.
object CommonStyle extends StyleSheet.Standalone {
import dsl._
"h1" - (
fontSize(28.px)
)
"h2" - (
fontSize(24.px)
)
"table" - (
borderSpacing(0.px),
borderCollapse.collapse,
borderWidth(1.px),
borderStyle.solid,
)
"blockquote" - (
fontStyle.italic,
marginLeft(15.px),
padding(8.px)
)
other extensions
また、Laminar の拡張用ライブラリとしてルーティングに使うwaypoint, さまざまな拡張機能の入った laminext 、アニメーション機能のanimas が OSS で公開されています. laminext には tailwindcss などフロントエンドエンジニアになじみの深いフレームワークのラッパーも用意されています. 今回はえっちらおっちら CSS を Scala で書きましたが、tailwindcss のラッパーを使っても良かったかもしれませんね.
JavaScript にも RxJs や cycle.js などのリアクティブプログラミングライブラリがありますが、JavaScript や TypeScript の表現力では辛い場面があるのではないでしょうか? そんなとき Scala の高い表現力、関数型フレンドリーな文法が役に立つかもしれませんよ?(^ω^)
Laminar, udash framework,scalajs reactなどの充実したライブラリ、バックエンドとのシームレスなコミュニケーション、ScalablyTyped で TypeScript -> Scala の型自動生成、文法が改善されコンパイルも速くなった Scala 3....
俺たちの戦いはこれからだ!!!
Laminar の公式のドキュメントやチュートリアルはとても充実しているのでちらっと覗いてみるといいかもしれません.
また、youtube で zio 界隈のつよつよエンジニア kitlangton さんが Laminar を使ってライブコーディングを公開したりしているのでそれを見てみるのもいいかもしれません.
例えば、この動画では magnolia というマクロライブラリを活用して Laminar のフォームの自動生成をしています.
Discussion