そろそろReactに入門したいなぁ、でもClojurianとしてはJavaScriptやTypeScriptではなくClojureScriptで書きたいなぁ、ということでClojureScriptとReagentでReact公式チュートリアルに取り組んでみた。
最終的なReactチュートリアルアプリ(tic-tac-toe; 三目並べ)の実装例はこちら:
lagenorhynque/react-tutorial
React->Reagentのポイント
ReactのコードをReagentで書く際のポイントを簡単にまとめてみると、
React | Reagent |
---|---|
JSX (React element) |
Hiccup風のDSL |
React component | ClojureScriptの関数 |
props |
コンポーネント関数の引数 |
state |
reagent.core/atom |
ClojureScript/Reagentの始め方
プロジェクト生成
簡単に始めるために、ここではshadow-cljsのテンプレートcreate-cljs-appでClojureScript/Reagentプロジェクトを生成する(※ Node.jsとJava (JDK)は事前にインストールしておく)。
$ npx create-cljs-app react-tutorial
生成されるプロジェクトの構成は以下の通り。
ここでは、直接 src/app/core.cljs
にチュートリアルアプリを実装してみる。
$ tree -a -I '.git|node_modules' react-tutorial/
react-tutorial/
├── .clj-kondo
│ └── config.edn
├── .gitignore
├── .zprintrc
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── css
│ │ └── style.css
│ ├── favicon.ico
│ └── index.html
├── shadow-cljs.edn
└── src
├── app
│ ├── cards.cljs
│ ├── core.cljs
│ ├── hello.cljs
│ └── hello_cards.cljs
└── e2e
└── core.cljs
6 directories, 15 files
テスト起動
ちなみに、この時点でアプリケーションを起動してみると、 http://localhost:3000/
から自動生成された初期画面が確認できる。
$ cd react-tutorial/
$ npm start
実行イメージ(with React Developer Tools)
チュートリアルの事前準備
最後に、チュートリアルのStarter Codeを参考に、土台となるHTMLの差分をpublic/index.htmlに、CSSをpublic/css/style.cssに反映すれば準備完了。
Reactコンポーネントを作る/使う
Reagent公式ページやdocを参考に、まずはReagentでReactコンポーネントを作ってみる。
コンポーネントの定義
例えば、Starter CodeにあるReactコンポーネント Game
は、JavaScriptで以下のように書かれている。
class Game extends React.Component {
render() {
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<div>{/* status */}</div>
<ol>{/* TODO */}</ol>
</div>
</div>
);
}
}
Reagentでは、JSXの代わりにHiccupスタイルのDSL(ClojureScriptのVector, Map, Keyword, etc.)でHTMLを表現できる。
そして、ReactコンポーネントはHiccup風の式を返すClojureScriptの関数として表現できる。
したがって、上の Game
コンポーネントはReagentで次のような game
関数として定義できる。
※ Clojure/Lispでは関数や変数の命名にUpperCamelCaseやlowerCamelCaseではなくlisp-case(別名kebab-case)を使うので、Lisp風の命名に変えてある。
(defn game []
[:div.game
[:div.game-board
[board]]
[:div.game-info
[:div
;; status
]
[:ol
;; TODO
]]])
コンポーネントの利用
上記の例にすでに表れているように、既存のコンポーネントを利用するにはReactのJSXで
<Board />
のように書くところを、Reagentでは
[board]
のように関数名を []
で括って書く。
※ board
コンポーネントもReagentでは関数の形で定義するので ()
で括って関数として呼び出すこともできるが、 []
で括ることでReactコンポーネントとして扱われ効率的に再描画される対象になる。
コンポーネントの props
を扱う
props
の導入
Reactコンポーネントに設定情報を渡すために利用する props
は、Reagentではコンポーネント関数の引数として表現する。
例えば、Passing Data Through Propsの Square
コンポーネントは props
を利用して以下のように書かれている。
class Square extends React.Component {
render() {
return (
<button className="square">
{this.props.value}
</button>
);
}
}
これをReagentで書くと、
(defn square [& {:keys [value]}]
[:button.square
value])
あくまでClojureScriptの関数引数なので、このように可変長引数や分配束縛によるオプション/デフォルト引数なども利用することができる(もちろん通常の位置引数でも良い)。
function components
Reactでは render
メソッドのみのReactコンポーネントを function components
として定義できる。
ここまでのReagentでのコンポーネント定義方法(A Simple Function)はちょうどそれに相当するものだといえる。
※ Reactのclassによるコンポーネント定義に相当する定義方法はこちら: A Class With Life Cycle Methods
Function Componentsにある Square
コンポーネントの最終形も以下のように実装できる。
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
↓↓↓
(defn square [& {:keys [value on-click]}]
[:button.square {:on-click on-click}
value])
ちなみに、このように props
を持つコンポーネントを利用するには、Reactで
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
/>
と書くのに対して、Reagentでは
[square
:value (squares i)
:on-click #(on-click i)]
のように書ける。
コンポーネントの state
を扱う
state
の導入
最後に、Reactコンポーネントに個別の状態 state
を持たせてみる。
Making an Interactive Componentでは、 Square
コンポーネントが内部状態として state.value
を持ち、buttonのonClickで null
から 'X'
に更新する。
class Square extends React.Component {
constructor(props) {
super(props);
this.state = {
value: null,
};
}
render() {
return (
<button
className="square"
onClick={() => this.setState({value: 'X'})}
>
{this.state.value}
</button>
);
}
}
このような更新可能な状態(つまり state
)を扱うために、Reagentでは reagent.core/atom
を利用する。
これは、コンポーネントの再描画に関わっていることを除いて clojure.core/atom
と同様なので、 @
で deref
したり reset!
したり swap!
したりできる(atom
を deref
しているコンポーネントは自動的に再描画される)。
atom
による状態を def
でnamespaceのトップレベルに定義することもできるが、ここではコンポーネントの個々のインスタンスにローカルな内部状態として持たせたいので、 atom
を参照するクロージャを返す方法(A Function Returning A Function)を利用する。
つまり、以下のようにReact要素(Hiccup風の式)をラムダ式でラップし、外側の atom
を参照させる。
(ns app.core
"This namespace contains your application and is the entrypoint for 'yarn start'."
(:require
[reagent.core :as reagent]))
(defn square []
(let [value (reagent/atom nil)]
(fn []
[:button.square {:on-click #(reset! value "X")}
@value])))
これにより、 square
コンポーネントはインスタンスごとに変更可能なstateとして value
を持ち、現在の値を表示するとともに、onClickで値を更新することが可能になる。
チュートリアルの実装例
以上のことを踏まえて、適宜Reagent公式ページも参照しつつReactチュートリアルを進めてみると、ClojureScriptとReagentでシンプルにチュートリアルアプリが実装できた。
lagenorhynque/react-tutorial
チュートリアル本編を最後まで進めたClojureScriptコードの最終形は以下の通り。
cf. 公式サンプルコード(JavaScript)のFinal Result
次は、チュートリアル末尾の追加課題(some ideas for improvements; cf. Wrapping Up)に取り組んでみたり、re-frameを導入してみたり、re-natalを試してみたりしても良いかもしれない。
(ns app.core
"This namespace contains your application and is the entrypoint for 'yarn start'."
(:require
[reagent.core :as reagent]))
(defn square [& {:keys [value on-click]}]
[:button.square {:on-click on-click}
value])
(defn calculate-winner [squares]
(let [lines [[0 1 2]
[3 4 5]
[6 7 8]
[0 3 6]
[1 4 7]
[2 5 8]
[0 4 8]
[2 4 6]]]
(reduce (fn [_ [a b c]]
(when (and (squares a)
(= (squares a) (squares b))
(= (squares a) (squares c)))
(reduced (squares a))))
nil
lines)))
(defn board [& {:keys [squares on-click]}]
(letfn [(render-square [i]
[square
:value (squares i)
:on-click #(on-click i)])]
[:div
[:div.board-row
(render-square 0)
(render-square 1)
(render-square 2)]
[:div.board-row
(render-square 3)
(render-square 4)
(render-square 5)]
[:div.board-row
(render-square 6)
(render-square 7)
(render-square 8)]]))
(defn game []
(let [state (reagent/atom {:history [{:squares (vec (repeat 9 nil))}]
:x-is-next? true
:step-number 0})]
(letfn [(handle-click [i]
(let [{:keys [history x-is-next? step-number]} @state
history (vec (take (inc step-number) history))
current (nth history (dec (count history)))
squares (:squares current)]
(when-not (or (calculate-winner squares)
(squares i))
(swap! state assoc
:history (conj history
{:squares
(assoc squares i (if x-is-next? "X" "O"))})
:x-is-next? (not x-is-next?)
:step-number (count history)))))
(jump-to [step]
(swap! state assoc
:x-is-next? (even? step)
:step-number step))]
(fn []
(let [{:keys [history x-is-next? step-number]} @state
current (nth history step-number)
winner (calculate-winner (:squares current))
status (if winner
(str "Winner: " winner)
(str "Next player: " (if x-is-next? "X" "O")))
moves (map-indexed (fn [move _]
(let [desc (if (zero? move)
(str "Go to game start")
(str "Go to move #" move))]
[:li {:key move}
[:button {:on-click #(jump-to move)}
desc]]))
history)]
[:div.game
[:div.game-board
[board
:squares (:squares current)
:on-click handle-click]]
[:div.game-info
[:div
status]
[:ol
moves]]])))))
(defn ^:dev/after-load render
"Render the toplevel component for this app."
[]
(reagent/render [game] (.getElementById js/document "app")))
(defn ^:export main
"Run application startup logic."
[]
(render))
実行イメージ(with React Developer Tools)
まとめ
- ClojureScript & Reagentで簡単にReactを始められる!
- ClojureScript楽しい>ω</
- Reagent楽しい>ω</
Further Reading
- Reagent: A minimalistic ClojureScript interface to React.js
- re-frame: A Reagent Framework For Writing SPAs, in Clojurescript.
- ClojureScript: Clojure to JS compiler
- React: A declarative, efficient, and flexible JavaScript library for building user interfaces.
- ClojureのDuctとClojureScriptのre-frameによるREST API + SPA開発入門
- Clojure/ClojureScript関連リンク集 > Webフロントエンド (ClojureScript)