Lambdaカクテル

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

Invite link for Scalaわいわいランド

刮目せよ!! 2023年秋、TS連携もファイルサイズもUIもイケるようになった最近のScala.js事情の紹介

最近Scala.jsの話をすると結構な人がRTしてくれる。TypeScriptの他にAltJSには今どんなのがあるのかな、という話に引用RTでScala.js今アツいですよという話をしたら結構ウケた。世間的にはTypeScript alternativeに興味がある人も多いようだ。一方、ネットに残っているScala.js情報は数年前のものが多いようで、あまり積極的に日本語での情報発信がなされていない様子。そこで、ここ最近Scala.jsはどういう感じなのか、そしてどうすれば始められるのかという情報をまとめると需要があるかもしれないと思い、書くことにした。

想定読者

  • Scalaのことはちょっと知ってる、あるいは知らない人
  • TypeScriptといったAltJSのことはある程度知ってる人
  • 最近のScala.jsどうなん?という人

そもそもScalaとは

TypeScriptは知っててもScalaは知らないという人もいるかもしれない。Scalaは、以下のような特徴を持つ汎用プログラミング言語である:

  • JVMで動作する
    • Java用ライブラリを透過的に利用できる
    • 高度なJVMの最適化を利用できるため、かなり速い
    • どこでも動く
  • 関数型言語としての特徴と、Java由来のオブジェクト指向のメカニズムを高度に統合している
    • 必要に応じて関数型的な書き方をすることが可能
  • 強力な型システムを保有する
    • 関数型言語としての強みはこの型システムの強力さによるところが大きい
    • 型システムが強力だと、型安全なまま柔軟な表現力を手にすることができる
    • 型システムが強力だと、既に実装されているものから派生した実装を自動的にコンパイラが導いてくれることがある
  • コレクションメソッドがやたらと強力
    • おそらく他のどの言語よりも強力

よく知られたScalaの利用例としてはTwitterのバックエンド、Chatworkのバックエンド、はてなブックマークのバックエンド、MinecraftのMod基盤であるForgeなどがある。加えて、Scalaアプリケーション/ライブラリとしては、分散データ処理基盤であるSparkや、分散システム基盤であるAkka/Pekkoが著名である。

zenn.dev

Scalaはその哲学として、その名前の由来でもあるScalableを軸として据えており、簡潔な記述で小規模なツールから大規模なシステムまでをサポートできる。

典型的なScalaのプログラムは以下のような感じだ:

println("hello, world!") // Hello world

Seq(1, 2, 3, 4, 5).reduce(_ * _) // => 1 * 2 * 3 * 4 * 5 = 120

// pattern matching
case class HttpResponse(status: Int, headers: Map[String, Seq[String]], body: String)

val res: HttpResponse = ...

res match
case HttpResponse(200, _, _) => "OK!"
case HttpResponse(n, _, _) if (n % 100) == 2 => "Successful!"
case _ => "Not Successful!"

また、Scalaの強力な型システムの威力を示す例として、Circeライブラリを使ってクラスをJSONに変換する機構を利用する様子を紹介する:

//> using scala 3.3.0
//> using dep "io.circe::circe-core:0.14.6"
//> using dep "io.circe::circe-generic:0.14.6"

import io.circe._, io.circe.generic.auto._, io.circe.syntax._

// ユーザデータを入れるクラスを定義する
case class User(name: String, age: Int, address: Address)
case class Address(zip: Long, line1: String, line2: String)

// valの型は自動的に推論されるので書かなくてよい
val me = User("Windymelt", 30, Address(1234567, "line1", "line2"))

println(me.asJson.noSpaces)

これを実行すると{"name":"Windymelt","age":30,"address":{"zip":1234567,"line1":"line1","line2":"line2"}}が出力される。一見何の変哲もないJSON操作だが、 一般的なスクリプト言語と比べて、JSONに変換できるかどうかがコンパイル時に保証されるという違いがある。 したがってコンパイルさえ成功すれば実行時に変換に失敗してエラーが起こるということが無いという大きなメリットがある。あるデータをJSONに変換できることが確定しない限りコンパイルを通さない仕組みになっているからだ。この検査は型レベルで行われる。

こういったコンパイル時に数々の動作を保証できる特徴により、Scalaではテストコードの量を大きく減らすことが可能だ。実行時にtypoが原因で失敗するというミスを極限まで減らす設計が可能であり、それはコーディング時の安心感に直接つながる。

Scala.jsとは

Scala.jsはScalaをJavaScriptにトランスパイルしてブラウザ上で実行しようというプロジェクトである。Scalaのサブセットを作ってそれを実行できるミニ言語を作るのではなく、あくまで本物のScalaをJavaScriptにトランスパイルする点に特色がある。このため、Scala用ライブラリのうち多くがそのままScala.jsでも利用できるという強力なメリットを抱えている一方で、TypeScriptやJavaScriptで利用されているライブラリもそのまま呼ぶことができる仕組みが用意されており、既存の資産の有効活用が可能である。

www.scala-js.org

Scala.jsは、TypeScriptと比べると以下のような特徴を持つ:

  • 既存のJavaScriptに型を付けたTypeScriptと比べると、Scala.jsは最初からScalaの型システムを利用するのでかなり素直な振舞いをする
    • 地雷の少なさにつながる
  • Scalaのきわめて豊富なコレクションメソッドを使える
    • コレクションライブラリの再発明をしなくても良い
  • 「なんか有名な地雷」みたいな要素がない
  • 関連ツールがシンプル
    • バージョンがちょっと変わってビルドパイプラインがどんどん壊れるということがない
    • Scalaはビルドツールの出来が良いので、ビルドツール自体のバージョン、言語のバージョン、ライブラリのバージョンが全てプロジェクトディレクトリに局所化される
  • マクロ機構が標準で存在する
    • あんまり使わないけど脱出ハッチにできる

一昔前まではオモシログッズみたいな枠だったんだけど、最近はClosure Compilerによるコンパイル最適化が行われたり、到達性チェックを行うことでバンドルサイズが小さくなったり、TypeScriptの型定義ファイルを読み込んでそのまま使えるようにする便利なプラグインが登場したりと、かなり活発に開発が行われている。ちなみにScala.jsは今年で10周年開発されている。

www.reddit.com

Scala.jsでHello, Worldする

能書きはさておき、実際にScala.jsでHello, Worldを行ってみよう。最初はNode.jsをランタイムとして遊ぶことにする。

scala-cliコマンドを使うので、以下の記事を参考に、Scalaの開発環境を用意してほしい。

blog.3qe.us

次に、以下のようなコードをhellojs.scalaとして保存してほしい:

import scala.scalajs.js

object Main {
  def main(args: Array[String]): Unit = {  
    val msg = "Hello World from Scala.js"  
    println(msg)  
  }
}

なんの変哲もないHello worldだ。

scala-cli--jsパラメータをつけて実行することで、JVM上ではなくScala.jsを経由してJavaScriptに変換した上で実行できる:

% scala-cli --js scalajs.scala
Compiling project (Scala 3.3.0, Scala.js)
Compiled project (Scala 3.3.0, Scala.js)
Hello World from Scala.js

また、scala-clipackageサブコマンドを利用することで、node.jsで実行可能なjsファイルを生成できる:

% scala-cli --power package --js scalajs.scala -o hello.js
% node hello.js
Hello World from Scala.js

Scala CLIはスクリプティング/REPL用途のツールなので、本格的な開発のためにはsbtやmillといったビルドツールを使うことになる。

ブラウザで動くjsを作る

次は、ブラウザでScala.jsを動作させてみよう。Scalaのビルドツールsbtにはテンプレート機能があり、sbt newと入力するとおすすめのテンプレートを提示してくれる。ここではViteを使ったScala.jsのビルド環境を構築する。Viteとtscがそうであるように、Vite用プラグインがビルドツールsbtと連携し、Scala.jsのトランスパイル結果をリンクしてくれるのだ。

% sbt new
Welcome to sbt new!
Here are some templates to get started:
 a) scala/toolkit.local               - Scala Toolkit (beta) by Scala Center and VirtusLab
 b) typelevel/toolkit.local           - Toolkit to start building Typelevel apps
 c) sbt/cross-platform.local          - A cross-JVM/JS/Native project
 d) scala/scala3.g8                   - Scala 3 seed template
 e) scala/scala-seed.g8               - Scala 2 seed template
 f) playframework/play-scala-seed.g8  - A Play project in Scala
 g) playframework/play-java-seed.g8   - A Play project in Java
 i) softwaremill/tapir.g8             - A tapir project using Netty
 m) scala-js/vite.g8                  - A Scala.JS + Vite project
 n) holdenk/sparkProjectTemplate.g8   - A Scala Spark project
 o) spotify/scio.g8                   - A Scio project
 p) disneystreaming/smithy4s.g8       - A Smithy4s project
 q) quit
Select a template:  m # scalajsプロジェクトを作成する
name [scalajs-vite-example]: scalajs-vite # 適当な名前を指定する
use_yarn [TRUE/false]: # yarnを使うならこのままEnterを指定する

% cd scalajs-vite # 作られたプロジェクトに移動する

プロジェクトを作り終わったら、関連ライブラリをインストールしてテストサーバを起動してみよう。まずはyarnを実行し、yarn run devを実行してみる:

% yarn
...
% yarn run dev
...
(初回は少し時間がかかる)
...
  VITE v4.4.9  ready in 29747 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h to show help

ローカルサーバが立ち上がるので、そのアドレスにアクセスすると驚くなかれ、Scala.jsでトランスパイルされたjsが動いている。自動的にソースコードの変更に追従するには、これに加えてsbt '~fastLinkJS'を起動しておく。fastLinkJSはScala.jsをビルドしてjsファイルを生成するコマンドだ。そしてsbtでは~をコマンドの先頭に付けることでファイルの変更を検知して自動的にコマンドを実行し直してくれる。

最終的なバンドルファイルを生成するにはyarn run buildを実行する:

% yarn run build
✓ 9 modules transformed.
dist/index.html                  0.45 kB │ gzip: 0.30 kB
dist/assets/index-1516cc57.css   0.20 kB │ gzip: 0.16 kB
dist/assets/index-f1bf866e.js   27.73 kB │ gzip: 6.55 kB
✓ built in 12.60s
Done in 12.91s.

するとdist/以下に必要なファイルが全て出力される。Scalaの標準ライブラリなどをカバーするために少しファイルサイズが大きくなるが、現代的な通信環境ではあまり問題にならないだろう。

TypeScriptのライブラリを呼び出す

Scala.jsからはJavaScriptを呼び出す機構が元々あるのだが、ScalablyTypedという仕組み(プラグイン)を利用すると、TypeScriptの型定義ファイルを読み込んでそのままScalaから利用できるようにしてしまう。簡単に紹介しておこう。

scalablytyped.org

ScalablyTypedは、ビルドツールのブラグインとして動作し、TypeScriptの型定義ファイルを読み込んでScalaの型定義に変換する。この過程はコンパイルタイミングで透過的に自動的に行われるため、生成されたファイルなどをコミットする必要はない。IDEからはいきなりTypeScriptのライブラリの名前空間が出現するように見える。

ここでは公式チュートリアル通り、chart.jsをScala.jsから呼び出してみよう。

まずはyarnでライブラリを追加しておく。

% yarn add chart.js@2.9.4
% yarn add -D @types/chart.js@2.9.29 typescript@4.9.5

次にScalablyTypedをプラグインとして登録する。project/plugins.sbtに以下のように追記する:

addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta41")

最後にビルド定義でこのプラグインを使うように指示しよう。build.sbtに以下の3行を追加する:

import org.scalajs.linker.interface.ModuleSplitStyle

lazy val root = project
  .in(file("."))
  .enablePlugins(ScalaJSPlugin)
  .enablePlugins(ScalablyTypedConverterExternalNpmPlugin) // 追記
  .settings(
    name := "scalajs-vite",
    scalaVersion := "3.2.2",
    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",
    externalNpm := baseDirectory.value, // 追記
    useYarn := true, // 追記
  )

ここで一度sbtを起動し、~fastLinkJSコマンドを実行することでうまく動くか確認する。初回は型をインポートしてScalaの型定義に変換していくため、しばらく時間がかかる。

さて、chart.jscanvas要素にチャートをレンダする。 index.htmlを編集してタグを増やそう:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <canvas id="chart"></canvas><!-- 追加 -->
    <script type="module" src="/main.js"></script>
  </body>
</html>

次はここに実際にチャートをレンダするコードを書いていく。ScalablyTypedは、インポートしたTypeScriptの型定義をtypings.以下のパッケージに展開する*1。これを使ってsrc/main/scala/example/Main.scalaにチャート作成用の設定を追加する。

val chartConfig = {
  // モジュール直下のシンボルはmod.に展開される。
  // 例えば { ChartData } from 'chart'のようなシンボルがそれ。
  import typings.chartJs.mod.*
  new ChartConfiguration {
      // typeはScalaの予約語なので`type`に展開される
      `type` = ChartType.bar
      data = new ChartData {
        datasets = js.Array(
          new ChartDataSets {
            label = "Price"
            borderWidth = 1
            backgroundColor = "green"
          },
          new ChartDataSets {
            label = "Full price"
            borderWidth = 1
            backgroundColor = "blue"
          }
        )
      }
      options = new ChartOptions {
        scales = new ChartScales {
          yAxes = js.Array(new CommonAxe {
            ticks = new TickOptions {
              beginAtZero = true
            }
          })
        }
      }
  }
}

やや冗長だが、正しくチャートの定義を設定できた。

さらにチャートを描画するためにMain.scalaを編集する:

@main
def helloWorld(): Unit = {
  import typings.chartJs.mod.*
  import scala.scalajs.js.JSConverters.*
  val canvas: dom.HTMLCanvasElement = dom.document.querySelector("#chart").asInstanceOf
  val chart = Chart.apply.newInstance2(canvas, chartConfig)
  // データを追加する。toJSArrayでJavaScriptのArrayに変換できる
  chart.data.labels = Seq("foo", "bar", "buzz").toJSArray
  chart.data.datasets.get(0).data = Seq(10.0, 20.0, 30.0).toJSArray
  chart.data.datasets.get(1).data = Seq(10.0, 20.0, 30.0).toJSArray
  chart.update()
}

これを実行するとブラウザにはchart.jsによるチャートが表示される。

Scala.jsとUIライブラリ

Scala.jsにはLaminarというUIライブラリが存在しており、リアクティブなデータストリームを経由したUIを構築できる。要するにReactみたいなことはできる。ただし、LaminarはVDOMを利用せず、イベントにもとづいて直接要素を編集する。

laminar.dev

Laminarについては以下記事で少し解説している:

blog.3qe.us

Laminarでは、以下のように直感的にデータと要素とを紐付けたり、外部データとの連携をFuture(JSで言うところのPromise)を利用して行えるようになっている。

def counterButton(): Element =
  val counter = Var(0) // Intの状態を持つ
  button(
    tpe := "button",
    "count is ",
    child.text <-- counter, // テキストはcounterの値に紐付く
    // クリック時はcounterを更新する
    onClick --> { event => counter.update(c => c + 1) },
  )
end counterButton

Laminarを利用した例として110416氏のLaika-Laminarがある。

i10416.github.io

まとめ

この記事では最近のScala.js事情を紹介した。これまでScalaを書いたことがある人も書いたことがない人も、Scala.jsによる安全で表現力の高いフロントエンド開発にチャレンジしてみてはいかがだろうか。Smithy4sなどのツールを使えば、フロントエンドとバックエンドで共通のインターフェイス定義を使ったりもできる。

*1:設定で変更可能

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