見出し画像

既存サービスに Hotwire を部分導入して得られた知見

はじめに

こんちには。万葉でエンジニアをしている koheitakahashi です。
この記事では、既存のサービスに Hotwire を部分的に導入した際に得られた知見として、どのような問題に直面したかということと、その対応策をご紹介します。

Hotwire の導入を検討している方にとって、この記事が導入の判断材料になれば幸いです。

導入したサービスの概要

今回は、弊社が参画しているローカルワークス様のリフォマ というサービスに Hotwire を導入しました。

リフォマは、住まいの「困った!」を解決するマッチングサービスです。2016年から稼働しています。詳しくは、ローカルワークス様の以下の記事を御覧ください。

建設業界の「信頼性」を可視化する! リフォームのマッチングプラットフォーム「リフォマ」についてご紹介

主な技術スタックは以下の通りです(2022年11月現在)。

  • Ruby 3.1.2

  • Ruby on Rails 7.0.3.1

  • JavaScript

  • jQuery

  • Next.js (一部入力フォームのみに使用)

また、サービスの機能は以下の3つになります。

  • リフォームしたい人向け機能

  • リフォーム業者向け機能

  • ローカルワークス様の社員向け機能(以下、社員向け機能)

それぞれ、JavaScript のエントリーポイントが独立しています。
今回は、この中の「社員向け機能」にHotwire を導入しました。

導入の理由

主に以下の2つの理由から Hotwire の導入が決まりました。

  • リフォマを開発しているエンジニアはフルスタックな人が多いが、バックエンドに強みや関心がある人が多いので、その強みを活かしていきたいから

  • Rails のレールに乗ることで、他の JS フレームワークを入れるよりも開発コストを抑えて、素早くユーザビリティ向上を図っていきたいから

詳しい理由については、ローカルワークス様の以下の記事にまとめられているので、ぜひ御覧ください。

Rails 7+Hotwire導入のご紹介。プロダクトのユーザビリティ向上を目指す。 株式会社ローカルワークス

導入の方針

サービスにHotwireをどのように導入するかについては、「社員向け機能」全体では Turbo Drive を無効化した上で、部分部分で Turbo Drive を有効にする方針としました。

そして、よく使うリンク(約20箇所)で Turbo Drvie を有効にし、一部フォーム(約6箇所)を Turbo Frames と Stimulus を使ってインライン化していきました。

このように、部分部分に Hotwire を導入していく方針をとったのは、影響範囲を最小限にして、意図しない不具合を防ぐという目的からです。

具体的には、以下の手順で部分的に Turbo Drive を有効にしました。

まず、Turbo Drive を全体的に無効にします。それには JavaScript のエントリーポイントとなるファイルで以下のように記述します。

Turbo.session.drive = false

続いて、Turbo Drive を有効にしたい要素に対して、data-turbo="true" とカスタムデータ属性を追加します。

<a href="xxxxx" data-turbo="true">

これにより、このカスタムデータ属性が付与された要素で囲われたリンクなどで Turbo Drive が有効になります。

導入によって得られた効果

導入した結果、ローカルワークス様社員の方から以下のようなポジティブなフィードバックをいただきました。

  • (よく使うリンクで Turbo Drive 有効にしたことに対して)「(ページ遷移が高速化したことにより)複数の画面を行き来するオペレーションで業務効率が上がったように感じる」

  • (フォームのインライン化をしたことに対して)「フォームの表示・更新が早くなり、サクサク動くようになって嬉しい」

ローカルワークス様は、導入による業務効率向上について定量的な計測もされており、次のような結果が出ています。

  • 「案件」データの入力操作・・・1案件あたり、60秒削減

  • 「案件」データの内容確認・ステータス変更操作・・・1案件あたり、18秒削減

この結果から、 Hotwire によるUI改善で業務効率化を図れることが確認できます。利用者が多い機能、頻繁に利用される機能に Hotwire によるUI改善を施すことで、開発コストを抑えながら効率改善を実現できると考えます。

導入は大変だったか?

導入作業には2人で1ヶ月ほどかかりましたが、部分的な導入だったため、そこまで大変ではなかったという印象です。

導入前はもっと多くの問題が出るかと思っていましたが、蓋を開けてみると起きた問題はそこまで多くはありませんでした。ただし、ネット上にまだまだ知見が少ないため、問題が起きてからの解決方法調査には時間がかかりました。

以下に、どのような問題に直面して、どのような対応策をとったのかをご紹介します。

導入に際して直面した問題とその対応策

導入に際して、次のような問題に直面しました。

  1. ページ遷移時に実行されるはずの JavaScript の処理が実行されなくなった

  2. 既存の JavaScript の処理が動かないことをシステムスペックで検知できなかった

  3. システムスペックが落ちるようになった

  4. ESLint を流すと Parsing error が起きた

それぞれ、詳しく書いていきます。

1. ページ遷移時に実行されるはずの JavaScript の処理が実行されなくなった

問題の詳細

Hotwire 導入前の状態(=Turbo Drive が無効の状態)では、ページを遷移すれば、毎回  JavaScript のエントリーポイントが読み込まれます。しかし、Turbo Drive を有効にすると、画面内の head 要素は更新されず、body 要素のみが更新されるようになります。これにより、ページ遷移時にエントリーポイントの再読み込みが発生しなくなります。

つまり、画面の初期化処理など、エントリーポイントが読み込まれた時に実行したい処理があった場合、ケアをしないとその処理が実行されなくなってしまいます。

具体例を用いて説明します。たとえば、あるページを表示したときに、「ようこそ○○ページへ!」とh1要素で表示するための、以下のような JavaScriptコードがあるとします。

// app/javascript/index.js(エントリーポイントとなるファイル)

// body のカスタムデータ属性から文字を受け取る
const pageName = document.body.dataset.pageName

// 画面に追加する h1 要素を作成
const welcomeMessage = document.createElement("h1")
welcomeMessage.textContent = `ようこそ${pageName}ページへ!`

// 画面に h1 要素を追加
document.body.appendChild(welcomeMessage)

このコードは、次のようなHTML内に埋め込まれた data-page-name を読み取って、その内容を埋め込んだ h1 要素を作ります(この例では、「ようこそタスク詳細ページへ!」という h1 要素が作成されます。

<html>
  <head>
    <script src="/assets/index.js"></script>
  </head>
  <body data-page-name="タスク詳細">
    ...(省略)
  </body>
</html>

Hotwire 導入前の状態(=Turbo Drive が無効の状態)では、ページ遷移時に head 要素が読み込まれ、エントリーポイントの再読み込みが走ります。そのため、意図通りに h1 要素が作成され、表示されます。

しかし、Turbo Drive を有効にすると、ページ遷移時に head 要素を更新しないため、エントリーポイントの読み込みが発生しません。そのため、 h1 要素が作成・表示されなくなってしまいます。

私たちが遭遇した問題は、このような、ページ遷移後に毎回実行したい JavaScript の処理が実行されなくなったという問題でした。

対応策

この問題には、Stimulus で用意されているライフサイクルコールバックを使うことで対応しました。

以下に、先ほどの h1 要素作成の例を用いて、対応内容を具体的に説明します(予め Stimulus が導入されている前提で記載します)。

まず、Stimulus の Controller を作成し、connect というライフサイクルコールバックに、「遷移時に毎回実行したい処理」を記述します(公式リファレンス)。ここでは、initializeController という名前で Controller を作成することにします。

// app/javascript/controllers/initialize_controller.js

import { Controller } from '@hotwired/stimulus';

export default class initializeController extends Controller {
  connect() {
    // body のカスタムデータ属性から文字を受け取る
    // コード例としてわかりやすくするため targets は利用していない
    const pageName = document.body.dataset.pageName;

    // 画面に追加する h1 要素を作成
    const welcomeMessage = document.createElement('h1');
    welcomeMessage.textContent = `ようこそ${pageName}ページへ!`;

    // 画面に h1 要素を追加
    document.body.appendChild(welcomeMessage);
  }
}

そして、この Controller を 画面の body 要素にアタッチします(data-controller 属性で指定します)。

<html>
  <head>
    <script src="/assets/index.js"></script>
  </head>
  <body data-controller="initialize" data-page-name="タスク詳細">
  </body>
</html>

Turbo Drive が有効な場合、ページ遷移すると head 要素は更新されませんが、body 要素は更新されます。そのため、body 要素にはページ遷移ごとに initializeController がアタッチされ、connect コールバックが実行されます。これにより、ページ遷移時に必ず connect に書いた処理が実行されるようになります。

その他の対応策

実は上記以外にも次のような対応策があります。

  1. Turbo に用意されているイベントにフックして実行する

  2. ページ遷移時にフルリロードする

1. Turbo に用意されているイベントにフックして実行する」とは、以下のように turbo:load のイベントを使う方法です。

// app/javascript/index.js(エントリーポイントとなるファイル)

document.addEventListener("turbo:load", function () {
    // body のカスタムデータ属性から文字を受け取る
    const pageName = document.body.dataset.pageName;

    // 画面に追加する h1 要素を作成
    const welcomeMessage = document.createElement('h1');
    welcomeMessage.textContent = `ようこそ${pageName}ページへ!`;

    // 画面に h1 要素を追加
    document.body.appendChild(welcomeMessage);
})

turbo:load は、最初のページロード時と Turbo を訪問したときに発火するイベントです。そのため、このイベントのハンドラーに書いた処理は、ページ遷移時に実行されます。

今回遭遇した問題はこの方法で対応しても良かったと思いますが、前述の方法で対応し終わった後で気づいたため、採用していません。

2. ページ遷移時にフルリロードする」は、エントリーポイントに記述された処理が実行されるように、ページをフルリロードさせる方法です。フルリロードしたいページの head 要素に以下のように記述します。

<head>
  ...
  <meta name="turbo-visit-control" content="reload">
</head>

このように記述することで、この meta 要素があるページにアクセスしたときにページのフルリロードが発生します。ページ全体がリロードされ、エントリーポイントに記述された処理が実行されるので、今回の問題を解決できます。

しかし、今回は Turbo Drive を有効にしたい全ページに「遷移後に実行したい処理」がありました。そのため、上記の方法で解決を目指す場合は、全てのページの head に上記の meta 要素を記述することになります。

Turbo Drive では一度読み込んだ CSS・JavaScript をページ遷移後に再読み込みしないことで高速化を図っています。そのため、上記の meta 要素を用いた方法では、毎回 CSS・JavaScript の読み込みが発生することになります。これでは、せっかくのTurbo Drive による高速化の恩恵を受けられないと考え、この方法は採用しませんでした。

2. 既存の JavaScript の処理が実行されていないことをシステムスペックで検知できなかった

問題の詳細

この問題は、前述の「1. ページ遷移時に実行されるはずの JavaScript の処理が実行されなくなった」問題をシステムスペックで検知できていなかったという問題です。

システムスペックでは、以下のように Capybara の visit メソッドを使って対象のページにアクセスすることが多いと思います(タスク一覧を表示する機能があると仮定しています)。

RSpec.describe "タスク一覧表示機能", type: :system do
  before do
    visit tasks_path
  end
end

しかし、visit で画面にアクセスするということは、ブラウザにURLを入力してアクセスするようなものです。アプリ内のほかの画面のリンクを辿って遷移してきたわけではありません。そのため、visit のリクエストは Turbo のリクエストにはなりません。よって、画面がまるごと読み込まれるようになります。

つまり、visit では Turbo Drive によるページ遷移が行われていない状態になります。この状態では、Turbo Drive によるページ遷移後に、そのページが正しく動いているかを保証できません。

これが原因で、既存の JavaScript の処理が実行されていないことに気づくのが遅れてしまいました。

対応策

システムスペックで Turbo Drive によるページ遷移を確認できるように、 visit ではなく、アプリケーション内のリンクのクリックで遷移するようにしました。(厳密には、そうすると逆に Turbo Driveの遷移でないケースを確認できなくなるわけですが、現状のアーキテクチャでは、 Turbo Drive の遷移でない場合にだけ問題が生じることは考えにくかったので、リンクにすることが合理的だと考えました。)

なお、既存のシステムスペックの visit を全て書き換えるのは大変なので、Turbo Drive を有効にしている部分だけを修正しました。

この経験から得られる教訓として、 Hotwire を既存アプリケーションに導入する際は、まず Turbo Drive を有効にしたいと思っている部分のシステムスペックの visit をリンクからの遷移に書き換えておくのが良さそうです。そうすることで、導入後に Turbo Drive 起因の問題が生じたときに、すぐに気づくことができると思います。

3. システムスペックが落ちるようになった

問題の詳細

システムスペックで Turbo Drive によるページ遷移が発生したときに、Capybara がページの遷移を待てずに要素探索に失敗していたケースがありました。

「ページ遷移後に表示される要素の find を書いていたが、find がページ遷移よりも先に実行されてしまい、要素が見つからなくてテストが落ちる」というケースです。

対応策

単純な解決方法としては、sleep を挟んでページの更新を待つという対応策が考えられます。しかし、むやみに sleep を挟むとテストの実行時間が長くなるので、できれば必要最小限の時間だけ待つ方法を探したいところです。

調査したところ、以下のようにプログレスバーの有無で DOM が差し替えられたかどうかを判断する方法が Hotwire Discussion で紹介されていました。そこで、この方法を採用することにしました。

def wait_for_turbo(timeout = nil)
  if has_css?('.turbo-progress-bar', visible: true, wait: (0.25).seconds)
    has_no_css?('.turbo-progress-bar', wait: timeout.presence || 5.seconds)
  end
end

参考: System tests for TurboStream'd app without `sleep 1`

上記のコードは「.turbo-progress-bar が存在しない状態になるまで待つ(ただし、設定した timeout の秒数の間だけ待つ)」という意味になります。

Turbo Drive を有効にすると、ページ遷移に時間がかかる場合には .turbo-progress-bar というセレクタが付与されたプログレスバーが画面に表示されます。そのため、「ページに .turbo-progress-bar というセレクタが存在しない状態」を「ページ遷移が完了した状態」とみなすことができるというわけです。

具体的な手順としては、前述の wait_for_turbo メソッドをテストヘルパーに追加して、システムスペックでページ遷移が発生するリンクをクリックした後に呼び出します。すると、Turbo Drive によるページ遷移を待ってくれるようになります(以下は、タスク一覧画面があり、そこへページ遷移すると仮定したコードです)。

click_link('タスク一覧画面')
wait_for_turbo_drive

expect(page.current_path).to eq "/tasks"

4. ESLint を流すと Parsing error が起きた

問題の詳細

リフォマでは、JavaScript の Lint ツールとして ESLint を使用しています。使用していた ESLint のバージョンは 7.22 でしたが、ESLint を実行すると Stimulus の記法に対応できずに以下のようなエラーが起きました。

error  Parsing error: Unexpected token =

なぜエラーになるのでしょうか?

Stimulus ではパブリッククラスフィールドという ECMAScript 2022 で導入された構文を使用しています。具体的には、targets という機能で使用されています。targets は DOM要素を名前で参照できるようにする機能です。例えば、hello_controller.js という Stimulus の Controller で特定のDOM要素を name という名前で扱いたい場合は、以下のようなコードを記述します。ここにパブリッククラスフィールドの構文が使われているのです。

// app/javascript/hello_controller.js
import { Controller } from '@hotwired/stimulus';

export default class HelloController {
  static targets = ["name"]

  ...
}

ESLint 7系がこの ECMAScript 2022 に対応していないため、上記のエラーが発生していました。

対応策

ESLint 8系が ECMAScript 2022 に対応しているので、ESLint のバージョンを 8系に上げて、以下のように ESLint 設定ファイルを書き換えることで解決しました。

// .eslintrc.yml

env:
  es2022: true
  ...

なお、ESLint のバージョンを上げる際には、node のバージョンも上げる必要がありました。

まとめ

本記事では、既存サービスに Hotwire を部分導入した際に発生した問題と解決策をご紹介しました。無事に部分導入ができてホッとしています。

もしも、最初から「社員向け機能」の全体に Hotwire を適用していたなら、今回とは別の新たな問題に遭遇していたかもしれません。そうなると、導入のハードルはもっと高くなってしまっただろうと思います。今回のように、部分的に導入して使い勝手を小さく試すことができるのも、Hotwire のよい点だと感じました。

本記事が、Hotwire 導入を考えている方の判断材料の一つになれば幸いです。



株式会社万葉ではエンジニアの採用を行っています!
https://everyleaf.com/we-are-hiring