Edited at

Ovto(オブト)というRubyで書けるシンプルなクライアントサイドWebフレームワークを作りました


はじめに

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 します。


Gemfile

source 'https://rubygems.org'

gem 'ovto'
gem 'rake'


同じディレクトリに、以下のようなHTMLファイルを用意します。


index.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」というメソッドでビューを書くことにしました。第一引数がタグ名で、引数やブロックで属性やタグの中身を書くことができます(リファレンス)。


app.rb

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」と出るはずです。

スクリーンショット 2018-05-30 23.18.05.png

出ないときは開発者コンソールを見て、エラーが出ていないか確認してください。


Tips: 自動コンパイル

上のコマンドを何度も叩くのは面倒なので、ifchangedというgemを使ってapp.rbを保存したタイミングで自動でコンパイルを行う手順を紹介しておきます。


  1. Gemfileにgem "ifchanged"を追加

  2. bundle install

  3. 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

こんな感じになるはずです。

スクリーンショット 2018-05-30 23.31.01.png


Stateにメソッドを追加する

次はこれを華氏に変換してみましょう。stateMyApp::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と出るはずです。

スクリーンショット 2018-05-30 23.34.09.png


アクションを追加する

次は摂氏の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から始まる属性はイベントハンドラの定義になります。引数eOpal::Nativeのインスタンスで、JavaScriptのイベントオブジェクトをラップしたものです。ここではe.target.valueでinputの文字列が取得できます。

ブラウザをリロードして左のinputを100に書き換えてください。Tabキーを押すかページの適当な部分をクリックしてフォーカスを外すと、右のinputが212に変化したはずです。


何が起こったのか

このとき内部でどのような処理が行われているか、気になった人向けに簡単に説明しておきます。


  1. 左のinputに100と入力してTabを押す

  2. JavaScriptのonchangeイベントが発行される

  3. OvtoがMyApp::MainComponentで登録したイベントハンドラを呼ぶ

  4. イベントハンドラからactions.set_celsiusが呼ばれる。actionsOvto::WiredActionsのインスタンスで、MyApp::Actionsと同名のメソッドを持っているが、追加で以下のような仕事をする:



    1. stateMyApp::Actionsのメソッドに渡す。

    2. その返り値をglobal stateにマージする。

    3. 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を用意する


リンク