13
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

BuckleScript から ReasonReact を使う

Posted at

BuckleScript は OCaml を JavaScript にトランスパイルする AltJS です。
いわゆる関数型 AltJS の一角で、 Elm や PureScript などに近い存在です。

ReasonML もまた、関数型 AltJS の一種で、 OCaml の型システムと JavaScript に近い文法を持つ独特な AltJS です。
この言語は現在のところ、 JavaScript に直接トランスパイルされるのではなく、一旦 OCaml にトランスパイルされた後、 BuckleScript 経由で JavaScript になります。
つまり、本当は AltJS ではなく AltOCaml なのですね。
そういうわけで、 ReasonML から BuckleScript の機能を使う事もできます。

さて、その ReasonML なのですが、かなり独特な文法をしています。
JavaScript に近いので、 JavaScript から来た人は書きやすいのかもとも思うのですが、 正直、私は BuckleScript の方が書きやすいなぁ、とか感じたりします。

しかし、 ReasonML には使えて BuckleScript には使えない機能があります。
それが JSX です。これが問題になるのは、勿論 React を使うときです。
ReasonML 向けの React バインディングである ReasonReact ライブラリがあるので、 ReasonML からは容易に React を使う事ができます。
しかし BuckleScript は JSX を使えないので、 React の関数を生で使って UI を組み立てる他ありません。

jsx in ocaml

というわけで、 BuckleScript だけでどうにかして React を書いていけないかどうか、ちょっと試してみました。

前提

BuckleScript と ReasonReact を組み合わせて使ってみます。
ReasonML のライブラリは BuckleScript からも使う事ができるので、 ReasonReact も使う事ができます。
勿論、 React の JavaScript のライブラリを BuckleScript から直接使う手もあるかと思うのですが、公式が提供してるバインディングを利用する方が後々安全かなぁという軟弱者根性でこれを使ってみる事にします。

今回利用したバージョンは、 bs-platform が 3.1.5 、reason-react が 0.4.2 であるようです。

基本的に、 UI の組み立て部分にのみ焦点を当てます。
というのも、上記したとおり ReasonML と BuckleScript との間には機能的な差異はほとんど無いので、文法が違うだけで、同じ事を行うのは難しく無いからです。
まぁ、その文法の違いが重要で、敢えて BuckleScript から ReasonReact を使おうとしているのですが……。

ReasonML の場合

まず、 ReasonML でどのようにコンポーネントを作るかを見てみましょう。

let component = ReasonReact.statelessComponent("App");

let make = (~name, _children) => {
  ...component,
  render: _self =>
    <div className="container">
      <p>(ReasonReact.string({j|Hello, $name!|j}))</p>
      <p>(ReasonReact.string("This is a component from ReasonReact!"))</p>
    </div>
};

これは単純な、ステートを持たないコンポーネントです。
name という名前のプロパティを取り( ReactJS では Props から取得できますね)、それを使って HTML を組み立てます。
これといったギミックもなく、驚くような挙動も示さない、平々凡々としたコンポーネントですね。

JSX を利用できる ReasonML では、 HTML の組み立てを JavaScript や TypeScript を使った ReactJS と同じように行います。

このコンポーネントは、次のように使います。

ReactDOMRe.renderToElementWithId(<App name="Taro" />, "root");

同じく JSX を利用して、プロパティを渡しつつコンポーネントを組み立てます。

BuckleScript の場合

では、上のコンポーネントを BuckleScript で書き換えてみましょう。

参考にしたのはこの辺りです。
JSX がどのようにデシュガーされるのかが書かれています。

jsx.md

このようになりました。

let component = ReasonReact.statelessComponent "App"

let make ~name _children = {
  component with
  render = fun _self ->
    ReasonReact.createDomElement "div" ~props:[%bs.obj {className= "container"}] [|
      ReasonReact.createDomElement "p" ~props:(Js.Obj.empty ()) [|
        ReasonReact.string {j|Hello, $name!|j}
      |];
      ReasonReact.createDomElement "p" ~props:(Js.Obj.empty ()) [|
        ReasonReact.string "This is a component from ReasonReact!"
      |]
    |]
}

ReasonML から BuckleScript に変わり、文法はほぼ OCaml となりました。
括弧の数などが目に見えて減っていますね。

それで、肝心の HTML を組み立てる部分なのですが、 ReasonReact.createDomElement 関数を使っています。
これは、タグ名、 props (名前付き引数で渡す)、個要素の配列を渡す事で、 JSX を使ったのと同じようにDOM要素を作ってくれる関数です。

props には Js.Obj.t 型の値を渡します。これは JavaScript レベルのオブジェクトを表現する型で、 OCaml のレコードや Map とは全く別物なのですが、 BuckleScriptでは [%bs.obj { ... }] という記法で作る事ができます。
また空オブジェクトは Js.Obj.empty 関数で作る事ができます。

ちなみにこの ReasonReact.createDomElement 関数は、将来的に無くなる予定です。
現在は、 ReasonML の JSX の中で DOM コンポーネントに子要素を配列のまま与える手段が無いので、方便として用意されているようですね。

このコンポーネントを利用するには、以下のようにします。

ReactDOMRe.renderToElementWithId (ReasonReact.element @@ App.make ~name:"Taro" [||]) "root"

JSX で単純に <App name="Taro" /> としていた部分は、 ReasonReact.element 関数と App.make 関数をそれぞれ利用する形式へと膨らんでいます。
この他、 React なので key や ref といった名前のプロパティは特別扱いする必要があります。これらのプロパティの値は、 App.make 関数ではなく ReasonReact.element に引き渡します。

このように、 ReasonReact を BuckleScript から直接使うのは簡単です。
簡単なのですが、やはり、 JSX を利用した場合に比べて全体的に冗長な感は否めません。

JSX と BuckleScript

毎回毎回 ReasonReact.createDomElement と書くのは大変です。

module RR = ReasonReact

とか、

let elem = ReasonReact.createDomElement

とかやるだけでもかなり見通しが良くなりますが、それでも余計な情報が多くてソースコードが見辛くなります。

BuckleScript には ReasonML の JSX を変換する為のマクロが用意されており、例えば上の JSX は、

let component = ReasonReact.statelessComponent "App"
let make ~name  _children =
  {
    component with
    render =
      (fun _self  ->
         ((div ~className:"container"
             ~children:[((p
                            ~children:[ReasonReact.string {j|Hello, $name!|j}]
                            ())[@JSX ]);
                       ((p
                           ~children:[ReasonReact.string
                                        "This is a component from ReasonReact!"]
                           ())[@JSX ])] ())[@JSX ]))
  }

という OCaml のコードにトランスパイルされ、それから JavaScript のコードになります。

/* render */(function () {
    return React.createElement("div", {
                className: "container"
              }, React.createElement("p", undefined, "Hello, " + (String(name) + "!")), React.createElement("p", undefined, "This is a component from ReasonReact!"));
  }),

ご覧の通り、比較的綺麗で見やすい JavaScript コードに変換されています。

しかし問題は、この [@JSX] マクロを使った書き方が、人間にとってはなかなか厳しい見た目をしている事でしょう。
このマクロは ReasonML の JSX を BuckleScript に変換する為の中間表現として、またライブラリ作者向けの機能として用意されているようで、 UI を組み立てる普段遣いの機能としては想定されていないようです。

この記法で大規模な UI を組み立てるのは、ちょっと厳しいかもしれませんね。

しかし、関数型言語の力強さを利用すれば、それに代わる HTML を組み立てる DSL を作るのはそれほど難しくありません。

DSL を作る

早速作ってみましょう。

buckleScriptReact.ml ファイルを作ります。

そして、こんなコードを書いてみます。

module RR = ReasonReact

external s : string -> RR.reactElement = "%identity"

let empty () = Js.Obj.empty ()

let div ?(props=empty ()) children =
  RR.createDomElement "div" ~props children

let p ?(props=(empty ())) children =
  RR.createDomElement "p" ~props children

すると、 UI の組み立てはこんな感じで書けるようになります。

open BucklescriptReact

let component = ReasonReact.statelessComponent "App"

let make ~name _children = {
  component with
  render = fun _self ->
    div ~props:[%obj {className= "container"}] [|
      p [| s {j|Hello, $name!|j} |];
      p [| s "This is a component from ReasonReact!" |]
    |]
}

すっきりとしましたね。関数型 AltJS でよく見る Elm 風の記法で UI を組み立てる事ができました。

用意した関数の中身を見てみましょうか。

s

external s : string -> RR.reactElement = "%identity"

これ、実は ReasonReact.string 関数の定義と全く同じです。

ReasonReact.re
external string : string => reactElement = "%identity";

関数呼び出しのオーバーヘッドを嫌ったので、全く同じ定義を書いています。
(最近の JavaScript エンジンの最適化能力を考えると、別に必要無い配慮である気もします……。)
やっている事は、見せかけの型を変えているだけですね。

div, span

let empty () = Js.Obj.empty ()

let div ?(props=empty ()) children =
  RR.createDomElement "div" ~props children

let p ?(props=(empty ())) children =
  RR.createDomElement "p" ~props children

ReasonReact.createDomElement 関数は、内部では React の createElement() 関数を呼び出しています。
第一引数に type (タグの種類、文字列)、第二引数に props (これは JavaScript のオブジェクト)、第三引数に children (子要素で、 reactElement の配列)を引き渡す事で、仮想 DOM のオブジェクトを生成する関数ですね。詳しくは React のドキュメントを読むのが良いでしょう。

そして TEA 風の DSL を可能ならしめる為に、タグ毎に関数を用意してあげます。
これで、

  div ~props:[%obj {className= "container"}] [|
    p [| s {j|Hello, $name!|j} |];
    p [| s "This is a component from ReasonReact!" |]
  |]

という書き方ができるようになります。
JSX と比べても、まぁ見やすい記法ではないでしょうか。

props の渡し方

上の DSL はそこそこ良く出来ていますが、少し気になる部分もあります。

  • props 引数が少しぎこちない
  • children がリストではなく配列

名前付き引数の props に渡す値が、 Js.t 型になっています。これを作るには、前述したとおり [%bs.obj {...}] という書き方をしてあげる必要があります。
たったこれだけで、 OCaml の世界の中で JavaScript のオブジェクトを作る事ができるというのは、それはそれで凄いのですが、この部分で突如裏側の実装が露出してしまっている感は否めません。
折角 BuckleScript を使っている以上、 JavaScript の世界を意識せずコーディングできる方が好ましいでしょう。

children にリストではなく配列を渡しているという点を問題視しているのも、同様の理由です。
リストにも配列にもリテラルを与え、どちらも平等に扱っているのが OCaml の良い点の一つですが、関数型言語ですから、特に理由が(破壊的変更を行いたい、とか)無ければリストを使う方が自然な事が多いかもしれません。
何より、リストは最小2文字で作れますが配列は最低でも4文字必要です。

これらをどうにかする為に、ちょっと修正してみました。

module RR = ReasonReact

module RD = ReactDOMRe

external s : string -> RR.reactElement = "%identity"

let empty () = Js.Obj.empty ()

let div ?className children =
  RR.createDomElement "div" ~props:(Obj.magic @@ RD.props ?className ()) @@ Array.of_list children

let p children =
  RR.createDomElement "p" ~props:(empty ()) @@ Array.of_list children

何が変わったのでしょうか。

まず、 props という名前の引数を取っていた所が、 className という具体的なプロパティ名のオプショナル引数を取るようになっている点ですね。

これは、今回は className のプロパティしか利用していないので引数はこれだけですが、他のプロパティ(例えば style とか disabled とか)を取るのであれば、名前付き引数をその分増やしていく事になります。今回は最小構成なので必要なプロパティだけを生やしていますが、ライブラリなどを作る事を考えるならそのタグが取りうる全てのプロパティを引数として取ってやる必要があるでしょう。

そしてその、名前付き引数で取得したプロパティを使い、 ReactDOMRe.props 関数を利用して ReactDOMRe.props 型のオブジェクトを作って、それを props 引数として渡しています。 ReactDOMRe.props 型は、実態としては JavaScript のオブジェクトなのですが、 BuckleScript 上では別の型になってしまっているので、 Obj.magic を使って無理やり引き渡しています(丁寧にやるのであれば、変換用の関数を書いてもいいと思います)。

また、 children をリストで取るようになっているので、 Array.of_list で配列に変換してから渡すようにしています。

これらの関数を使うと、 UI 部分はこのように書けます。

let component = ReasonReact.statelessComponent "App"

let make ~name _children = {
  component with
  render = fun _self ->
    div ~className:"container" [
      p [ s {j|Hello, $name!|j} ];
      p [ s "This is a component from ReasonReact!" ]
    ]
}

また、 ?className というのはオプショナル引数ですので、引き渡さない事もできます。

div [
  p [ s "Text" ]
]

HTML を組み立てる DSL として、申し分ないのではないでしょうか?

問題点

綺麗な DSL が出来上がって万々歳、で終われば良いのですが、残念ながらそうもいきません。
まず、今回はプロパティとして className しか使っていないので、それしか取らないような実装にしていますが、実際に何か Web アプリを作る事を考えると、引数に取る必要があるプロパティはもっともっと多くなるでしょう。この辺りあまり詳しくないので、一般的にどの要素がどのプロパティを取り得るのか全てを知ってはいないのですが、 Form 系の要素など見るからに多くの種類のプロパティを取りそうです。

まぁそれだけなら、一度苦労すればそれまでですし、 DSL を利用する側から見ればあまり関係のない話かもしれません。

問題は、 React コンポーネントを呼び出す場合です。
こればかりは、綺麗に書く手段がありません。

div [
  (ReasonReact.element @@ App.make ~className:"application" [
    p [ s "..." ]
  ])
]

のように冗長な記述をする他ないのです。
綺麗に書きたいのであれば、モジュールの中にヘルパ関数を作るという解決策もありますが、これもボイラープレートをコンポーネント毎に書き連ねる事になりそうで、中々辛そうです。

私はちょっと思いつかなかったのですが、実は何かいい方法があったりするのでしょうか。

JavaScript から見る

さて、この BuckleScript のコードはどのようにトランスパイルされるのでしょうか。

          /* render */(function () {
              return div(/* Some */["container"], /* :: */[
                          p(/* :: */[
                                "Hello, " + (String(name) + "!"),
                                /* [] */0
                              ]),
                          /* :: */[
                            p(/* :: */[
                                  "This is a component from ReasonReact!",
                                  /* [] */0
                                ]),
                            /* [] */0
                          ]
                        ]);
            }),

render 部分です。適宜コメントが入っていますが、シンプルな関数呼び出しに変換されています。

function div(className, children) {
  var tmp = { };
  if (className) {
    tmp.className = className[0];
  }
  return Curry._3(ReasonReact.createDomElement, "div", tmp, $$Array.of_list(children));
}

function p(children) {
  return Curry._3(ReasonReact.createDomElement, "p", { }, $$Array.of_list(children));
}

呼び出される関数はこのように変換されています。

まず、 Curry._3 とか $$Array.of_list といった関数呼び出しが挟まっていますね。
そして、 props を組み立てる部分が、

  var tmp = { };
  if (className) {
    tmp.className = className[0];
  }

という、空オブジェクトと if 文の組み合わせに変換されています。
( BuckleScript の Option 型は、 JavaScript 上では配列と 0 を使って表現されます。)

ReasonML の JSX を利用した記法が吐き出す JavaScript と比べて、単純にわかり難く、余計な関数呼び出しが多いものになっていますね。
理想的な AltJS の世界を考えるならば、トランスパイルされた後の JavaScript のコードの見た目がどうであろうとあまり気にする必要はないはずですが、残念ながら、トランスパイラが吐き出した JavaScript のコードと睨めっこする必要がある場面は現実に存在します。
そういう事を考えると、込み入ったコードを吐き出されてしまうのは嬉しくないですね。
とは言え、 BuckleScript は「読めるコード」を生成するのに定評のあるトランスパイラでもあるので、 JSX の自然な変換には劣りますが、可読性という意味ではそれほど悪いものではありません。

(実は私はこのコードを見て、関数呼び出しが多いのでその分オーバーヘッドがかかるのでは無いかと思ったのですが、実際に計測してみたら速度に違いはほとんどありませんでした。 JavaScript の最適化能力は凄いですね。)

幾つか問題点を挙げてきましたが、一番の懸念点は、この UI の組み立て方が ReasonReact が本来想定しているやり方とは異なるという事でしょう。
ReasonReact は ReasonML の JSX で UI を組み立てる事を念頭に開発されているのですから、当然それに合わせて最適化をされる筈ですし、 ReasonML と JSX を使っていれば受けられる筈のサポートも用意されるでしょう。
公式から外れた方法を取るという事は、そういったサポートを諦め、問題が起きれば自分で解決する必要があるという事です。また、場合によってはライブラリ内部向けの API を利用する必要もありますが、そうした API は非互換に変更される事もあるので、追従していく必要があります。これは中々の手間になるかもしれません。

何か止むに止まれぬ事情がある場合を除いて、公式のやり方に従っておくのが良いのではないでしょうか。

まとめ

  • BuckleScript から ReasonML を触るのは簡単
  • BuckleScript で UI 用の DSL を作るのも簡単
  • でもそれを 実用 するかどうかは別問題

その他

個人的に ReasonML の文法が苦手で、なんとかして BuckleScript と ReasonReact で開発したかったのでカッとなって書きました。
本当に BuckleScript から ReasonReact の UI を組み立てる素直な方法は無いのですかね。

13
6
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
13
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?