Lambdaカクテル

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

Invite link for Scalaわいわいランド

入門Laminar: 0. もうひとつの世界のReact -- Laminarをビルドする

この記事はScala.jsのUIライブラリであるLaminarをステップバイステップで学習していこうという記事の第一弾だ。

いくつかの記事に分けて、現時点で自分が知っているLaminarの知識や知見をまとめていくつもりだ。

あまりどのような構成にするかは考えていないけれど、Scalaにある程度親しんでいる人がLaminarで基本的なアプリケーションを書けるようになることを目指す。

今回は、Laminarの紹介と初歩的な使い方、ビルド方法を説明する。

Laminarとは

Laminar(発音はラミナー)とは、Scala.jsのために開発されたUIライブラリで、 今最も活発に開発されているScala.jsライブラリのうちの一つだ。

laminar.dev

僕が作成しているマストドンシェアボタンもLaminarで最近書き直され、メンテナビリティが劇的に向上している。

github.com

Scala.js -- TypeScriptの対抗馬

Scala.jsについても軽く説明しておこう。Scala.jsとは、ScalaをJavaScriptにトランスパイルする機構だ。 このプロジェクトはScalaのコードをそのままChromeなどのブラウザで実行できるようにするという野心的なもので、Scalaのめちゃくちゃ強い型システムの上にそのまま乗っかった開発が可能になる。 ScalaのサブセットとしてScala風のScala.js言語があるのではなく、Scala.jsは完全なScalaだ。

www.scala-js.org

最終的にJavaScriptになるという点において、 Scala.jsはTypeScriptと同じだ。Scala.jsはScalaのビルドツールのプラグインとして提供されるため、Scalaの言語機能をそのまま利用できる。

Scala.jsは完全なScalaの機能を利用することができるため、例えば型による導出を用いたボイラープレート不要のプログラミングや、Catsなどの強力な関数型ライブラリの機能をフロントエンド開発に導入できる。JSONライブラリであるCirceもそのまま利用可能だ。

また、TypeScriptで書かれたnpmライブラリに自動的にScalaの型を付けるScalablyTypedというプラグインも存在しており、ほぼシームレスにTypeScriptコードも呼び出すことが可能だ。すごくない?

scalablytyped.org

一方Scala.jsはJVMの外で動作するため、JVM固有の機能(例えばJDKが提供しているライブラリ機能)の一部は利用できない。 それでもScala.jsの開発陣はどんどんpolyfillを充実させており、リリースごとにその差は縮んでいるのが現状だ。 いまも、大抵の機能はうまくJavaScriptにトランスパイルされるようになっている。

ちょっとScala.jsを使ったScalaコードの例を示そう。

// Scala.jsの例
import org.scalajs.dom

@main
def main(): Unit = {
  dom.document.querySelector("#target").innerHTML =
    (1 to 100)
      .toSeq
      .map(_ * 2)
      .mkString("[", ", ", "]")
}

上掲のコードのように、Scala.jsはdomまわりのAPIを提供しているため、 JavaScript/TypeScript同様にブラウザのAPIを呼び出すことが可能だ。このコードを実行すると、#targetのIDを持つ要素に[2, 4, 6, 8, 10, ..., 196, 198, 200]が挿入される。

Scala.jsを利用するにはScalaのビルドツールであるsbtにScala.js用のプラグインを導入するか、 さらにそれと組み合わせてViteを利用するのが定石となっている。

Laminar

Laminarが特徴的なのは、Scalaの言語機能を活用しつつ、scala-js-domというDOM構築ライブラリとairstreamというイベント配送ライブラリを組み合わせて、うまく宣言的なUIを構築できるようにしている点だ。この2つのライブラリによって、複雑な状態を持つUIや、非同期的な処理を孕む処理を最小限の複雑さで記述できる。リークしうるリソースやイベント配送の制御は、Scalaの型システムを用いて秘密裏に制御され、要素が消滅すると自動的に紐付いたリソースが解放されるようになっている。

またLaminarはVDOMを構成せず、前述のairstreamによってリアクティブにDOMを更新する手法でレンダリングを行う。

Laminarを使っている大きな例を示しておこう:

これは110146さんによるテキストエディタの実装。

github.com

i10416.github.io

最小のLaminarプロジェクト

さて、そんなScala.jsの上で動作するLaminarについてこれから入門していく。要素の作成方法などはこれから覚えていくので、まずは最小のLaminarプロジェクトを作成してブラウザで動かすところから始めよう。この構成は全てのLaminarプロジェクトに共通なので、覚えておくと便利だ。

ビルド定義

まずはScala.jsが有効になっているプロジェクトにLamnarの依存性を注入する必要がある。Scala.jsが使えることを確認しよう:

// project/plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.2")

build.sbtにLaminarの依存性を追加しよう。加えてどのモジュールから起動すればよいかを教えてあげよう:

// build.sbt

val scala3Version = "3.3.0"

lazy val root = project
  .in(file("."))
  .settings(
    name := "laminar-example",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := scala3Version,
    libraryDependencies ++= Seq(
      "com.raquo" %%% "laminar" % "16.0.0"
    ),
  )
  .enablePlugins(ScalaJSPlugin)

// ブラウザにはMainモジュールから実行してもらうことを宣言する
scalaJSUseMainModuleInitializer := true

これで、Laminarが依存しているscala-js-domairstreamも自動的に導入される。ビルド側の設定はこれで十分だ。

DOMを組み立てる

ここからは実際のソースコードの記述に入る。LaminarにDOMをレンダーしてもらおう:

// src/main/scala/Main.scala

// Laminar用の必要なimportを全部呼び出す(必要に応じて加減可能)
import com.raquo.laminar.api.L.{*, given}

// DOM操作用API
import org.scalajs.dom

// エントリポイント
@main def main: Unit = {
  // #app要素を探す(この時点ではloadされていないおそれがあるのでlazyにする)
  lazy val appContainer = dom.document.querySelector("#app")
  // loadが完了したらapp要素をレンダーしてもらう
  renderOnDomContentLoaded(appContainer, app)
}

// 要素定義
def app: HtmlElement = div("Hello, ", laminarSpan)
def laminarSpan: HtmlElement = span("Laminar!")

基本的にLaminarの世界観では、頂点となるDOMを1つレンダーし、 そのDOMがまた別のDOMを埋め込むことで呼び出していくというモジュラーな形式でコンポーネントを書いていく。このあたりはReactとかと同じ。

ここではapplaminarSpanを呼び出しているのが分かるだろうか。Laminarの主役はこのHTMLElement型で構成された要素たちだ。

イベントストリーム

さらに発展的には、これらの要素が持つ各種属性がイベントストリームと呼ばれる機構を経由して型安全に結び付いていく、という形式になる。

val n: Var[Int] = Var(0) // 状態を持つイベントストリーム
def btn: HtmlElement = button(
  typ := "button",
  // クリックすると状態に1足していく
  onClick.mapTo(1) --> { one => n.update(_ + one) },
  // childは子要素を指す仮想的な要素
  // ボタンのラベルをイベントストリームに紐付けている
  child <-- n.signal.map(_.toString)
)

上掲のLaminarコードは、クリックするたびにボタンの数字が増えていく動作になる。

gyazo.com

イベントストリームには、文字通りイベントが流れてくるEventStream、 さらにイベントを手動で流す機能を追加したEventBus、さらに「現在の状態」 を保持できるようにしたVarの3種があり、これらを組み合わせて使っていくことになる。

  • EventStream: 何らかのデータが流れてくる
  • EventBus: EventStreamに手動でデータを流す手段を追加したもの
  • Var: EventBusに「現在の状態」を保持する機能を追加したもの

イベントストリームを経由して属性が書き変わった場合は、自動的にLaminarがDOMを書き換えるため、 ユーザは何も気にせず値を書き換えればよい。

動作確認用HTMLファイル

最後に動作確認用のHTMLファイルを用意する:

<!DOCTYPE html>
<!-- dev.html -->
<html>
  <head>
    <meta charset="UTF-8">
    <title>Laminar</title>
  </head>
  <body>
  <script type="text/javascript" src="./target/scala-3.3.0/laminar-example-fastopt/main.js"></script>
  <div id="app"></div>
  </body>
</html>

最小のLaminarプロジェクトをビルドする

これで全てのファイルが揃った。Scala.jsをビルドするにはfastLinkJSを実行すればよい:

$ sbt
> fastLinkJS

すると、target/scala-3.3.0/laminar-example-fastopt/main.jsがビルドされ、 動作確認ページから様子を見られるようになる。

<div id="app"><div>Hello, <span>Laminar!</span></div></div>

dev.htmlを開くと以上のようなHTMLがレンダーされているはずだ。

まとめ

今回はざっとLaminarプロジェクトをビルドする方法を説明した。Laminarはフレームワークというよりはライブラリに近いので、Laminar専用のツールキットといったものはない。あくまで普通のScala.jsのライブラリとして働くし、他のライブラリとも仲良く働く。

次回はLaminarではDOMをどのように書けばよいかなどについて見ていく予定だ。

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