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

# はじめに

[Ovto](https://yhara.github.io/ovto/)(オブト)というRubyで書けるシンプルなクライアントサイドWebフレームワークを作りました。RubyからJavaScriptへの変換には[Opal](http://opalrb.com)を使っています。

(2019/02/08追記：Rubyist Magazineに紹介記事を[寄稿しました](https://magazine.rubyist.net/articles/0059/0059-Ovto.html)。本記事より少し詳しめです。)
(2018/11/02追記：0.2.0を[リリースしました](https://yhara.jp/2018/11/02/ovto-0.2.0))
(2018/11/30追記：これを使って[Vision](https://github.com/yhara/vision)というTODOアプリを作りました。3ヶ月ほど実用していますがとてもいい感じです。クライアント側のコードは[このへん](https://github.com/yhara/vision/tree/7f92040840082f6cfddbe147244daaa2223aef0c/app/assets/javascripts/ovto_app)です)

## 特徴

- Rubyで書ける
- Redux風のAPI (Virtual DOM＋シングルステート)
  - [hyperapp](https://github.com/hyperapp/hyperapp)の影響を強く受けています。
- 実装が短い(lib/以下の合計が721行) 

# 例

例として華氏と摂氏を変換するやつをやります。

```rb
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')
```

![スクリーンショット](https://qiita-image-store.s3.amazonaws.com/0/43/61659ed5-6e4c-c40b-6137-73f7053552a5.png)

左右の数値は連動していて、例えば左を0に変えると右が32になります(摂氏0度＝華氏32度なので)。

以下、チュートリアル形式で順を追って説明します。

## 必要なもの

- Ruby
- Bundler (`gem install bundler`)

## 準備

最初にGemfileを用意し、`bundle install` します。

```rb:Gemfile
source 'https://rubygems.org'

gem 'ovto'
gem 'rake'
```

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

```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と組み合わせて使うことも可能です。[リポジトリ](https://github.com/yhara/ovto/)のexample以下を参考にしてください。)

## 最小限のOvto app

同じディレクトリに、以下のような`app.rb`を用意します。これは最小のOvtoアプリです。

Reactではjsxを使ってビューを定義しますが、Rubyには似たようなものがないので、Ovtoでは「o」というメソッドでビューを書くことにしました。第一引数がタグ名で、引数やブロックで属性やタグの中身を書くことができます([リファレンス](https://yhara.github.io/ovto/api/component.html))。

```rb: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](https://qiita-image-store.s3.amazonaws.com/0/43/c51f6643-e798-667a-ed55-035f74132f40.png)

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

### Tips: 自動コンパイル

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

1. Gemfileに`gem "ifchanged"`を追加
1. `bundle install`
1. `bundle exec ifchanged ./app.rb --do 'bundle exec opal -c -g ovto app.rb > app.js'`

## Stateに項目を追加する

さて、では摂氏・華氏変換アプリのコードを書いて行きましょう。最初に`MyApp::State`クラスに項目を追加します。今回は摂氏での温度を状態として持つことにします。初期値は0とします。

```rb
  class State < Ovto::State
    item :celsius, default: 0
  end
```

ビュー側からは`state.celsius`で現在の値が取れます。これを画面に表示してみましょう。`MyApp::MainComponen`を以下のように直します。

```rb
  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](https://qiita-image-store.s3.amazonaws.com/0/43/788b4052-11c1-8217-e2f0-408969ee4f24.png)

## Stateにメソッドを追加する

次はこれを華氏に変換してみましょう。`state`は`MyApp::State`クラスのインスタンスなので、好きにメソッドを追加することができます。例えば以下のようにすると、`state.fahrenheit`で現在の温度を華氏に変換したものを取得できるようになります。

```rb
  class State < Ovto::State
    item :celsius, default: 0

    def fahrenheit
      (celsius * 9 / 5.0) + 32
    end
  end
```

これも画面に表示してみましょう。

```rb
  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](https://qiita-image-store.s3.amazonaws.com/0/43/a7b3ac62-25ae-afde-6c91-0af9ae6915c2.png)

## アクションを追加する

次は摂氏の0度以外も変換できるようにしましょう。左のinputを書き換えたら`state.celsius`が変化するようにしたいですね。

といっても、Ovtoでは`state.celsius = 100`みたいにして値を書き換えることはできません。app stateを変化させたい場合は必ず「アクション」を経由する、という決まりになっています。アクションは`MyApp::Actions`クラスに定義されたメソッドで、古い`state`(とアクションごとの引数)を取り、`state`の変更点をハッシュで返します。アクションを呼び出すと、この返り値のハッシュがapp stateにマージされます。

```rb
  class Actions < Ovto::Actions
    def set_celsius(state:, value:)
      return {celsius: value}
    end
  end
```

このアクションはビューから`actions.set_celsius`のようにして呼び出すことができます。`MyApp::MainComponent`の1つ目のinputを以下のように変更してください。

```rb
        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に変化したはずです。

## 何が起こったのか

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

1. 左のinputに100と入力してTabを押す
2. JavaScriptのonchangeイベントが発行される
3. Ovtoが`MyApp::MainComponent`で登録したイベントハンドラを呼ぶ
4. イベントハンドラから`actions.set_celsius`が呼ばれる。`actions`は`Ovto::WiredActions`のインスタンスで、`MyApp::Actions`と同名のメソッドを持っているが、追加で以下のような仕事をする:
  1. `state`を`MyApp::Actions`のメソッドに渡す。
  1. その返り値をglobal stateにマージする。
  1. viewの再描画を行う。

より詳しく知りたい場合は[lib/以下のソース](https://github.com/yhara/ovto/tree/master/lib)を見てください。いまのところ全部で721行しかないので、そんなに大変ではないと思います。

## 逆も変換できるようにする

ここまで来たら、華氏から摂氏への変換をサポートするのも簡単です。ビューの2つ目のinputを以下のようにしてください。

```rb
        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にセットします。

```rb
    def set_fahrenheit(state:, value:)
      new_celsius = (value - 32) * 5 / 9.0
      return {celsius: new_celsius}
    end
```

これで、右のinputを書き換えると左が変化するようになったはずです。お疲れ様でした :-)

# 謝辞等

Ovtoは[hyperapp](https://github.com/hyperapp/hyperapp)の影響を強く受けています。たった400行しかないのにReact/Reduxにあるようなさまざまな機能が実現されているのは驚きで、これなら簡単に移植できるかも、と思ったのがOvtoを作るきっかけでした。

APIだけでなく実装もhyperappのコードを一部流用させてもらっています。[lib/ovto/runtime.rb](https://github.com/yhara/ovto/blob/master/lib/ovto/runtime.rb#L20-L21)がそれで、hyperappのVDom実装がほぼそのまま入っています(イベントやコンポーネントのレンダリング周りがOpalのオブジェクトに対応できるよういじってあるくらいかな)。

## Ovtoという名前について

あまり深い理由はないです。

名前をつける前にoメソッドでビューを書くことが決まっていたので、Oから始まる名前にしたくてOctoとかOctoAppとかを考えたのですが、そういう普通の名前はたいていすでに同名のプロジェクトがあって困っていた折、ふとタイプミスで画面に表示されたovtoという文字列を見てこれでいいじゃん、という感じで決めました。

## 今後の予定

- gemとしてリリースする
- サーバ側のAPIを叩くようなexampleを用意する

## リンク

- 公式サイト：https://yhara.github.io/ovto/
- ソースコード：[github:yhara/ovto](https://github.com/yhara/ovto)