驚くなかれ、ScalaはJavascript(ESModuleかCommonJS)にコンパイルできる。それがScala.jsである。
普通に10年開発されていて、普通にぜんぜんScalaが動く。ぜんぜんバグがない。昔はHello,Worldすると20MBのJSが吐き出されるというギャグがあったが、最近は普通に176KiBくらいのJSなので全然使える。 Node.jsでめちゃ強い型システムが使えてうれしい。ScalaっぽいAltJSが動くね〜という感じではなく、本物のScalaがJSにトランスパイルされ、JS環境で本物のScalaが動くという感じ。普段JVMに慣れているせいで、なんかめちゃくちゃ起動早いんですけど!?みたいな驚きがある。
ほんで、そのScala.jsの上に建てられたLaminarというUIライブラリがある。
普通はこれで終わりなんだけど、LaminarのHPは説明に凝り(?)すぎて結局何ができるんだよお前は!?みたいな要素があんまりないので、Reactと比べてどんなもんなのかを紹介するというコーナーです。
依存性とかなんとか準備
Scala.jsなのでScala.jsのプラグインと、Laminarの依存を入れたら終わり。このへんの詳細は参考文献で説明してたりする。
// project/plugins.sbt addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0")
build.sbt
ではどんなモジュールが欲しいのか指定したりする。
// build.sbt import org.scalajs.linker.interface.ModuleSplitStyle lazy val root = project .in(file(".")) .enablePlugins(ScalaJSPlugin) .settings( name := "laminar-and-react", scalaVersion := "3.4.0", scalacOptions ++= Seq("-encoding", "utf-8", "-deprecation", "-feature"), scalaJSUseMainModuleInitializer := true, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) .withModuleSplitStyle(ModuleSplitStyle.SmallModulesFor(List("example"))) }, libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.4.0", libraryDependencies += "com.raquo" %%% "laminar" % "16.0.0", )
バンドラはViteを使うのでpackage.json
をちょっと設定する。
{ "name": "laminar-and-react", "version": "0.1.0-SNAPSHOT", "main": "index.js", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "license": "MIT", "dependencies": { "@scala-js/vite-plugin-scalajs": "^1.0.0", "vite": "^4.1.0" } }
あとはViteの設定。
// vite.config.js import { defineConfig } from "vite"; import scalaJSPlugin from "@scala-js/vite-plugin-scalajs"; export default defineConfig({ plugins: [scalaJSPlugin()], });
あとはScalaのコードを呼び出すだけのmain.js
を書く。
import 'scalajs:main.js'
ぜんぜんScalaの準備よりViteの準備が多いじゃんよ。
最後にindex.html
を書く。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite App</title> </head> <body> <div id="app"></div> <script type="module" src="/main.js"></script> </body> </html>
あとはnpm run vite dev
して、別窓でsbt ~fastOptJS
を実行しておくと自動的にビルドされる。
素朴なカウンタを作る
まずは何事もimport
から。
// 基本的にこの2つをimportすれば全部やってくる import org.scalajs.dom import com.raquo.laminar.api.L.{*, given}
前者はDOMのAPIで、後者がLaminarのAPI。
状態を持つにはReactだとuseState
するけど、LaminarだとVar
を使う。メンタルモデルはだいたい同じ。
val count = Var(0)
Var
はupdate
とかsignal
といったメソッドが生えている。
count.update(_ + 1) // インクリメントする count.set(42) // 上書きする count.now() // 今の値を直接取り出す
これを使ってDOM内の要素と紐付けると値を入れたり出したりできるようになる。ちなみにDOMはdiv("Hi!")
のように、要素名がそのまま関数になって提供されている。
input(className := "foobar", typ := "password") // type属性は名前が被るのでtypということになっている
Laminarで値同士を紐付けるには-->
と<--
を使う(変化しない場合は:=
を利用する)。Laminarはイベントや子要素などを紐付ける対象として用意してくれている。基本的に要素側が左になる。
// クリックイベントを紐付ける button(onClick --> { _ => println("something clicked") }) // Varの変更ごとに表示が更新されるようにする // childは要素の中身に対応するプレースホルダ的な値 div(child <-- count.signal.map(_.toString))
実は-->
は単なる紐付けオブジェクトを返すメソッドなので、外に切り出しても使うことができる。最終的に要素にくっつけると有効になるという寸法。
// クリックしたらprintlnしろという紐付け val clickHandler = onClick --> { _ => println("something clicked") } // それをbutton要素にくっつける button(clickHandler)
試しにおなじみのカウンタを実装する。
// Comparison to React's useState def Counter() = val count = Var(0) val onClickPlus = (_: dom.MouseEvent) => count.update(_ + 1) val onClickMinus = (_: dom.MouseEvent) => count.update(_ - 1) div( h2(s"Count:", child <-- count.signal.map(_.toString)), button(className := "btn btn-primary", onClick --> onClickPlus, "+"), button(className := "btn btn-primary", onClick --> onClickMinus, "-") )
これだけでカウンタが作れた。useState
とそんなに遜色ないのでは?むしろこっちが分かりやすいかもしれない。
実際にこのコードを動かすにはrender
で実際のDOMにコンポーネントをマウントする必要がある。
// src/main/scala/example/Main.scala package example import org.scalajs.dom import com.raquo.laminar.api.L.{*, given} @main def helloWorld(): Unit = val root = dom.document.querySelector("#app") render(root, Counter())
Reactでもrender
を呼んだりすると思うが、それと全く同じ。違和感ないですな。
ちなみに定義したコンポーネントはただのオブジェクトなので他のコンポーネントに埋めて呼び出しても普通に動く。このへんもReactのJSXと同じ感じ。
useEffectっぽいやつ
なんかの値が変化したら特定の処理を実行したいことがある。ReactだとuseEffect
の出番だ。Laminarでも状態に連動して処理をさせるのは簡単だ。
試しに、架空のダークモードのスイッチを作るとする。さっきも使ったけど、Var
の.signal
を利用すると、変化のたびに関数が呼び出されるようにできる。
def DarkSwitch() = val darkMode = Var(false) val handler = (_: dom.MouseEvent) => darkMode.update(!_) // .signal.changesと書くことで、変化した場合のみ起動する val modeEffect = darkMode.signal.changes --> { isDark => if isDark then dom.document.body.dataset("bsTheme") = "dark" else dom.document.body.dataset("bsTheme") = "light" } Container( button("Toggle dark mode", onClick --> handler, className := "btn btn-warning"), modeEffect, // ここに埋める )
ボタンがクリックされて状態が変化するたびに特定の関数が呼ばれるようになった。短く書きたければ本当はonClick
に直接処理を紐付けるのだが、今回は「ある値の変化に応じた副作用を起こす」のが主眼なので、敢えてVar
を利用している。
modeEffect
が要素の中に入れられているのがわかるだろうか。Laminarでは、要素に紐付いて何かをする場合は、基本的に要素の中にそれを入れておく必要がある。あくまで-->
は「紐付けオブジェクト」を生成するだけ、と覚えておくとよい。
ちなみにVar
やSignal
などは引数を通じて受け渡しできるので、これを利用してコンポーネント間の通信ができる仕組みになっている。
むすび
というわけでLaminarの初歩を紹介した。これを機にLaminarやScala.jsで遊んでもらえたら嬉しい。自分はScalaのコミュニティ『Scalaわいわいランド』をやっているので、ブログトップのリンクから参加すると色々助けることもできるはずだ。