はじめに
scalajs-reactは、Scala.jsでReactを使うためのライブラリです。
この記事では、scalajs-reactの仮想DOMの使い方を紹介しつつ、ScalaとJSの世界を繋ぐアプローチを覗き見してみようと思います。
対象読者
- Scala.jsに興味がある方。
- Reactや仮想DOMが好きな方。
前提条件
- Reactそのものの思想や仕組みはある程度把握しているものとします。
- Scala.jsの基本的な使い方を把握しているものとします。
準備
プロジェクトのセットアップは公式ドキュメントを参照してください。
仮想DOMを記述するファイルではタグのセットをインポートしますが、プレフィックス付きで利用することが推奨されています。(プレフィックスなしにもできますが、名前の衝突の危険性があります)
<.div(^.id := "my-id") // <div id="my-id">
タグはHTMLとSVGが用意されています。
import japgolly.scalajs.react.vdom.html_<^._
import japgolly.scalajs.react.vdom.svg_<^._
使ってみる
詳細はドキュメントを参照してください。
ここでは最低限の使い方をざっくりと紹介します。
手前味噌ですが、私の発表資料もよければどうぞ。
要素
<.タグ名
のシンタックスで要素を表現します。
要素は入れ子にすることができます。
<.ul(
<.li("a"),
<.li("b"),
<.li("c")
)
要素は、型としてはVdomElement
として扱われます。詳細についてはのちほど見たいと思います。
属性
要素の属性は^.属性名
のシンタックスで表現されます。
<.div(
^.id := "my-id"
)
先ほどは要素の入れ子を表現しましたが、属性も子要素と同じように並べて書くことができます。
仕組みはのちほど解説しますが、:=
がTagMod
を返すメソッドになっているのがミソです。
イベントハンドリング
属性として表現しますが、値の設定のみの場合とちょっと異なります。
-->
や==>
を使います。
<.button(
^.onClick --> Callback.alert("clicked!")
)
今回のテーマからは逸れるので、イベントハンドリングについてはこれ以上は触れません。
スタイル
属性にスタイルを指定してもよしなに処理してくれるようになっています。
例えばmargin
はスタイルですが、属性と同じシンタックスで指定できます。
<.div(^.margin := "3px")
内部的にはStyle
という専用の型があり、TagMod
として処理されています。
また、ScalaCSSはscalajs-reactの作者が作ったライブラリで互換性があり、scalajs-reactの仮想DOM上でスタイルをシンプルに結合できます。
詳細はドキュメントを参照してください。
内部を掘り下げてみる
scalajs-reactの仮想DOMは、Scala上の型と、JS上でReactが扱う型との間の橋渡しをします。
japgolly.scalajs.react.raw
パッケージ以下にあるのが、JS側のReactの型に対するファサードです。これらの型に対して、Scala側で扱う型をうまくマッピングして相互運用することになります。
では、Scala側の仮想DOMを表現する型はどのようなものがあるのでしょうか?
主なものを見ていきます。
TagMod
scalajs-reactの仮想DOM表現を支える重要な基底型です。次項以降見ていく仮想DOMの構成要素がこのTagMod
から派生していることにより、柔軟な組み合わせが可能となっています。
TagMod
はapplyTo
という抽象メソッドを持ったtrait
です。
def applyTo(b: Builder): Unit
Builder
は仮想DOMの操作を抽象化したtrait
で、Scala側からJS側のReactの仮想DOMを構築する、などのケースで使用されます。
下記はBuilder
の一部の抜粋です。詳細は割愛しますが、雰囲気は感じていただけるかと思います。
いずれのメソッドも返り値の型はUnit
です。つまり、Builder
は、内部に可変な状態を持ったオブジェクトです。Vdom
の構築処理は高頻度で発生し、高いパフォーマンスが求められるので、こうするのがベストなのでしょう。
trait Builder {
val addAttr : (String, js.Any) => Unit
val addClassName : js.Any => Unit
val addStyle : (String, js.Any) => Unit
val addStylesObject: js.Object => Unit
val appendChild : RawChild => Unit
val setKey : js.Any => Unit
}
TagMod
の責務はapplyTo
メソッドの具象実装を介して、Builder
を使用して仮想DOMを構築することになります。
派生型の詳細は次項以降で順に見ていきますので、ここではTagMod
を利用する(受け取ったり、返したり、コレクションに格納したり)ことにより複雑な入れ子構造である仮想DOMのデータ構造を柔軟に取り扱うことが出来るようになる、ということだけ述べるに留めておきます。
TagOf
TagOf
は、タグセット(HTML/SVG)に対応したタグを表す型です。後述するVdomElement
から派生した具象クラス
となります(VdomElement
はtrait
です)
class TagOf[+N <: TopNode] private[vdom](final val tag: String,
final protected val modifiers: List[Seq[TagMod]],
final val namespace: Namespace) extends VdomElement
modifiers
の型がList
の中にSeq
が入っていますが、この理由はapply
メソッドを見ると分かります。
def apply(xs: TagMod*): TagOf[N] =
copy(modifiers = xs :: modifiers)
つまり、任意の可変長TagMod
を受け付けてイミュータブルに付け足せるようにしているわけですね。
実装を追うとわかるのですが、付け足したTagMod
は最終的に古いものから順に評価されます(VdomElement
の項で触れます)。これにより、最後に付け足したTagMod
で既存の処理を上書きできることになります(というか、それができないと付け足せる意味がないですよね)
次に、HTMLのタグに対応するTagOf
を生成する(ファクトリとして振る舞う)HtmlTagOf
クラスを見てみましょう。
final case class HtmlTagOf[+N <: HtmlTopNode](name: String) extends AnyVal {
def apply(xs: TagMod*): TagOf[N] =
new TagOf(name, xs :: Nil, Namespace.Html)
}
コンストラクタ引数のname
はHTMLタグ名(div
やspan
やinput
など)です。
このクラスは、可変長引数xs: TagMod*
を取るapply
メソッドを持っています。
これが、scalajs-reactの仮想DOMで要素を生成する<.タグ名(...)
の正体です。
<.div
が何を表しているのか見てみましょう。
final def div = HtmlTagOf[*.Div]("div") // *はorg.scalajs.dom.htmlのエイリアス
タグ名"div"
を引数にHtmlTagOf
のインスタンスを生成していますね。
こうして得られたHtmlTagOf[html.Div]
のapply
がTagMod*
を引数に取り、TagOf
を返しています。
VdomNode
TagMod
から直接派生したtrait
で、JS側のReactのNodeを返すdef rawNode
を持っています。
applyTo
を実装していますが、Builder
のappendChild
にrawNode
を引き渡すだけのシンプルなものです。
trait VdomNode extends TagMod {
def rawNode: Raw.React.Node
override def applyTo(b: Builder): Unit =
b.appendChild(rawNode)
}
Raw.React.Node
が何者かというと
type Node = ChildrenArray[Empty | String | JsNumber | Element]
となっています。
ここで、Scala.jsのunion type
が出てきました。
A | B | C
は、A
、B
、C
のどれか、を表す型となります。
(TypeScriptではおなじみかと思います。Scalaも3系からは導入される予定です)
ChildrenArray
もタイプエイリアスですので、こちらも見てみましょう。
type ChildrenArray[A] = A | recursiveTypeAliases.ChildrenArray[A]
A
か、recursiveTypeAliases.ChildrenArray[A]
だそうです。
後者はさらに
type ChildrenArray[A] = js.Array[raw.ChildrenArray[A]]
となり、つまるところ、この型は再帰的に展開される配列となります。
A
は、Empty
か、String
か、JsNumber
か、Element
でした。
なので、Raw.React.Node
は、空か、文字列か、数値か、Elementの、単体か、あるいは再帰的な配列となるかと思います。
これで、入れ子で可変長で、文字列や、数値などのプリミティブも持てるNode
の柔軟なデータ型の表現が実現できています。
VdomElement
前述のVdomNode
から派生したtrait
です。
以下、一部抜粋します。
trait VdomElement extends VdomNode {
override def rawNode = rawElement
def rawElement: Raw.React.Element
}
VdomNode
で宣言された抽象メソッドrawNode
を実装し、Raw.React.Element
を返すdef rawElement
を新たに抽象メソッドとして宣言しています。
このrawElement
はJS側のReactのElement
となります。
rawElement
の具象実装がどこにあるのか、というと、すでに登場したTagOf
がVdomElement
から派生した具象実装クラスだったので、この中にあるはずです。
ということで、見てみましょう。
override lazy val rawElement: Raw.React.Element = {
val b = new Builder.ToRawReactElement()
val arr = new js.Array[Seq[TagMod]]
var current = modifiers
while (current != Nil) {
arr.push(current.head)
current = current.tail
}
var j = arr.length
while (j > 0) {
j -= 1
arr(j).foreach(_.applyTo(b))
}
b.render(tag)
}
lazy val
になっているのは、TagOf
がイミュータブルなので、一度計算すれば結果は変わらないからです。
最初にBuilder
のインスタンスを作成しています。このBuilder
を使い、TagOf
をJS側のElement
に変換します。
modifiers
の型はList[Seq[TagMod]]
でした。
これを一度、while
ループで、js.Array
に詰め替えています。
この後のループでインデックス指定によるランダムアクセスを行なっていますが、List
のままだとそこが遅い、ということなのでしょうか。加えて、Scalaのデータ構造を使うとそれだけでオーバーヘッドがあるので、パフォーマンスを気にする箇所ではJS側のデータ構造の方が良い、というのもあると思います。
ちなみに、List
を使っているのは、TagOf
のapply
でmodifiers
を追加する処理が先頭への要素の追加で、これがList
であればO(1)
で実行できて、かつイミュータブルである、というところなのかと思います。
(この辺、実際に自分でベンチマークを取ったわけではないので、あくまで推測に過ぎないです。ただ、こう書いてあるということは、これが現実的に早いということだと理解しています)
蛇足ですが、パフォーマンスに関して。
大量の(あるいは高頻度で実行されるような)ループ処理の場合、限定されたスコープ内であれば、可変な変数を使ってチューニングすることも検討するのがいいと思います。イミュータブルなデータ構造は状態の変更を伴うメソッド呼び出しの度に新しいデータ構造を作って返します。一度の処理のオーバーヘッドは小さくても、それが大量のループの中で実行されると、無視できない計算量になってきます。
(不安があれば、データ量を見積もって計測しましょう)
...ちょっと話題がそれましたが、戻ります。
最終的に詰め替えたmodifiers
をループで回して順にBuilder
を渡してapplyTo
していきます。
この時、indexを後ろから辿っていますが、この処理で、後に追加したTagMod
が後に評価されるようにしています。
全てを終えたら、Builder.render
メソッドで結果をElement
として返して終了です。
VdomArray
前述のVdomNode
から派生したfinal class
です。
一部抜粋します。
final class VdomArray(val rawArray: js.Array[Raw.React.Node]) extends VdomNode {
override def rawNode = rawArray.asInstanceOf[Raw.React.Node]
}
Node
自体は再帰的に配列として持てる構造だったので、rawArray
はNode
にキャスト出来るということですね。
さて、このVdomArray
には重要な特徴があります。それは、破壊的なNode
の追加が可能というものです。
VdomArray
に生えている2つのメソッドを見てみます。
def +=(n: VdomNode): this.type = {
rawArray push n.rawNode
this
}
def ++=[A](as: TraversableOnce[A])(implicit f: A => VdomNode): this.type = {
for (a <- as)
rawArray push f(a).rawNode
this
}
rawArray
に直接Node
を追加して、自分自身を返していますね。おそらくはパフォーマンス的な要件で必要になると思われますが、外に漏らすと不変条件をぶっ壊す可能性があるので、要注意です(というか、通常の用途では使うことはないのかと思います)
VdomAttr/Attr/Style
(ここにはイベントも含まれるのですが、今回は除外します)
要素に対する属性値を表しています。
前述したように、純粋な属性値の他にスタイルも同様のシンタックスで扱えるように工夫されています。
属性名と値のペアは以下のように生成できます。
^.id := "my-id"
これがTagMod
を返すことは先に軽く触れました。これをもう少し詳しく見ていきたいと思います。
^.属性名
の部分の意味合いは、<.要素名
と似たものです。この呼び出しで、名前に該当する属性(の派生であるスタイルなど)のインスタンスが返ります。そして、そのインスタンスには:=
メソッドが生えています。
:=
メソッドは、抽象クラスであるAttr[-U]
の中で、以下のように宣言されています。
def :=[A](a: A)(implicit t: ValueType[A, U]): TagMod
implicit
な引数として受け取っているValueType[A, U]
は、属性が受け取ることの出来る値(:=
メソッドの右辺)の型に対する制約をかけています。
href
の例(この属性は文字列の値を要求しています)を見てみます。
final def href = VdomAttr[String]("href")
VdomAttr
はAttr
のコンパニオンオブジェクトへのエイリアスとして機能していて、結果としてhref
の型はAttr[String]
となります。
この場合、def :=[A]
メソッドが要求するimplicit
なインスタンスはValueType[A, String]
となります。
ImplicitsForVdomAttr
のtrait
の中で、各種のインスタンスが定義されていますが、その中にValueType[String, String]
も含まれるため、このメソッドに文字列を渡せば無事にコンパイルが通ります。
属性の種類によっては(例えばスタイルなど)この後の処理が若干違ってくるのですが、ここでは汎用の属性の処理だけ見ることにします。
:=
メソッドはValueType[A, U]
のapply
メソッドを呼び出します。
このメソッドは以下のようになっています。
def apply(name: String, value: A): TagMod =
TagMod.fn(b => fn(b.addAttr(name, _), value))
TagMod.fn(f: Builder => Unit)
を利用することで、要素に対して属性を足すという処理を持ったTagMod
として定義されています。
ここで指定したf
は、applyTo
の中で呼び出されます。属性は、適用対象の要素のmodifiersとして保持されているので、applyTo
に引き渡されるBuilder
は、適用対象の要素のコンテキストを持っています。これで、ちゃんと指定した要素に対する属性として機能するようになっているわけです。(適用処理はVdomElement
の項を参照してください)
TagModのパワー
TagMod
の抽象化のおかげで、様々な単位でパーツを切り出して組み合わせたり再利用することが容易になります。
例えば、TagOf
は、apply(xs: TagMod*)
メソッドを使って任意のTagMod
を後から付け加えることができることができました。これは、TagOf
を生成する処理と、TagMod
を生成する処理を分離するなど、柔軟な構成を可能にします。
Viewのヘルパ関数を作る時にもTagMod
による抽象化は重宝します。
汎用的な(例えばコンポーネント的なもの)処理に具象実装を引き渡す時、TagMod
を返す関数を受け取ったりできます。
例えば、あるデータ型のコレクション(ここではList[Item]
とします)を受け取って、何かしらのレイアウトを適用するコンポーネントを作りたい時、各Item
を受け取って、TagMod
を返す関数を引き渡すことができます。
TagMod
の代わりにVdomElement
を返す関数でも十分ではあるのですが、TagMod
の方が抽象度が高く、より汎用的なケースに対応出来ます。
最終的に大きな一つの仮想DOMを組み上げることには変わりはないのですが、それを合理的に小さな部品に分解出来るのが便利なのです。
あとがき
scalajs-reactの仮想DOMまわりの処理について、少し追ってみました。
Scalaという静的な型付けの世界からJSという動的な型付けの世界へ橋渡しをするには、色々な工夫が必要ですが、その一端を垣間見ることができて非常に興味深かったです。
また、パフォーマンスを維持するためにはどんな実装が必要かという観点からも、Scalaコードの書き方に関するヒントがたくさんありました。
機会があれば、今回取り上げていない側面、コンポーネント周りであるとか、イベントハンドラ周りも掘り下げてみたいですね。