はじめに
2022年、RubyはWASMに対応しました。
つまりこれからはRubyでフロントエンドの開発も可能となります。
RubyのWASMについて調べているうちに、「Rubyだけで仮想DOMの仕組みも作れるのでは?」と思い、試しにRuby(ruby.wasm)を使って仮想DOMの実装をしてみました。
この記事ではRubyで仮想DOMの実装をしていく中での学びを記事としてまとめていこうと思います。
仮想DOMとは
仮想DOMとは、生のDOM情報を直接操作するのではなく、仮想的なDOM情報(一般的にはオブジェクト情報として管理する)をもとに生のDOM情報を生成していくという考え方です。このように仮想的なDOMをプログラムとしてアクセスしやすいデータ構造として保持することで、値の更新や差分検知/更新を直感的かつコスパ良く行うことができます。
詳しくはこちらの記事などが参考になると思います。
完成したもの
完成したものはこちらです。
デモはこちらで確認ができます。
今回はデモとして「リアルタイムバリデーション」を用意してみました。
このUIは↓のようなRubyコードで生成されています。
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:
)
実装は↓の記事を参考にしています。
それぞれのコードの解説をします。
DomManager
このモジュールには仮想のDOMオブジェクトをもとに、実際にDOMの生成や、差分の更新を行う処理を実装しています。
ruby.wasmではjs
というライブラリを用いることでブラウザのAPIへのアクセスを可能にしています。
↓の処理は受け取ったDOMオブジェクト情報をもとにDOMの生成をしています。
このように、JS.global
を用いることでブラウザのdocument
にもアクセスが可能です。
def create_element(node_obj)
document = JS.global[:document]
return document.createTextNode(node_obj.to_s) unless v_node?(node_obj)
element = document.createElement(node_obj[:node_name])
set_attributes(element, node_obj[:attributes])
node_obj[:children].each do |child|
element.appendChild(create_element(child))
end
element
end
App
Appは、DOMオブジェクトの更新→DOMの更新のフローを実装しています。
DOMオブジェクトの更新はFLuxアーキテクチャに基づいたデータフローでのみ更新を行えるようにしています。
このようにすることで、React Hooksなどに近いような宣言的なUI実装を可能にしています。
また、オブジェクトの更新が短い時間で繰り返し起こった時に、生のDOMの更新処理を間引くための処理なども加えています。
詰まった点
RubyからJSのオブジェクトへのアクセスの仕方
ruby.wasmにはjs
というライブラリが用意されているものの、ドキュメントはまだ少なく、どのようにJSのオブジェクトにアクセスをすれば良いのか悩む場面がありました。
特にメソッドではなくプロパティにアクセスしたい時にどのように参照するのかがわかりませんでしたが、
色々試行錯誤しているうちに以下のような仕様になっていることがわかりました。
- メソッドにアクセスしたいときは
object.method()
でアクセスできる - プロパティへアクセスしたいときは
object[:property]
でアクセスできる
この一行を書くために数時間使いました...
current_node = parent_node[:childNodes][current_node_index]
JSの関数にRubyのコードをどのように渡すのか
逆にJSの関数にRUbyのコードをどのように渡すのかもとても詰まりました。
非同期処理はRubyのコードだけでは難しく、Promise
やsetTimeout
にRubyの処理を渡したかったのですが、
それがどのようにすると実現できるのかがわからず、とても詰まりました。
結論を言うとJS.try_convert
というメソッドを用いることで、RubyのコードをJSのオブジェクトに変換できます。
JS.try_convert
を用いることで↓のようにRubyのメソッドを非同期化することができました。
def schedule_render
if !@skip_render
@skip_render = true
render = ->() {
if @current_node_obj
DomManager.update_element(@element, @current_node_obj, @new_node_obj)
else
@element.appendChild(DomManager.create_element(@new_node_obj))
end
@current_node_obj = @new_node_obj
@skip_render = false
}
JS.global.call(:setTimeout, JS.try_convert(render))
end
end
最後に
今回Rubyで仮想DOMの実装をしてみました。
実用的かどうかはわかりませんが、Rubyでこのレベルのフロントエンドの実装ができてしまう時代はすごいなぁと衝撃を受けました。
ruby.wasmの使い道はまだまだ色々ありそうなので、これからも色々模索していきたいです。
最後までお読みいただきありがとうございました。
Devトークも公開しているので、もし直接話してみたい、と感じていただけた方はぜひDevトークの方も「話したい」を押していただけると嬉しいです!