Slide 1

Slide 1 text

Rails7 のフロントエンド開発ライブラリTurbo を、 より素な形で使ってみたり、中身を見てみたりする shodan 2024.11.08 福岡フロントエンド勉強会 #2

Slide 2

Slide 2 text

自己紹介 / shodan 初学者です プログラミング未経験 -> 昨年夏から「フィヨルドブートキャンプ」というオ ンラインスクールで勉強しています 今日は就職活動の関係で、兵庫から福岡に来ました フロントエンド関係の話題のつもりですが…… 限定的な話題(Rails関連)です 行ったことも、学びと感じたことも、初学者の視点によるものです

Slide 3

Slide 3 text

サマリ 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 自体の中身を読んでみた 結果: 魔術的な動きも、自分が学習の中で通った基礎的な概念・技術の積み重ねで 実現されていることを感じることができた

Slide 4

Slide 4 text

謝辞 「素のTurbo」というアイデアやSinatra(後述)への導入については、kinoppydさんの下の記 事を大変参考にさせていただきました ありがとうございました 素Turbo https://kinoppyd.dev/blog/suturbo/ suturbo https://github.com/kinoppyd/suturbo

Slide 5

Slide 5 text

Turbo について / 全体の概要 Rails7 から導入された、Rails アプリケーションに SPA ライクな振る舞いを簡便に導入できる フレームワークHotwireの中核ライブラリ Turbo 有効下では、フォーム・リンクのクリックからのリクエストは全て fetch による非同期通信になる この fetch に対して、サーバーは HTML をレスポンスする 返ってきた JSON を使いクライアント側で DOM を構築するスタイルではない サーバーが返してきたHTMLの断片を、あくまで元の画面の必要な箇所に置き 換えたり差し込んだりするだけ サーバーサイドにロジックを寄せられ、開発者がJSを書く量が減り、開発効 率が上がる Hotwire には Stimulus と Hotwire Native という構成要素もありますが、今回 は触れません

Slide 6

Slide 6 text

Turbo について / Turbo Drive Turbo 有効下のページにおいて、リンクやフォームのクリックをインターセプト して、 GET による通常のページ遷移を fetch に切り替える レスポンスされたHTML内の body 部分のみを、現在のものと置き換える URLは History APIを使って更新されるので、 「戻る」 「進む」も可能 画面上では普通のページ遷移と変わらずに見えるが、高速化されている

Slide 7

Slide 7 text

Turbo について / Turbo Frames Drive の部分置換版 部分的に置換したいページの一部を「フレーム」として区切る フレームとしたい要素を 要素で囲む Turbo 有効下で、フレーム内のリンクやフォームからの GET が発生 fetch に切り替え レスポンスされたHTML内に同じフレームが含まれていた場合、 Turbo は元 のページ上のそのフレーム部分だけを置き換える

Slide 8

Slide 8 text

Turbo Frames 例

Slide 9

Slide 9 text

Turbo Frames 例

Slide 10

Slide 10 text

Turbo Frames 例

Slide 11

Slide 11 text

Turbo Frames 例

Slide 12

Slide 12 text

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| %>
<%= form.label :title, style: "display: block" %> <%= form.text_field :title %>
<%= form.label :content, style: "display: block" %> <%= form.text_area :content %>
<%= form.submit %>
<% end %> <% end %>

Slide 13

Slide 13 text

Turbo について / Turbo Streams ページ全体( body 要素)や指定フレームの置換ではなく、要素の部分的な追加・ 更新・削除を行いたいときに使う Turbo 有効下で、リンクやフォームからの POST/PUT/PATCH/DELETE のリクエスト が発生 Turbo はAcceptリクエストヘッダーに turbo_stream 用のMIMEタイプ ( text/vnd.turbo-stream.html )を追加して fetch サーバー側がそれに応じて、追加・更新する要素を 要素で囲 んだHTMLの断片を返す のように、属性で 「操作種別」と「操作対象」を指定することで、ページの特定部分への追 加・更新・削除といった操作が可能になる

Slide 14

Slide 14 text

Turbo Streams 例

Slide 15

Slide 15 text

Turbo Streams 例

Slide 16

Slide 16 text

Turbo Streams 例

Slide 17

Slide 17 text

Turbo Streams 例

Slide 18

Slide 18 text

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 %>

<%= link_to "Show this blog", @blog %>

<% end %>

Slide 19

Slide 19 text

凄いけれど、魔術みがある 『 Turbo は言語にとらわれないJSフレームワーク』とのことだが、Railsのヘルパ ーにActiveRecordオブジェクトを渡して使っている分にはその感じがわからない 非RailsのWebアプリ(ヘルパー・AR使えない)で、 Turbo を使ってみる そのうえで、Railsのヘルパーの中身を見てみる Turbo 有効下では、通常のページ遷移を留めて fetch に切り替えることで高速化 && SPAライクな動きを実現させているけれど、どうやって「留めて」いるのかわ からない Turbo の中身を見てみる

Slide 20

Slide 20 text

非RailsのWebアプリで「素のTurbo」を試してみる Ruby製のミニマルなWebアプリケーション制作フレームワーク Sinatra を使って、Railsのヘル パー・ActiveRecord を用いず、SPAライクなブログアプリを作ってみる # app.rb # $ ruby app.rb でサーバーが立ち上がる require 'sinatra' get '/' do 'Hello world!' end import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/[email protected]';

Slide 21

Slide 21 text

Turbo化していない状態のブログアプリ 通常のページ遷移を伴うシンプルなブログのCRUDアプリ

Slide 22

Slide 22 text

Sinatra + 素のTurbo ブログアプリ: ブログの編集 ページ遷移がなく、記事画面がそのままフォームに変わる

Slide 23

Slide 23 text

Sinatra + 素のTurbo ブログアプリ/ ブログ編集フォームへのリンク views/index.erb (ブログ記事一覧、個々の記事の下に編集リンクがある)
<% @blogs.each do |blog| %>

<%= h(blog['title']) %>

<%= h(blog['content']) %>

編集 削除
<% end %>

Slide 24

Slide 24 text

Sinatra + 素のTurbo ブログアプリ: ブログの編集・サーバー処理 app.rb # ActiveRecord非使用なので自前でSQLを書き、取得した記事をハッシュの形でテンプレートに渡す get '/blogs/:id/edit' do |id| @blog = settings.db.execute('SELECT * FROM blog WHERE id = :id', id:).first erb :edit end

Slide 25

Slide 25 text

Sinatra + 素のTurbo ブログアプリ: ブログの編集フォームの表示 views/edit.erb (編集画面)
タイトル
本文 <%= h(@blog['content']) %>
変更

Slide 26

Slide 26 text

Sinatra + 素のTurbo ブログアプリ: ブログの更新 ページ遷移がなく、フォーム部分が更新後の記事に変わる

Slide 27

Slide 27 text

Sinatra + 素のTurbo ブログアプリ: ブログの更新・編集フォーム views/edit.erb (編集画面)
タイトル
変更

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

Sinatra + 素のTurbo ブログアプリ: ブログの更新 views/update.erb

<%= h(@blog[:title]) %>

<%= h(@blog[:content]) %>

編集 削除

Slide 30

Slide 30 text

Sinatra + 素のTurbo ブログアプリ ブログのCRUD全体を画面遷移無しで出来るようにしています https://github.com/junohm410/turbo-sinatra-testapp/tree/setting-turbo

Slide 31

Slide 31 text

Railsのturbo_frames_tag ヘルパーのコードを見てみる turbo_frame_tag(@todo) の実行時に起きていることを、内部に潜って見てみる @todo はコントローラから渡されたActiveRecordのモデルオブジェクト <%# app/views/todos/show.html.erb %> <%= turbo_frame_tag @todo do %>

<%= @todo.description %>

<%= 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より引用)

Slide 32

Slide 32 text

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 にインクルードさ れている

Slide 33

Slide 33 text

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 )

Slide 34

Slide 34 text

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") # =>
end end # `tag.turbo_frame()`は`TagBuilder.new(self).turbo_frame()`と同義 def tag_builder @tag_builder ||= TagBuilder.new(self) end

Slide 35

Slide 35 text

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 => "
" turbo-test(dev)> helper.tag.h1 => "

"

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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}".html_safe end turbo_frame_tag(@todo) #=> ``

Slide 38

Slide 38 text

Railsのturbo_frames_tag ヘルパーのコードを見てみる Railsで使う turbo_frames_tag は、あくまで とい うHTMLタグの文字列を得るためのメソッド 実際の要素置換はJSが行っている(ヘルパーのgemに Turbo のJSソースが同 梱されていて、Railsのアセットパイプラインが参照している) モデルオブジェクトを渡すとidを自動生成するなど、コード量が少なくシンプ ルに書けるような仕組みをヘルパーは提供している

Slide 39

Slide 39 text

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() // 各種オブザーバーをまとめて起動 }

Slide 40

Slide 40 text

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() // フォーム送信のオブザーバーの起動 // ... } }

Slide 41

Slide 41 text

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 } }

Slide 42

Slide 42 text

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) } } } }

Slide 43

Slide 43 text

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) } }

Slide 44

Slide 44 text

まとめ 素の Turbo Turbo のJSさえあれば、開発者が配置した Turbo 関連要素とイベントに応答 してSPAライクな画面を作ることができる RailsやActiveRecordとは完全に分離されたもの Railsのヘルパー Railsの環境と Turbo をスムーズにつなぎ合わせるためのヘルパーであり、あ くまで Turbo の動き自体はJSが担当している ActiveRecordオブジェクトを渡した際の簡便な記述や実現や特殊なHTMLタグ を作成のために、メタプログラミングを有効活用している Turbo のリンククリックのジャック 動的に生成される要素へのクリックにも対応するために、イベント伝播の仕 組みを有効活用している デフォルトのページ遷移の抑制は preventDefault() を使っている(基本)

Slide 45

Slide 45 text

まとめ 魔術的な動きも、自分が学習の中で通った基礎的な概念・技術の積み重ねで実現されているこ とを感じることができた Rubyのメタプログラミング / イベント伝播 / DOM操作 学習の中でどれも教科書的に通ってきたものけど…… Turbo のヘルパーをただ触って確認している段階では、これらの背後の存在 について想像することもできなかった 今回、Turbo内部で追ったのはTurboの機能のごく一部 そもそもだいぶざっくり読み…… フレームの置換や指定要素の追加・更新など、 Frames や Streams の内部は全く 追えていない カスタム要素( )など、まだこれまでの学習 で主体的に通っていないことが色々関わっていそう

Slide 46

Slide 46 text

まとめ 基礎知識を活きた知識にするためにも、興味を持った対象への疑問は深堀りしていきたい