Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scala.jsのフロントエンドUIライブラリLaminarの書き味がどんなもんなのかのメモ

驚くなかれ、ScalaはJavascript(ESModuleかCommonJS)にコンパイルできる。それがScala.jsである。

www.scala-js.org

普通に10年開発されていて、普通にぜんぜんScalaが動く。ぜんぜんバグがない。昔はHello,Worldすると20MBのJSが吐き出されるというギャグがあったが、最近は普通に176KiBくらいのJSなので全然使える。 Node.jsでめちゃ強い型システムが使えてうれしい。ScalaっぽいAltJSが動くね〜という感じではなく、本物のScalaがJSにトランスパイルされ、JS環境で本物のScalaが動くという感じ。普段JVMに慣れているせいで、なんかめちゃくちゃ起動早いんですけど!?みたいな驚きがある。

ほんで、そのScala.jsの上に建てられたLaminarというUIライブラリがある。

laminar.dev

普通はこれで終わりなんだけど、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)

Varupdateとか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では、要素に紐付いて何かをする場合は、基本的に要素の中にそれを入れておく必要がある。あくまで-->は「紐付けオブジェクト」を生成するだけ、と覚えておくとよい。

ちなみにVarSignalなどは引数を通じて受け渡しできるので、これを利用してコンポーネント間の通信ができる仕組みになっている。

むすび

というわけでLaminarの初歩を紹介した。これを機にLaminarやScala.jsで遊んでもらえたら嬉しい。自分はScalaのコミュニティ『Scalaわいわいランド』をやっているので、ブログトップのリンクから参加すると色々助けることもできるはずだ。

関連記事

blog.3qe.us

blog.3qe.us

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?