半年くらい前にこんな記事を書きました。
https://zenn.dev/mast1ff/articles/3d6f4b9e4a38bb
様々なプロダクトにメインとして使用しているNext.js製のフロントエンドをStimulusとTurboに書き換えて運用してみました。
https://hotwired.dev/
今回は実際に運用してみた所管などをまとめたいと思います。
はじめに
とはいえ、Hotwireたちは飽くまでフロントエンドのみ。バックエンド側のロジックを持つことのできないこれらのライブラリとの単純な比較対象としては、サーバーレスでも運用できるNext.jsとは若干ズレが出てきます。
今回私はNode.js製のバックエンドサーバーの上にNext.jsが乗っかていたもののフロントエンドの部分を、従来型のpugのマークアップとHotwireに置換し運用しました。
https://pugjs.org/api/getting-started.html
ですので、今回はNext.jsではなくReactとの比較を行っていきます。
ここは一つご留意ください。
ちなみにVercelなどのPaasなどでHotwireを使用したいのであれば、Go製のSSGであるHugoなどがおすすめです。
バンドルする環境整えるのは面倒くさいですが。
https://gohugo.io/
開発環境、書き心地
これはTurboとStimulusとで若干クセが分かれます。
Stimulus
こっちはReactでいうstateを管理したりイベントハンドラを管理したりするライブラリですが、Stimulus内のControllerクラスを継承したクラス内で普通のJavascriptのロジックを書きます。
なにかの便利な関数が用意されていたりするわけではなく、HTML内のdata-controller=""
に基づいてスコープを追加したりイベントを付与したりするためのライブラリになります。
そのため、このコントローラー内でReactやjQueryを使うことももちろんできます。
ここに関してはやはりRuby on Railsの開発元らしく、「dry」を保つつくりとなっており、Reactになれたコンポーネント指向とはだいぶ異なった考え方となっています。
ロジックとHTMLマークアップは完全に分離されており、Controllerクラスを継承して独自ロジックを書き、それをHTMLツリーのどこからでも利用できるようにする、という考え方になります。
以下は同じ使い回し可能なカウンターコンポーネントをReactとStimulusそれぞれのアプローチで作成したケースになります。
React
// Reactでは関連するDOMも一緒にマウントします。
import React, { useState, useCallback } from 'react';
import { render } from 'react-dom';
const Counter = (): JSX.Element => {
const [count, setCount] = useState<number>(0);
const increment = useCallback(() => setCount((prev) => prev + 1), []);
const decrement = useCallback(() => setCount((prev) => prev - 1), []);
return (
<div>
<button onClick={decrement}>−</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
};
const roots = document.querySelectorAll('[data-component="counter"]');
if (roots.length > 0) {
for (const root of roots) {
render(<Counter />, root);
}
}
<!DOCTYPE html>
<html lang="ja">
<head>
<script defer src="/static/app.js"></script>
</head>
<body>
<main>
<div data-component="counter">
<!-- ここにマウントされる -->
</div>
</main>
</body>
</html>
Stimulus
// Stimulusではjavascript(typescript)でロジックを書きますが、コレだけでは動作しません。
import { Controller, Application } from 'stimulus';
class CountController extends Controller {
static targets = ['display']
displayTarget!: HTMLElement
static values = {
count: Number
}
countValue!: number
initialize() {
this.countValue = 0;
this.render();
}
increment() {
this.countValue += 1;
this.render();
}
decrement() {
this.countValue -= 1;
this.render();
}
render() {
this.displayTarget.textContent = this.countValue.toString();
}
}
const app = Application.start();;
app.register('counter', CounterController);
<!-- HTML内にdata-controllerとdata-actionなどのメソッドを定義して初めて動く -->
<!DOCTYPE html>
<html lang="ja">
<head>
<script defer src="/staic/app.js"/></script>
</head>
<body>
<div data-controller="counter">
<!-- ここがCounterControllerのスコープになる -->
<button data-action="counter#decrement">−</button>
<span data-counter-target="display"></span>
<button data-action="counter#increment">+</button>
</div>
</body>
</html>
上記の違いを見ると、明らかにReactのほうが直感的です。
ですが、HTMLマークアップやCSSなどのデザインシステムとは切り離されたところでアプリケーションの状態やロジックを持つことが出来るので、分業する場合はJavascriptを一切触らなくて良いことはメリットといえばメリットかもしれません。
アイコンなどのセットをControllerにもたせておき、data-icon-target="mount"
みたいな感じでコンストラクタでマウントしてあげることで、そういった細かいセットの使い回しができたのは便利に感じました。
また、ロジックとその状態だけを持ち回ることが出来るので、いいねボタンのようなものやログイン状態などを巨大なストアなしにHTMLツリー全体でどこからでも参照でき、またそれらのデザインは個々に作ることが出来るのは一つの魅力と感じました。
Reactで言うところのカスタムフックをたくさん作ってHTMLから簡単に呼び出せる感覚でしょうか。
あと、targetやvalueなどはHTMLを見て自動的に作られる値ですが、クラス内に明記しない以上当然存在しない値と扱われるので、Typescriptでの書き心地は正直良くは無いです。
逆にここをいちいち宣言的にしなくてはならなくなるとstimulusの良さが半分くらいになっちゃうので、悩ましいところではあります。
generateコマンドとかあると良いのでしょうか。。。
Turbo
Turboは大きく分けて
- Turbo Drive
- Turbo Frames
- Turbo Streams
の3つの機能が存在します。
Turbo Drive
Turbo DriveはRailsでお馴染みのTurbo linksです。
こちらは最初に読み込むjsで明示的にTurbo.start()
を呼び出し、aのhrefに記述されたURLの遷移先のHTMLのプリフェッチを行うことで、画面遷移時にHTMLのbodyタグを書き換えることでSPAを実現する、というものです。
こうしてみると、実際の挙動はReact Routerに似ていますね。
しかしこのTurbo Driveで理解する必要があるのは、基本的にbodyタグの中を全部書き換える、というところ。
Reactでは仮想DOMツリーに持った情報を元に差分レンダリングをしますが、Turbo Driveではbodyタグの中をごっそり置換するダイナミックな仕様です。
ですので、bodyタグの中に書いたscriptタグなんかは再度読み込みが発生します。
逆にhead内は一切書き換えないので、リロードが必要なリソースには<link rel="" href="" data-turbo-track="reload">
と明示的に書く必要があります。
その他にも、404のときのブラウザバックの挙動など、Reactでいうところのライフサイクル的な挙動の変化を捉えてイベントハンドリングが必要なケースがちょこちょこありました。
ページ読み込みが速くてパフォーマンスは良いですが、この辺のハンドリングのしづらさはRails的じゃないな、と見てました。
Turbo Frames
Turbo Framesは、frameの名前の通りiframeを継承したカスタムエレメントです。<turbo-frame></turbo-frame>
内に書いたマークアップはコンテンツとして隔離され、例えばURL遷移やformの送信後のリダイレクトなどの処理がturbo-frame内で行われます。
このTurbo Framesこそが、これらHotwireたちをSPAの域まで持ち上げるための重要なプロダクトになります。
Ruby on Railsの基本のコントローラ設計では、例えばBooksControllerを作った場合、
- GET /books (一覧表示)
- GET /books/new (新規作成フォーム)
- POST /books/create (新規作成)
- GET /books/:id (詳細表示)
- PUT /books/:id (詳細編集)
- GET /books/:id/edit (詳細編集フォーム)
- DELETE /books/:id (詳細削除)
などの用にアクションとビューを紐づけます。(私は製品レベルでRailsを使用しないので、今どきではありません。すみません。)
これらのなかで実際に表示できるページだけでも/, /new, /:id, /:id/edit と4つあり、これらをいちいち行き来するのは今どきのフロントエンドから見ると少し古く見えます。
そこで、これらの一連のフォームの切り替えや送信後の画面遷移などをTurbo Frameを使用することで、部分更新に留めることが出来る、というのが真骨頂になります。
<div>
<turbo-frame id="book_1">
<!-- フォームのアクションはこのフレーム内で完結することが出来る。 -->
<form method="PUT" action="/books/1">
<input name="book[name]" type="text" value="My first book">
<input type="submit">
</form>
</turbo-frame>
</div>
もっとも、コレはバックエンドありきとなってしまうので、fetchを使用した非同期でのフォーム送信などとは根本的に使い勝手が異なります。
が、Turbo Framesにはもう一つの使い方があります。
<div>
<turbo-frame id="term-of-service" src="/legal/tos"></turbo-frame>
</div>
<div>
<turbo-frame id="term-of-service">
<h1>利用規約</h1>
<h2>このサービスについて</h2>
...
</turbo-frame>
</div>
上記の例だと、例えば/legal/tosで利用規約ページを用意したとして、その内容を<turbo-frame>
に収めておけば、他のページからでもその部分を動的に読み込むことが出来るというものです。
こうすることで、routeのは飽くまでRESTに従って作成し、いろいろなページから参照することでSPA化するということが可能になります。
この機能はReactだと、React Routerのlocation stateを利用したUI管理に置き換えることができ、例えば有名ドコロだとPC web版 Twitterの左側「ツイートする」ボタンを押すとURLは変わるけど背景は変わらずモーダルだけが表示される、みたいな挙動を作るのによく似ています。(実際は違いますが。)
https://reactrouter.com/web/api/location
ただ、これらは実装も状態管理もテストもめちゃくちゃ面倒くさく、プロダクトで実際に導入している人を見かけないのも事実。
- モーダルから全ページで参照出来る
- そのコンテンツ専用のルートも用意する
などのUIを実装するのに、ルートとコンポーネントを複雑にすることなく実現できるのはTurbo Framesのすごいところでもあります。
Turbo Streams
このTurbo Streamsはweb socketのような動的なコンテンツの取得・更新をHTMLカスタムエレメントである<turbo-stream>
を使用して簡単に実装出来る、というものです。
elixir phoenixのlive viewに似ています。
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
こちらに関しては、私のアプリケーションがリアルタイム通信を必要としなかったことと、私の作成したNode.js(express)バックエンドとの相性があまり良くなかったことから今回は使用しませんでした。こちらの書き心地や環境については、またどこかで遊んでみてからご紹介できればと思います。
ファイルサイズ比較
それぞれプロダクションビルドでバンドルしたサイズを比較しました。
構成
使用したツールはwebpack、ローダーはts-loaderとbabel-loader, @babel/preset-env, @babel/preset-react。
単一のファイルのみの構成でコード分割はなしになります。
module.exports = {
mode: 'production',
entry: {
react: './path/to/react.tsx',
hotwired: './path/to/hotwired.ts'
},
output: {
path: './dist',
filename: '[name].bundle.js
},
devtool: false,
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
},
{ loader: 'ts-loader' }
]
}
]
},
resolve: {
extensions: ['.ts', '.tsx'],
modules: ['node_modules']
}
};
last 2 versions
> 2%
公平を保つために、双方とも上記のようなカウンターコンポーネントを作成。
また、ルーティング関連の機能として、react-router-domとTurboもインポートして最低限組み込みます。
import React, { useState, useCallback } from 'react';
import { render } from 'react-dom';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
const Counter = (): JSX.Element => {
const [count, setCount] = useState<number>(0);
const increment = useCallback(() => setCount((prev) => prev + 1), []);
const decrement = useCallback(() => setCount((prev) => prev - 1), []);
return (
<Router>
<Switch>
<Route exact path="/count">
<div>
<button onClick={decrement}>−</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
</Route>
<Route>
<div>404</div>
</Route>
</Switch>
</Router>
);
};
const roots = document.querySelectorAll('[data-component="counter"]');
if (roots.length > 0) {
for (const root of roots) {
render(<Counter />, root);
}
}
import { Controller, Application } from 'stimulus';
import Turbo from '@hotwired/turbo';
class CounterController extends Controller {
static targets = ['display'];
displayTarget!: HTMLElement;
static values = {
count: Number
};
countValue!: number;
initialize() {
this.countValue = 0;
this.render();
}
increment() {
this.countValue += 1;
this.render();
}
decrement() {
this.countValue -= 1;
this.render();
}
render() {
this.displayTarget.textContent = this.countValue.toString();
}
}
const app = Application.start();
app.register('counter', CounterController);
Turbo.start();
結果
React: 228.6kb
Hotwire: 185.6kb
Hotwireのほうが少しだけ小さいファイルサイズとなりました。
しかしこれは表面的であり、例えばIE対応が必須となる場合には、Hotwireにはregenerator-runtimeが必要になるので、ファイルサイズはグッと大きくなります。
しかしながら、Reactでは個々からページを作ったりコンポーネントを複数作っていくと当然ながら大きくなっていくので、レガシーブラウザへの対応が必要ない場合は、実際にはHotwireのほうがグッとコンパクトになるでしょう。
仮想DOMを扱わないので当然といえば当然ですが・・・
ユースケース
最後に、上記の比較や実際の運用から見えてきたHotwireのユースケースをご紹介したいと思います。
Reactについては今やどんな小さなプロダクトにも採用されるほど多岐にわたって使用されるので、今回は割愛します。
1.プロダクト黎明期
これはサーバーレスが当たり前になりつつある昨今でも、小さなチームや個人で開発しているととりあえずrails new
やlaravel new
で始めることはあると思います。
そういった、いわゆるフルスタックフレームワークでの開発では最短距離でとても良いパフォーマンスを保つことができます。
また、jQueryなどで要素を取得するのにクラスやidがどんどん増えてHTMLとJavascriptがごちゃごちゃする、みたいなことも避けれられるのは嬉しいです。
2.ReactやVueなどを使用しないSSGフレームワークやCMS(Wordpressなど)の採用時
冒頭でも上げたHugoなどは、個人的にも気に入っている静的サイトジェネレーターです。が、当然ながらReactベースでないので、Reactを使用したい場合環境構築が必要です。しかもSSRはできません。
それなら、ミニマムでとても明示的でHTMLとロジックを切り離せるTurboとStimulusはまっさきに選択肢に入れるべきでしょう。
また、Wordpressなんかとも相性が良いとは思っています。パフォーマンス面やユーザビリティは大きく改善されます。
wp_なんちゃらを使用しなくても、ページさえ持っておけばTurbo Frameでどこからでもページ参照出来るのは便利ですね。
3.SPAとは切り離されたヘルプページやステータスページなどの静的ページの作成
アプリケーション本体はReactやVueを利用したSPAで作るにしても、例えばよくある質問や利用規約、お問い合わせのようなアプリ本体から切り離して管理したいようなコンテンツページでは特におすすめできます。
何度も言いますが、ミニマムで明示的なのは嬉しいことです。
おわりに
今回私はNext.jsを置換して運用してみました。
総括として、決して仮想DOMを使用したReactなどのライブラリに取って替われるものではありませんでした。ましてやNext.jsは、ルーティングなどやファイルシステムなどの部分まで面倒を見てくれるため、環境構築の手数やバックエンドの必要性の有無など、根本的に比較できるものではありません。
それでも単純なフロントエンド開発の手段としてのReactとHotwireを比較したときに、生のJavascript(Typescript)とHotwireだけで完結する手軽さや、HTMLマークアップと完全分離した回帰的な手法にrailsのdryな指向を組み込んだ使い勝手は、分業する上ではとてもスッキリしてよかったと思っています。
ReactやVueなどの巨大なフロントエンドシステムが苦手なエンジニアさんも、Hotwireであきらめないフロントエンド開発をしてみてください。
また、普段ReactやVueばっかり!って方も、ぜひ一度触れてみてください。
以上です。