LoginSignup
2
1

More than 3 years have passed since last update.

scalajs-reactの仮想DOMに見る、Scala.js的ライブラリのアプローチ

Posted at

はじめに

scalajs-reactは、Scala.jsReactを使うためのライブラリです。
この記事では、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から派生していることにより、柔軟な組み合わせが可能となっています。

TagModapplyToという抽象メソッドを持った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から派生した具象クラスとなります(VdomElementtraitです)

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タグ名(divspaninputなど)です。
このクラスは、可変長引数xs: TagMod*を取るapplyメソッドを持っています。

これが、scalajs-reactの仮想DOMで要素を生成する<.タグ名(...)の正体です。

<.divが何を表しているのか見てみましょう。

final def div = HtmlTagOf[*.Div]("div") // *はorg.scalajs.dom.htmlのエイリアス

タグ名"div"を引数にHtmlTagOfのインスタンスを生成していますね。
こうして得られたHtmlTagOf[html.Div]applyTagMod*を引数に取り、TagOfを返しています。

VdomNode

TagModから直接派生したtraitで、JS側のReactのNodeを返すdef rawNodeを持っています。

applyToを実装していますが、BuilderappendChildrawNodeを引き渡すだけのシンプルなものです。

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は、ABCのどれか、を表す型となります。
(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の具象実装がどこにあるのか、というと、すでに登場したTagOfVdomElementから派生した具象実装クラスだったので、この中にあるはずです。
ということで、見てみましょう。

  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を使っているのは、TagOfapplymodifiersを追加する処理が先頭への要素の追加で、これが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自体は再帰的に配列として持てる構造だったので、rawArrayNodeにキャスト出来るということですね。

さて、この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")

VdomAttrAttrのコンパニオンオブジェクトへのエイリアスとして機能していて、結果としてhrefの型はAttr[String]となります。

この場合、def :=[A]メソッドが要求するimplicitなインスタンスはValueType[A, String]となります。
ImplicitsForVdomAttrtraitの中で、各種のインスタンスが定義されていますが、その中に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コードの書き方に関するヒントがたくさんありました。

機会があれば、今回取り上げていない側面、コンポーネント周りであるとか、イベントハンドラ周りも掘り下げてみたいですね。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1