関数型 AltJS である BuckleScript と、それを使って SPA を作る方法を簡単にご紹介します。
想定読者
- OCaml の基本的な文法を知っている
- JavaScript か TypeScript で React を使った事がある
BuckleScript
Facebook 開発の、 OCaml をもとにした AltJS です。
トランスパイラは OCaml のコンパイラを改造して作られているので、ほぼそのまま OCaml が動きます。
基本的な文法
公式サイトにおまかせします。
しかし、見て分かる通り OCaml そのままです。
それもそのはず、 BuckleScript は「 OCaml 風の AltJS 」 ではなく 、「 OCaml コードを JavaScript に変換するトランスパイラ」だからです。
なので、 OCaml をご存知の方には何の追加の説明も要らないでしょう。
インターオペ
AltJS で重要なのはインターオペのやりやすさでしょう。
性質上、どうしても生の JavaScript を触らざるを得ない場面が出てくるからです。
BuckleScript のインターオペはとても簡単で、そして強力です。
その分、自ら制限して使っていかないと、静的型付け AltJS の利点が消えてしまう事になる点には注意してください。
https://bucklescript.github.io/docs/en/embed-raw-javascript
https://bucklescript.github.io/docs/en/intro-to-external
SPA
では、そんな BuckleScript で SPA を構築してみましょう。
BuckleScript のさらに上に被さる形で、 ReasonML という AltJS があります。
この ReasonML 向けに、 React をラップした ReasonReact というライブラリがあります。
ReasonML は BuckleScript ( OCaml )に変換される言語なので、 BuckleScript からもこの ReasonReact を利用する事ができます。
ではこれを使って、何か SPA を作ってみましょう。
TODO アプリ
JavaScript フレームワークの例示として飽きる程出てくる TODO アプリを例に取って、 BuckleScript で SPA を作る方法を解説しましょう。
コード全体は以下のレポジトリに置いてあります。
基盤を作る
まず、 bs-platform を入れて bsb
コマンドを使えるようにします。
これは npm で入れる事ができます。
$ npm install -g bs-platform
次に、 bsb -init
を使って新しい BuckleScript プロジェクトの雛形を作ります。
$ bsb -init your-app-name
これで、 your-app-name
ディレクトリの下に BuckleScript を使うためのファイル一式が生成されます。
また、今回はバンドルに parcel を使いたいので、無ければ入れておきましょう。
$ npm install -g parcel-bundler
アプリを作る
通例通り、コンポーネントに分けてアプリを考えましょう。
アプリ全体を App コンポーネントで表現し、中に TodoInput コンポーネントと TodoList コンポーネントを持つ事とします。
TodoInput コンポーネントはテキストインプットとボタンを持ち、新しい Todo を作るために使います。
TodoList は既存の Todo 一覧で、各 Todo は終了状態にしたり、削除したりできます。
そんな設計でコンポーネントを作っていきましょう。
DSL を作る
その前に、要素を組み立てる為の DSL を作っておきましょう。
BuckleScript では JSX を使う事ができません。 @JSX
マクロというものは存在しますが、
((div ~className:"hoge" ~children:[ReasonReact.string("fuga")] ()) [@JSX])
のような見た目なので普段遣いには辛いものがあります。
この辺りの説明は以前の記事で書いたので、詳しくは説明しません。
コードの一部を載せておきます。
module RR = ReasonReact
module RD = ReactDOMRe
module RE = ReactEvent
external s : string -> RR.reactElement = "%identity"
let div ?className ?onClick children =
let props = RD.props
?className
?onClick
() in
RD.createElementVariadic "div" ~props @@ Array.of_list children
let input ?type_ ?onChange ?value children =
let props = RD.props
?type_
?onChange
?value
() in
RD.createElementVariadic "input" ~props @@ Array.of_list children
...
概ね、 Elm 風の DSL ができあがります。
これを、 bSReact.ml
というファイル名で保存します。
TodoInput コンポーネント
open BSReact
let component = RR.statelessComponent "TodoInput"
let make ~dispatcher ~todo_input _children = {
component with
render= fun _self ->
div ~className:"todo-input row" [
div ~className:"column" [
input ~type_:"text" ~onChange:dispatcher#todoInput ~value:todo_input [];
];
div ~className:"column" [
button ~onClick:dispatcher#todoAdd [
s {j|追加|j}
]
]
]
}
let c ~dispatcher ~todo_input children =
RR.element @@ make ~dispatcher ~todo_input children
ReasonReact のコンポーネントの作り方は以下のとおりです。
- モジュールの中で、 ReasonReact.statelessComponent などの関数を使って コンポーネントの雛形 を作る
- 名前付き引数で props を、そして子コンポーネントを引数に取る make 関数を定義し、その雛形を拡張したコンポーネントを返すようにする。
これで、 ReasonML の JSX で利用可能なコンポーネントができあがります。
ReasonReact.statelessComponent はステートレスなコンポーネントの雛形を作る関数です。引数にはデバグなどで利用する為のコンポーネントの名前を渡します。
次に make 関数を定義します。
make 関数では、 props の要素(プロパティ)を名前付き引数で受け取るようにします。
TodoInput コンポーネントはアクションを起こすための dispatcher と、現在のテキストボックスに何の文字列が入っているかを示す text_input とをプロパティとして取るようにしています。
どちらも型を明示してはいませんが、コンパイラがその引数の使われ方から型推論をしてくれるので、整合の取れない型を渡された場合はきちんとコンパイルエラーになります。
ちなみにここでは、 text_input は string 型に、 dispatcher は「最低限、 ReactEvent.Form.t -> unit 型の todoInput メソッドと、 ReactEvent.Mouse.t -> unit 型の todoAdd メソッドとを持つオブジェクト」の型に推論されます。
それらの値を利用して、コンポーネントのビュー要素を組み上げます。
make 関数は component 型の値を返す必要があるのですが、これを、先程 ReasonReact.statelessComponent 関数で作り出した値(レコード)を拡張する事で作ります。
どうするかというと、 self -> reactElement 型の render フィールドを上書きします。
self 型の引数は、 state を取り出したり action を起こしたりするのに利用します。
その render フィールドの無名関数の中で、 React 要素を組み立ててビューを作ります。
今回は自前で定義した DSL を使いましたが、これを JSX で表すとこんな感じになるでしょうか。
<div className={todo-input row}>
<div className="column">
<input type="text" onChange={dispatcher#todoInput} value={todo_input} />
</div>
<div className="column">
<button onClick={dispatcher#todoAdd}>
追加
</button>
</div>
</div>
最後に、ヘルパー関数として c 関数を用意しています。
make 関数で返されるのはあくまで component 型なのですが、 ReasonReact で要素として扱えるのは reactElement 型です。
ReasonML の JSX を使うと make 関数を利用してコンポーネント定義から reactElement 型の値を作る処理を自動で生成してくれるのですが、それを自前で行ってやる必要があります。
というわけで、 ReasonReact.element 関数を使います。
ReasonReact.element 関数は、オプショナル引数として key
と ref
とを取り、最後に component 型の値を取って reactElement の値を作ってくれます。
key
と ref
とを別に取る理由は、 React ではこれらのプロパティを特別扱いする必要があるからです。
(なので、コンポーネントの make 関数で key や ref といった名前の引数を使う事はできません。 ReactJS と同様ですね。)
TodoList コンポーネント
open BSReact
module Todo = struct
type t = {
desc: string;
state: bool
}
let toggle ({state} as todo) =
{ todo with state= not state }
let component = RR.statelessComponent "Todo"
let make ~idx ~dispatcher ~todo:{desc; state} _children = {
component with
render= fun _self ->
let desc =
if state then
del [ s desc ]
else
s desc in
let visible_index = idx + 1 in
tr [
td [ s {j|#$(visible_index)|j} ];
td [
div ~onClick:(dispatcher#todoToggle idx) [
desc
]
];
td [
button ~className:"button" ~onClick:(dispatcher#todoDelete idx) [
s "x"
]
]
]
}
let c ~idx ~dispatcher ~todo children =
RR.element @@ make ~idx ~dispatcher ~todo children
end
let component = RR.statelessComponent "Todo"
let make ~dispatcher ~todos _children = {
component with
render= fun _self ->
let todos =
todos
|> Array.mapi (fun idx todo ->
Todo.c ~idx ~dispatcher ~todo [])
|> Array.to_list in
div ~className:"todo-list" [
table [
thead [
tr [
th [];
th [];
th [];
]
];
tbody todos
]
]
}
let c ~dispatcher ~todos children =
RR.element @@ make ~dispatcher ~todos children
TodoList コンポーネントの中には Todo コンポーネントが入っています。
ReasonReact のコンポーネントは、単なる make 関数を持ったモジュールなので、 OCaml のインナーモジュールはそのままインナーコンポーネントにできます。
App コンポーネント
open BSReact
module Todo = TodoList.Todo
type state = {
todo_input: string;
todos: TodoList.Todo.t array
}
type action
= ChangeInput of string
| AddTodo
| ToggleTodo of int
| DeleteTodo of int
let initialState () = {
todo_input= "";
todos= [||]
}
let reducer action state = match action, state with
ChangeInput todo_input, _ ->
RR.Update { state with todo_input }
| AddTodo, {todo_input; todos} ->
RR.Update { todo_input= ""; todos= Array.append todos [|{ Todo.desc= todo_input; state= false }|] }
| ToggleTodo idx, {todos} ->
let todos = Array.copy todos in
let todo = todos.(idx) in
todos.(idx) <- Todo.toggle todo;
RR.Update { state with todos }
| DeleteTodo idx, {todos} ->
let todos =
todos
|> Array.mapi (fun i todo -> i, todo)
|> Array.fold_left
(fun acc (i, todo) -> if i = idx then acc else todo :: acc)
[]
|> List.rev
|> Array.of_list in
RR.Update { state with todos }
class dispatcher self =
let send = self.RR.send in
object
method todoInput event =
let todo_input = (RE.Form.target event)##value in
send (ChangeInput todo_input);
method todoAdd event =
RE.Mouse.preventDefault event;
send AddTodo;
method todoToggle idx event =
RE.Mouse.preventDefault event;
send (ToggleTodo idx);
method todoDelete idx event =
RE.Mouse.preventDefault event;
send (DeleteTodo idx);
end
let render self =
let {todo_input; todos} = self.RR.state in
let dispatcher = new dispatcher self in
div ~className:"todo-app" [
h1 [ s "TODO LIST" ];
TodoInput.c ~dispatcher ~todo_input [];
TodoList.c ~dispatcher ~todos []
]
let component = RR.reducerComponent "App"
let make _children =
{ component with initialState; reducer; render }
let c children =
RR.element @@ make children
今まで作った2つのコンポーネントがどちらも statelessComponent だったのに対して、 App コンポーネントはステートを持つコンポーネント、すなわち reducerComponent です。
statelessComponent の場合、 render フィールドだけを上書きすれば良かったのですが、 reducerComponent の場合はそれに加えて initialState フィールドと reducer フィールドも上書きする必要があります。
(ご想像の通り、他にも ReactComponent のライフサイクルフック系のフィールドが用意されています。例えば didMount
や willUpdate
などです。)
initialState は unit -> 'state 型で、 reducer は 'action -> 'state -> update 型です。
'state や 'action は型引数なので、コンポーネント毎に自由に決める事ができます(というか、決める必要があります)。
なので、まず state 型と action 型を定義しましょう。
type state = {
todo_input: string;
todos: TodoList.Todo.t array
}
type action
= ChangeInput of string
| AddTodo
| ToggleTodo of int
| DeleteTodo of int
state は、今のテキストインプットの状態を表す todo_input
フィールドと、 TODO 一覧を表す todos
フィールドを持ちます。
action は、このアプリで発生する全てのアクション、「テキストインプットの状態更新」「 TODO 追加」「 TODO の状態変更」「 TODO 削除」を代数的データ型で表す事とします。
そして、 initialState 関数を定義します。
let initialState () = {
todo_input= "";
todos= [||]
}
テキストインプットは空文字、 TODO 一覧も空配列。よくある初期状態ですね。
次に、 reducer 関数を定義します。
let reducer action state = match action, state with
ChangeInput todo_input, _ ->
RR.Update { state with todo_input }
| AddTodo, {todo_input; todos} ->
RR.Update { todo_input= ""; todos= Array.append todos [|{ Todo.desc= todo_input; state= false }|] }
| ToggleTodo idx, {todos} ->
let todos = Array.copy todos in
let todo = todos.(idx) in
todos.(idx) <- Todo.toggle todo;
RR.Update { state with todos }
| DeleteTodo idx, {todos} ->
let todos =
todos
|> Array.mapi (fun i todo -> i, todo)
|> Array.fold_left
(fun acc (i, todo) -> if i = idx then acc else todo :: acc)
[]
|> List.rev
|> Array.of_list in
RR.Update { state with todos }
reducer 関数は 'action -> 'state -> update 型であると述べました。
Redux をご存知の方は、 reducer 関数の型が action -> state -> state ではない点に疑問を抱くかと思います。
実は、ReasonReact の reducer は、副作用を取り扱う為のミドルウェアを内包しています。
update 型の値は、 RR.Update 等のコンストラクタを利用して作り出します。
let reducer action state = match action with
(* 何もしません *)
NoAction -> ReasonReact.NoUpdate
(* 単純に状態を変化させます *)
| PureAction new_state -> ReasonReact.Update new_state
(* 副作用を起こします *)
| ImpureNoAction f -> ReasonReact.SideEffects f
(* 状態を更新した後、副作用を起こします *)
| ImpureAction (new_state, f) -> ReasonReact.UpdateWithSideEffects (new_state, f)
ReasonReact.SideEffects や ReasonReact.UpdateWithSideEffects が取る副作用の為の関数は self -> unit 型で、この self 型の引数を使う事で副作用中にアクションを起こす事ができます。
今回のアプリでは、副作用は利用しないのでどの action でも ReasonReact.Update だけを利用しています。
次は、 dispatcher を定義します。
この dispatcher というのは、 ReasonReact で定義された何かではありません。子コンポーネントに挙動を引き渡す方法はたくさんあり、どのような手段を使ってもいいと思います。
今回は、 OCaml のクラスを利用してルートコンポーネントのアクションを子コンポーネントに伝える手法を取ってみました。その為に、 dispatcher というクラスを定義する事としました。
class dispatcher self =
let send = self.RR.send in
object
method todoInput event =
let todo_input = (RE.Form.target event)##value in
send (ChangeInput todo_input);
method todoAdd event =
RE.Mouse.preventDefault event;
send AddTodo;
method todoToggle idx event =
RE.Mouse.preventDefault event;
send (ToggleTodo idx);
method todoDelete idx event =
RE.Mouse.preventDefault event;
send (DeleteTodo idx);
end
子コンポーネントで利用する処理を、メソッドで定義してあります。アクションを起こすには render や SideEffects で引数として与えられる self 型の値が必要なので、これを引数として受け取るようにしてあります。 self.send 関数は 'action -> unit 型で、この関数が呼ばれるとその引数のアクションが reducer に、現在の状態とともに引き渡されます。
この手法の利点は、あるコンポーネントで必要な処理が増えた場合や、他にコンポーネントが増えて処理が追加された場合にも、 dispatcher にメソッドを追加するだけで良く、既存のコンポーネントの引数定義を変える必要が無いという点にあります。
(ReactEvent.Form.target event)##value
は、ご察しの通り event.target.value
と同じ意味です。
ReactEvent.Mouse.preventDefault event
も、 event.preventDefault
です。
initialState, reducer, dispatcher を作り終えたら、 render はささやかなものです。
let render self =
let {todo_input; todos} = self.RR.state in
let dispatcher = new dispatcher self in
div ~className:"todo-app" [
h1 [ s "TODO LIST" ];
TodoInput.c ~dispatcher ~todo_input [];
TodoList.c ~dispatcher ~todos []
]
子コンポーネントに、現在の状態のうち必要な部分と dispatcher を引き渡しています。子コンポーネントは、状態やアクションについては何も知りませんが、型の正しさをコンパイラが検査して、正しく処理を呼び出せる事を保証してくれます(勿論、ロジック自体が誤っていた場合はこの限りではありません)。
その他のファイル
では、このコンポーネントをマウントしましょう。
let _ = ReactDOMRe.renderToElementWithId (App.c []) "app"
これを index.ml
という名前で保存します。
そして HTML を用意します。
<html>
<head>
<title>TODO LIST</title>
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
<link rel="stylesheet" href="//cdn.rawgit.com/necolas/normalize.css/master/normalize.css">
<link rel="stylesheet" href="//cdn.rawgit.com/milligram/milligram/master/dist/milligram.min.css">
<style>
#app {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
.todo-app {
margin-top: 2rem;
width: 76%;
}
</style>
<script src="src/index.bs.js" defer></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
これは index.html
という名前で保存します。
BuckleScript をトランスパイルします。
$ bsb -make-world
これで、 *.bs.js
という名前で JavaScript にトランスパイルされたファイルが吐き出されます。
それらのファイルを、 webpack や rollup でまとめ上げても良いのですが、今回は parcel を使ってみます。
$ parcel index.html
localhost:1234
で TODO アプリが起動するはずです。
確認してみましょう。
ReasonML を利用する
同じアプリを、 BuckleScript ではなく ReasonML で書いてみるとどうでしょうか。
元々 ReasonReact は ReasonML 用のライブラリです。 ReasonML は BuckleScript ができる事はほぼできますし、それに加えて JSX を利用する事ができます。
BuckleScript では自前 DSL で構築したビュー部分を、 JSX で記述するとどのようになるでしょうか。
let component = ReasonReact.statelessComponent("TodoInput");
let make = (~dispatcher, ~todo_input, _children) => {
...component,
render: _self =>
<div className="todo-input row">
<div className="column">
<input type_="text" onChange=dispatcher#todoInput value=todo_input />
</div>
<div className="column">
<button onClick=dispatcher#todoAdd>
(ReasonReact.string({j|追加|j}))
</button>
</div>
</div>,
};
これは TodoInput コンポーネントです。
React に慣れ親しんだ方であれば、より自然な形に見えるかもしれません。
ReactJS の JSX と違う点は、文字列などをそのまま渡す事はできず、 ReasonReact.string 関数を使って reactElement 型に(見かけ上)変換してあげる必要があるという点ですね。
同様に、 null を使いたい場合は ReasonReact.null 関数を、配列を要素に変換したい場合は ReasonReact.array 関数を使います。
ReasonML でも ReasonReact は使えて(というか本来 ReasonML で利用する為のライブラリ)、しかも JSX まで使えるのなら、 BuckleScript じゃなくて ReasonML で書けばいいじゃん、と思われるかもしれません。大変尤もなご意見です。
ただ、 BuckleScript が OCaml の文法そのままなのに対し、 ReasonML は OCaml と JavaScript のキメラのような独自の文法をしているので、その分学習コストや、スイッチングコストがかかります。その辺りの不都合と、 JSX が使えない不自由とを天秤にかけた上で、どちらを利用するかを決めると良いと思います。
利点と欠点
BuckleScript (か、 ReasonML )と ReasonReact を利用して、簡単かつ高速に TODO アプリを作る事ができました。
BuckleScript 及び ReasonML の利点は何でしょうか。
1つは、強力な型システムが存在する事です。
OCaml の型システムをそのまま持ち込んでいるので、すでに長年の薫陶を経て洗練された力強く実績のある型システムを、 JavaScript の上で使う事ができます。
ML 系の言語に慣れているのであれば BuckleScript を、 JavaScript に親しいのであれば ReasonML を使えば、文法に戸惑う事も少ないでしょう。
ReasonML は JSX に対応しており、公式の提供する ReasonReact ライブラリを使えば React アプリケーションを構築する事が容易なのも利点です。
この辺りのエコシステムが最初から整備されている点は、大変魅力的でしょう。
反面、これは全ての静的型付き AltJS に言える事ですが、型定義ファイルが無い場合、既存の JavaScript 資源を使う事ができません。
特に BuckleScript は、 TypeScript の any のような「抜け穴」が無いので、どんな場合もきちんと型定義を書いてやる必要があります。
にも関わらず TypeScript 程のメジャーさを持っていない BuckleScript で既存のライブラリを使うのであれば、多くの型定義を自分で行う必要があります。
勿論、 BuckleScript や ReasonML で既存のライブラリの型定義を書くのは然程難しい事ではないのですが……。
また、 BuckleScript にしても ReasonML にしても、そして ReasonReact にしても、現段階で積極的に開発中です。
最新の情報に追随し、積極的にマイグレーションを行う気力が必要とされるでしょう。
勿論これは、速いペースで改善されていくという意味でもあるので、必ずしも欠点とは言えません。
まとめ
- 非常に強力で、安定感があり、定評もある OCaml の型システムを JavaScript で利用できる
- OCaml や ML 系に慣れている人には特におすすめ
- TypeScript 程メジャーではないので、情報やライブラリは少ない