Hotwire への道 2. Turbo Streams編

2022/12/12に公開

この記事は、Redmine Advent Calendar 2022 の12日目の記事で、5日目の「Hotwire への道 1. アセットパイプライン編」の続きです。前日の記事は aki360pさんの 「全文検索プラグインのアップデートエラー対処」でした。

要点

  • Redmine では、Hotwire相当の機能を既存の手法で部分的に実現している。
  • まず Turbo Streams の導入から始めることにした。
  • 既存の機能の裏側を書き換えただけで、まだ UI は改善しません。

はじめに

Redmineに限りませんが、歴史のあるWebアプリにHotwireを導入しようとする場合、入門書の通りには行かないケースも多々あるのではないかと思います。

Hotwire入門記事では、ゼロからアプリケーションを開発したり、Hotwire的な処理を行っていないアプリに導入したりという記事がほとんどです(入門記事だから当たり前なんですが)。Hotwire は「プログレッシブエンハンスメント(漸進的拡張)」を提唱しています。Hotwireが導入されていないところに、できるところから順番に導入するという考え方(Railsの技: “プログレッシブエンハンスメント”でHotwire的思考を身につける(翻訳))を取っています。

その場合、やはり TurboDrive → TurboFrame → TurboStreams → Stimulusという順で導入するのが合理的です。

Hotwire の入門記事を一通り読んでデモアプリも作ってみて、さて Redmine のコードに向きあうわけですが、「正直どこから始めたら良いのか良くわからん」という状態でした。

Redmineにせよ他のRailsアプリにせよ、結局、移行のためにはアプリをよく調べる必要があります。

移行方法の検討

アプリケーション全体でTurbo Drive を有効にして、どこかで発生しているであろう不具合をモグラたたきのように見つけ出そうする、というのは論外です。
労力がかかりすぎるし、見落しが発生しそうです。一度に変更するのは一ヶ所のみとして不具合の発生はすぐに把握するのが望ましい方法です。

導入当初は、アプリケーション全体では Turbo を無効にしておきます。

import "controllers"
import "@hotwired/turbo-rails"

Turbo.session.drive = false;

その上で、コードの特徴を数値で確認して、移行しやすそうな所から取りかかることにしました。

Hotwire は、JavaScript を大量に記述しなくても SPAのような挙動を実現できる、というのが売り文句です。ただ Redmine には、すでにそれなりの量の JavaScript コードがコミットされています。どのような形で JavaScript が使用されているのか、数値で把握できるように調査してみました。

件数 調査方法
1 js ファイルで公開されている関数 107 grep -E "^function" public/javascripts/*.js
2 viewファイルの中でヘルパーと一緒に使用されているもの 45 grep -r -E "link_to_function" app/views/*
3 viewファイルの中で、ページにscript要素で埋めこまれているもの 58 grep -r -E "javascript_tag" app/*
4 html要素のイベント属性に直接書きこまれているもの 114 grep -r -E "onclick|onchange|onsubmit" app/*
5 SJRのコードとして使用されるもの 62 find app/views/ -name “*.js.erb”

調査結果とにらめっこした結果、まず 5 のコードを Turbo Streams に移行するところから取りかかることにしました。

SJRとは? なぜ SJR から?

Redmine の ソースを読んでいると、ビューのディレクトリでnew.js.erb といったファイルに行き当たります。これは Rails がブラウザからの要求を受けて JavaScript のソースコード返すときのテンプレートです。rubyのように言語の機能でメタプログラミングをするのではなく、rubyのテンプレートエンジンでその都度、JavaScriptのコードを生成しているわけです。

ブラウザとのやりとりでこうした JavaScript のコード片を生成する手法を “SJR(Server-generated JavaScript Response)” と呼びます。

SJRは少くとも2013年には登場しました。 DHH本人による解説はこちらです。 Server-generated JavaScript Responses

解説の中で DHH は、SJRの利点として以下の3点をあげています。

  1. 既存のビューテンプレートを再利用できること
  2. クライアント側の処理が軽量で済むこと(言いかえるとサーバサイドの処理の比重が高くなる)
  3. 処理の流れが追いやすいこと

このうち、1のビューテンプレートの再利用と2のサーバサイドの処理重視は、実はTurbo Streams の解説記事でも特徴としてあげられています。

前回の記事で、「Hotwire は、完全に新しい技術というよりは、それまでRailsで培ってきた諸々のライブラリやパターンをブラッシュアップして独立させたものです」と書きました。Turbo Drive が Turbolinks の進化形であるように、Turbo Streams は SJRの進化形なのです。

そんなわけで、SJRの処理は、ほぼ1対1の関係で機械的に Turbo Streams に置き換えられると考えたわけです。

Turbo Streams の進化、そして request.js

Hotwireも 発表からそろそろ2年です。発表当初はいろいろな機能制限があったのですが、開発が進むとともに便利そうな機能が揃ってきました。

特に9月に発表された最新の turbo 7.2 では以下の機能が実現しました(現在の最新版はv7.2.4)。

  • turbo のレスポンスに含まれる scriptタグが有効になった
  • turbo streams が GETリクエストでも利用できるようになった
  • turbo streams の デフォルトの7種類以外のアクションを定義できるようになった

さらに、request.js というあまり目立たないライブラリの中に思わぬ便利機能がありました。

  • request.js は、rails でブラウザの fetch API を使用するための薄いラッパーです。リクエストを発信するときにCSRFトークンを自動的に登録してくれます。
  • リクエスト時にオプションのrequestKindturbo-stream としておくと Turbo Streamsのレスポンス取得からDOMの書き換えまで全自動でやってくれる 🎉 jQuery の $ajax関数 で html を取得してDOMを書き換えるというような処理をもっと簡潔な記述で実現できるようになります。
  • これは本来は、turbo-rails の中に入れておいてほしかった機能のような気もするのですが。

書き換え事例

以下はSJRからTurbo Streamsへの移行の事例です。なお、テンプレートの置き換え以外に Railsのコントローラ側の修正も必要です。また、Turboの導入にともない、Rails側のHTTPのレスポンスコードについても考慮が必要となります。詳しくは記事末尾の参考記事をご覧ください。

単純なコンテンツの置換

SJRのテンプレート。<%=から%> までがサーバ上で置換され、JavaScriptのコードとしてブラウザに返ります。

app/views/projects/bookmark.js.erb
$('#project-jump div.drdn-items.projects').html('<%= j render_projects_for_jump_box(projects_for_jump_box(User.current), selected: @project) %>');
$('.contextual a.icon.bookmark').replaceWith('<%= j bookmark_link @project %>');

Turbo Streams に置き換えたものです。処理内容は同じで、ヘルパーのおかげでより Railsっぽい表記になっています[1]

app/views/projects/bookmark.turbo_stream.erb
<%= turbo_stream.update_all '#project-jump div.drdn-items.projects' do %>
  <%= render_projects_for_jump_box(projects_for_jump_box(User.current), selected: @project) %>
<% end %>

<%= turbo_stream.replace_all '.contextual a.icon.bookmark' do %>
  <%= bookmark_link @project %>
<% end %>

モーダルを開く、閉じる

モーダルウィンドウの開閉は Redmine ではよく使う機能です。非表示となっているウィンドウのhtmlにコンテンツを流しこんだ上でモーダルを開きます。

  • SJR の場合
app/views/wiki/new.js.erb
$('#ajax-modal').html('<%= escape_javascript(render :partial => 'wiki/new_modal') %>');
showModal('ajax-modal', '600px');
  • Turbo Streams の場合

Turbo Streams はブラウザ上のhtml要素に対して、内容を置き換える(update)、上部/下部への追加する(append/prepend)などの7種類の機能があります。それ以上の処理を加えたい場合、stimulusを使用するか、turbo 7.2から使えるようになった"カスタムアクション"を使います。stimulusで処理を追加する場合、Turbo Streams の記述がやや冗長になってしまいます[2]。カスタムアクションを使用する場合、以下のように応答の記述が簡単になります。[3]

app/views/wiki/new.turbo_stream.erb
<%= turbo_stream.show_modal 'ajax-modal', width: '600px' do %>
  <%= render partial: 'wiki/new_modal' %>
<% end %>

やや複雑な JavaScript

  • その場でしか使わず、あまり汎用性のない JavaScript の処理は、script要素を turbo-stream要素くるんでレスポンスで返すことで実現します。ただ JavaScriptとERBの混在はやはり読みづらいので、これは暫定的な措置として、後でうまく整理する方法を考えます。
app/views/journals/edit.js.erb
$("#journal-<%= @journal.id %>-notes").hide();

if ($("form#journal-<%= @journal.id %>-form").length > 0) {
  // journal edit form already loaded
  $("#journal-<%= @journal.id %>-form").show();
} else {
  $("#journal-<%= @journal.id %>-notes").after('<%= escape_javascript(render :partial => 'notes_form') %>');
}

上記の .js.erb を script要素でくるんだ上で、後述のstimulusコントローラーと紐づけすることでブラウザ側でDOMに追加して内容を実行した直後、すぐに削除されます。

app/views/journals/edit.turbo_stream.erb
<%= turbo_stream.append 'content' do %>
  <%= javascript_tag data: { controller: 'transient' } do %>
    $("#journal-<%= @journal.id %>-notes").hide();

    if ($("form#journal-<%= @journal.id %>-form").length > 0) {
      // journal edit form already loaded
      $("#journal-<%= @journal.id %>-form").show();
    } else {
      $("#journal-<%= @journal.id %>-notes").after('<%= escape_javascript(render :partial => 'notes_form') %>');
    }
  <% end %>
<% end %>
  • script要素は、DOMに追加されたタイミングで実行されます。実行後は不要なのでDOMから削除します。この時、少しだけ stimulus を使用します。
app/javascript/controllers/transient_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="transient"
export default class extends Controller {
  // DOM要素に追加されたときに実行される処理
  connect() {
    // このコントローラが付与された要素自身を削除する。
    this.element.remove()
  }
}
  • self destructing controller という呼び方で紹介されていました。

システムテストの実行結果

js.erb コード、62ファイルを全て、turbo stream に書き換えました。

2022-12-11 14:26   M─┐ [hotwire] Merge branch 'sudo_mode' into importmap
2022-12-11 00:05   │ o [sudo_mode] Convert SJR to Turbo Streams in sudo_mode view
2022-12-11 14:26   M─│─┐ Merge branch 'import' into importmap
2022-12-01 22:58   │ │ o [import] Convert SJR to Turbo Streams in imports view
2022-12-11 14:26   M─│─│─┐ Merge branch 'attachment' into importmap
2022-11-30 21:48   │ │ │ o [attachment] Convert SJR to Turbo Streams in attachments view
2022-11-30 21:15   │ │ │ o Convert SJR to Turbo Streams in repositories view
2022-11-30 21:00   │ │ │ o Convert SJR to Turbo Streams in issue view
2022-11-30 20:44   │ │ │ o [journal] Convert SJR to Turbo Streams in journal view
2022-12-11 14:26   M─│─│─│─┐ Merge branch 'principal' into importmap
2022-12-11 02:29   │ │ │ │ o [principal] Convert SJR to Turbo Stream in users view
2022-11-28 21:25   │ │ │ │ o Convert SJR to Turbo Streams in members view
2022-11-28 20:19   │ │ │ │ o [group] Convert SJR to Turbo Streams in groups view
2022-11-29 09:27   │ │ │ │ o Convert SJR to Turbo Streams in principle_memberships view
2022-12-11 14:26   M─│─│─│─│─┐ Merge branch 'version' into importmap
2022-11-13 09:26   │ │ │ │ │ o [version] Convert SJR to Turbo Stream in version view
2022-12-11 14:26   M─│─│─│─│─│─┐ Merge branch 'bulk_edit' into importmap
2022-11-16 22:48   │ │ │ │ │ │ o [bulk_edit] Convert SJR to Turbo Streams in bulk edit form
2022-12-11 14:26   M─│─│─│─│─│─│─┐ Merge branch 'timelog' into importmap
2022-11-25 22:57   │ │ │ │ │ │ │ o [timelog] Convert SJR to Turbo Streams in timelog view
2022-11-25 22:54   │ │ │ │ │ │ │ o Add stimulus clear controller
2022-12-11 14:26   M─│─│─│─│─│─│─│─┐ Merge branch 'issue_relations' into importmap
2022-12-10 16:35   │ │ │ │ │ │ │ │ o [issue_relations] Convert SJR to Turbo Streams in issue relations view
2022-12-11 14:26   M─│─│─│─│─│─│─│─│─┐ Merge branch 'mypage' into importmap
2022-11-27 08:42   │ │ │ │ │ │ │ │ │ o [mypage] Convert SJR to Turbo Streams in mypage view
2022-12-11 14:26   M─│─│─│─│─│─│─│─│─│─┐ Merge branch 'wiki' into importmap
2022-11-13 05:10   │ │ │ │ │ │ │ │ │ │ o [wiki] Convert SJR to Turbo Stream in wiki view
2022-12-11 14:26   M─│─│─│─│─│─│─│─│─│─│─┐ Merge branch 'watchers' into importmap
2022-11-25 21:40   │ │ │ │ │ │ │ │ │ │ │ o [watchers] Convert SJR to Turbo Streams in watchers view
2022-11-29 09:26   │ o─┴─┴─┴─│─│─│─│─│─│─┘ [transient] Add stimulus controller transient
2022-12-11 14:26   M─│─┌─────┘─│─│─│─│─│─┐ Merge branch 'projects' into importmap
2022-11-26 07:17   │ │ │ ┌─────┘ │ │ │ │ o [projects] Convert SJR to turbo streams in project bookmark
2022-12-11 14:25   M─│─│─│─┌─────┘─│─│─│─│─┐ Merge branch 'messages' into importmap
2022-11-26 05:08   │ │ │ │ │ ┌─────┘ │ │ │ o [messages] Convert SJR to Turbo Streams in messages view
2022-12-11 14:25   M─│─│─│─│─│─┌─────┘─│─│─│─┐ Merge branch 'custom_field_enumerations' into importmap
2022-11-20 18:39   │ │ │ │ │ │ │ ┌─────┘ │ │ o [custom_field_enumerations] Convert SJR to Turbo Streams in custom_field_enumerations
2022-12-11 14:25   M─│─│─│─│─│─│─│─┌─────┘─│─│─┐ Merge branch 'custom_field' into importmap
2022-11-20 19:18   │ │ │ │ │ │ │ │ │ ┌─────┘ │ o [custom_field] Convert SJR to Turbo Stream in custom field view
2022-12-11 14:25   M─│─│─│─│─│─│─│─│─│─┌─────┘─│─┐ Merge branch 'issue_categories' into importmap
2022-11-21 14:42   │ │ │ │ │ │ │ │ │ │ │ ┌─────┘ o [issue_categories] Convert SJR to Turbo Streams in issue_categories view
2022-12-11 14:25   M─│─│─│─│─│─│─│─│─│─│─│─┌─────┘─┐ Merge branch 'email_address' into importmap
2022-12-10 16:39   │ │ │ │ │ │ │ │ │ │ │ │ │ o─────┘ [email_address] Convert SJR to Turbo Streams in email_addresses
2022-12-10 16:35   │ │ │ │ │ o─│─│─│─│─┴─│─│─┘ [customaction] Add turbo stream custom actions show,focus,clear
2022-11-28 12:48   │ o─│─│─│─┘ │ │ │ │ ┌─┘ │ add turbo_delete_link helper
2022-11-25 20:10   │ o─┴─┴─┴───┘ │ │ │ │ ┌─┘ [form_controller] Add stimulus form controller
2022-12-11 14:25   M─│─┌─────────┘─│─│─│─│─┐ Merge branch 'search_field' into importmap
2022-11-26 21:53   │ │ │ ┌─────────┘ │ │ │ o [search_field] use search_field stimulus controller in watchers
2022-11-26 13:21   │ │ │ │ ┌─────────┘ │ │ o Add search_field stimulus controller
2022-11-15 09:31   │ o─│─│─│─┌─────────┘─│─┘ Add requestjs-rails
2022-11-21 21:49   o─┴─┴─┴─┴─┴───────────┘ Add turbo stream custom actions
2022-11-10 22:40   o Add importmap and hotwire tools
2022-12-08 21:33   o [propshaft] {tohosaku/propshaft} Fix tests
2022-12-09 19:32   o Add asset pipline support for themes and plugins
2022-09-10 14:34   o Add propshaft to enable asset pipeline

全てマージした上で、システムテストを実行してみました。エラー対応はこれからです。

Finished in 123.331552s, 0.4784 runs/s, 2.8379 assertions/s
59 runs, 350 assertions, 14 failures, 11 errors, 0 skips

所感

  • JavaScript を中心に調べていくことで、Redmine の中にも今まで気付かなかったユーザー体験向上のための仕組みに気付くことができました。
    • 一方で、「え?ここに戻るボタンないの?」みたいなところもあった。JavaScriptの整理を進めるなかで画面ごとにちょっと違うUIも整理する機会になるかもしれません。
  • SJRから Turbo Stream への移行で、処理が読みやすくなりました。
    • rubyのテンプレート と JavaScript のコード混在はやっぱり読みづらい。
    • エディタの支援も受けづらい。
    • ここ数年の Web開発の流れでは LSP など開発支援ツールが充実している。
    • 複数言語が混在しているとこうしたツールの恩恵が受けづらくなる。コードを上手いこと切り分ければ、エディタの補完などの支援が期待できる(かもしれない)。
  • rails-ujs への依存を減らすことができる。
    • rails7 で rails new したアプリでは rails-ujs が使用されず turbo が同じ役割を担っている。早めに移行することが望ましい。
  • プラグイン、テーマの修正が必要になる部分も出る可能性がある。まあこれは今回に限った話ではありません。

参考記事

脚注
  1. ERBではなく、slimやhamlといったテンプレートエンジンも利用可能です ↩︎

  2. まあヘルパーを書けば良いんですが ↩︎

  3. カスタムアクションの定義方法は、記事末尾の参考URLを参照してください。ただしbeta版をベースにした記事のようで一部、リリース版と定義方法が異なります ↩︎

Discussion