Scala.js(+Laminar)でちょっと変わった Web フロント開発

最近 Web 開発に傾倒しつつあるクライアントエンジニアの@Pctg_x8です。

今年に入って Scala3 の RC が外れたのをきっかけに少しずつ趣味の範囲で触っています。 その中で、今回は Scala コードを JavaScript コードにコンパイルできる Scala.js と、その上で動く UI ライブラリの Laminar の紹介と、触ってみた感じの話を書きます。

Scala.js とは

Scala で書いたコードを JavaScript に変換してくれるコンパイラバックエンドです。 フロント部分は Scala そのものなので Scala3 も問題なく使用することができます。原理上は(厳密には周辺のライブラリなどの対応が微妙に追いついていません)。
Scala3 は最高の言語なので(個人談)、要するに Scala.js を使うと最高の言語で Web 開発ができるようになります。

JavaScript 以外だと webpack や npm など既存のシステム/パッケージが使えなくなるのではないかといった心配があるかと思いますが、scalajs-bundler を使うことでビルドパイプラインに統合できます。単に統合できるどころか、通常 package.json に書くパッケージ依存性定義を build.sbt で一緒に管理できるため便利です。

Scala.js で使えるフレームワーク

Scala.js には公式に dom パッケージがありますが、さすがに生の DOM を直接いじるのはしんどいのでフレームワークが使えると便利です。

Scala.js ではなんと React がほぼそのまま使用できます(というよりのちに紹介する Laminar よりこっちのほうが主流らしい?)。 さすがに JSX 記法は使えないですが、Hooks とかも問題なく使える上 Slinky というフレームワークを使えば JavaScript でクラスコンポーネント作るのとほぼ変わらない形で使えるらしいです。自分はこちらは深く触ってはいないですが、JavaScript やっていて他の言語も触ってみたいな、となった際には有力な候補の一つなのではないかと思います。

で、React あるなら React でいいじゃん、とは思いますが、今回はせっかくなので Scala.js 向けに作られたフレームワークも紹介します。

Laminar は Scala.js 向けに作られたフレームワーク(ライブラリ)の一つです。 こちらは仮想 DOM を用いた差分管理をするタイプではなく、ネイティブの DOM ツリーを直接管理するタイプになります。 Laminar は他のフレームワークと違ってコンポーネントの概念が存在しません。Laminar は DOM ツリーを構築しやすくするための DSL と、後述する Airstream と連携して必要な時にレンダリングする機能を提供するだけです。そういった意味ではフレームワークというよりはライブラリに近いかもしれません。

Airstream は Laminar と同じ作者によるライブラリで、状態管理および FRP(Functional Reactive Programming)基盤を提供します。Laminar で状態の変化に応じて表示内容を変更したり、ボタンクリックに応じてアクションを行ったりする場合は Airstream の機能を用いて実現することがほとんどです。もちろん他のライブラリを使うこともできますが、Laminar 自体が Airstream と相互運用しやすい形をしているので大きな理由がなければ Airstream を使うのが簡単でしょう。
Airstream は FRP ができるライブラリな訳ですが、FRP を使う場合に直面する問題のひとつである FRP Glitches をうまく回避できたり、Owner の概念をもって購読の自動解除やリークの抑制などができるようになっていたりします。 詳しい話は記事の範囲を超えるので載せませんが、Airstream の README にどうやってグリッチを解消しているか詳しく書かれているので気になる方はぜひ読んでみると良いと思います。

実際に使ってみる

Scala.js

Scala3 は sbt を使うとプロジェクトテンプレートがあって楽なので、sbt を使います。 Scala.js にもプロジェクトテンプレートが存在しますが、こちらは入れるのは大して難しくないので手動で入れます。

$ sbt new scala/scala3.g8

Scala.js は sbt 向けのプラグインの形で導入します。

// project/plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.7.0")
// build.sbt
lazy val root = project
  .in(file("."))
  .enablePlugins(ScalaJSPlugin)
  .settings(
    // ...省略
    scalaJSUseMainModuleInitializer := true,
  )

scalaJSUseMainModuleInitializerは Scala.js の出力ファイルをそのまま実行する際に必要なフラグです。

この時点でsbt runをするとコンパイルされて node 経由で実行されます。

Laminar

これは普通のライブラリなのでlibraryDependenciesに追加すれば OK です。

// build.sbt
lazy val root = project
  .in(file("."))
  .enablePlugins(ScalaJSPlugin)
  .settings(
    // ...省略
    libraryDependencies ++= Seq(
      // ...省略
      "com.raquo" %%% "laminar" % "0.13.0"
    )
  )

試しに簡単なカウンタを作ってみます。 Scala3 からはmainアノテーションをつければそれが勝手にエントリポイントになるのでかなり楽になりました。

import com.raquo.laminar.api.L.*
import org.scalajs.dom

@main
def main() =
  val count = EventBus[Int]()
  renderOnDomContentLoaded(
    dom.document getElementById "app",
    div(
      p(child.text <-- count.events.foldLeft(0)(_ + _)),
      button(onClick.map(_ => 1) --> count, "+")
    )
  )

renderOnDomContentLoadedを使うとDOMContentLoadedイベントを待ってからレンダリングしてくれるようになります。

上記例ではボタンクリックでEventBusに 1 を送り、それをfoldLeftで集計して表示しています。 これはEventBusを使った例ですが、Varを使って手続き的に書くこともできます。

def main() =
  val count = Var(0)
  renderOnDomContentLoaded(
    dom.document getElementById "app",
    div(
      p(child.text <-- count.signal),
      button(onClick --> { _ => count update { _ + 1 } }, "+")
    )
  )

この状態でsbt fastOptJSを実行するとtarget/scala-3.0.0/scala3-simple-fastopt.jsが出来上がります。 ブラウザでこれを実行するには html ファイルが必要なので、ざっくりと作ります。

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>sjs</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="target/scala-3.0.0/scala3-simple-fastopt.js"></script>
  </body>
</html>

scalajs-bundler

scalajs-bundler プラグインを使用することで、通常の webpack を使った開発体験をほぼそのまま sbt 上に持ってくることができます。

scalajs-bundler は次のようにして導入することができます。

// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.20.0")
// build.sbt
lazy val root = project
  .in(file("."))
  .enablePlugins(ScalaJSPlugin, ScalaJSBundlerPlugin)
  // ...省略

これでsbt "fastOptJS / webpack"を実行することで自動で webpack を使ったバンドリングが行われるようになります。

scalajs-bundler はtarget/scala-3.0.0/scalajs-bundler/mainになにもしないwebpack.config.jsを自動生成してくれますが、 プラグインなどを使ってより複雑なことをしたい場合は自前で書いたwebpack.config.jsを使うようにすることも可能です。

今回はCopyWebpackPluginを使って上で作った html ファイルをコピーし、webpack-dev-server を立ち上げてホットリロードしてくれるようにしてみます。

まずはbuild.sbtに依存情報と使う config ファイルの指定を記述します。CopyWebpackPluginはビルド時にしか使わないので devDependencies に指定します。 このときバージョン番号に注意する必要があって、デフォルトでは webpack が 4 系のちょっと古いバージョンを使うようになっているので、それにあったバージョンのcopy-webpack-pluginを指定する必要があります。

// build.sbt settings(...)内
npmDevDependencies in Compile ++= Seq(
  "copy-webpack-plugin" -> "^5.0.0"
),
webpackConfigFile in Compile := Some(file("custom.webpack.config.js"))

custom.webpack.config.jsの中身は次のようにしておきます。いたってシンプルな webpack 構成です。 このファイルはtarget/scala-3.0.0/scalajs-bundler/mainにコピーされてから使われるので__dirnameもそこを指すようになります。

const path = require("path");
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  entry: {
    "scala3-simple-fastopt": path.resolve(
      __dirname,
      "scala3-simple-fastopt.js"
    ),
  },
  output: {
    path: path.resolve(__dirname, "."),
    filename: "[name]-bundle.js",
  },
  plugins: [
    new CopyWebpackPlugin([
      {
        from: path.resolve(__dirname, "../../../../index.html"),
        to: path.resolve(__dirname, "index.html"),
      },
    ]),
  ],
  devServer: {
    port: "8999", // ここはお好み
  },
};

また、html ファイルも js と同じディレクトリにコピーされるので、スクリプトへのパスも同じディレクトリのものを見るように修正します。

これで、sbt "fastOptJS / webpackStartDevServer"で webpack-dev-server を立ち上げることができます。 止めるときはfastOptJS / webpackStopDevServerを使います。

おわり

簡単ですが以上で紹介はおしまいです。

触ってみた感じとしては、数行でさっと書けるのでだいぶ書きやすい印象をもちました。 内部の仕組みも単純でそこまでコーディングスタイルやアーキテクチャを縛るものはないので、使い慣れた MVVM や MVC といったアーキテクチャが自然に組み込めるのも触りやすく利点に感じました。 React でも最近は Hooks とか使ってビューとロジックを分離することはできますが、React の場合は React の機能に依存してしまっているのに対して、Laminar でロジックを分離した場合はロジック部分は UI ライブラリに依存しない純粋な Scala コードになります。そのため、ロジック部分のテストのしやすさといった点でも優れているように感じます。

Scala 自体は個人的には OOP と FP の組み合わさり方がかなり理想のものに近いと感じていて、Scala3 になってから文法周りもインデントベースでシンプルになったので見た目的にもかなり好きな言語です。 Scala.js によって JVM 上で動かす以外にこういう形でも使えるというのはとても嬉しい限りです。

Scala2 はコンパイルが激遅だったんですが、それも 3 ではだいぶ改善されていて良い点のひとつだと思います。さすがに Go や D ほど爆速ではないですが......

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。