Turbo ソースコードの実体を確かめる
こんにちは、万葉でエンジニアをしている吉原です。
この記事では、Hotwire の中核である Turbo がブラウザで動作するために、ソースコードがどのように届けられているか、どこに配置されているかを探求していきたいと思います。
Rails 7 ではデフォルトで Turbo を使用することができます。あまりに簡単に利用できるので、私はどのようにコードが実行されているかがわからず、魔法のように感じていました。
こういった便利な機能は、フロントエンドの知識や関心が薄いエンジニアにとってはブラックボックスになってしまいがちだと思います。しかし、ブラックボックスとして利用しているだけでは、いざという時に問題解決ができなかったり、他のフレームワークとの組み合わせなど Rails を離れての利用ができなかったりと、歯がゆい部分があるのではないでしょうか。
そこで、これから Turbo のソースコードがどのようにブラウザに到達するのかを順番に調べる形で解説していきます。それによって、この記事の読者の方がブラウザで実際に使用される Turbo のソースコードを読んで確認したり、手を入れて動作の確認ができるようになることを目指しています。
Turbo が実行される様子を確認する
まずは Rails で Turbo が実行される様子を確認したいと思います。
動作確認環境
今回は下記のツール・バージョンで動作確認を行います。
Ruby: 3.1.2
Rails: 7.0.4
Google Chrome: 103.0.5060.134 (Official Build) (x86_64)
サンプルアプリケーションを作成する
はじめに、Turbo の動作確認をするためのサンプルアプリケーションを作成し、サーバを起動します。このサンプルアプリケーションは、「本」(Book)の登録・更新・削除などの機能を備えています。
% mkdir sample && cd $_
% rails new .
% rails generate scaffold Book title:string
% rails db:migrate
% rails s
Turbo の動作を確認する
ブラウザで本の一覧画面( http://localhost:3000/books )にアクセスし、リンクやフォーム送信の際の画面遷移の様子を確認します。
上記の動画をよく見てみてください。本の登録画面に移動したり、本の内容を登録したりする際に、あまりタイムラグがなく、ページがちらついたりもしていないことがわかります。ページ全体をロードすることなく表示内容や URL が切り替わっているのです。
このように Rails 7 では特別な設定を行わなくても Turbo によって SPA (※) のような体験をユーザーに提供することができます。
※ Single Page Application の略。ブラウザによる画面遷移を行わず1画面上で JavaScript で画面の動きを実現するようなアプリケーションのことを指します
Turbo がブラウザに届く仕組みを読み解く
無事、Turbo が動いていることが確認できました。それでは、いよいよ Turbo のソースコードがどのようにブラウザへ届けられているかを確認していきましょう。まずは、先ほど作成したサンプルアプリケーションのコードを見てみます。
application.js を見る
Rails の JavaScript のエントリーポイントは、app/javascript/application.js です。そこで、まずこのファイルの内容を確認します。
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
上記を見ると、@hotwired/turbo-rails が import されていることがわかります。
Importmap
Rails 7 のデフォルトでは Importmap を使用して JavaScript のソースコードをブラウザに取り込んでいます。Importmap とは、JavaScript ライブラリを論理名を通じてURLからインポートできるようにするマッピングの技術です。
Rails 7 には Importmap を扱うために importmap-rails という Gem が同梱されています。この importmap_rails gem の設定ファイルである config/importmap.rb を確認してみましょう。
# Pin npm packages by running ./bin/importmap
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"
@hotwired/turbo-rails について書かれた4行目(2つめの pin)に着目しましょう。@hotwired/turbo-rails は "turbo.min.js" へマッピングされていることがわかります。
しかし、ここでは "turbo.min.js" というファイル名しかわかりません。このファイルは、Rails アプリケーション内で、実際にはどこにあるのでしょうか?
それを確かめるために、マッピングの設定がブラウザでどのように反映されているかを見てみましょう。ブラウザの開発者ツールを開いてみます。
上記のように、type="importmap" をもつ script 要素が確認できます。ここで @hotwired/turbo-rails が /assets/turbo.min.js というパスにマッピングされていることがわかります(※)。
つまり、turbo.min.js はアプリケーションの /assets 下に配置されるということです。
※ 見やすさのためにマップ先のファイル名に付与されるダイジェストの設定をオフにしています
turbo.min.js はどのように /assets に配置されるのか?
turbo.min.js が /assets に配置されていることがわかりましたが、turbo.min.js は元々どこにあって、どのように /assets に配置されるのでしょうか?
Rails では JavaScript などのアセット(※)は、アセットパイプラインという仕組みによって、コンパイルなどの処理を経て /assets 配下に出力されます。従って、アセットパイプラインへの入力を調べることで turbo.min.js が元々どこにあったかを確認することができそうです。早速みてみましょう。
アセットパイプラインはアプリケーションの config.assets.precompile という設定で指定されたファイルをコンパイルしています。まずは、config.assets.precompile に turbo.min.js が
含まれることを rails console を使って確認してみましょう。
% rails console
Loading development environment (Rails 7.0.4)
irb(main):001:0> Rails.application.config.assets.precompile
=>
["manifest.js",
"turbo.js",
"turbo.min.js",
"turbo.min.js.map",
"actiontext.js",
"trix.js",
"trix.css",
"es-module-shims.js",
"es-module-shims.min.js",
"es-module-shims.js.map",
"stimulus.js",
"stimulus.min.js",
"stimulus.min.js.map",
"activestorage",
"activestorage.esm",
"actioncable.js",
"actioncable.esm.js"]
配列の3つ目に "turbo.min.js" があることがわかります。しかし、ここにはディレクトリの情報がありません。コンパイル対象のファイルはどのような場所から見つけられるのでしょうか?
アセットパイプラインはコンパイル対象のファイルを config.assets.paths という配列から検索しています。先程と同じように、config.assets.paths の内容を rails console で確認してみましょう。
% rails console
Loading development environment (Rails 7.0.4)
irb(main):001:0> Rails.application.config.assets.paths
=>
["/Users/f-yoshihara/tmp/sample/app/assets/config",
"/Users/f-yoshihara/tmp/sample/app/assets/images",
"/Users/f-yoshihara/tmp/sample/app/assets/stylesheets",
"/Users/f-yoshihara/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/stimulus-rails-1.1.1/app/assets/javascripts",
"/Users/f-yoshihara/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/turbo-rails-1.3.2/app/assets/javascripts",
"/Users/f-yoshihara/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/importmap-rails-1.1.5/app/assets/javascripts",
"/Users/f-yoshihara/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/actiontext-7.0.4/app/assets/javascripts",
"/Users/f-yoshihara/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/actiontext-7.0.4/app/assets/stylesheets",
"/Users/f-yoshihara/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/actioncable-7.0.4/app/assets/javascripts",
"/Users/f-yoshihara/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/activestorage-7.0.4/app/assets/javascripts",
"/Users/f-yoshihara/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/actionview-7.0.4/lib/assets/compiled",
#<Pathname:/Users/f-yoshihara/tmp/sample/app/javascript>,
#<Pathname:/Users/f-yoshihara/tmp/sample/vendor/javascript>]
上記から、~/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/turbo-rails-1.3.2/app/assets/javascripts というパスが指定されていることがわかります。ここに目的の turbo.min.js ファイルがありそうです。
実際に調べてみると ~/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/turbo-rails-1.3.2/app/assets/javascripts/turbo.min.js というファイルがあることが確認できました。
※ JavaScript、CSS、画像などのリソースのこと
ファイルの編集と動作確認
~/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/turbo-rails-1.3.2/app/assets/javascripts/turbo.min.js を確認したところ、minify された JavaScript ファイルが配置されていました。
このファイルが実行されているということを確認するために編集を行い、変更されたコードがブラウザ上で実行されることを確認してみましょう。
Turbo ではリンクやフォーム送信を Fetch API によるリソース取得に置き換えているため fetch() を呼び出している箇所の直前に下記のように console.log() を書き加えました。(const e=… の前までが書き加えた部分です。)
console.log('Hello from turbo.min.js');const e=await fetch(this.url.href,s);
この状態で、アプリケーションの「New book」リンクをクリックしてページ遷移を発生させ、先程のfetch() を呼び出してみましょう。すると、次の画面のように、仕込んだログが出力されていることを確認できます。
これで、私たちが変更を加えたturbo.min.js ファイルが実際に実行されているということを確認することができました。
ここまでのまとめ - デフォルト設定の Rails 7
ここまでの調査で下記のことがわかりました。
デフォルト設定の Rails 7 では JavaScript のコードを Importmap 経由でブラウザへ届けている
Importmap で届けられる Turbo のソースコードは Gem としてインストールされた turbo-rails 内にある turbo.min.js である
turbo.min.js は minify されている
読みやすい状態の Turbo のソースコードを利用する
先ほど確認できた turbo.min.js は minify されているため、Turbo のコードで色々と実験したり、読み解いたりしたい場合には不便かもしれません。そこで、読みやすい状態の Turbo のコード(※)を利用するための手軽な方法として、 importmap-rails ではなく jsbundling-rails を使用する方法をご紹介します。
※ Turbo のオリジナルのソースコードは TypeScript で書かれていますが、この方法ではそれを JavaScript に変換した状態のコードを見ることになります
サンプルアプリケーションを作る
まずは、Importmap の例と同様にサンプルアプリケーションを作成します。今回は JavaScript bundler として esbuild を使用するのでサンプルアプリケーション作成時に esbuild を使用するオプションを付与します。
% mkdir esbuild_sample && cd $_
% rails new . -j esbuild
% yarn install
% rails generate scaffold Book title:string
% rails db:migrate
% bin/dev
これで、esbuild を利用して Turbo が組み込まれた Rails アプリケーションを用意することができました。このアプリケーションで、Turbo の JavaScript がどのように得られ、動くのかを確認していきたいと思います。
エントリーポイントから順に見ていく
まずは JavaScript のエントリーポイントである app/javascript/application.js を確認します。
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
Rails 7 が JavaScript のパッケージマネージャーとして採用している yarn はパッケージを node_modules ディレクトリにインストールするため、node_modules 配下にパッケージとしての "@hotwired/turbo-rails" がありそうです。
確認してみると node_modules/@hotwired/turbo-rails というディレクトリが見つかったのでこの中身を見ていきましょう。
まずは @hotwired/turbo-rails/package.json を読んでエントリーポイントを確認してみます。esbuild ではデフォルトで module フィールドを読み込むようになっているので、module フィールドに着目します。すると、次のような記述があります。
"module": "app/javascript/turbo/index.js"
上記で指定された turbo-rails/app/javascript/turbo/index.js ファイルの内容を見てみると下記のように @hotwired/turbo を import しています。
import * as Turbo from "@hotwired/turbo"
そこで、@hotwire/turbo に対応するソースコードを確認します。それには、@hotwired/turbo-rails の時と同様に、package.json の module フィールドを確認します。
"module": "dist/turbo.es2017-esm.js"
つまり、以下の図のように参照されていることがわかります。
これで、esbuild を使用する場合に実行される Turbo のソースコードが esbuild_sample/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js らしいということがわかったので、さっそく、動かして確認してみましょう。
コードを少し変えて動かしてみる
Importmap の時と同様に fetch() 呼び出し付近を編集してみましょう。
async perform() {
var _a, _b;
const { fetchOptions } = this;
(_b = (_a = this.delegate).prepareHeadersForRequest) === null || _b === void 0 ? void 0 : _b.call(_a, this.headers, this);
await this.allowRequestToBeIntercepted(fetchOptions);
try {
this.delegate.requestStarted(this);
// ↓ この行を書き足しました
console.log('Hello from turbo.es2017-esm.js');
const response = await fetch(this.url.href, fetchOptions);
return await this.receive(response);
}
catch (error) {
if (error.name !== 'AbortError') {
this.delegate.requestErrored(this, error);
throw error;
}
}
finally {
this.delegate.requestFinished(this);
}
}
TypeScript から JavaScript へコンパイルされているものの turbo.min.js と比べると minify されていない分だけ読み書きしやすいと思います。
アプリケーションを起動しているコンソールを確認してみましょう。下記のようなログを見つけることができます。先ほど私たちが加えた編集が検知されて、build が行われていることがわかります。
15:50:38 js.1 | [watch] build started (change: "node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js")
15:50:38 js.1 | [watch] build finished
ブラウザで先ほどと同じように new book リンクをクリックして動作を見てみましょう。私たちが付け加えたログ出力がされることを確認することができます。
ここまでのまとめ - JavaScript bundler を使った場合
esbuild を例に動作確認を行い、下記のことがわかりました。
JavaScript bundler を使用してブラウザへ届けられる Turbo のソースコードは JavaScript のパッケージとして node_modules ディレクトリ配下にインストールされている
TypeScript から JavaScript にコンパイルされた状態でインストールされている
JavaScript bundler により bundle されてブラウザへ届けられる
まとめ
本記事では、Rails アプリケーションからブラウザへ Turbo のソースコードが届けられる際の経路とその配置場所の確認を行いました。
調査前はブラックボックスのように感じられた Turbo ですが、ソースコードが物理的にどこにあり、どのような過程を経てブラウザで実行されているのかを把握することができたことで、より身近で取り扱い可能なものとして捉えられるようになりました。
Turbo の動作で予期しないことやわからないことがあったときに、実際のコードを確認することができれば、解決の糸口が掴めることもあると思います。参考にしていただければ幸いです。
株式会社万葉ではエンジニアの採用を行っています!
https://everyleaf.com/we-are-hiring