🥑

Hotwireのススメ ~React製SPAをフルSSRでStimulusとTurboに書き換えた話~

2021/02/11に公開

はじめに

私はフリーランスのプログラマで、普段はwebサイト・webアプリを中心に開発を請け負っています。
私のチームでは、CMSを利用した静的webサイトを制作する場合はNext.jsとvercelなどのPaasを推奨しており、webアプリの制作ではNode.jsまたはPHPでサーバー構築することが多いです。
今回はその中の主にクライアントサイド(フロントエンド)とサーバーサイド(バックエンド)のSPAにまつわる争いを止める(?)内容です。

例によって長いので、時間がない方はブラウザバックを。。。

Hotwireとは

https://hotwire.dev/
サーバーサイドの設定を不要とする、HTML主体のSPAライクな環境を構築するためのJavascriptセットです。
Railsプログラマには馴染み深いturbolinksを起源に持ち、そこに新しいライブラリを追加してリニューアルしたプロジェクトがHotwireとなりました。

Hotwireは主に3つのライブラリから構成されます。

Turbo

Railsのturbolinksとほぼ同じ機能で、サーバーサイドでの設定なしにSPAライクな環境を作れるライブラリです。
HTMLの<a></a>タグを解析・リンク先のHTMLをロードし、ページ遷移時に<body></body>タグを書き換えることでSPAのような挙動を実現します。
https://turbo.hotwire.dev/

Stimulus

HTMLに「Controller」というスコープを追加し、様々なイベントとHTMLを紐づけて機能やアニメーションセットごとに管理することが出来ます。
https://stimulus.hotwire.dev/

Strada

HTMLベースでネイティブアプリを構築するためのライブラリ。2021年内に公開予定。

何がいいの?ストーリー

jQueryとSPA

webサイトといえばPHPやJavaだった時代から、デザイナーとプログラマが完全分業となっているケースは現代においても少なくないようです。
私の周りでもHTMLマークアップとCSSと少しのアニメーションを伴うJavascriptでLPを作るときなんかはデザイナーに依頼するケースも多いようです。
そしてそういった「ちょっとした」Javascriptで現役大活躍してるのが言わずと知れたjQueryです。
https://jquery.com/

ですが、昨今ではSPAの構築がフロントエンドの主流になってきており、さらにNext.jsやGatsbyなどの静的サイトジェネレータの登場により、ちょっとしたwebサイトでもSPA化するケースが増えてきました(事実、私たちのチームも開発効率やテストの容易性を重視してそうしています)。
https://nextjs.org/
https://www.gatsbyjs.com/

jQeuryの良さとReactやVue.jsの欠点

私は実際に制作したプロダクトでjQueryを利用することはほとんどありませんでした。
しかしながらその良し悪しはさんざん議論してきたしされてきました。
私自身はjQueryを、

  • プログラミングに不慣れなデザイナーや初心者でも扱いやすい構文
  • DOM要素の高さや位置などへのアクセスのしやすさとブラウザ差分の吸収

の2点が大きく有用であると思っています。
ですがこれがまたSPAを作るためのライブラリ群(ReactやVue.js)との相性があまりよくない・・・(もちろん使用できます)。

また、ReactにしてもVue.jsにしても、実際に処理を書くのは素のJavascriptなので、ブラウザ差分やサーバーサイドのJavascriptを意識したコーディングには、プログラミングに不慣れな方にはハードルが高いと感じるようです。
これがReactやVue.jsプログラマの単価が高い要因でしょうか?

デザイナーフレンドリーなHTMLベース

そこでHotwireの出番です。

  1. Turboを読み込むことで普通のHTMLを簡易SPA化
  2. StimulusでHTMLをバインディングしてイベントを追加

この二つでそこそこ複雑なページやアプリケーションを構成できます。
私は実際にexpressとNext.jsで書かれたアプリケーションのフロントエンドをHotwireでリファクタリングしました。

これにはNext.jsの持つ優秀なコード分割機能やプリロード機能をすべて捨てることになりましたが、アプリケーション自体が非常に小さいものだったため、ライブラリのコード分割のみ自前のwebpackで実装して書き換えることでパフォーマンスの向上を期待しています。

何をどうしたって、素のHTMLが一番速くて高パフォーマンスなのです!

実際に書いてみる

Turbo

ターボの実装には2通りあります。

HTMLから直接読み込む

<script type="module" src="https://cdn.skypack.dev/@hotwired/turbo"></script>

なんとこれで終わりです!!
これだけでHTMLマークアップを読み込んで、aタグのリンク先をプリロードし、SPA化することができます!
※HTMLから読み込む場合、type="module"に対応したブラウザでしか機能しません。ブラウザ対応をしっかりするならコンパイル環境を用意する必要があります。

Javascript or Typescript

インストール

npm install --save @hotwired/turbo
# or
yarn add @hotwired/turbo

Javascript

/** ES6 */
// import 'core-js/stable';
// import 'regenerator-runtime';
// 上記はレガシーブラウザに対応する場合
import * as Turbo from '@hotwired/turbo';

const main = () => {
    Turbo.start();
};

main();

Typescript

import * as Turbo from '@hotwired/turbo';

type Main = () => void
const main: Main = () => {
    Turbo.start();
}

main();

Javascriptでの記述でも、ノーコードでSPA化が完了します。
あとは上記のファイルをwebpackやbabelなどのお好きな環境でトランスパイル・コンパイルしてHTMLで読み込むだけです。優秀!
ただ一つの注意点は、必ず<head></head>タグ内で読み込む必要があることです。これさえ守れば特別な設定は要りません。

フォームの送信など

フォームの送信などを行う場合、POSTメソッドにまでTurboが動いたりすると挙動がおかしくなります。
このようなフォームや特定のページへの遷移でTurboを動かしたくない場合はHTMLに下記を追記するだけでTurboはそのHTMLタグを無視します。

<form method="POST" data-turbo="false"></form>
<a href="/hoge" data-turbo="false"></a>

Stimulus

StimulusもHTMLで読み込み可能ですが、こちらはES6のクラス構文を使用するため、javascriptでの使用を強くおすすめします。

HTMLで読み込み

ボタンを押したらHello!と表示されるだけの簡単なスクリプトです。

<div data-controller="hello">
    <p data-target="message"></p>
    <button type="button" data-action="hello#showMessage">click!</button>
</div>
<script src="https://unpkg.com/stimulus@2.0.0/dist/stimulus.umd.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script type="text/babel">
    class HelloController extends Controller {
	static targets = ['message']
	
	showMessage() {
	    this.messageTarget.textContent = 'Hello!';
	}
    }
    
    const application = Application.start();
    application.register('hello', HelloController);
</script>

Javascript or Typescript

インストール

npm install --save stimulus
# or
yarn add stimulus

Javascript

/** ES6 */
// import '@stimulus/polyfills';
// 上記はレガシーブラウザに対応する場合
import { Application, Controller } from 'stimulus';

class HelloController extends Controller {
    static targets = ['message']

    showMessage() {
        this.messageTarget.textContent = 'Hello!';
    }
}

const main = () => {
    const application = Application.start();
    application.register('hello', HelloController);
}

main();

Typescript

import { Application, Controller } from 'stimulus';

class HelloController extends Controller {
    static targets = ['message']
    
    messageTarget!: HTMLParagraphElement
    
    showMessage(): void {
        this.messageTarget.textContent = 'Hello!';
    }
}

type Main = () => void
const main = () => {
    const application = Application.start();
    application.register('hello', HelloController);
}

HTMLマークアップとの相関

Stimulusは独特なHTMLとの相関を覚えてしまえば、簡単にDOMにアクセスできます。

<div data-controller="hello"></div>
class HelloController extends Controller {
    // ...
}
const application = Application.start();
application.register('hello', HelloController);

HTMLに記述したdata-<コントローラー名>とJavascriptに記述したapplication.register('<コントローラー名>', <実際にコーディングしたコントローラークラス>);
がそれぞれ対応して、その要素含む以下子要素すべてにアクセスできるようになります。

<p data-target="message"></p>
static targets = ['message']
showMessage() {
    this.messageTarget.textContent = 'Hello!';
}

Javascriptで定義したtargets=['<ターゲット名>']をHTMLのdata-target="<ターゲット名>"に記述することで、その要素へ直接アクセスできます。
アクセス時は

  • this.<ターゲット名>TargetがHTMLElement
  • this.has<ターゲット名>Targetそのターゲットが存在するか否か
    をそれぞれ使用することが出来ます。

イベントは

<button type="button" data-action="hello#showMessage">click!</button>

data-action="<コントローラー名>#<メソッド名>"でイベントを登録することが出来ます。

今のところHotwireで覚えることはこれだけ

他にも細かい要素へのアクセス方法や状態管理の方法などがあるとはいえ、Turboの読み込みとStimulusの基本3つのHTMLとJavascriptさえ覚えてしまえば、あとはクラスを付け替えしてcssで動きをつけたり外部通信をしたりと、追加で覚えなくてはならないことはほとんどありません。

何よりうれしいのは生のJavascriptとHTMLで書けること。
Stimulusのコントローラー自体はただの要素のバインディングなので、jQueryやSwiperなどの他の優秀なライブラリを別で使用してもなんの問題もありません。

ES6を使用するのでビルド環境を整える必要はありますが、そこだけテンプレートを作ってしまえばあとは普通のHTMLと普通のJavascriptでゴリゴリ書ける!
だからフロントエンドコーダーやデザイナーが「SSRしたReactコンポーネント内でどうやってwindow.requestAnimationFrame()を動かせばいいのか」なんてことを悩まなくていいし、サーバーサイドで「SSR様とSPA様のためだけにNode.jsサーバーを立ち上げる」なんてことも起こらないわけです。
だって往年のLAMPスタックでいいんだもの!

敢えて欠点を挙げるとすれば・・・

良いところを紹介したいから心苦しいですが、欠点を列挙すると

  • 巨大なアプリケーションになるとコントローラーのスコープの深さや共通化について頭を悩ませる気がする
  • クラス名をuniqueにするためにCSSModuleを導入すると一気にHTMLの見通しが悪くなる(故にBootstrapとかtailwindCSSとかとの相性はいいかも)
  • なんとなく↑のコードでお察し、Typescriptはやや書き方がくどくなる(<ターゲット名>Targetの型をを明示的に定義する必要がある)
  • ドキュメントが全部英語なので、誰かがしっかり覚えてチームにちゃんと共有する必要はあるのかも

ってとこかと思ってます。
とはいえまだ私もNext.jsからリファクタリングしただけで運用は始まってないので、運用してまた利点・欠点ともに見えてくるかもしれません。
Next.jsの高パフォーマンスなアプリケーションを、どこまで対抗できるのか!楽しみです。

最後にポエム

ユーザー体験はとても大切だと思うし、実際プログラムの複雑さや優秀さなんてものはユーザーにとってはトラブルが起きなければいいくらいの認識だと思います。
だからSPAって素晴らしいなあって思う反面、Create React App とか Vue CLI なんかでインスタントにサクッと始めちゃうと、実際のリリース時にはめちゃくちゃ重たいアプリ!なんて話もよく聞きます。
各ライブラリ・フレームワークがスターターに力を入れてる分始めるのはとても簡単ですが、SPAの本当の難しさはそのパフォーマンスの向上だと思っています。
せっかくユーザー体験よくするためにSPAにしたのに、JSが重たくなってパフォーマンス悪かったり、ケースによってはレガシー環境で表示すら出来ないなんて悲しい話です・・・。

昨今webアプリケーションを作ろうと思ったら何かしらのフレームワークを使うことが多いのではないでしょうか。

  • Laravel (PHP)
  • Ruby on Rails (Ruby)
  • express (Node.js)
  • Django (Python)
  • Phoenix (Elixir)

ちょっと挙げても、よく使われてるのでこれだけの選択肢があって、おそらく世の中の大半のアプリケーションがこの中のどれかを使用している or 使用した歴史があるでしょう。
そしてこれらのフレームワークの素晴らしいのは、どれもパフォーマンスの高いテンプレートエンジンを持っていて、それらを使用して高品質なアプリケーションを(そしてフロントエンドを)構成するポテンシャルがあるということです。

あなたのアプリケーションはそういった資産を捨ててまでSPAに時間と人を費やす必要が果たしてあるのか?これは最近よく見かける【なんとなく】SPAに対するある種のアンチテーゼとも取られるでしょう。
でもSPAってカッコいい?わかります。

そこへの答えの一つがおそらくNext.jsやGatsbyであり、このHotwireだと思っています。

敢えて言うなら、今からフロントエンドやSPAの仕組みづくりを1から始めるならNext.jsがおすすめですし私の最推しです。そのパフォーマンスの高さは今この記事を見て書いてる「zenn.dev」が裏付けているでしょう。

ですが、もし今LAMPスタックのような「いつもの」環境を持っていて、SPAをプロダクトに導入したいと考えるならば・・・
時間と人員リソースを費やし、たくさんの人件費をかけて仮想DOMを実装するよりも、一行のコードから始められるHotwireをまず試してみてはいかがでしょう。

最後に、この素晴らしいHotwireプロジェクトを指揮するBasecampのリンクを貼って終わりにします。
もちろんこのBasecampも、その会社が並行して運営するHeyもバリバリHotwireを使って作られています。そしてこのパフォーマンスとユーザー体験の良さ!

こだわらないことって大事だよなあ

https://basecamp.com/
https://hey.com/

Discussion