Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

Rails7のフロントエンド開発ライブラリTurboを、より素な形で使ってみたり、中身を見てみ...

shodan
November 08, 2024
250

 Rails7のフロントエンド開発ライブラリTurboを、より素な形で使ってみたり、中身を見てみたりする

SinatraとTurboで作成した、簡素なCRUDアプリのレポジトリは以下です。
https://github.com/junohm410/turbo-sinatra-testapp/tree/setting-turbo

shodan

November 08, 2024
Tweet

Transcript

  1. サマリ Rails に触れる中で Hotwire / Turbo というフロントの FW / ライブラリを知った

    Rails(正確に言うとgem)が用意するヘルパーメソッドでHTMLタグを囲むだけで SPA ライクな画面を実装できる 初学者にはこれが魔術的な動きに感じた Turbo is a language-agnostic framework written in JavaScript (hotwired/turbo-rails: Use Turbo in your Ruby on Rails app より引用) ヘルパータグで囲っているだけでは、 「言語を問わない」イメージがわかない Rails を使わない「素のTurbo」で、その動きを確認してみた そのうえで、Rails で使えるヘルパーや、 Turbo 自体の中身を読んでみた 結果: 魔術的な動きも、自分が学習の中で通った基礎的な概念・技術の積み重ねで 実現されていることを感じることができた
  2. Turbo について / 全体の概要 Rails7 から導入された、Rails アプリケーションに SPA ライクな振る舞いを簡便に導入できる フレームワークHotwireの中核ライブラリ

    Turbo 有効下では、フォーム・リンクのクリックからのリクエストは全て fetch による非同期通信になる この fetch に対して、サーバーは HTML をレスポンスする 返ってきた JSON を使いクライアント側で DOM を構築するスタイルではない サーバーが返してきたHTMLの断片を、あくまで元の画面の必要な箇所に置き 換えたり差し込んだりするだけ サーバーサイドにロジックを寄せられ、開発者がJSを書く量が減り、開発効 率が上がる Hotwire には Stimulus と Hotwire Native という構成要素もありますが、今回 は触れません
  3. Turbo について / Turbo Drive Turbo 有効下のページにおいて、リンクやフォームのクリックをインターセプト して、 GET による通常のページ遷移を

    fetch に切り替える レスポンスされたHTML内の body 部分のみを、現在のものと置き換える URLは History APIを使って更新されるので、 「戻る」 「進む」も可能 画面上では普通のページ遷移と変わらずに見えるが、高速化されている
  4. Turbo について / Turbo Frames Drive の部分置換版 部分的に置換したいページの一部を「フレーム」として区切る フレームとしたい要素を <turbo-frame>

    要素で囲む Turbo 有効下で、フレーム内のリンクやフォームからの GET が発生 fetch に切り替え レスポンスされたHTML内に同じフレームが含まれていた場合、 Turbo は元 のページ上のそのフレーム部分だけを置き換える
  5. Railsでの使用 / turbo_frame_tag ヘルパー # views/blogs/index.erb.html <%= turbo_frame_tag Blog.new do

    %> # フレームで区切る <%= link_to "New blog", new_blog_path %> <% end %> # views/blogs/_form.erb.html # 該当するフレームを返すようにする。ローカル変数blogはコントローラから渡されたBlog.new <%= turbo_frame_tag blog do %> <%= form_with(model: blog) do |form| %> <div> <%= form.label :title, style: "display: block" %> <%= form.text_field :title %> </div> <div> <%= form.label :content, style: "display: block" %> <%= form.text_area :content %> </div> <div><%= form.submit %></div> <% end %> <% end %>
  6. Turbo について / Turbo Streams ページ全体( body 要素)や指定フレームの置換ではなく、要素の部分的な追加・ 更新・削除を行いたいときに使う Turbo

    有効下で、リンクやフォームからの POST/PUT/PATCH/DELETE のリクエスト が発生 Turbo はAcceptリクエストヘッダーに turbo_stream 用のMIMEタイプ ( text/vnd.turbo-stream.html )を追加して fetch サーバー側がそれに応じて、追加・更新する要素を <turbo-stream> 要素で囲 んだHTMLの断片を返す <turbo-stream action="prepend" target="messages"> のように、属性で 「操作種別」と「操作対象」を指定することで、ページの特定部分への追 加・更新・削除といった操作が可能になる
  7. Railsでの使用 / turbo_stream ヘルパー # controllers/blogs_controller def create @blog =

    Blog.new(blog_params) if @blog.save render :create # create ビューテンプレートをレンダリング else render :new, status: :unprocessable_entity end end # views/blogs/create.turbo_stream.erb # turbo_stream 形式でレスポンスを返すので、create.html.erb ではない <%= turbo_stream.prepend 'blogs' do %> <%= render @blog %> <p><%= link_to "Show this blog", @blog %></p> <% end %>
  8. 非RailsのWebアプリで「素のTurbo」を試してみる Ruby製のミニマルなWebアプリケーション制作フレームワーク Sinatra を使って、Railsのヘル パー・ActiveRecord を用いず、SPAライクなブログアプリを作ってみる # app.rb # $

    ruby app.rb でサーバーが立ち上がる require 'sinatra' get '/' do 'Hello world!' end <!-- viwes/layout.erb --> <head> <!-- Turbo の読み込み --> <script type="module"> import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/[email protected]'; </script> </head>
  9. Sinatra + 素のTurbo ブログアプリ/ ブログ編集フォームへのリンク views/index.erb (ブログ記事一覧、個々の記事の下に編集リンクがある) <div id="blogs"> <%

    @blogs.each do |blog| %> <!-- 個々の記事をフレームで区切る。idはブロックパラメータblog(中身はハッシュ)から動的に生成 --> <turbo-frame id="blog-<%= blog['id'] %>"> <article class="card mt-4"> <div class="card-body"> <h2><%= h(blog['title']) %></h2> <p><%= h(blog['content']) %></p> <div class="d-flex"> <!-- 編集画面に遷移するリンク。Turboがジャックしてfetchになる --> <a href="blogs/<%= blog['id'] %>/edit" class="btn btn-primary">編集</a> <form action="/blogs/<%= blog['id'] %>" method="post"> <input type="hidden" name="_method" value="delete"> <button class="btn btn-secondary ms-2">削除</button> </form> </div> </div> </article> </turbo-frame> <% end %> </div>
  10. Sinatra + 素のTurbo ブログアプリ: ブログの編集フォームの表示 views/edit.erb (編集画面) <!-- 個々の記事のフレームを持たせるようにする -->

    <turbo-frame id="blog-<%= @blog['id'] %>"> <div class="card mt-4 p-2"> <form action="/blogs/<%= @blog['id'] %>" method="post"> <div class="mt-3"> <label for="title" class="form-label">タイトル</label> <input type="text" id="title" name="title" class="form-control" value="<%= h(@blog['title']) %>" required> </div> <div class="mt-3"> <label for="content" class="form-label">本文</label> <textarea id="content" name="content" class="form-control" required><%= h(@blog['content']) %></textarea> </div> <div class="mt-3"> <input type="hidden" name="_method" value="patch"> <button class="btn btn-primary">変更</button> </div> </form> <div class="mt-2"> <a href="/blogs">戻る</a> </div> </div> </turbo-frame>
  11. Sinatra + 素のTurbo ブログアプリ: ブログの更新・編集フォーム views/edit.erb (編集画面) <!-- 個々の記事のフレームを持たせるようにする -->

    <turbo-frame id="blog-<%= @blog['id'] %>"> <div class="card mt-4 p-2"> <form action="/blogs/<%= @blog['id'] %>" method="post"> <div class="mt-3"> <label for="title" class="form-label">タイトル</label> <input type="text" id="title" name="title" class="form-control" value="<%= h(@blog['title']) %>" required> </div> <!-- ... --> <div class="mt-3"> <!-- 変更ボタン。/blogs/:idへのpatchになるように設定 --> <input type="hidden" name="_method" value="patch"> <button class="btn btn-primary">変更</button> </div> </form> <!-- ... --> </div> </turbo-frame>
  12. Sinatra + 素のTurbo ブログアプリ: ブログの更新・サーバー処理 app.rb patch '/blogs/:id' do |id|

    @blog = { **blog_params, id: } settings.db.execute('UPDATE blog SET title = :title, content = :content WHERE id = :id', @blog) # レスポンスヘッダのContent-TypeをTurboStream用に設定 content_type 'text/vnd.turbo-stream.html' erb :update, layout: false end
  13. Sinatra + 素のTurbo ブログアプリ: ブログの更新 views/update.erb <!-- turbo-streamタグで、追加・更新・削除をする内容を挟み込む --> <!--

    行う操作はaction属性で設定。updateはターゲットにした要素の子要素をtempleteタグ内で更新 --> <turbo-stream action="update" target="blog-<%= @blog[:id] %>"> <template> <article class="card mt-4"> <div class="card-body"> <h2><%= h(@blog[:title]) %></h2> <p><%= h(@blog[:content]) %></p> <div class="d-flex"> <a href="blogs/<%= @blog[:id] %>/edit" class="btn btn-primary">編集</a> <form action="/blogs/<%= @blog[:id] %>" method="post"> <input type="hidden" name="_method" value="delete"> <button class="btn btn-secondary ms-2">削除</button> </form> </div> </div> </article> </template> </turbo-stream>
  14. Railsのturbo_frames_tag ヘルパーのコードを見てみる turbo_frame_tag(@todo) の実行時に起きていることを、内部に潜って見てみる @todo はコントローラから渡されたActiveRecordのモデルオブジェクト <%# app/views/todos/show.html.erb %> <%=

    turbo_frame_tag @todo do %> <p><%= @todo.description %></p> <%= link_to 'Edit this todo', edit_todo_path(@todo) %> <% end %> <%# app/views/todos/edit.html.erb %> <%= turbo_frame_tag @todo do %> <%= render "form" %> <%= link_to 'Cancel', todo_path(@todo) %> <% end %> (https://github.com/hotwired/turbo-rails READMEより引用)
  15. Railsのturbo_frames_tag ヘルパーのコードを見てみる def turbo_frame_tag(*ids, src: nil, target: nil, **attributes, &block)

    # 第一引数に渡されたオブジェクトがモデルとして扱えるかをto_keyに応答するかで確認 id = ids.first.respond_to?(:to_key) ? ActionView::RecordIdentifier.dom_id(*ids) : ids.join('_') src = url_for(src) if src.present? tag.turbo_frame(**attributes.merge(id: id, src: src, target: target).compact, &block) end 渡されたオブジェクトが to_key メソッドを持っていれば、さらに dom_id メソッ ドに渡される ARのモデルオブジェクトは応答可能 to_key は主キー( id カラム)を返すメソッド ActiveModel::Conversion モジュールに定義されていて、これは ActiveModel::API モジュール経由で ActiveRecord::Base にインクルードさ れている
  16. Railsのturbo_frames_tag ヘルパーのコードを見てみる turbo_frame_tag は第一引数に渡したモデルオブジェクトを dom_id メソッドにわたす # モデルオブジェクトに応じた、一意なidの生成 dom_id(Todo.find(45)) #=>

    "todo_45" dom_id(Todo.new) #=> "new_todo" 素Turboでは自分で書いた以下の処理を楽に実現してくれる( views/edit.erb ) <!-- 個々の記事を一意なフレームで囲む --> <turbo-frame id="blog-<%= @blog['id'] %>"> <!-- ... --> </turbo-frame>
  17. Railsのturbo_frames_tag ヘルパーのコードを見てみる tag.turbo_frame() は TagBuilder.new(self).turbo_frame() と同義 def turbo_frame_tag(*ids, src: nil,

    target: nil, **attributes, &block) # ( 例 id: 'todo_1' ) tag.turbo_frame(**attributes.merge(id: id, src: src, target: target).compact, &block) end # ActionView::Helpers::TagHelper def tag(name = nil, options = nil, open = false, escape = true) if name.nil? # tag.turbo_frameのメソッドチェーンではtagの呼び出しに引数がないのでここはtrue tag_builder else # tag(引数)の形で呼び出した際の処理、例: tag("br") # => <br /> end end # `tag.turbo_frame()`は`TagBuilder.new(self).turbo_frame()`と同義 def tag_builder @tag_builder ||= TagBuilder.new(self) end
  18. Railsのturbo_frames_tag ヘルパーのコードを見てみる tag.turbo_frame() は TagBuilder.new(self).turbo_frame() と同義 TagBuilder には、 #br のような一般的なタグ用のメソッドは準備されている

    ちなみに、 def br のように静的にコーディングされているわけではなく、内 部で動的に用意されている形だった しかし、 #turbo_frame なんてメソッドはまったく用意されていない ❯ rails c Loading development environment (Rails 7.2.1.2) turbo-test(dev)> helper.tag.br => "<br>" turbo-test(dev)> helper.tag.h1 => "<h1></h1>"
  19. Railsのturbo_frames_tag ヘルパーのコードを見てみる tag.turbo_frame に対応するメソッドは TagBuilder のインスタンスには存在しない -> 存在しないメソッドが呼ばれたときに、 method_missing で動的に処理を生成する(メタプ

    ログラミング) # ActionView::Helpers::TagHelper::TagBuilder def method_missing(called, *args, escape: true, **options, &block) # スネークケースのメソッドとして呼んだhtmlタグ名をハイフン形式に変換 name = called.name.dasherize # HTML5のタグ名として無効なタグ名の場合はエラーを返す(正規表現を使用) TagHelper.ensure_valid_html5_tag_name(name) # tag.turbo_frameの呼び出し時に引数で渡した属性/値のペアはoptionsに入る # また、呼び出し時に渡したブロック(フレームで囲んだ内部のerb)もそのまま渡される tag_string(name, *args, options, escape: escape, &block) end
  20. Railsのturbo_frames_tag ヘルパーのコードを見てみる content_tag_string メソッドが最終的にシンプルな文字列(HTML)を返す # ActionView::Helpers::TagHelper::TagBuilder def tag_string(name, content =

    nil, options, escape: true, &block) # ブロック(=フレームで囲んだ内部のerb)があれば、内部で実行(yield)して文字列として返す content = @view_context.capture(self, &block) if block content_tag_string(name, content, options, escape) end def content_tag_string(name, content, options, escape = true) # 属性="値" の形にoptionsを変換 tag_options = tag_options(options, escape) if options if escape && content.present? content = ERB::Util.unwrapped_html_escape(content) end "<#{name}#{tag_options}>#{PRE_CONTENT_STRINGS[name]}#{content}</#{name}>".html_safe end turbo_frame_tag(@todo) #=> `<turbo-frame id="todo_1"></turbo-frame>`
  21. Railsのturbo_frames_tag ヘルパーのコードを見てみる Railsで使う turbo_frames_tag は、あくまで <turbo-frame></turbo-frame> とい うHTMLタグの文字列を得るためのメソッド 実際の要素置換はJSが行っている(ヘルパーのgemに Turbo

    のJSソースが同 梱されていて、Railsのアセットパイプラインが参照している) モデルオブジェクトを渡すとidを自動生成するなど、コード量が少なくシンプ ルに書けるような仕組みをヘルパーは提供している
  22. Turboがページ遷移をどう留めているのかを見てみる Session クラスのインスタンスのイニシャライズ、オブザーバー(後述)をまとめて起動する // src/index.js import * as Turbo from

    "./core" window.Turbo = { ...Turbo, StreamActions } Turbo.start() // Turboの起動 // src/core/index.js import { Session } from "./session" const session = new Session(recentRequests) /** * Starts the main session. * This initialises any necessary observers such as those to monitor * link interactions. */ export function start() { session.start() // 各種オブザーバーをまとめて起動 }
  23. Turboがページ遷移をどう留めているのかを見てみる Session クラスは「リンクのクリック」 「フォームの送信」などを監視するための Observer クラスのインスタンスをメンバに持つ // src/core/session.js export class

    Session { // ... // オブザーバーのインスタンスを作ってsessionのメンバに格納 linkClickObserver = new LinkClickObserver(this, window) formSubmitObserver = new FormSubmitObserver(this, document) // ... start() { if (!this.started) { // ... this.linkClickObserver.start() // リンククリックのオブザーバーの起動 this.formSubmitObserver.start() // フォーム送信のオブザーバーの起動 // ... } }
  24. Turboがページ遷移をどう留めているのかを見てみる リンク要素のみにクリック時の処理を登録するのではなく、クリックイベント伝播時に必ず通 る window にその処理の設定を登録することで、動的に生成されるリンク要素にも対応する // src/observers/link_click_observer.js export class LinkClickObserver

    { started = false constructor(delegate, eventTarget) { this.delegate = delegate this.eventTarget = eventTarget } start() { if (!this.started) { // eventTargetはwindowオブジェクトでイニシャライズされる // クリック時処理の実際の登録を行うclickCaptured関数をキャプチャーフェーズで登録 this.eventTarget.addEventListener("click", this.clickCaptured, true) this.started = true } }
  25. Turboがページ遷移をどう留めているのかを見てみる クリックされたものがリンクかどうか等の確認後、 preventDefault() でページ遷移を無効化 // src/observers/link_click_observer.js clickCaptured = () =>

    { this.eventTarget.removeEventListener("click", this.clickBubbled, false) this.eventTarget.addEventListener("click", this.clickBubbled, false) } clickBubbled = (event) => { // clickEventIsSignificantはalt/ctrlキーを伴うクリックなど、特殊なクリックでないかを判定 if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { // クリックの発生元の要素を取得 const target = (event.composedPath && event.composedPath()[0]) || event.target // 発生元の要素もしくは親要素から、別タブ開き/downloadではないリンク要素があれば取得 const link = findLinkFromClickTarget(target) // リンクがなければ早期離脱 if (link && doesNotTargetIFrame(link.target)) { // 取得したhref属性の値を読み取り、完全なURLを生成 const location = getLocationForLink(link) // turboが無効になっていないか、ページ内移動でないか等を確認 if (this.delegate.willFollowLinkToLocation(link, location, event)) { // デフォルトのページ遷移をキャンセル event.preventDefault() // リンク遷移を非同期処理として実行 this.delegate.followedLinkToLocation(link, location) } } } }
  26. Turboがページ遷移をどう留めているのかを見てみる 最終的に FetchRequest#perform で fetch が実行される // src/http/fetch_request.js export class

    FetchRequest { // ... async perform() { const { fetchOptions } = this this.delegate.prepareRequest(this) const event = await this.#allowRequestToBeIntercepted(fetchOptions) try { this.delegate.requestStarted(this) if (event.detail.fetchRequest) { this.response = event.detail.fetchRequest.response } else { this.response = fetch(this.url.href, fetchOptions) } const response = await this.response return await this.receive(response) } catch (error) { if (error.name !== "AbortError") { if (this.#willDelegateErrorHandling(error)) { this.delegate.requestErrored(this, error) } throw error } } finally { this.delegate.requestFinished(this) } }
  27. まとめ 素の Turbo Turbo のJSさえあれば、開発者が配置した Turbo 関連要素とイベントに応答 してSPAライクな画面を作ることができる RailsやActiveRecordとは完全に分離されたもの Railsのヘルパー

    Railsの環境と Turbo をスムーズにつなぎ合わせるためのヘルパーであり、あ くまで Turbo の動き自体はJSが担当している ActiveRecordオブジェクトを渡した際の簡便な記述や実現や特殊なHTMLタグ を作成のために、メタプログラミングを有効活用している Turbo のリンククリックのジャック 動的に生成される要素へのクリックにも対応するために、イベント伝播の仕 組みを有効活用している デフォルトのページ遷移の抑制は preventDefault() を使っている(基本)
  28. まとめ 魔術的な動きも、自分が学習の中で通った基礎的な概念・技術の積み重ねで実現されているこ とを感じることができた Rubyのメタプログラミング / イベント伝播 / DOM操作 学習の中でどれも教科書的に通ってきたものけど…… Turbo

    のヘルパーをただ触って確認している段階では、これらの背後の存在 について想像することもできなかった 今回、Turbo内部で追ったのはTurboの機能のごく一部 そもそもだいぶざっくり読み…… フレームの置換や指定要素の追加・更新など、 Frames や Streams の内部は全く 追えていない カスタム要素( <turbo-frame></turbo-frame> )など、まだこれまでの学習 で主体的に通っていないことが色々関わっていそう