はじめに
Ovto(オブト)というRubyで書けるシンプルなクライアントサイドWebフレームワークを作りました。RubyからJavaScriptへの変換にはOpalを使っています。
(2019/02/08追記:Rubyist Magazineに紹介記事を寄稿しました。本記事より少し詳しめです。)
(2018/11/02追記:0.2.0をリリースしました)
(2018/11/30追記:これを使ってVisionというTODOアプリを作りました。3ヶ月ほど実用していますがとてもいい感じです。クライアント側のコードはこのへんです)
特徴
- Rubyで書ける
- Redux風のAPI (Virtual DOM+シングルステート)
- hyperappの影響を強く受けています。
- 実装が短い(lib/以下の合計が721行)
例
例として華氏と摂氏を変換するやつをやります。
require 'ovto'
class MyApp < Ovto::App
class State < Ovto::State
item :celsius, default: 0
def fahrenheit
(celsius * 9 / 5.0) + 32
end
end
class Actions < Ovto::Actions
def set_celsius(state:, value:)
return {celsius: value}
end
def set_fahrenheit(state:, value:)
new_celsius = (value - 32) * 5 / 9.0
return {celsius: new_celsius}
end
end
class MainComponent < Ovto::Component
def render(state:)
o 'div' do
o 'span', 'Celcius:'
o 'input', {
type: 'text',
onchange: ->(e){ actions.set_celsius(value: e.target.value.to_i) },
value: state.celsius
}
o 'span', 'Fahrenheit:'
o 'input', {
type: 'text',
onchange: ->(e){ actions.set_fahrenheit(value: e.target.value.to_i) },
value: state.fahrenheit
}
end
end
end
end
MyApp.run(id: 'ovto')
左右の数値は連動していて、例えば左を0に変えると右が32になります(摂氏0度=華氏32度なので)。
以下、チュートリアル形式で順を追って説明します。
必要なもの
- Ruby
- Bundler (
gem install bundler
)
準備
最初にGemfileを用意し、bundle install
します。
source 'https://rubygems.org'
gem 'ovto'
gem 'rake'
同じディレクトリに、以下のようなHTMLファイルを用意します。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script type='text/javascript' src='app.js'></script>
</head>
<body>
<div id='ovto'></div>
<div id='ovto-debug'></div>
</body>
</html>
app.jsは以下のステップで生成されます。(このチュートリアルでは簡単のため静的にファイルを生成していますが、OvtoはRailsやSinatraと組み合わせて使うことも可能です。リポジトリのexample以下を参考にしてください。)
最小限のOvto app
同じディレクトリに、以下のようなapp.rb
を用意します。これは最小のOvtoアプリです。
Reactではjsxを使ってビューを定義しますが、Rubyには似たようなものがないので、Ovtoでは「o」というメソッドでビューを書くことにしました。第一引数がタグ名で、引数やブロックで属性やタグの中身を書くことができます(リファレンス)。
require 'ovto'
class MyApp < Ovto::App
class State < Ovto::State
end
class Actions < Ovto::Actions
end
class MainComponent < Ovto::Component
def render(state:) # 「:」を忘れないように注意してください。これはtypoではなく、
o 'div' do # キーワード引数のデフォルト値を省略した形です。
o 'h1', "HELLO" # Ovtoのメソッドはすべてキーワード引数を使います。
end
end
end
end
MyApp.run(id: 'ovto')
MyApp
は好きな名前に変えても問題ありません。id: 'ovto'
は、html側と合わせる必要があります。
コンパイルする
以下のコマンドを実行すると、app.rbからapp.jsが生成されます。
$ bundle exec opal -c -g ovto app.rb > app.js
ブラウザでindex.htmlを開くと「HELLO」と出るはずです。
出ないときは開発者コンソールを見て、エラーが出ていないか確認してください。
Tips: 自動コンパイル
上のコマンドを何度も叩くのは面倒なので、ifchanged
というgemを使ってapp.rbを保存したタイミングで自動でコンパイルを行う手順を紹介しておきます。
- Gemfileに
gem "ifchanged"
を追加 bundle install
bundle exec ifchanged ./app.rb --do 'bundle exec opal -c -g ovto app.rb > app.js'
Stateに項目を追加する
さて、では摂氏・華氏変換アプリのコードを書いて行きましょう。最初にMyApp::State
クラスに項目を追加します。今回は摂氏での温度を状態として持つことにします。初期値は0とします。
class State < Ovto::State
item :celsius, default: 0
end
ビュー側からはstate.celsius
で現在の値が取れます。これを画面に表示してみましょう。MyApp::MainComponen
を以下のように直します。
class MainComponent < Ovto::Component
def render(state:)
o 'div' do
o 'span', 'Celcius:'
o 'input', type: 'text', value: state.celsius
end
end
end
こんな感じになるはずです。
Stateにメソッドを追加する
次はこれを華氏に変換してみましょう。state
はMyApp::State
クラスのインスタンスなので、好きにメソッドを追加することができます。例えば以下のようにすると、state.fahrenheit
で現在の温度を華氏に変換したものを取得できるようになります。
class State < Ovto::State
item :celsius, default: 0
def fahrenheit
(celsius * 9 / 5.0) + 32
end
end
これも画面に表示してみましょう。
class MainComponent < Ovto::Component
def render(state:)
o 'div' do
o 'span', 'Celcius:'
o 'input', type: 'text', value: state.celsius
o 'span', 'Fahrenheit:'
o 'input', type: 'text', value: state.fahrenheit
end
end
end
初期値の摂氏0度が華氏に変換されて、32と出るはずです。
アクションを追加する
次は摂氏の0度以外も変換できるようにしましょう。左のinputを書き換えたらstate.celsius
が変化するようにしたいですね。
といっても、Ovtoではstate.celsius = 100
みたいにして値を書き換えることはできません。app stateを変化させたい場合は必ず「アクション」を経由する、という決まりになっています。アクションはMyApp::Actions
クラスに定義されたメソッドで、古いstate
(とアクションごとの引数)を取り、state
の変更点をハッシュで返します。アクションを呼び出すと、この返り値のハッシュがapp stateにマージされます。
class Actions < Ovto::Actions
def set_celsius(state:, value:)
return {celsius: value}
end
end
このアクションはビューからactions.set_celsius
のようにして呼び出すことができます。MyApp::MainComponent
の1つ目のinputを以下のように変更してください。
o 'input', {
type: 'text',
onchange: ->(e){ actions.set_celsius(value: e.target.value.to_i) },
value: state.celsius
}
oメソッドの第二引数はタグの属性をハッシュで取りますが、いくつか特別なキーがあります。onchange:
のようにonから始まる属性はイベントハンドラの定義になります。引数e
はOpal::Native
のインスタンスで、JavaScriptのイベントオブジェクトをラップしたものです。ここではe.target.value
でinputの文字列が取得できます。
ブラウザをリロードして左のinputを100に書き換えてください。Tabキーを押すかページの適当な部分をクリックしてフォーカスを外すと、右のinputが212に変化したはずです。
何が起こったのか
このとき内部でどのような処理が行われているか、気になった人向けに簡単に説明しておきます。
- 左のinputに100と入力してTabを押す
- JavaScriptのonchangeイベントが発行される
- Ovtoが
MyApp::MainComponent
で登録したイベントハンドラを呼ぶ - イベントハンドラから
actions.set_celsius
が呼ばれる。actions
はOvto::WiredActions
のインスタンスで、MyApp::Actions
と同名のメソッドを持っているが、追加で以下のような仕事をする: -
state
をMyApp::Actions
のメソッドに渡す。 - その返り値をglobal stateにマージする。
- viewの再描画を行う。
より詳しく知りたい場合はlib/以下のソースを見てください。いまのところ全部で721行しかないので、そんなに大変ではないと思います。
逆も変換できるようにする
ここまで来たら、華氏から摂氏への変換をサポートするのも簡単です。ビューの2つ目のinputを以下のようにしてください。
o 'input', {
type: 'text',
onchange: ->(e){ actions.set_fahrenheit(value: e.target.value.to_i) },
value: state.fahrenheit
}
次にset_fahrenheit
メソッドをMyApp::Actions
に追加します。このアクションは引数valueを摂氏に直したものをapp stateにセットします。
def set_fahrenheit(state:, value:)
new_celsius = (value - 32) * 5 / 9.0
return {celsius: new_celsius}
end
これで、右のinputを書き換えると左が変化するようになったはずです。お疲れ様でした :-)
謝辞等
Ovtoはhyperappの影響を強く受けています。たった400行しかないのにReact/Reduxにあるようなさまざまな機能が実現されているのは驚きで、これなら簡単に移植できるかも、と思ったのがOvtoを作るきっかけでした。
APIだけでなく実装もhyperappのコードを一部流用させてもらっています。lib/ovto/runtime.rbがそれで、hyperappのVDom実装がほぼそのまま入っています(イベントやコンポーネントのレンダリング周りがOpalのオブジェクトに対応できるよういじってあるくらいかな)。
Ovtoという名前について
あまり深い理由はないです。
名前をつける前にoメソッドでビューを書くことが決まっていたので、Oから始まる名前にしたくてOctoとかOctoAppとかを考えたのですが、そういう普通の名前はたいていすでに同名のプロジェクトがあって困っていた折、ふとタイプミスで画面に表示されたovtoという文字列を見てこれでいいじゃん、という感じで決めました。
今後の予定
- gemとしてリリースする
- サーバ側のAPIを叩くようなexampleを用意する
リンク
- 公式サイト:https://yhara.github.io/ovto/
- ソースコード:github:yhara/ovto