こんにちは。yharaです。みなさんはWebアプリを作るとき何を使っているでしょうか?Ruby界隈だと、Railsと答える人が多そうですね。ではフロント側は?React、Vue.js、Angularなどいろいろありますね。
そんな中で、hyperappというフレームワークを聞いたことはあるでしょうか。hyperappはわずか400行のJavaScriptで実装された「マイクロフレームワーク」ですが、そのサイズからは考えられないほど本格的な機能を持っています。
hyperappを見て私は思いました。これはすごい、たったこれだけでReact+Reduxのかなりの機能が提供できているじゃないか、と。そして、400行しかないのなら、これをまるごとRubyに移植できないだろうか?と。
そうしてできたのがRubyistのためのフロントエンドフレームワーク「Ovto」です。シンプルで高機能なAPIを持つのはhyperappと同じですが、アプリを全てRubyで書けるというのが違うところです:-)
本稿ではOvtoの概要と、簡単なサンプルアプリを作るところまでを解説します。Ovtoは、自分で言うのもなんですが使っていて楽しいフレームワークなので、ぜひ手を動かしてみてください。
Ovto(オブト)はReactやVue.js等と同じく、ブラウザ上で動く複雑なアプリケーションを作るためのフレームワークです。サーバ側の機能はないので、例えばDBにデータを保存したりしたい場合はRailsやSinatraなどと組み合わせて使うことを想定しています。
Ovtoの特徴は以下です。
難しいことを書きましたけど、一番大事なのはOvtoは「楽しい」ということです。Ovtoができたあと、何か実用的なアプリを作ってみようということでVisionというTODOアプリを作ったのですが、その過程はとても楽しかったです。
ここからは実際にOvtoアプリを作っていきます。今回は説明を簡単にするため、静的なhtmlファイルを使いますが、RailsやSinatraと組み合わせる場合もアプリの書き方は同じです。Rails・SinatraアプリにOvtoアプリを埋め込む方法については、以下のサンプルを参考にしてください。
RubyとBundlerはインストールされているものとします。以下のようなGemfileを作り、bundle install
します。
source 'https://rubygems.org'
gem 'ovto'
gem 'rake'
同じディレクトリに、以下の3つのファイルを作ります。
まずはindex.html
。このファイルはこれで完成、つまり本稿の最後までこのままです。ほとんどdivタグしかないように見えますが、中身はOvtoで作っていくのでこれで良いのです。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script type='text/javascript' src='app.js'></script>
<link rel='stylesheet' href='style.css' />
</head>
<body>
<div id='ovto'></div>
<div id='ovto-debug'></div>
</body>
</html>
次はstyle.css
。スタイルシートです。今回はサンプルコードなので、最小限のスタイルだけ適用します。以下のように書いてください(あとで使います)。
table#board td {
width: 50px;
height: 50px;
border: 1px solid black;
text-align: center;
}
最後にapp.rb
です。このファイルにOvtoのコードを書いていきます。まずは画面にHELLOと出すだけの、最小限のOvtoアプリを用意します。
require 'ovto'
class MyApp < Ovto::App
class State < Ovto::State
end
class Actions < Ovto::Actions
end
class MainComponent < Ovto::Component
def render(state:)
o 'div' do
o 'h1', "HELLO"
end
end
end
end
MyApp.run(id: 'ovto')
3つのファイルが用意できたら、さっそく動かしてみましょう。以下のコマンドを実行すると、app.rbがapp.jsに変換されます。
$ bundle exec opal -c -g ovto app.rb > app.js
index.htmlをブラウザで開くと、HELLOと表示されたはずです。
これでOvtoアプリを作る環境が整いました。試しに「HELLO」の部分を適当な文字列に変えて、もう一度コンパイルしてみてください。ブラウザをリロードすれば文字が変わるはずです。(変わらない場合はブラウザのキャッシュが効いているかもしれません。例えばGoogle Chromeの場合は、開発者コンソールを開いて「Disable Cache」にチェックを入れることで、一時的にキャッシュを無効化できます)
app.rbには、State, Actions, MainComponentという3つのクラスが出てきました。Ovtoではこの3つのクラスを使ってアプリを書いていきます。
Stateはアプリケーションの状態を保持するクラスです。MainComponentはビューの定義で、state
を受け取ってどんな画面を表示したいかを記述します。
Actionはstate
に変更を加えるものです。アプリケーションの動作中は、アプリの状態は刻一刻と変化していきますが、MainComponentから直接state
を書き換えることはできません。state
を書き換えたいときは、Actionsクラスに定義したメソッド(以下では単に「アクション」と呼びます)を経由する必要があります。一見めんどくさそうですが、このような制約を設けることにより、「状態がどのように変化するのか」「状態がどこで変化するのか」を調べるのがとても簡単になります。
以下では「カウンター」というデモを通して、3つのクラスの具体例を見ていきます。
最初に、Stateクラスを以下のようにします。
class MyApp < Ovto::App
class State < Ovto::State
item :count, default: 0
end
itemメソッドはStateクラスの要素を宣言します。ここではcountという要素を宣言しています。要素が複数あるときはitemメソッドの呼び出しを並べます。default:
はデフォルト値の指定です。
StateオブジェクトはState.newで作れます。引数には各要素の値を指定します。
state = MyApp::State.new(count: 15)
state.count #=> 15
引数を省略した場合はデフォルト値で初期化されます。
state = MyApp::State.new
state.count #=> 0
次にMainComponentを書いてみましょう。以下のように書き換えてみてください。
class MainComponent < Ovto::Component
def render(state:)
o 'div' do
o 'h1', 'Counter'
o 'div', state.count
end
end
end
これを実行すると以下のようになります。
開発者コンソールを開くと、<div id='ovto'>
内に以下のようなHTMLが生成されていることがわかります。
<div>
<h1>Counter</h1>
<div>0</div>
</div>
MyApp.run
でOvtoアプリを起動すると、まずMyApp::Stateクラスのオブジェクトが自動的に作成され、それを引数としてMainComponentクラスのrenderメソッドが呼ばれます。引数のstate:
というのは見慣れないかもしれませんが、キーワード引数の初期値を省略した形です。Ovtoではキーワード引数を多用するので、この機会に慣れてください。1
renderメソッドは、「このようなDOMを生成してほしい」という依頼を返さなくてはなりません。この依頼を作成するのがoメソッドです。
ぱっと見では箇条書きみたいに見えますが、「o」はOvto::Componentクラスが提供する1文字メソッドです。以下のように括弧をつければ、メソッド呼び出しであることがわかりやすいでしょうか。
o('h1', 'Counter')
oメソッドは以下の引数を取ります。
o(tag_name, attrs={}, content, &block)
tag_nameは'div'
など、HTMLのタグ名を指定します。attrsはタグの属性値をハッシュで指定します(省略可)。contentはタグの中身を文字列で指定します。
タグの中身はブロックで渡すこともできます。タグをネストさせたいときはブロックを使います。
stateを表示する方法がわかったので、次はstateを書き換えられるようにしましょう。
前述のように、Componentから直接stateを書き換えることはできません。書き換えたいときは必ずActionsを通す必要があります。ということで、Actionsクラスに「カウントを増やす」というメソッドを定義します。
class Actions < Ovto::Actions
def increment_count(state:, num:)
return {count: state.count + num}
end
end
Actionsクラスのメソッドは、stateをキーワード引数で受け取り、stateのどの要素がどう変化するかをハッシュで返します。
次にMainComponentを編集してこのアクションを呼ぶボタンを設置してみましょう。
class MainComponent < Ovto::Component
def render(state:)
o 'div' do
o 'h1', 'Counter'
o 'div', state.count
# カウントを増やすボタン
o 'input', type: 'button', onclick: ->(e){ actions.increment_count(num: 1) }, value: '+1'
o 'input', type: 'button', onclick: ->(e){ actions.increment_count(num: 3) }, value: '+3'
end
end
end
上記を実行してボタンを押すと、どうなるでしょうか?そう、画面に表示されるカウントが変化したはずです。
Ovtoはstateが変化すると、自動的に新しいstateで画面を再描画します。これは、jQueryを使ったプログラミングと一味違うところです。jQueryの場合は「画面をどう変化させるか」を常に考えなければいけないので、「○○画面を開いて最初のボタンをクリックして二番目のフォームを…」のように複雑なアプリになると大変になってきます。
一方Ovtoの場合は「各状態がどういう画面になるか」をしっかり書いておけば、あとは状態を変化させるだけで自動的に画面ができあがります!
「ボタンが押されたとき」のようなイベントハンドラの指定もoメソッドを使います。以下のonclick:
で渡しているのがイベントハンドラです。
o 'input', type: 'button', onclick: ->(e){ actions.increment_count(num: 1) }, value: '+1'
oメソッドの第二引数にハッシュを指定した場合、タグの属性値の指定になりますが、いくつか特殊な指定があります。
上のコードでは、onclickを使ってボタンが押されたときの処理を記述しています。実行したい処理はProcオブジェクトで指定します。->(){ ... }
はProcオブジェクトを作るRubyの文法です。
イベントハンドラ内では、actions
メソッドを経由してアクションを呼び出すことができます。アクションを実行すると、stateが更新され、新しいstateを引数にしてrenderが呼び出されて、画面が書き換わります。2
以上で、Ovtoの根幹であるStateとActionsとMainComponentについて解説できました。ここからはもう少し複雑なアプリケーションとして、三目並べゲームを作ってみます。
Ovtoでアプリを作るときは、まずStateから設計します。まずは盤面データが必要ですね。これは3x3の二次元配列にすれば良さそうです。各セルは「○」「×」「空」のいずれかなので、それぞれ0
, 1
, nil
で表すことにしましょう。
あとは今どっちの手番かも覚えておく必要があります。とすると、こんな感じでしょうか。
class State < Ovto::State
# 盤面データ(0または1またはnil)
item :board, default: [
[nil, nil, nil],
[nil, nil, nil],
[nil, nil, nil],
]
# 現在のプレイヤー(0または1)
item :player, default: 0
end
Stateが決まったら、それを使って画面を作ります。MyApp::MainComponent
を以下のように書き換えてください。
class MainComponent < Ovto::Component
PLAYER_MARK = {0 => '○', 1 => '×'}
def render(state:)
o 'div' do
o 'div#player' do
"PLAYER: #{PLAYER_MARK[state.player]}"
end
o 'table#board' do
state.board.each do |row|
o 'tr' do
row.each do |cell|
o 'td' do
PLAYER_MARK[cell]
end
end
end
end
end
end
end
end
ゲームボードとプレイヤー情報が出るようになりました。tdタグに枠線が付いているのは、最初に説明したstyle.cssのおかげです。
ボードが出たので、思わずクリックしたくなりますが、今はクリックしても何も起きません。tdタグのonclickイベントを使って、○×を置けるようにしましょう。○×を置くということはstate.board
を変更するということなので、まずはセルに○×を置くアクションが必要です。
セルの内容を更新するアクションということで、update_cell
という名前にしましょうか。引数としてどのセルなのか(x
, y
)を受け取る必要がありそうです。あとは○と×のどっちを置くかという情報も必要ですが、これはstate.player
を見ればわかるので引数にはしなくて良いでしょう。
class Actions < Ovto::Actions
def update_cell(state:, x:, y:)
# 新しい盤面を作る
new_board = state.board.map{|row| row.dup}
new_board[y][x] = state.player
# 新しいプレイヤーは、現在と逆のプレイヤー(0なら1、1なら0)
new_player = 1 - state.player
return {board: new_board, player: new_player}
end
end
ここで一つOvtoの大事なルールを紹介します。それはstateに入っているオブジェクトを破壊的に変更してはいけないということです。例えば、盤面を更新するのにstate.board[y][x] = ...
とするのではなく、新しい3x3のArrayを作ってやらないといけないということです。
というのはOvtoでは効率のため、stateが変化していない(==
が真を返す)場合は画面を更新しないからです。そのため、現在のstateは変化前のものとして触らないでおく必要があるのです。
上記ではnew_board = state.board.map{|row| row.dup}
のようにしてArray全体を複製しています。
アクションができたので、tdにonclickイベントのハンドラを追加し、セルがクリックされたらこのアクションを呼ぶようにします。そのときにどのセルがクリックされたかを渡す必要があるので、2箇所の.each
に.with_index
を付けて、x
とy
が取れるようにします。
o 'table#board' do
state.board.each.with_index do |row, y|
o 'tr' do
row.each.with_index do |cell, x|
o 'td', onclick: ->(e){ actions.update_cell(x: x, y: y) } do
PLAYER_MARK[cell]
end
end
end
end
end
これで、クリックすると○×が置けるようになりました。また、プレイヤー欄も○から×にちゃんと変化していますね。
上のコードにはバグがあるのですが、お気づきでしょうか?update_cell
では指定された座標に○または×を置いていますが、そこに今何が入っているかはチェックしていませんね。なので、なんと相手の手を上書きすることができてしまいます!
これは以下の1行を追加して、指定された場所にすでに何か入っている場合はすぐにreturnするようにすれば直ります。アクションがnilを返した場合、stateは更新されません。
def update_cell(state:, x:, y:)
# そこには置けない
return if state.board[y][x] != nil
...
end
これで対戦ができるようになりましたが、○か×を3つ並べてもそのままゲームが続いてしまいます。勝利条件が満たされたら勝者を表示するようにしましょう。そのあと、クリックでゲーム状態をリセットして次のゲームを開始できるといいですね。
あ、三目並べの場合は決着が付かずに引き分けになる場合もありますね。この場合もリセットボタンが表示されてほしいですね。
ということで、「ゲームが決着したかを返すメソッド」と「勝者がどちらかを返すメソッド」を作りたいと思います。これはどのクラスに定義することもできますが、いずれも状態から決まる情報なので、Stateクラスに定義するのが良いでしょう。
以下ではそれぞれgame_over?
とwinner
という名前でメソッドを定義しています。3つ並びがあるかの判定はいろいろな書き方ができると思いますが、今回はこんな感じにしてみました。
class State < Ovto::State
item :board, default: [
[nil, nil, nil],
[nil, nil, nil],
[nil, nil, nil],
]
item :player, default: 0
# ゲームが終了しているとき真を返す
def game_over?
# 勝者が決まったらゲーム終了
return true if winner
# 盤面が全部埋まったらゲーム終了
return true if board.all?{|row| row.all?{|cell| cell != nil}}
# それ以外の場合はゲーム中
return false
end
# 勝者(0または1)を返す。勝者がいないときはnilを返す
def winner
# 横一列が作られたかをチェック
board.each do |row|
winner = check_winner(*row)
return winner if winner
end
# 縦一列が作られたかをチェック
board.transpose.each do |col|
winner = check_winner(*col)
return winner if winner
end
# 斜めの列が作られたかをチェック
winner = check_winner(board[0][0], board[1][1], board[2][2])
return winner if winner
winner = check_winner(board[0][2], board[1][1], board[2][0])
return winner if winner
# 勝者がいない(=まだ試合が続いているか、引き分けで終わった)
return nil
end
private
# a,b,cが等しいときその値を返す
def check_winner(a, b, c)
if a == b && b == c
return a
else
return nil
end
end
end
これを使って、勝者表示を実装してみましょう。MainComponentのPLAYER表示の下あたりに以下のif式を入れます。
o 'div#player' do
"PLAYER: #{PLAYER_MARK[state.player]}"
end
# 勝者を表示する
if state.game_over?
o 'div#winner' do
"WINNER: #{PLAYER_MARK[state.winner]}"
end
end
○が横一列に並んだので、「WINNER: ○」と出ていますね。
これだけだとWINNERが出たあともセルをクリックできてしまうので、tdのonclickに「ゲーム終了でない場合」という条件を追加しておきましょう。
o 'td', onclick: ->(e){ actions.update_cell(x: x, y: y) unless state.game_over? } do
...
あとはWINNERが決まったのにPLAYER欄が出ているのは変なので、ゲーム終了時はdiv#player
を描画しないよう、unlessで囲みましょう。
unless state.game_over?
o 'div#player' do
"PLAYER: #{PLAYER_MARK[state.player]}"
end
end
だいぶゲームらしくなってきましたね。最後にリセットボタンを付けて、ゲームが終わったあと次のゲームをプレイできるようにしましょう。
まずはゲームの状態をリセットするアクションが要りそうです。MyApp::Actions
にメソッドを追加しましょう。
# ゲームをリセットする
def reset_game(state:)
new_board = [
[nil, nil, nil],
[nil, nil, nil],
[nil, nil, nil],
]
new_player = case state.winner
when 0 then 1
when 1 then 0
else state.player
end
return {board: new_board, player: new_player}
end
boardはアプリケーション開始時と同じく、3x3の空の配列にしています。playerは今回のゲームで負けた方を選ぶようにしてみました。
ビューの方は、WINNER表示の下にa
タグを追加して、クリックされたら上のreset_gameアクションを呼ぶようにします。
if state.game_over?
o 'div#winner' do
"WINNER: #{PLAYER_MARK[state.winner]}"
end
# リセットボタン
o 'a', href: '#', onclick: ->{ actions.reset_game } do
"RESET"
end
end
これでリセットボタンが出るはずです。試しに○を3つ並べてみると…
うん、大丈夫そうですね。RESETを押すと新しいゲームが始まります。これで、完成です:grin:
最後に全体像を貼っておきます。ちょうど120行です。
require 'ovto'
class MyApp < Ovto::App
class State < Ovto::State
# 盤面データ(0または1またはnil)
item :board, default: [
[nil, nil, nil],
[nil, nil, nil],
[nil, nil, nil],
]
# 現在のプレイヤー(0または1)
item :player, default: 0
# ゲームが終了しているとき真を返す
def game_over?
# 勝者が決まったらゲーム終了
return true if winner
# 盤面が全部埋まったらゲーム終了
return true if board.all?{|row| row.all?{|cell| cell != nil}}
# それ以外の場合はゲーム中
return false
end
# 勝者(0または1)を返す。勝者がいないときはnilを返す
def winner
# 横一列が作られたかをチェック
board.each do |row|
winner = check_winner(*row)
return winner if winner
end
# 縦一列が作られたかをチェック
board.transpose.each do |col|
winner = check_winner(*col)
return winner if winner
end
# 斜めの列が作られたかをチェック
winner = check_winner(board[0][0], board[1][1], board[2][2])
return winner if winner
winner = check_winner(board[0][2], board[1][1], board[2][0])
return winner if winner
# 勝者がいない(=まだ試合が続いているか、引き分けで終わった)
return nil
end
private
# a,b,cが等しいときその値を返す
def check_winner(a, b, c)
if a == b && b == c
return a
else
return nil
end
end
end
class Actions < Ovto::Actions
def update_cell(state:, x:, y:)
# そこには置けない
return if state.board[y][x] != nil
# 新しい盤面を作る
new_board = state.board.map{|row| row.dup}
new_board[y][x] = state.player
# 新しいプレイヤーは、現在と逆のプレイヤー(0なら1、1なら0)
new_player = 1 - state.player
return {board: new_board, player: new_player}
end
def reset_game(state:)
new_board = [
[nil, nil, nil],
[nil, nil, nil],
[nil, nil, nil],
]
new_player = case state.winner
when 0 then 1
when 1 then 0
else state.player
end
return {board: new_board, player: new_player}
end
end
class MainComponent < Ovto::Component
PLAYER_MARK = {0 => '○', 1 => '×'}
def render(state:)
o 'div' do
unless state.game_over?
o 'div#player' do
"PLAYER: #{PLAYER_MARK[state.player]}"
end
end
# 勝者を表示する
if state.game_over?
o 'div#winner' do
"WINNER: #{PLAYER_MARK[state.winner]}"
end
# リセットボタン
o 'a', href: '#', onclick: ->{ actions.reset_game } do
"RESET"
end
end
o 'table#board' do
state.board.each.with_index do |row, y|
o 'tr' do
row.each.with_index do |cell, x|
o 'td', onclick: ->(e){ actions.update_cell(x: x, y: y) unless state.game_over? } do
PLAYER_MARK[cell]
end
end
end
end
end
end
end
end
end
MyApp.run(id: 'ovto')
最後にOvtoアプリの拡張方法について説明しておきます。今回の三目並べは120行でできましたが、もっと大きなものを作ろうとすると、Stateクラスの要素が増えたり、Actionsクラスのメソッドが増えたり、MainComponent#renderが長くなったりして大変になると思います。
でも大丈夫、Ovtoではそれぞれについて対処法を考えてあります。
Stateについては「Stateを入れ子にする」という方法があります。例えば三目並べの例であれば、盤面に関する部分だけを独立したクラスBoardにするのが良いでしょう。まず以下のようにしてOvto::State
を継承したクラスBoardを作ります。
class Board < Ovto::State
item :cells, default: Array.new(3){ Array.new(3){ nil }}
...
end
次にMyApp::State
が要素としてBoardオブジェクトを持つようにします。
class State < Ovto::State
# 盤面データ(0または1またはnil)
item :board, default: Board.new
# 現在のプレイヤー(0または1)
item :player, default: 0
end
これでだいぶすっきりするはずです。Boardクラスはboard.rbなど別のファイルに切り出すのも良いでしょう。opalコマンドでコンパイルする場合は-I .
オプションを付ければ、require "board"
で読み込むことができます。
Actionsのメソッドが増えてきた場合は、module
を使って分割するのが良いでしょう。普通のRubyクラスを整理する手順と同じです。
module XxxActions
...
end
module YyyActions
...
end
class Actions < Ovto::Actions
include XxxActions
include YyyActions
end
MainComponent#renderが長くなった場合は、サブのComponentを定義するのが良いです。例えば三目並べの盤面部分をサブComponentにしてみると、以下のようになります。3
class Board < Ovto::Component
def render(state:)
o 'table#board' do
state.board.each.with_index do |row, y|
o 'tr' do
row.each.with_index do |cell, x|
o 'td', onclick: ->(e){ actions.update_cell(x: x, y: y) unless state.game_over? } do
PLAYER_MARK[cell]
end
end
end
end
end
end
end
MainComponentではoメソッドのタグ名にクラスを指定することで、サブComponentをレンダリングできます。
o Board
サブComponentのレンダリング時に引数を渡すこともできます。例えば盤面の色を指定できるようにするとしたら、こんな感じでしょうか。
class Board < Ovto::Component
def render(state:, color:)
...
end
end
...
o Board, color: "red"
サブComponentもそれぞれ別のファイルに分けて、本体からrequireするようにするとより良いでしょう。
Ovtoアプリの分割については、以下に実際のアプリケーションでの例があるので参考にしてください。
今回は「Rubyistのためのフロントエンドフレームワーク」Ovtoについて紹介しました。Ovtoの楽しさが伝われば嬉しいです。
yhara (原 悠) twitter: @yhara blog: yhara.jp
Ruby歴18年。↑のブログもRubyで作っています。
キーワード引数の初期値を省略した場合は「必須キーワード引数」という扱いになり、メソッド呼び出し時にこのキーワードを指定し忘れていないかチェックしてくれるようになります。フロントエンドのコードは頻繁に書き換えが起こるので、引数名を変えたりしたときに変更漏れがすぐ分かるように、キーワード引数を使うよう設計しました。 ↩
注意深い読者なら、呼び出し側にstate:
がないのが気になったかもしれません。実はactions
が返すのはMyApp::Actions
のインスタンスではなく、Ovto::WiredActions
というクラスのインスタンスです。WiredActionsはアクション実行後のstate更新や画面の再描画などの処理を担当します。現在のstateをActionsのメソッドに渡すのもWiredActionsの仕事です。stateを手で渡してしまうと複数のアクションが同時に実行されたときに値がおかしくなる可能性があるので、WiredActionsの方で自動的に現在のstateを渡すようになっています。 ↩
StateのところでBoardというクラスを作った場合は名前が被ってしまうので、別のクラス名をつけるか、MyApp::State::Board
とMyApp::MainComponent::Board
みたいにネストした名前空間に定義するのが良いでしょう。 ↩