この記事は Scala Advent Calendar 2023 13 日目の記事です。
はじめに
私的には Scala.js の利点として
- JavaScript / TypeScript 向けのライブラリを利用しつつ Scala の書き心地を得られる。
- 純粋関数型プログラミングを容易にできる。
という二点が特に大きいと考えています。
しかし、せっかく Scala で副作用を隔離して気持ちよくコードを書いていても、JS / TS 向けライブラリの中には import するだけで環境変数読み込みや HTTP リクエスト等の副作用が発生するような危険なライブラリが存在します。1
この記事では、そのような 「import で副作用を起こすライブラリ」 をどのように副作用を起こさずに扱うかを書き記したいと思います。
また、この記事では副作用の隔離について Effect runtime として Cats Effect の IO
及びそれを容易に利用できる IOApp
を使用しています。
ご存知ない方に怒られそうなレベルで雑に解説すると map / flatMap を実装して合成を可能にした副作用の実行を遅らせることが出来るデータ構造です。
その程度の認識で読めるとは思いますが、もう少し詳細に理解したい方や興味を持った方は様々な解説などを読んでみると良いかもしれません。
TL;DR
@JSImport()
を利用して読み込んでいたライブラリを scala.scalajs.js.import()
を利用した動的 import にすることで JS ファイルのロード を実行時まで遅延させることができます。
ただし、ScalaJSBundler 等で 1 つのファイルに minify できなくなるという欠点が存在します。
import で副作用が発生する JS の作成
今回はライブラリの代わりとして以下のような JS を library.js
として定義して利用します。
export const value = (() => {
console.log("side-effect!!!!!!!!!!");
return {"nya": "meow"};
})();
仕組みとしては見ての通り、トップレベルに副作用が発生するコードを含む値を定義するコードが記載されているだけです。
普通に書いてみる
まずは何も考えずに普通にコードを書いてみます。
SomethingJSLibrary
の部分は実際には ScalablyTyped での自動生成等、自分で定義しないことがほとんどだと思いますが今回は自分で定義します。
import scala.scalajs.js
import scala.scalajs.js.JSImport
import cats.effect.IO
import cats.effect.IOApp
import cats.effect.ExitCode
import scala.concurrent.duration.*
// ScalablyTyped でよくあるタイプの定義と等価な facade の定義
object SomethingJSLibrary {
@JSImport("./library.js")
@js.native
val value: js.Object = js.native
}
object Main extends IOApp {
// run に定義された IO[ExitCode] は最終的に main 関数として自動で実行される
override def run(args: List[String]): IO[ExitCode] = for {
_ <- IO.sleep(1.seconds) // 副作用のタイミングを明確にするために 1 秒スリープする
_ <- IO.println("test")
_ <- IO.println {
js.JSON.stringify(SomethingJSLibrary.value) // JS 側から取得した値も一応表示する
}
} yield ExitCode.Success // プログラムを成功として返す、今回の場合はおまじないとして考えてもらっても問題ありません
}
正しく副作用を IO で包むことができていれば
$ node dist/index.js
test
side-effect!!!!!!!!!!
{"nya":"meow"}
のような順に出力されるはずですが、上記のコードを実行すると
$ node dist/index.js
side-effect!!!!!!!!!!
test
{"nya":"meow"}
のような順で出力されてしまいました。
また、「side-effect!!!!!!!!!!」 の表示から 1 秒経ってから 「test」 の表示が確認できたため、最初の IO.sleep(1.seconds)
よりも前に副作用が発生していることがわかりました。
生成された JS を確認する
次に、実際どのように import / require が呼ばれているのか生成された JS を確認してみました。
以下の JS コードは fastLinkJS で生成されたコードの冒頭 10 行です。実際のコードは 49562 行にも渡るため当然省略します。
'use strict';
var $i_$002e$002ftest$002ejs = require("./library.js");
var $linkingInfo = Object.freeze({
"esVersion": 6,
"assumingES6": true,
"productionMode": false,
"linkerVersion": "1.13.1",
"fileLevelThis": this
});
var $getOwnPropertyDescriptors = (Object.getOwnPropertyDescriptors || (() => {
2 行目に記載されている require("./library.js")
の部分により JS ファイルのロード直後に副作用が発生するコードが読み込まれるようになっていました。
facade の定義自体を IO の中に入れてみる
Scala.js のトランスパイルの挙動を知らないため、とりあえず物は試しということで @JSImport()
を含む facade 部分をまるごと IO
の中に入れてみます。
import scala.scalajs.js
import scala.scalajs.js.JSImport
import cats.effect.IO
import cats.effect.IOApp
import cats.effect.ExitCode
import scala.concurrent.duration.*
- object SomethingJSLibrary {
- @JSImport("./library.js")
- @js.native
- val value: js.Object = js.native
- }
object Main extends IOApp {
override def run(args: List[String]): IO[ExitCode] = for {
_ <- IO.sleep(1.seconds)
_ <- IO.println("test")
_ <- IO.println {
+ object SomethingJSLibrary {
+ @JSImport("./library.js")
+ @js.native
+ val value: js.Object = js.native
+ }
js.JSON.stringify(SomethingJSLibrary.value)
}
} yield ExitCode.Success
}
再度実行してみたところ以下のような出力結果が得られました。
$ node dist/index.js
side-effect!!!!!!!!!!
test
{"nya":"meow"}
やはりというか、初回の書き方と出力される結果は何も変わらず、副作用が先に発生する状態でした。
js.import を使ってみる
手詰まりで頭を抱えながら何か利用できそうな仕組みは無いかと Scala.js の公式ドキュメントを見直していると、Dynamic import の記述が目に止まりました。
scala.scalajs.js.import[A <: js.Any](specifier: String): js.Promise[A]
というシグネチャのその関数は、JavaScript の import()
に対応する関数です。
これなら生成される JS でも該当箇所に require が生成されるのでは?と考え、使ってみました。
import scala.scalajs.js
import cats.effect.IO
import cats.effect.IOApp
import cats.effect.ExitCode
import scala.concurrent.duration.*
- object SomethingJSLibrary {
- @JSImport("./library.js")
- @js.native
- val value: js.Object = js.native
- }
+ trait SomethingJSLibrary extends js.Object {
+ val value: js.Object
+ }
object Main extends IOApp {
override def run(args: List[String]): IO[ExitCode] = for {
_ <- IO.sleep(1.seconds)
_ <- IO.println("test")
- _ <- IO.println {
- js.JSON.stringify(SomethingJSLibrary.value)
- }
+ lib <- IO.fromPromise(IO(js.`import`[SomethingJSLibrary]("./library.js")))
+ _ <- IO.println(js.JSON.stringify(lib.value))
} yield ExitCode.Success
}
そして実行してみたところ以下のような出力結果が得られました。
$ node dist/index.js
test
side-effect!!!!!!!!!!
{"nya":"meow"}
「test」 の後に 「side-effect!!!!!!!!!!」 が表示されました。大勝利です。
しかし、動的 import を利用している都合上、ScalaJSBundler 等で 1 ファイルに minify 出来なくなってしまいました。
この問題に関しては minifier 側でどうにか出来るのかもしれませんが、記事執筆時点では解決方法を見つけることは出来ませんでした。
まとめ
import 時に副作用が発生するような JS コードを IO
等を利用して副作用を遅延させたい場合、js.import を利用した動的 import によって実現することが出来ます。
しかし、ScalaJSBundler 等の minifier が動的 import として扱うためファイルの圧縮に影響が出ることがありそうです。
解決方法を知っている方が居たら是非教えていただければ幸いです。
まぁそもそもそんなライブラリは滅多に無いのでこの問題に直面したことある人はほとんど居ないかもしれませんが....。
-
例えば、かなり有名所だと
@actions/github
と言う GitHub Actions を制作者向けのライブラリが import 時に環境変数を読み込む副作用を持っています。 ↩