LoginSignup
9
2

More than 1 year has passed since last update.

RubyだけでJSXチックに仮想DOMをかけるようにした

Last updated at Posted at 2022-12-24

はじめに

Qiitaのアドベントカレンダーで前回↓のような記事を書きました。

今回は仮想DOMをJSXライクにかけるようにしてみた、という記事です。

挑戦したこと

前回の記事では、↓のような記述をするだけで、ぬるぬる動くUIが実現できるようになりました。

state = {
  url_name: '',
}

actions = {
  update_url_name: ->(state, value) { state[:url_name] = value }
}

view = ->(state, actions) {
  isvalid = state[:url_name].length >= 4

  h(:form, { class: "w-full max-w-sm" }, [
    h(:label, { class: "block mb-2 text-sm font-medium text-700 dark:text-500" }, ['ユーザー名']),
    h(:input, {
      type: 'text',
      class: isvalid ? "bg-green-50 border border-green-500 text-green-900 placeholder-green-700 text-sm rounded-lg focus:ring-green-500 focus:border-green-500 block w-full p-2.5 dark:bg-green-100 dark:border-green-400" : "bg-red-50 border border-red-500 text-red-900 placeholder-red-700 text-sm rounded-lg focus:ring-red-500 focus:border-red-500 block w-full p-2.5 dark:bg-red-100 dark:border-red-400",
      oninput: ->(e) { actions[:update_url_name].call(state, e[:target][:value].to_s) }
    }, []),
    h(:p, { class: isvalid ? "mt-2 text-sm text-green-600 dark:text-green-500" : "mt-2 text-sm text-red-600 dark:text-red-500" }, [isvalid ? "有効です" : "ユーザー名は4文字以上にしてください"])
  ])
}

App.new(
  el: "#app",
  state:,
  view:,
  actions:
)

これはこれで結構感動したのですが、どうしてもhの関数で記法されているところが一般的なHTML記法とは異なり、気持ちが悪いです。
本来であればここもJSXのようにスマートに書きたいと思いました。

イメージはこんな感じです。

state = {
  url_name: '',
}

actions = {
  update_url_name: ->(state, value) { state[:url_name] = value }
}

view = <<EOS
  <div>
    <div onclick='{->(){}}' clsss='{isvalid? hoge : fuga}'>
  </div>
EOS

App.new(
  el: "#app",
  state:,
  view:,
  actions:
)

完成したもの

単刀直入に成果物を紹介します。
↓のようなコードを書くだけで前回の記事で紹介したサンプルと全く同じ仮想DOMが実現できるようになりました。

state = {
  url_name: '',
}

actions = {
  update_url_name: ->(state, value) { state[:url_name] = value }
}

view = ->(state, actions) {
  isvalid = state[:url_name].length >= 4

  eval DomParser.parse(<<-DOM)
    <form class='w-full max-w-sm'>
      <label class="block mb-2 text-sm font-medium text-700 dark:text-500">
        ユーザー名
      </label>
      <input
        type='text'
        class='{isvalid ? "bg-green-50 border border-green-500 text-green-900 placeholder-green-700 text-sm rounded-lg focus:ring-green-500 focus:border-green-500 block w-full p-2.5 dark:bg-green-100 dark:border-green-400" : "bg-red-50 border border-red-500 text-red-900 placeholder-red-700 text-sm rounded-lg focus:ring-red-500 focus:border-red-500 block w-full p-2.5 dark:bg-red-100 dark:border-red-400"}'
        oninput='{->(e) { actions[:update_url_name].call(state, e[:target][:value].to_s) }}'
      >
      <p class='{isvalid ? "mt-2 text-sm text-green-600 dark:text-green-500" : "mt-2 text-sm text-red-600 dark:text-red-500"}'>
        {isvalid ? "有効です" : "ユーザー名は4文字以上にしてください"}
      </p>
    </form>
  DOM
}

App.new(
  el: "#app",
  state:,
  view:,
  actions:
)

こちらのコードで実装したサンプルは以下のページから確認できます。

ぬるぬる動いています。

ダウンロード.gif

この実装は以下のような方針で実現しました。

  • 文字列として受け取ったHTMLをh関数の記述に文字列として変換
  • h関数の記述に変換された文字列を、eval関数でrubyコードで評価

本来であればHTMLのパースを行なった時点でh関数を介さずともオブジェクトの表現に変えてしまってもよかったのですが、
前回の実装との互換性を考え、h関数の表現に変換をするようにしました。

HTMLをh関数の記述に変換するための実装は以下の通りです( GitHubでも確認できます
少々荒削りな実装ですがご容赦ください。

dom_parser.rb
require 'js'

module DomParser
  module_function

  def parse(doc)
    parser = JS.eval('return new DOMParser()')
    document = parser.call(:parseFromString, JS.try_convert(doc), 'text/html')
    elements = document.getElementsByTagName('body')[0][:childNodes]

    build_vdom(elements)
  end

  def build_vdom(elements)
    vdom = []
    elements.forEach do |element|
      if element[:nodeType] == JS.global[:Node][:TEXT_NODE]
        value = element[:nodeValue].to_s.chomp.strip

        next if value.empty?

        vdom << if embed_script?(value)
                  get_embed_script(value)
                else
                  "'#{value}'"
                end

        next
      end

      attributes_str = []
      attributes = element[:attributes]
      length = attributes[:length].to_i
      length.times do |i|
        attribute = attributes[i]
        key = attribute[:name].to_s
        value = attribute[:value].to_s

        result = if embed_script?(value)
                   script = get_embed_script(value)
                   ":#{key} => #{script}"
                 else
                   ":#{key} => '#{value}'"
                 end
        attributes_str << result
      end

      vdom << "h(:#{element[:tagName].to_s.downcase}, {#{attributes_str.join(', ')}}, [#{build_vdom(element[:childNodes])}])"
    end
    vdom.join(',')
  end

  def embed_script?(doc)
    doc.match?(/\{.+\}/)
  end

  def get_embed_script(script)
    script.gsub(/\{(.+)\}/) { ::Regexp.last_match(1) }
  end
end

少しややこしいですがやっていることは以下の通りです。 

  • 文字列として受け取ったHTMLをDOMに変換
    • Rubyに組み込みライブラリがなかったため、JSのライブラリを使用
  • 変換されたDOMに対して以下の処理を行なっていく
    • 要素がテキストだった場合
      • テキストが{}で囲われていたら→中身をRubyコードとして扱う
      • 囲われていなかったら→文字列として扱う
    • HTMLElementだった場合
      • 各要素に対して以下の処理をする
        • テキストが{}で囲われていたら→中身をRubyコードとして扱う
        • 囲われていなかったら→文字列として扱う
      • 子要素に対しても同様の処理を行い、結果を文字列としてh関数の要素配列に結合していく

苦戦したところ

今回苦戦したところは「文字列として受け取ったHTMLをどのようにパースするか」です。
初めはRubyのrexml/documentを利用しようと思っていたのですが、3.1系から標準ライブラリではなくなったらしく、手軽にruby.wasm上で扱うことができませんでした。

また、JSにはDOMParserがあることは確認できたのですが、JSのインスタンスをどのようにRubyで受け取るのかがわからず、悩みました。

結論から言うと、JS.evalreturnを用いてインスタンスを返すと、Rubyでもちゃんとそのインスタンスを受け取ることができます。

parser = JS.eval('return new DOMParser()')
document = parser.call(:parseFromString, JS.try_convert(doc), 'text/html')

この二行を書くのにとても苦戦しました...

最後に

今回RubyでJSXチックに仮想DOMをかける実装をしてみました。
ruby.wasmができてから、Rubyの表現の幅が格段に広がっているので、書いていてもとても楽しいですね。

最後までお読みいただきありがとうございました。
Devトークも公開しているので、もし直接話してみたい、と感じていただけた方はぜひDevトークの方も「話したい」を押していただけると嬉しいです!

9
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2