はじめに
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:
)
こちらのコードで実装したサンプルは以下のページから確認できます。
ぬるぬる動いています。
この実装は以下のような方針で実現しました。
- 文字列として受け取ったHTMLをh関数の記述に文字列として変換
- h関数の記述に変換された文字列を、
eval
関数でrubyコードで評価
本来であればHTMLのパースを行なった時点でh関数を介さずともオブジェクトの表現に変えてしまってもよかったのですが、
前回の実装との互換性を考え、h関数の表現に変換をするようにしました。
HTMLをh関数の記述に変換するための実装は以下の通りです( GitHubでも確認できます )
少々荒削りな実装ですがご容赦ください。
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.eval
でreturn
を用いてインスタンスを返すと、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トークの方も「話したい」を押していただけると嬉しいです!