JavaScript
OCaml
BuckleScript
reasonml

Reason 面白そうだったので、ちょっと触った感想を残す。
まとまってなかったり、情報が古かったりする可能性もあるので注意下さい。

1. BuckleScript

Reason を知る前に、まずは BuckleScript を知る必要があった。

What & Why - Intro - BuckleScript
Concepts Overview - Intro - BuckleScript

BuckleScript は、OCaml Compiler の為の新しい Backend ( 中間形式からコードを生成する部分 ) で、OCaml ( or Reason ) を受け取って、Javascript を生成する

OCaml と Javascript のスムーズな統合 を志向しており、相互運用性を高めるためのアイデアが各所に散りばめられている。

  • Readable な Javascript code の出力
    • OCaml が分からない JSer でも読める
    • Source code と Destination code の対応関係が明確
  • OCaml の Javascript module としての export
    • ファイル毎のコンパイル
      • Bundler が扱いやすい
      • 段階的導入もしやすい
  • OCaml の Type system の恩恵を失わずに Javascript を利用する工夫
    • external syntax により、簡単に Inline で Javascript 関数を OCaml 内に定義可能
      • 豊富な Annotation により言語間のギャップを埋める
      • Abstract Type を介することで、型の恩恵を受けつつ内部実装も意識しない
    • Last-resort
      • Raw Javascript Code の実行

それ以外にも、

  • 依存パッケージの不要なコードの削除
    • 多分、Tree Shaking 的な最適化
  • コンパイル時の最適化による高速化
    • Prepack 的なやつかな?詳細不明
  • 高速なコンパイル
    • 体感速度だけど、早く感じる

なんて特徴がある。
また、Windowser として嬉しいのは、BuckleScript 自体は Windows でも動くこと。

主な情報は、以下で手に入る

2. Reason

What & Why - Intro - Reason
What is ReasonML - Blog Post

Reason は、Facebook が作ってる新しいプログラム言語。
特徴としては、OCaml をベースに JSer にも親和性の高い Syntax が加えられている点。
Frontend Developer には嬉しい JSX も利用可能

Comparison to OCaml

  • OCaml の Alternate Syntax
    • コンパイル時に OCaml AST に変換される
    • OCaml モジュールも使える
    • Javascript に対する AltJS みたいな感じ
  • BuckleScript によりコンパイル可能

主な情報は、以下で手に入る

3. Windows で Reason 環境構築

ということで、早速 Windows で使ってみる。

  • Windows 10 (Fall Creators Update)
  • VS Code

取り敢えず動かす

取り敢えず、何も考えずに公式通りに動かしてみる。
Quick Start - Reason

PS> npm install -g bs-platform
PS> bsb -init my-first-app -theme basic-reason
PS> cd my-first-app
├── bsconfig.json
├── node_modules
│   └── bs-platform -> /home/usrname/nodejs/bin/node_modules/bs-platform/
├── package.json
├── README.md
├── src
│   └── demo.re
└── tasks.json

これで、雛形が完成する。
次に、ビルドしてみる。

PS> npm run build
├── bsconfig.json
├── lib
│   └── bs
│       ├── build.ninja
│       ├── MyFirstApp.cmi
│       ├── MyFirstApp.cmj
│       ├── MyFirstApp.cmt
│       ├── MyFirstApp.js
│       ├── MyFirstApp.mlmap
│       └── src
│           ├── demo.mlast
│           ├── demo.mlast.d
│           ├── demo-MyFirstApp.cmi
│           ├── demo-MyFirstApp.cmj
│           └── demo-MyFirstApp.cmt
├── package.json
├── README.md
├── src
│   ├── demo.bs.js
│   └── demo.re
└── tasks.json
PS> node .\src\demo.bs.js
# Hello, BuckleScript and Reason!

動いた。

Flow の Windows 対応を見てきた経験から、Windows で最初からきちんと動くとは思わなかったのでちょっと感動した。

...と喜んだのも束の間、次のフェーズに進もうとすると困ったことが起きる。

VS Code で開発する

ひとまず動いたので、しっかり開発を進めるために Vs Code での環境を整える。

OCaml and Reason IDE

VS Code 向けに素晴らしい拡張が用意されている。

https://marketplace.visualstudio.com/items?itemName=freebroccolo.reasonml

ということで、拡張を入れて VS Code を開くと、以下のエラーが出る

WIP : 画像

原因は?

どうやら、拡張が内部で利用している Merlin というコード解析や Completion を担っているツールが、Windows 環境では動かないらしい。

様々対策が検討されているが、もう少し時間がかかりそう。

reason #1698 - Fundraiser for better Editor Support in Windows (Without WSL)
vscode-reasonml #113 - [Windows] Add wslMode to settings
vscode-reasonml #102 - Option to Disable Merlin (important for Windows)
ocaml-language-server #45 - WSL support

Reason 環境の WSL 化

そこで、今コミュニティでは Reason toolchain や BuckleScript platform を WSL (Windows Subsystem for Linux) 上に構築し、Windows 側からは bash -ic をつけて呼び出すという形を推奨している。

Instructions on Getting Started with Windows

WSL

元々、Bash on Windows と呼ばれていたが、10月に入った Fall Creators Update からは Windows Subsystem for Linux と呼ばれるようになった。

古いバージョンが入っている場合は、最新の物をストアから取得しておく。

● WSL のバージョン確認

古いバージョンは、こんな感じに Legacy と出るので、この場合は最新の物を入れる。
before.jpg

● 最新 WSL のインストール

Install your Linux Distribution of Choice

インストールが完了したら、Ubuntu が規定になっていることを確認する。
after.jpg

WSL 上に環境を構築

● 既存環境の削除

の前に、まず先程入れた Windows 側の bs-platform を一応消しておく。

PS> npm uninstall -g ps-platform

● 環境変数の分離

WSL は、どうやらデフォルトで Windows の $Env:Path を継承しているらしく、色々面倒が多いので自分は継承しないように設定している。

https://gist.github.com/kentork/8bd05edbc64fa547cf2159b4a4a3dd09#file-isolatepathforstorewsl-reg

● ocaml-reason-wsl による自動インストール

次に、WSL 上への環境構築だが、先程の記事にあるように、Manual に入れることもできるが、 Automated に入れてくれるパッケージが用意されているので、以下コマンドだけで済む。

PS> npm install -g ocaml-reason-wsl
PS> npm install -g bs-platform

PS> bsb -version
# 2.1.0
PS> ocaml -version
# The OCaml toplevel, version 4.02.3

中を見ると分かるが、コマンドをつなげてWSL 上で実行しているだけである。

https//github.com/fhelwanger/ocaml-reason-wsl/blob/master/bin/preinstall.bat
@echo off

setlocal EnableDelayedExpansion
set delayedexp=1

REM Update package list
set "bashcmd=sudo apt-get update; sudo apt-get upgrade -y"

REM Install ocaml
set "bashcmd=%bashcmd%; sudo apt-get install -y m4 ocaml-nox opam"

REM Configure opam
set "bashcmd=%bashcmd%; opam init --auto-setup --dot-profile=~/.bashrc"
set "bashcmd=%bashcmd%; opam update"
set "bashcmd=%bashcmd%; opam switch 4.02.3"
set "bashcmd=%bashcmd%; opam install -y reason"
set "bashcmd=%bashcmd%; opam install -y merlin.2.5.4"

REM Install nodejs
set "bashcmd=%bashcmd%; curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash -"
set "bashcmd=%bashcmd%; sudo apt-get install -y nodejs"

REM Fix npm permission issues
REM https://docs.npmjs.com/getting-started/fixing-npm-permissions
set "bashcmd=%bashcmd%; mkdir -p ~/.npm-global"
set "bashcmd=%bashcmd%; npm config set prefix '~/.npm-global'"
set "bashcmd=%bashcmd%; grep -Fq 'export PATH=~/.npm-global/bin:\$PATH' ~/.bashrc || echo 'export PATH=~/.npm-global/bin:\$PATH' >> ~/.bashrc"
set "bashcmd=%bashcmd%; source ~/.bashrc"

REM Install bucklescript
set "bashcmd=%bashcmd%; npm install -g bs-platform"

call "%~dp0base.bat"

既に Node や OCaml の環境があったりする場合は必要なものだけ個別にインストールする必要があるだろう。

ちなみにこの中で、npm の Global なインストール先を npm-global というフォルダに変更している箇所があるが、これは以下に対応してのこと。

How to Prevent Permissions Errors

Note : インストール中は WSL のパスワードを聞かれる機会が何度かあるが、Yarn で実行すると Reasline が現れず、そこで処理が進まなくなってしまうので注意。

Note2 : Merlin が、入ったと思ったら入っていなかったので、その場合は直接 WSL 上で実行すると良い

Note3 : opam switch 4.02.3 が Permission Denied したら、直接 WSL 上で実行すると上手くいく事がある

Note4 : つまり、全然 Automated じゃない

改めて、VS Code で開発する

ということで、改めてプロジェクトを作り直し、VS Code で開く。

PS> bsb -init my-first-app -theme basic-reason
PS> cd my-first-app
PS> code .

error.jpg

出ました。ようやく出ました。Merlin 。
( JS のモジュールを Merlin が検出できていない気もするが、後にしよう )

4. Reason からの Javascript, OCaml Code の呼び出し

Reason の掲げる、OCaml と Javascript のスムーズな統合 を実現する上で欠かせない、『Reason からはどのように Javascript, OCaml が利用できるのか』を検証していく。

4.1. OCaml Code の利用

Reason は OCaml の呼び出しをサポートしているので、簡単にできるはず。
( ただし、opam の library に依存してる場合は、次節でまとめている問題を抱えることに )

試しに、Compare.ml を作って使ってみる。

PS> touch src/Compare.ml
src/Compare.ml
type order = Lt | Eq | Gt

let order (n, m) =
  if n < m then Lt
  else if n > m then Gt
  else Eq
demo.re
Compare.order((20, 10)) |> Js.log;

これだけで動く。

PS> yarn run build
PS> node .\lib\js\src\demo.bs.js
# 2

OCaml は、Reason と同じ感覚で使えることが分かった。

ちなみに、この Compare.ml は、以下のコードにコンパイルされた。

Compare.bs.js
// Generated by BUCKLESCRIPT VERSION 2.1.0, PLEASE EDIT WITH CARE
'use strict';

var Caml_obj = require("bs-platform/lib/js/caml_obj.js");

function order(param) {
  var m = param[1];
  var n = param[0];
  if (Caml_obj.caml_lessthan(n, m)) {
    return /* Lt */0;
  } else if (Caml_obj.caml_greaterthan(n, m)) {
    return /* Gt */2;
  } else {
    return /* Eq */1;
  }
}

exports.order = order;
/* No side effect */

気になるのは bs-platform/lib/js/caml_obj.js というファイル。
bs-platform/lib/js/ の下を見ると、bs_***.js やら caml_***.js やらいうファイルがたくさん出てくる。
BuckleScript が何をしているのかが見えてくる。

4.2. Javascript Code の利用

今度は、Javascript。
以下 Document を参照しながら、思いつくケースで呼び出してみる。

Cheatsheet - interop - BuckleScript
Interop - JavaScript - Reason

callJs.re
/* 直接呼び出し */
[%%bs.raw {| console.log("this code is javascript") |}];

/* 直接呼び出しから値を取得 (型無し) */
let x = [%bs.raw {| parseInt("1234", 10) |}];
Js.log(x);

/* 直接呼び出しから値を取得 (型あり) */
let y: int = [%bs.raw {| parseInt("1234", 10) |}];
Js.log(y + 10);

/* 定数を利用 */
[@bs.val] external pi : float = "Math.PI";
pi *. 2.0 |> Js.log;

/* 関数を利用 */
[@bs.val] external parseInt : (string, int) => int = "parseInt";
parseInt("1234", 10) |> Js.log;

/* 組み込みオブジェクトの関数を利用 (どっちでも可) */
[@bs.val] [@bs.scope "Math"] external random : unit => float = "random";
[@bs.val] external random : unit => float = "Math.random";
random() |> Js.log;

/* オブジェクトを利用 */
type math = {
  .
  "_PI": float, /* 大文字始まりは、アンダーバーをつけて mangling される */
  [@bs.meth] "floor": float => int
};
[@bs.val] external mathObj : math = "Math";
mathObj##floor(mathObj##_PI) |> Js.log;  /* ## でアクセス */

/* クラスを利用 */
type date;
[@bs.new] external newDate : unit => date = "Date";
newDate() |> Js.log;

/* 抽象型を利用し、Map 型を使う */
/* bs.send は、第一引数を this としての method 呼び出しに変換される */
type map;
[@bs.new] external createMap : unit => map = "Map";
[@bs.send] external setToMap : (map, 'a, 'b) => unit = "set";
[@bs.send] external deleteFromMap : (map, 'a) => unit = "delete";

let mp = createMap();
setToMap(mp, "one", 1);
setToMap(mp, "two", 2);
setToMap(mp, "three", 3);
deleteFromMap(mp, "two");

type ittr;
[@bs.send] external keys : map => ittr = "keys";
[@bs.send] external next : ittr => 'a = "next";

let i = keys(mp);
Js.log(next(i));
Js.log(next(i));

/* ライブラリを利用 ( ES6 の default は、... = "default" で利用可能 ) */
[@bs.module "path"] external dirname : string => string = "dirname";
dirname("C:/path/to/source/code.re") |> Js.log;

/* 可変長引数の関数を利用 */
[@bs.module "path"] [@bs.splice]
external join : array(string) => string = "join";
join([|"C:/path/to", "source", "code.re"|]) |> Js.log;

/* チェインメソッド */
[@bs.send.pipe : array('a)] external mapNative : ('a => 'b) => array('b) = "map";
[@bs.send.pipe : array('a)] external forEachNative : ('a => unit) => array('a) = "forEach";
[|1, 2, 3|] |> mapNative(x => x + 1) |> forEachNative(x => Js.log(x));

/* Node の特殊変数 */
let dirname: option(string) = [%bs.node __dirname];
dirname |> Js.log;
PS> yarn run build
PS> node .\lib\js\src\callJs.bs.js

this code is javascript
1234
1244
6.283185307179586
1234
0.3828845668181238
3
2018-01-28T13:06:01.582Z
{ value: 'one', done: false }
{ value: 'three', done: false }
C:/path/to/source
C:\path\to\source\code.re
2
3
4
[ 'C:\\my-first-app\\lib\\js\\src' ]

感想としては、

  • ほとんどのケースでは、簡単に宣言でき、簡単に利用できる
    • 型の恩恵もわりと簡単に受けられる
  • とは言え、落とし穴もある
    • ここ とか見ると、嵌ったら危険そうなケースも
  • 実は、BuckleScript は Javascript API と互換性のあるモジュール を既に用意してくれている
    • 上でやったことは、実はほとんどやる必要がない
      • Math.*DateArray.* もあります
    • 足りないものだけ足せば良い

5. Package, Modules 利用

各 Code が Reason 上で実行できることが分かったので、今度は Reason Package, OPAM Package, npm Package の利用に挑戦しよう。

5.1. Reason Package

Reason Package は、以下から検索できる。
reason package index

検索すれば分かるが、Javascript の既存ライブラリの BuckleScript Binding が多い。

image.png
↑ moment.js の BuckleScript Bindings

1/26 現在で、Javascript library binding が 100 個程度で、純正 Reason library が 50 個程度と、まだまだ少ない印象。Reason Package Ecosystem の発展はこれからの様だ。

● e.g rebase を追加

現在、standard library とタグ付けされたライブラリで、唯一 neglected じゃなかった rebase を使ってみる。

image.png

パッケージの管理自体は NPM で行うのが一般的なようだ。

PS> yarn add @glennsl/rebase

上記コマンドで package.json の dependencies にパッケージが追記されるが、これだけでは BuckleScript は満足しない。

これをコード内で使うことを BuckleScript に別途知らせる必要があり、bsconfig.json の bs-dependencies にも追加しなければいけない。
( 今の所、手動追記である。Metadata とか見て自動追記できないかなぁ )

bsconfig.json
{
...
  "bs-dependencies": [],
+  "bs-dependencies": ["@glennsl/rebase"],
...
}

これで、rebase を利用することが可能となる。

demo.re
open Rebase;

let x = Some(42) |> Option.map((n) => n * 2);

5.2. npm Package

npm に数多くある Javascript の資産はどのように利用できるのか。

a. Reason package index で Binding を探す

React, jest など FB Products は流石に用意されているが、それ以外にも axios, apollo のような Third Party の Binding や、WebRTC のようなブラウザ機能の Binding もある。

とは言え、まだちょっと少ないかな。

● e.g fetch を追加

fetch の BuckleScript Binding は bs-fetch を利用する。

PS> yarn add bs-fetch isomorphic-fetch
bsconfig.json
{
  "name": "my-first-app",
  "version": "0.1.0",
  "sources": ["src"],
  "package-specs": {
    "module": "commonjs",
-    "in-source": true
+    "in-source": false  // ← ついでに、生成物の出力先を`src`から`lib/js`に変更
  },
  "suffix": ".bs.js",
-  "bs-dependencies": ["@glennsl/rebase"],
+  "bs-dependencies": ["@glennsl/rebase", "bs-fetch"],
  "namespace": true,
  "refmt": 3
}
demo.re
open Rebase;

[%raw "require('isomorphic-fetch')"];  /* 後で解説 */

let _ =
  Js.Promise.(
    Fetch.fetch("https://httpbin.org/get")
    |> then_(Fetch.Response.json)
    |> then_(json =>
      (
        Js.Json.decodeObject(json)
        |> Option.flatMap(dict => Js.Dict.get(dict, "url"))
        |> Option.map(url => Js.log(url))
      )
      |> resolve
    )
  );

これでビルドが通って、Fetch できれば完了。

PS> yarn run build
PS> node .\lib\js\src\demo.bs.js
# https://httpbin.org/get

( しかし、bs-XXXX の Install に凄い時間がかかる気が… Windows だからだろうか… )

Note : 試しに luxon の binding である bs-luxon も使ってみたが、上手く読み込めなかった。Binding の中には既に Reason や BuckleScript の進化スピードについていけてないレガシーもあるかも

b. 直接 Import する

簡単なものならば、自分で Binding 書くこともできる。

● e.g cowsay を追加

cowsay の Binding が無かったが、どうしても cowsay したい。
そんな時は、自分で Binding を書けば良い。

PS> yarn add cowsay

PS> touch src/Cow.re

external 構文は、ちょっとややこしい感じがするけど、一応こんな感じで書ける。
object の 『.』 の違和感もなかなか慣れない。Record 使えってことなんだろうか。

src/Cow.re
type option = {. "text": string};

[@bs.module "cowsay"] external say : option => string = "say";
demo.re
open Cow;

say({"text" : "fo fo fo fo"}) |> Js.log;

で、実行できる。

PS> yarn run build
PS> node .\lib\js\src\demo.bs.js
#  _____________
# < fo fo fo fo >
#  -------------
#         \   ^__^
#          \  (oo)\_______
#             (__)\       )\/\
#                 ||----w |
#                 ||     ||

c. Reason package index に Publish して、世界中の Reasoner で共有

で、こんな感じで BuckleScript Binding ができたら、今度はそれを世界中に公開したくなる。
そうすれば、世界中の誰もが cowsay できる。

公開の仕方は redex 公式にある通り。
Getting Published - redex

● npm 公開

npm で取ってくれば使えるので、これだけで十分な気もする。

● redex 公開

とは言え、ユーザとして検索機能もある redex に Publish されていないと探しづらいので、redex 公開もしよう。

Submitting published and unpublished packages both follow roughly the same process: Add the package to the appropriate collection in sources.json and submit a PR. Or just submit an issue with the same information.

と書かれているが、1/27 現在 Pull Request がまだ一個しか無くて、このやり方が正しいのか不安

追記: 2/20 現在は Pull Request 経由で2つ Package が追加されているので、多分これでやり方あってる。

ということで、npm の資産は使えるか

redex はまだまだ発展途上。
redex が実用レベルに発展するまでの間は、自分で Binding を書く機会は多いだろうが、難しいものではなかった。

OCaml の Type System は初めて使ったけど、個人的には好き。
Flow や Typescript よりも自分に合ってると感じた。

5.3. opam Package

OCaml には、opam という公式の package manager がある。
OCaml Package Manager

この資産は利用できるだろうか。

a. opam package を使う

調べていると、ちょっと昔のやり取りだが opam package を使いたいという人に対し、bobzhang さんが「 opam package は npm 化してね」と薦めていた

#706 - Using existing ocaml packages

確かに既存の OCaml library の利用方法として、公式でもこの方法が薦められているが、ここに opam への言及はなかった。

これが BuckleScript Way なのだろうか。
これは、opam ecosystem が使えることを期待していた人たちにとっては辛そう。

b. npm package として使う

現在、opam ecosystem から恩恵を得るのは難しそうと分かったので、じゃあ npm ecosystem に乗ってみよう。

お試しで npm に公開するのも気がひけるので、Github に作る。
プロジェクトの作り方は、一応 ここに 則る。

https://github.com/kentork/bs-ocaml-compare

最低限必要なのは、npm 公開に必要な package.json と、BuckleScript に「これは BuckleScript のプロジェクトですよ」と教える為の bsconfig.json。

で、これを npm package として取り込む。

PS> yarn add https://github.com/kentork/bs-ocaml-compare

次に、BuckleScript で使えるようにする。

bsconfig.json
...
-  "bs-dependencies": ["@glennsl/rebase", "bs-fetch"],
+  "bs-dependencies": ["@glennsl/rebase", "bs-fetch", "bs-ocaml-compare"],
...

src フォルダにある方の Compare.ml を消して、ビルドしてみる。

PS> yarn run build
PS> node .\lib\js\src\demo.bs.js
# 2

動いた。

あとは、Javascript package と同様 redex に公開すれば、みんなが使えるようになる。
ってこれ、よく見た redex に公開されている奴じゃないか。

ということで、OCaml の資産は使えるか

opam package がそのまま使えるかというと、それは難しそうだった。

BuckleScript は npm ecosystem に乗る形で自身の ecosystem を展開する気でいるため、OCaml library は新たに npm 上に ecosystem を作っていく必要がありそうだ。

6. 標準モジュール

Reason では初めから使える built-in 関数・モジュールがあるが、それを 誰が提供しているのか が微妙に違ったりするので、一度ここでまとめておく。

6.1. OCaml Standard Library

OCaml の Standard Library は、Reason でも同様に利用できる。
Pervasives Module だけは、デフォルトで open されている。

from Common Data Types - BuckleScript

BuckleScript uses the same standard library as OCaml; see the docs here (in Reason syntax).

以下、OCaml のマニュアル

Reason の公式にも標準で使える Module の API Document が用意されていて、一見 OCaml と同じだが、良く見比べると微妙に違う。バージョン違いなのか、独自カスタマイズなのか。
Reason API

● e.g OCaml Standard Library で parseInt

Pervasives は、文字列を Parse して整数に変換する為の int_of_string という関数を持っているので、何も open しなくても使える。

parseInt.re
let i = int_of_string("1234");

ただ、これだと Parse に失敗すると例外が raise されてしまう。
Option で返したい場合には、int_of_string_opt : string -> int option が用意されているが、これが OCaml >= 4.05 でしか使えない。残念。( BuckleScript は 4.02 ベース )

6.2. BuckleScript Libraries

BuckleScript は Standard Library に加え、便利なモジュールを用意している。

from Common Data Types - BuckleScript

Additionally, we provide the bindings to all the familiar JS primitives here. You can mix and match these two.

Libraries shipped with BuckleScript

以下、Top Level Module

  • Js
    • Javascript の Universal な API Binding な関数 + α
      • Js.Array, Js.String, Js.Promise, Js.Math ...
      • json 操作をする Js.Json のような便利関数もある
  • Node
    • Nodejs の標準 API への Binding 的な関数群
      • Node.Path, Node.Process, Node.Buffer ...
  • Dom
    • DOM や Event に関する type が定義されている
    • 将来的には操作する関数が追加される?
  • BS
    • 将来的に便利関数を追加するために予約されている

● e.g BuckleScript Libraries で parseInt

きっと Js Module 内に parseInt に対応する関数があるはず、と思ったが無い
Js.Float Module には fromString が存在するが、Js.Int Module にはない。

let i = "1234" |> Js.Float.fromString |> Js.Math.floor;

これも、Option を返すようにしてくれないかなと思ったが、Js Module 内の関数は、Option で返す物がほぼない

これは多分 Javascript との I/F の対応関係を重視するためと考えられ、例えば Js.Float.fromString は失敗すると Js.Float._NaN を返す。これは Javascript と同じ動作である。

let maybePort = Js.Float.fromString("1234");
let port = Js.Float.isNaN(maybePort) ? 3000 : Js.Math.floor(maybePort);

何を使うべきか

parseInt ひとつとっても、議論になっていたりする

Javascript の動作を模す必要があるなら Js.* を使い、そうでなければ OCaml Stdlib も含めベストを探す、くらいで良いのかなぁと今は思っている。
最終手段は、直接 Javascript 使えばいいし。

let parseint: string => int = [%raw {| x => parseInt(x, 10) |}];

7. ブラウザで利用

ここまでは Node.js としての利用だったが、やっぱりブラウザで使えないと面白くない。

Javascript モジュール化

モジュール化するためには、export できなければいけない。
その為には、***.re に対して公開用のファイル ***.rei を用意する必要がある。

demo.re
open Rebase;

[%raw "require('isomorphic-fetch')"];

let fetchHttpBin = () =>
  Js.Promise.(
    Fetch.fetch("https://httpbin.org/get")
    |> then_(Fetch.Response.json)
    |> then_(json =>
         Js.Json.decodeObject(json)
         |> Option.flatMap(dict => Js.Dict.get(dict, "url"))
         |> Option.map(url => Js.log(url))
         |> resolve
       )
  );
demo.rei
let fetchHttpBin: unit => Js.Promise.t(Rebase.Option.t(unit));

こんな感じで、関数や型の定義を行うと、export してくれる。

lib/js/demo.bs.js
// Generated by BUCKLESCRIPT VERSION 2.1.0, PLEASE EDIT WITH CARE
'use strict';

var Rebase       = require("@glennsl/rebase/lib/js/src/Rebase.bs.js");
var Js_json      = require("bs-platform/lib/js/js_json.js");
var Js_primitive = require("bs-platform/lib/js/js_primitive.js");

((require('isomorphic-fetch')));

function fetchHttpBin() {
  return fetch("https://httpbin.org/get").then((function (prim) {
                  return prim.json();
                })).then((function (json) {
                return Promise.resolve(Rebase.Option[/* map */0]((function (url) {
                                  console.log(url);
                                  return /* () */0;
                                }), Rebase.Option[/* flatMap */5]((function (dict) {
                                      return Js_primitive.undefined_to_opt(dict["url"]);
                                    }), Js_json.decodeObject(json))));
              }));
}

exports.fetchHttpBin = fetchHttpBin;
/*  Not a pure module */

ここまでくれば、後はもう Javascrip の世界。

画面への組み込み

今回は簡単にするために、 parcel を使う。

PS> yarn add parcel-bundler
PS> touch index.html index.js
index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Hello</title>
  <meta name="description" content="smart contract network on browser">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>

<body>
  <script src="index.js"></script>
</body>

</html>
index.js
import { fetchHttpBin } from './lib/js/src/demo.bs'

fetchHttpBin()

で、parcel のサーバを立ち上げて確認する。

package.json
...
  "scripts": {
+    "serve": "parcel index.html"
    "build": "bsb -make-world",
    "start": "bsb -make-world -w",
    "clean": "bsb -clean-world",
  },
...
PS> yarn run serve
# Server running at http://localhost:1234

image.png

ちなみに、parcel の production ビルドで 74 KB

PS> .\node_modules\.bin\parcel build .\index.html
PS> dir .\dist\XXXXXXXXXXXXXXXXX.js

# Mode                LastWriteTime         Length Name
# ----                -------------         ------ ----
# -a----       2018/01/25     6:12          75632  XXXXXXXXXXXXXXXXX.js

● ES6 モジュール化

Reason ( BuckleScript ) の良さには、『変換後の Javascript が読みやすい』というのもあるらしい。
Node.js で利用する場合には import/export 構文の問題があるが、parcel や webpack 等の Bundler を利用する場合は、commonjs で出力する必要はない。

bsconfig.json
...
  "package-specs": {
    "module": "es6",
    "in-source": true
  },
...

これで、ES6 で出力される。
試しに demo.re を ES6 で出力した Javascript がこちら。

lib/es6/src/demo.bs.js
// Generated by BUCKLESCRIPT VERSION 2.1.0, PLEASE EDIT WITH CARE
'use strict';

import * as Rebase       from "@glennsl/rebase/lib/es6/src/Rebase.bs.js";
import * as Js_json      from "bs-platform/lib/es6/js_json.js";
import * as Js_primitive from "bs-platform/lib/es6/js_primitive.js";

((require('isomorphic-fetch')));

function fetchHttpBin() {
  return fetch("https://httpbin.org/get").then((function (prim) {
                  return prim.json();
                })).then((function (json) {
                return Promise.resolve(Rebase.Option[/* map */0]((function (url) {
                                  console.log(url);
                                  return /* () */0;
                                }), Rebase.Option[/* flatMap */5]((function (dict) {
                                      return Js_primitive.undefined_to_opt(dict["url"]);
                                    }), Js_json.decodeObject(json))));
              }));
}

export {
  fetchHttpBin ,

}
/*  Not a pure module */

import / export しか違いは無いけど、普段 ES6 で書いているなら多少読みやすい。

8. メモアプリを作る

と、ここまでで Reason の感覚が掴めてきたので、これからは実際のプロダクトを作るとどうなるのかを確認していく。

画面側もサーバ側も一通り見られうように、Next.js で簡単な Web アプリ『A4』を作ってみる。

8.1. 環境

  • Rendering・Routing ( Front side )・SSR
    • Next.js
  • API Endpoint
    • Micro
  • Hosting・Deploy
    • Now.sh
  • Transpiler
    • Reason
      • reason-react

環境は、以下で構築

PS> yarn add micro micro-route next react react-dom
PS> yarn add --dev cross-env npm-run-all
package.json
...
  "scripts": {
    "dev": "node src/server.js",
    "start": "cross-env NODE_ENV=production node src/server.js",
    "build": "run-s bs:build next:build",
    "next:build": "next build src",
    "bs:build": "bsb -make-world",
    "bs:start": "bsb -make-world -w",
    "bs:clean": "bsb -clean-world"
  },
...

8.2. フォルダ構成

登場人物が多いので、フォルダ構成がややこしい感じになってしまった。

● Reason

多分、一番融通が効かないのが Reason で、ドキュメントによると、

"in-source": true generates output alongside source files, instead of by default isolating them into lib/js. The output directory is otherwise not configurable.

src ファイルの隣 (同階層) に置くか、lib/js に置くかの2択らしいので、ひとまず in-source: true で src フォルダ内で出力することに。

bsconfig.json
{
  "name": "a4",
  "version": "0.1.0",
  "sources": ["src"],
  "package-specs": {
    "module": "commonjs",
    "in-source": true
  },
  "suffix": ".bs.js",
  "bs-dependencies": [],
  "namespace": true,
  "refmt": 3
}

● Next.js

Next.js は Default で、ルートフォルダ以下にある pages フォルダが参照される。
これを src フォルダにするには、基本的には以下の変更のみで対応可能。

package.json
...
  "scripts": {
-    "dev": "next",
-    "build": "next build",
-    "start": "next start"
+    "dev": "next src",
+    "build": "next build src",
+    "start": "next start src"
  },
...
package.json
...
// from https://github.com/zeit/next.js/tree/canary/examples/custom-server-micro
-  "scripts": {
-    "dev": "node server.js",
-    "build": "next build",
-    "start": "cross-env NODE_ENV=production node server.js"
-  },
+  "scripts": {
+    "dev": "node src/server.js",
+    "build": "next build src",
+    "start": "cross-env NODE_ENV=production node src/server.js"
+  },
...

しかし、今回のように Web Framework に Handler を渡して呼び出してもらう場合には、もう一手間必要となる。

以下のように next() で app 初期化する際に『./src 以下を見てね』と教えてあげないといけない。

src/server.js
const micro = require('micro')
const match = require('micro-route/match')
const { parse } = require('url')
const next = require('next')

const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
- const app = next({ dev })
+ const app = next({ dir: './src', dev }) // ← ここに、対象ディレクトリを指定する
const handle = app.getRequestHandler()

const server = micro(async (req, res) => {
...

詳しくは、ここで議論されている

● Now

Now は、npm run build して npm run start しているだけなので、ローカルでそれができていれば完了である。

8.3. Javascript ( Nodejs ) → Reason

Frontend や Nodejs についての sample, reference code の多くが ES(5|2015|2016|2017) や Typescript, Flow で書かれているので、まずはそれらを Reason に変換するところから始まる。

公式ページ にも手順が存在しているので、この通りに変換してみる。

0. 元になるコード

Next.js の公式にある custom-server-micro をベースとする。

server.js
const micro = require('micro')
const match = require('micro-route/match')
const { parse } = require('url')
const next = require('next')

const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dir: './src', dev })
const handle = app.getRequestHandler()

const server = micro(async (req, res) => {
  const parsedUrl = parse(req.url, true)
  const { query } = parsedUrl

  if (match(req, '/a')) {
    return app.render(req, res, '/b', query)
  } else if (match(req, '/b')) {
    return app.render(req, res, '/a', query)
  }

  return handle(req, res, parsedUrl)
})

app.prepare().then(() => {
  server.listen(port, err => {
    if (err) throw err
    console.log(`> Ready on http://localhost:${port}`)
  })
})

1. Syntax の変換

https://reasonml.github.io/docs/en/converting-from-js.html#syntax

  • Convert the function call syntax over.
  • Convert the var/const over to let.
  • Hide the requires.
  • Make other such changes. For idioms that don't have a BuckleScript equivalent, use bs.raw

それ以外にも、

  • ''""
  • 式の最後に ;
  • return を削除
server.re
/* const micro = require('micro')
const match = require('micro-route/match')
const { parse } = require('url')
const next = require('next') */

let port = parseInt(process.env.PORT, 10) || 3000;
let dev = process.env.NODE_ENV !== "production";
let app = next({ dir: "./src", dev });
let handle = app.getRequestHandler();

let server = micro(async (req, res) => {
  let parsedUrl = parse(req.url, true);
  let { query } = parsedUrl;

  if (match(req, "/a")) {
    app.render(req, res, "/b", query);
  } else if (match(req, "/b")) {
    app.render(req, res, "/a", query);
  }

  handle(req, res, parsedUrl);
});

app.prepare().then(() => {
  server.listen(port, err => {
    if (err) throw err;
    console.log(`> Ready on http://localhost:${port}`);
  });
});

まだ Javascript っぽい。

2. 型情報 ( 暫定 )

https://reasonml.github.io/docs/en/converting-from-js.html#types-pass-1

  • Change foo.bar to foo##bar. This escape-hatch BuckleScript feature will be your medium-term friend.
  • Convert {foo: bar} to [%bs.obj {foo: bar}] (docs). After refmt, this will sugar to {"foo": bar}.
  • To communicate with external JS files, use external. They're BuckleScript's foreign function interface.
    • Inline externals. No need to create clean, well-separated files for externals for now. We'll come back to these.
    • If it's too cumbersome to correctly type an external's input/output, use some placeholder polymorphic types, e.g. external getStudentById: 'whatever => 'whateverElse = ....
    • For data types & patterns that are hard to properly convert over, you can occasionally create converters like external unsafeCast : myPayloadType => anotherDataType = "%identity";.

ついでに、

  • Promise の書き方を Reason 風に
  • console.logJs.log
server.re
[@bs.module] external micro : (('request, 'response) => string) => 'server = "micro";
[@bs.module] external matchRoute : ('request, string) => bool = "micro-route/match";
[@bs.module] external next : 'option => 'app = "next";
[@bs.module "url"] external parse : (string, bool) => 'parsed = "parse";

[@bs.send] external getRequestHandler : 'app => 'handle = "getRequestHandler";
[@bs.send] external render : ('app, 'request, 'response, string, 'query) => 'handle = "render";
[@bs.send] external prepare : 'app => Js.Promise.t(unit) = "prepare";

[@bs.send] external listen : ('server, int, ('error => unit)) => Js.Promise.t('a) = "listen";

let port = parseInt(process##env##PORT, 10) || 3000;
let dev = process##env##NODE_ENV !== "production";

let app = next([%bs.obj { dir: "./src", dev }]);
let handle = getRequestHandler(app);

let server = micro(async (req, res) => {
  let parsedUrl = parse(req##url, true);
  let { query } = parsedUrl;

  if (matchRoute(req, "/a")) {
    render(app, req, res, "/b", query);
  } else if (matchRoute(req, "/b")) {
    render(app, req, res, "/a", query);
  };

  handle(req, res, parsedUrl);
});

prepare(app)
  |> Js.Promise.then_(() => {
    listen(server, port, err => {
      if (err) {throw(err);};
      Js.log(`> Ready on http://localhost:${port}`);
    });
  });

やっては見たものの、全くコンパイルは通らない。

  • Javascript だからできる Shorthand は使わない
    • || による Short Circuit
  • ES2015, 2016, 2017 の便利な Syntax は一部諦める
    • 分割代入
      • const { a } = obj
    • Template Literal
      • `i am ${ name }`
    • オブジェクト初期化時の Key 省略
      • const a = 100; const b = { c: 20, a };
    • async / await
  • process.*Node.Process.* に変換
  • throwJs.Exn.raiseError()

これだけやって、ようやくコンパイルが通った。
もはや Javascript ではなくなった。

server.re
[@bs.module] external micro : (('request, 'response) => string) => 'server = "micro";
[@bs.module] external matchRoute : ('request, string) => bool = "micro-route/match";
[@bs.module] external next : 'option => 'app = "next";
[@bs.module "url"] external parse : (string, bool) => 'parsed = "parse";

[@bs.send] external getRequestHandler : 'app => 'handle = "getRequestHandler";
[@bs.send] external render : ('app, 'request, 'response, string, 'query) => 'handle = "render";
[@bs.send] external prepare : 'app => Js.Promise.t(unit) = "prepare";

[@bs.send] external listen : ('server, int, ('error => unit)) => Js.Promise.t('a) = "listen";

let port =
  Js.Dict.get(Node.Process.process##env, "PORT")
  |> (
    fun
    | Some(ps) =>
      Js.Float.fromString(ps)
      |> (
        fun
        | p when Js.Float.isNaN(p) => 3000
        | p => Js.Math.floor(p)
      )
    | None => 3000
  );

let dev =
  Js.Dict.get(Node.Process.process##env, "NODE_ENV")
  |> (
    fun
    | Some(e) => e !== "production"
    | None => false
  );

let app = next({"dir": "./src", "dev": dev});

let handle = getRequestHandler(app);

let server =
  micro((req, res) => {
    let parsedUrl = parse(req##url, true);
    let query = parsedUrl##query;
    if (matchRoute(req, "/a")) {
      render(app, req, res, "/b", query);
    } else if (matchRoute(req, "/b")) {
      render(app, req, res, "/a", query);
    };
    handle(req, res, parsedUrl);
  });

prepare(app)
|> Js.Promise.then_(() =>
     listen(
       server,
       port,
       err => {
         if (err) {
           /* throw(err); */
           Js.log("Javascript error has occoured !");
         };
         Js.log("> Ready on http://localhost:" ++ string_of_int(port));
       }
     )
   );

出力される Javascript はこんな感じ。

server..bs.js
// Generated by BUCKLESCRIPT VERSION 2.1.0, PLEASE EDIT WITH CARE
'use strict'

var Url = require('url')
var Next = require('next')
var Curry = require('bs-platform/lib/js/curry.js')
var Micro = require('micro')
var Js_math = require('bs-platform/lib/js/js_math.js')
var Process = require('process')
var Pervasives = require('bs-platform/lib/js/pervasives.js')
var Match = require('micro-route/match')

var param = Process.env['PORT']
var port
if (param !== undefined) {
  var p = Number(param)
  port = isNaN(p) ? 3000 : Js_math.floor(p)
} else {
  port = 3000
}

var param$1 = Process.env['NODE_ENV']
var dev = param$1 !== undefined ? +(param$1 !== 'production') : /* false */ 0

var app = Next({
  dir: './src',
  dev: dev
})

var handle = app.getRequestHandler()

var server = Micro(function(req, res) {
  var parsedUrl = Url.parse(req.url, /* true */ 1)
  var query = parsedUrl.query
  if (Match(req, '/a')) {
    app.render(req, res, '/b', query)
  } else if (Match(req, '/b')) {
    app.render(req, res, '/a', query)
  }
  return Curry._3(handle, req, res, parsedUrl)
})

app.prepare().then(function() {
  return server.listen(port, function(err) {
    if (err) {
      console.log('Javascript error has occoured !')
    }
    console.log('> Ready on http://localhost:' + Pervasives.string_of_int(port))
    return /* () */ 0
  })
})

exports.port = port
exports.dev = dev
exports.app = app
exports.handle = handle
exports.server = server
/* param Not a pure module */

しっかり読める Javascript が出力される。

3. 実行時セマンティクス

https://reasonml.github.io/docs/en/converting-from-js.html#runtime-semantics

  • Type the shape of JS objects (the things that required ##).
  • Convert whichever parts to records/variants/idiomatic OCaml types.

と、ここまでで Javascript が出力できるようになったが、微妙に意図と違う動作をしている箇所があるので、出力コードを確認しつつ動かしながら、修正していく。

  • true / false が 0 / 1 に変換される
  • return を消したところが return されない ( 当然 )
    • そもそも、Reason では早期リターンができない
      • 最後に評価された値が返るのでしょうがない
server.re
...
let dev =
  Js.Dict.get(Node.Process.process##env, "NODE_ENV")
  |> (
    fun
    | Some(e) => Js.Boolean.to_js_boolean(e !== "production")
    | None => Js.false_
  );

...

let server =
  micro((req, res) => {
    let parsedUrl = parse(req##url, Js.true_);
    let query = parsedUrl##query;
    if (matchRoute(req, "/a")) {
      render(app, req, res, "/b", query);
    } else if (matchRoute(req, "/b")) {
      render(app, req, res, "/a", query);
    } else {
      handle(req, res, parsedUrl);
    }
  });
...
server..bs.js
...
var param$1 = Process.env["NODE_ENV"];

var dev = param$1 !== undefined ? Js_boolean.to_js_boolean(+(param$1 !== "production")) : false;

...

var server = Micro(function(req, res) {
  var parsedUrl = Url.parse(req.url, true)
  var query = parsedUrl.query
  if (Match(req, '/a')) {
    return app.render(req, res, '/b', query)
  } else if (Match(req, '/b')) {
    return app.render(req, res, '/a', query)
  } else {
    return Curry._3(handle, req, res, parsedUrl)
  }
})
...

これで、意図通りに出力できるようになった。

4. クリーンアップ

https://reasonml.github.io/docs/en/converting-from-js.html#clean-up-types-pass-2

  • Make sure you don't have any 'whatever types left in externals.
  • You can keep the externals inlined, or pull them out into a file.

ここからは、型変数の部分を type に連ねていきつつ、役割ごとにファイルを分割していく。

Url.re
type query;
type parced = {. "query": query};

[@bs.module "url"] external parse : (string, Js.boolean) => parced = "parse";
Http.re
type req = {. "url": string};
type res;
type server;
type port = int;
type content;

[@bs.send] external listen : (server, port, Js.Nullable.t(Exn.t) => unit) => Js.Promise.t(unit) = "listen";
Micro.re
type handleResult;

[@bs.module] external serve : ((Http.req, Http.res) => Http.content) => Http.server = "micro";
[@bs.module] external route : (Http.req, string) => bool = "micro-route/match";
Next.js
type app;
type option = {
  .
  "dir": string,
  "dev": Js.boolean
};
type handle = (Http.req, Http.res, Url.parced) => Http.content;

[@bs.module] external next : option => app = "next";
[@bs.send] external getRequestHandler : app => handle = "getRequestHandler";
[@bs.send] external render : (app, Http.req, Http.res, string, Url.query) => Http.content = "render";
[@bs.send] external prepare : app => Js.Promise.t(unit) = "prepare";
Exn.re
type t;
Maybe.re
let (>>=) = (monad: option('a), trans: 'a => option('b)) : option('b) =>
  switch monad {
  | Some(value) => trans(value)
  | None => None
  };
server.re
open Maybe;

let port =
  Js.Dict.get(Node.Process.process##env, "PORT")
  >>= (p => Some(Js.Float.fromString(p)))
  |> (
    fun
    | Some(p) when Js.Float.isNaN(p) => 3000
    | Some(p) => Js.Math.floor(p)
    | None => 3000
  );
let dev =
  Js.Dict.get(Node.Process.process##env, "NODE_ENV")
  |> (
    fun
    | Some(e) => Js.Boolean.to_js_boolean(e !== "production")
    | None => Js.false_
  );

let app = Next.next({"dir": "./src", "dev": dev});
let handle = Next.getRequestHandler(app);

let server =
  Micro.serve((req, res) => {
    let parsedUrl = Url.parse(req##url, Js.true_);
    let query = parsedUrl##query;
    if (Micro.route(req, "/a")) {
      Next.render(app, req, res, "/b", query);
    } else if (Micro.route(req, "/b")) {
      Next.render(app, req, res, "/a", query);
    } else {
      handle(req, res, parsedUrl);
    };
  });

Next.prepare(app)
|> Js.Promise.then_(() =>
     Http.listen(
       server,
       port,
       err => {
         if (! Js.Nullable.test(err)) {
           Js.log("js error has occoured !");
         };
         Js.log("> Ready on http://localhost:" ++ string_of_int(port));
       }
     )
   );
  • Js.Exn.t が使えないので、自分で代わりを作る
  • もう少し、関数型っぽくしてみる

何をするにも、JS -> Reason の翻訳から始めなければならないのは骨が折れる。

8.4. Javascript ( Frontend ) → Reason

8.4.1. React (jsx)

今度は React 部分を Reason 化していく。Reason は jsx が書けるのが売りでもあるので。

ReasonReact Document

Reason で jsx 書く場合。これが、

MyComponent.js
import React from 'react'

export default (name, onCreate, children) => (
  <ul>
    <li>
      <a>{name}</a>
    </li>
    <li>
      <a onClick={onCreate}>b</a>
    </li>
    <li>
      {children}
    </li>
  </ul>
)

こんな感じになる。( 正確には等価ではない )

MyComponent.re
let component = ReasonReact.statelessComponent("MyComponent");

let make = (~name: string, ~onCreate, children: array(ReasonReact.reactElement)) => {
  ...component,
  render: _self =>
    <ul>
      <li> <a> (ReasonReact.stringToElement(name)) </a> </li>
      <li> <a onClick=onCreate> (ReasonReact.stringToElement("b")) </a> </li>
      <li> children </li>
    </ul>
};
  • Component は、ひとつの Module
  • ReasonReact.statelessComponent で Component の Template を作り、make 関数で customize して返す
    • ReasonReact.***Component にはいくつかバリエーションがある
  • make は特殊な関数で、必ず必要。後述。
  • string をコンテンツにする場合、ReasonReact.stringToElement が必要
    • 面倒...

ビルドする準備をして、

PS> yarn add --dev reason-react
bsconfig.json
...
-  "bs-dependencies": [],
+  "bs-dependencies": ["reason-react"],
+  "reason": {
+    "react-jsx": 2
+  },
...

ビルドすると、

PS> yarn run bs:build   # "bsb -make-world"

これができる。

MyComponent.js
// Generated by BUCKLESCRIPT VERSION 2.1.0, PLEASE EDIT WITH CARE
'use strict'

var React = require('react')
var ReasonReact = require('reason-react/src/ReasonReact.js')

var component = ReasonReact.statelessComponent('MyComponent')

function make(name, onCreate, children) {
  var newrecord = component.slice()
  newrecord[/* render */ 9] = function() {
    return React.createElement(
      'ul',
      undefined,
      React.createElement('li', undefined, React.createElement('a', undefined, name)),
      React.createElement(
        'li',
        undefined,
        React.createElement(
          'a',
          {
            onClick: onCreate
          },
          'b'
        )
      ),
      React.createElement('li', undefined, children)
    )
  }
  return newrecord
}

exports.component = component
exports.make = make
/* component Not a pure module */

気になるのが、exports.default が無いこと。( 宣言してないから当然だけど )
で、案の定、ReasonReact で作られた Component は、Javascript で普通には使えなかった。

js
import React from 'react'
import MyComponent from './MyComponent.js'

/* exports.default が無いと言われる */
export default () => <MyComponent name="hoge" onCreate={() => {}}>hello</MyComponent>

ReasonReact における jsx

jsx - Core - ReasonReact

結論から言うと、ReactJS における jsx と ReasonReact における jsx は別物
以降は、jsx(js) と jsx(reason) で表記を分ける。

◆ jsx(js)

jsx(js) は、<MyComponent />React.createElement(MyComponent, ...) : React.Component に変換される。

/* jsx(js) */
<MyComponent />

/* js */
React.createElement(MyComponent, null)

React.createElement は、第一引数に以下を期待する

  • String - 'div', 'span'..
  • Functional Components - ({props}) -> React element
  • Class Components - extends React.Component

◆ jsx(reason)

しかし jsx(reason) は、組み込みタグ (div, li, p ...) 以外は、直接 React.createElement に変換されるわけではない。
以下のような変換がなされる。

/* jsx(reason) */
<MyComponent a={b}> 10 </MyComponent>

/* reason code */
ReasonReact.element(MyComponent.make(~a=b, [|10|]))

/* js code */
ReasonReact.element(0, 0, MyComponent$ProjectName.make(b, [10]))

つまり jsx(reason) は、以下を要求する

  • Component は Module
  • make 関数を持っている - (labeled props) -> Record customized Component

これでは、jsx(js) と互換が無いのも頷ける。
となると、お互いの Component を共有するには変換が必要で、それは公式 Document にもまとめられている。

Talk to Existing ReactJS Code - Core - ReasonReact

◆ ReactJS → ReasonReact

通常の Javascript で書かれた Component は、make 関数を用意する事で ReasnReact で利用できる。

component.re
[@bs.module] external myComponent : ReasonReact.reactClass = "./MyComponent";

let make = (children) =>
  ReasonReact.wrapJsForReason(
    ~reactClass=myComponent,
    children
  );

◆ ReasonReact → ReactJS

反対に、 ReasonReact で書かれた Component は、default 関数を用意する事で Javascript から利用可能となる。

component.js
 let default =
   ReasonReact.wrapReasonForJs(~component, jsProps =>
     make([||])
   );

まぁ、何が起きているかが分かれば大したことはないんだけど。

それ以外にも罠が。

  • children 問題
    • <Hoge>a b</Hoge> は、ReasonReact.element(Hoge.make([|a, b|])) に変換 → 分かる
    • <Hoge>a</Hoge> も、ReasonReact.element(Hoge.make([|a|])) に変換 → えっ
      • <Hoge>...a</Hoge> すれば、ReasonReact.element(Hoge.make(a)) に → ほっ
      • でも、これは組み込みタグでは使えない。 <div>...a</div> はエラー
        • その場合、jsx は諦める
  • props spred できない問題
    • ReasonReact は、make 関数にラベル付き引数で明示的に渡す必要がある
      • const HogeDiv = ({...props}) => (<div className={`hoge ${props.className}`} {...props} />) みたいな使い方はできない
      • root から leaf まで Typesafe を維持する為には、何を渡したのかを明確にする必要がある
    • HOC どうする?

Javascript の jsx 感覚で使おうとすると、思わぬ罠に嵌る
基本だが、ドキュメントを読みこむ必要がある。

8.4.2. State, Actions & Reducer

ReasonReact は 純粋な React Binding というだけでなく、Flux っぽい State Management 辺りもカバーしている。

State, Actions & Reducer - Core - ReasonReact

その場合、ReasonReact.statelessComponent ではなく ReasonReact.reducerComponent を利用する。

公式サンプル
type action =
  | Click
  | Toggle;

type state = {
  count: int,
  show: bool
};

let component = ReasonReact.reducerComponent("MyForm");

let make = (_children) => {
  ...component,
  initialState: () => {count: 0, show: false},
  reducer: (action, state) =>
    switch (action) {
    | Click => ReasonReact.Update({...state, count: state.count + 1})
    | Toggle => ReasonReact.Update({...state, show: ! state.show})
    },
  render: (self) => {
    let message = "Clicked " ++ string_of_int(self.state.count) ++ " times(s)";
    <div>
      <MyDialog
        onClick={_event => self.send(Click)}
        onSubmit={_event => self.send(Toggle)}
      />
      {ReasonReact.stringToElement(message)}
    </div>
  }
};

action は variant ( 代数的データ型みたいなやつ ) であり、state を init state として、reducer が (state, (action, ..)) => (state, optional SideEffect) と状態を遷移させていく有限オートマトン ( Mealy Machine ) となっている、らしい。

書き心地は redux と言うより Elm にとても近く、一周して元祖に戻った感じ。

8.4.3. Style

React のスタイルを指定する場合、CSS File, Inline Style, CSS Modules, CSS in JS 等あるが、この辺はどうなるのか。

◆ CSS File

jsx への class の適用は、React とほぼ同じ。

<div className="foo" />

◆ Inline Style

専用の関数が用意されている。

Style - Core - ReasonReact

<div style=(
  ReactDOMRe.Style.make(~color="#444444", ~fontSize="68px", ())
)/>

◆ CSS Module

webpack や parcel 等が提供している CSS Modules を利用する場合、*.css を Import して className に渡して style を適用させる。

import * as styles from './style.css'

export default () => <div className={styles.hoge} />

普通に module として取り込んで使用する。

[@bs.module] external styles : Js.t({..}) = "./style.css";

let make = _children => {
  ...component,
  render: self => {
    <div className=styles##hoge>
  }
}

これを Safety に解決したりする ppx は特に無かったが、自分で型定義すれば一応チェックもできる。

type style = {.
  "hoge": string
};

[@bs.module] external styles : style = "./style.css";

参考: Reasonでcss-modules

◆ CSS in JS

CSS in JS と一言でまとめられるほど共通しているわけではなく、種類は豊富で色々ある。

今回は普段使っている styled-components を使ってみる。
残念ながら、有効な Binding は見つけられなかった。

PS> yarn add styled-components
Styled.re
type styledComponents = {
  .
  [@bs.meth] "button": array(string) => ReasonReact.reactClass
};

[@bs.module "styled-components"] external styled : styledComponents = "default";
GreenButton
open Styled;

let button = styled##button([|"\n  background: green;\n"|]);

let make = children =>
  ReasonReact.wrapJsForReason(
    ~reactClass=button,
    ~props=Js.Obj.empty(),
    children
  );
...
let make = _children => {
  render: self => {
    <GreenButton> (ReasonReact.stringToElement("parapa")) </GreenButton>
  }
}
...

Note : styled-components の styled.div`display: block;` の部分は、Tagged templates という ES2015 の Syntax であるが、これも Reason では使えないのだが、代わりに こうやって書く 事ができる。

8.4.4. Test

やっぱり Test がないと。

とは言え、Javascript に変換してから既存の Testing Framework に任せる、でもできなくはない気がするが、どんな感じがベストなんだろう。

一応 Jest Binding はあったので、Jest を使ってみる。

https://redex.github.io/package/@glennsl/bs-jest/

Note : bs-jest は、bs-platform 2.1.0 ではビルドできなかったので、bs-platform 2.2.1 にupgrade している

◆ 普通の Test

PS> yarn add --dev jest @glennsl/bs-jest 
src/lib/Calc.re
let add = (a, b) => a + b;
__tests__/lib/Calc_test.re
open Jest;

describe("Expect", () => {
  open Expect;

  test("toBe", () =>
    expect(Calc.add(1, 2)) |> toBe(3))

});

で、実行する。

bsconfig.json
...
  "sources": [
    {
      "dir": "src",
      "subdirs": true
    },
+    {
+      "dir": "__tests__",
+      "subdirs": true,
+      "type": "dev"
+    }
  ],
+  "bs-dev-dependencies": ["@glennsl/bs-jest"],
...
package.json
...
+    "jest:run": "jest",
+    "test": "run-s bs:build jest:run",
...
PS> yarn run test

image.png

◆ Snapshot Test

流石に Facebook 製同士なので、それなりに Binding は揃っている。
まずは test renderer を追加する。

https://redex.github.io/package/bs-react-test-renderer/

PS> yarn add --dev bs-react-test-renderer

で、さっき作った GreenButton をテストする。

__tests__/components/GreenButton_test.re
open Jest;

describe("Component", () => {
  open ExpectJs;

  test("renders", () => {
    let component = ReactTestRenderer.create(
      <GreenButton>(ReasonReact.stringToElement("a"))</GreenButton>
    ) |> ReactTestRenderer.toJSON;

    expect(component) |> toMatchSnapshot;
  });
});

で、設定を変えて、

bsconfig.json
...
-  "bs-dev-dependencies": ["@glennsl/bs-jest"],
+  "bs-dev-dependencies": ["@glennsl/bs-jest", "bs-react-test-renderer"],
...
package.json
...
    "jest:run": "jest",
+    "jest:snapshot": "jest --updateSnapshot",
    "test": "run-s bs:build jest:run",
+    "test:snapshot": "run-s bs:build jest:snapshot"
...

初めに Snapshot を取って、

PS> yarn run test:snapshot

ちょっと変えて、

__tests__/components/GreenButton_test.re
-      <GreenButton>(ReasonReact.stringToElement("a"))</GreenButton>
+      <GreenButton>(ReasonReact.stringToElement("aaa"))</GreenButton>

もう一度テストする。

PS> yarn run test

image.png

まぁ、Jest が Typesafe に書けるってのは、それはそれで意味があるの。

8.4.5. GraphQL ( Apollo Client )

Reason で GraphQL を書く場合、graphql_ppx という syntax extension が使える。

https://github.com/mhallin/graphql_ppx

module HeroQuery = [%graphql {|
{
  hero {
    name
  }
}
|}];

これを使えば、Marlin による autocomplete, validation 等の支援が受けられる。

ちなみに、ppx は OCaml の拡張のことらしい。[%hoge ~ ] と書く。
なるほど、こういう拡張の仕方ができるのか。

使ってみようと思ったが、残念ながら Windows でビルドエラーが出て使えなかった。

8.5 結局どうなった

何かを作るまで行けなかったので、また次の機会にしたい。
一応、これまでの Source Code。

https://github.com/kentork/a4

9. 型情報の共有

一通り使ってみて、真っ先に思ったことが Typescript の型情報を使えないのかなという事。
で、調べてみるとやっぱりあった。

https://github.com/ReasonablyTyped/ReasonablyTyped

e.g url-parse

先程使っていた url-parse の型情報を作ってみる。

PS> yarn add --dev reasonably-typed @types/url-parse
package.json
...
  "scripts": {
+    "retyped": "retyped --",
...

実行してみると、

PS> yarn run retyped node_modules/@types/url-parse

image.png

あれ?
ReasonablyTyped の Readme を見ると、Instersection types は未対応との事

残念。

感想

実用は可能か

実用はまだもう少し先かな、というのが率直な感想。

API が安定していないし、謎の Compile Error にもよく遭遇するし、Platform 固有の問題もある ( とは言え Windows 対応自体は嬉しい ) し、安定とは言い難い。

しかし、型システム自体はとても安定していると感じた。
『え、そこの型合わないの』とか『そのくらい分かるだろ』みたいな理不尽さを余り感じないのは、ベースとした OCaml の恩恵なのかな。個人的には Typescript,Flow より書きやすかった。
ppx による拡張も夢が膨らむ。

公式ドキュメントは充実しているし、npm という実績ある Package Manager を利用しているのも取っ付きやすい。 そして、バックには Facebook がいる、という安心感もある。

既存の Project と共存はできるか

部分的な置き換えが可能なようにデザインされているので、gradual な置き換えはやり易いと感じた。

コミュニティは育っているか

redex にはまだまだ Package が少なく、しばらくは『ここの機能を Reason 化したいけど、この Module の Binding が無い』という状況が続きそう。

とは言え、ReasonablyTyped みたいな試みもあるし、そもそも Binding を自作するのもそこまで苦ではなく、Ecosystem が育つのを待たなくても何とかなる感じはある。

Reason はどんな用途に適しているのか

いまのところ、正直分からない。

AltJS としては、既に Typescript が頭一つ飛び抜けているし、Flow だってある。
関数型で言えば PureScript や ClojureScript もある。Elm もある。

私見だけど、鍵は ReasonReact で、Javascript と相互運用性の高い Elm を再発明したいのかなぁとか勝手に予想している。
View Library + State Management + Immutable State + Side Effect Management + Static Typing + ... を個別にあーでもないこーでもないしている現状は辛いものがあり、その辺全部 Reason + ReasonReact が面倒見ますよってなったら、一定の価値はあるんじゃなかろうか。

とは言え、その為に Reason, BuckleScript, OCaml の知識を新たに仕入れなければならないのはコスパ悪そう。

現状、自分は『OCaml すげー』ってだけで使っている。

おまけ

まず、Qiita が reason の Syntax highlight に対応して欲しいな。

そしてこの記事長い。分割したら5つぐらいの記事にできそうだ。

  • 6. 標準モジュール
  • 6.2. BuckleScript Libraries
  • 何を使うべきか
  • 7. ブラウザで利用
  • 8. メモアプリを作る
  • 9. 型情報の共有
  • 感想