かなり昔に Elasticsearch ベースの検索システム(Heineken)を作っていた。
Elasticsearch で部内 Wiki 検索高速化 - Speaker Deck
特に更新せず数年動かしていたのだけど、サーバーの置き換えに伴って Kubernetes に置きたいよねという話になり、ついでに Elasticsearch も新しくしたいよね、となった結果、現状のフロントエンドだと最新の Elasticsearch では動かないということがわかった。
フロントエンドの改修が必要なわけだが、ここでフロントエンドの構成を見ると…
- FlowType
- create-react-app
- PureComponent
- Bootstrap 3
古すぎる!絶対アップデート難しいし触りたくない技術しかない。
フロントまわりの構成を変えたいとずっと思っていた(フロントから Elasticsearch に直接アクセスする構成のやめたかった)こと、また SSR あんまり触ってなかったことがあり、1から作り直すことにした。
~~ここから2年弱が経過~~
フレームワークの選定
React を Vanilla で使うことは Webpack 頑張ればできるのだけど、最近は何かしらのフレームワークを使うことが主流らしい。
SSR もやりたいし、はじめは Next.js で作ろうと思っていた…のだけど、 Next.js は色々聞いてると考えることが多くて大変だな…という印象になっていた。検索アプリなので SSG は不要だし、キャッシュも静的ファイルの範囲内で十分だし、 fetch の override とかしないでほしい。もっと軽量なものが使いたい。自分は薄いフレームワークが好きなのです。
そこで Remix を使うことにした。 Remix は SSG はやらなくて SSR 専門だし、React router がベースなので馴染みもあるし、 Web standards はいいぞってトップページで主張していて好感度高い。超薄くはないけどまあ SSR するならこんなもんでしょう。
Remix で実装した
ということで数日かけて Remix で Heineken のフロントエンドを再実装した。今まではブラウザが fetch で直接 Elasticsearch にアクセスする構成だったが、 Remix のサーバーが Elasticsearch に問い合わせつつ SSR をして、 react-select などフロントで一部だけ描画する感じになった。
置き換えた感想としては、
描画が早い!!
あまりパフォーマンスのことを考えずに実装しても Elasticsearch の検索含めて 100ms 程度で描画されるようになった。SSR がすごいという話もあるし、各種コンポーネントのデータを並列で取得しているのも見ごたえあるし、パフォーマンスをあまり何も考えていない実装でそれを実現できているのが偉い。
結構ファーストビューの速さって体感に大きく関わるんだなと実感した。
細かいところ
Remix は外部ナレッジこそ少なめなものの、公式ドキュメントやチュートリアルが充実していて、 SSR 初心者でも割とスムーズに書くことができた。
とはいえ、色々思ったところ・つまづいたところはあったので書いていく。
Route
Remix では routes 以下にファイルを配置すると自動的にファイル名がパスとして認識され、そこで定義したコンポーネントにルーティングされる。SPA でルーティング書くの意外と面倒なので、これはなかなか便利だった。React router を内部で使っているんだろうけど、実際に意識することは全然なく、良い感じにやってくれている。
ファイル名ベースとディレクトリ名ベース両方使えるのも便利で、自分はディレクトリベースの route 指定を主に行うようにした。ディレクトリの中にそのパス関係のコードを全部置くことで Code colocation が上がってわかりやすい。
Nested routes
Remix では Nested routing という機能があり、 /foo/bar/baz/1
といった URL では /foo
のコンポーネントを描画し、その中に /foo/bar
を描画、 /foo/bar/baz
、 /foo/bar/baz/1
と続くように描画させることができる。
URL 設計をきれいにつくるときれいな階層型のサイトができるということなんだろうが、これはちょっと微妙だなと思った。Example にあるようなシンプルな構成のサイトだといいけど、(React で作りたくなるような JS ベースのサイトは特に!)そんなことはなく、下部のパスが同じだからといって上部のガワが共通であるとは限らず(特に上部パス)、結局下から上に伝えたり、上から下に伝えたりする必要が出てくると思う。コンポーネントが同じだったとしても、Props の内容とか変わってくるんじゃないですかね。それを親側の route で URL をパースして書き換えるのか?いやうーん…。
今回もそうで、 Nested routes として共通化できたのは上部のヘッダーぐらいで、検索関係のページの Route は共通化されたコンポーネントを組み合わせた大きなコンポーネントとなり、いつもの SPA といった感じになった。
URL や Nested routes にはこだわらず、どのようなデータをサーバーから取得したいか(loader がどのようなデータを返すのか)、で Route を分けるのが並列性を考えるとよいのかもしれない。
エラーハンドリング
Remix のエラーハンドリングは Route の中に ErrorBoundary という関数を定義して、そこでエラー内容を通常のコンポーネントの代わりに描画するというものだが、このやり方がなかなか難しかった。
例えば検索フォームの Route を考える。ここで遷移(Search params の変更)が起きたときに loader で何かおかしなことがあればエラーを出したいのだが Route ファイル内に ErrorBoundary を定義して単純にエラーを出すとフォームが丸ごとエラー表示に置き換わるのでフォームの修正や再送信ができなくなる。
かといって通常の Route と同じ Component を流用して描画を行うのも難しい。ErrorBoundary 内では loader
から得られるデータが使えないので描画に必要なデータを得ることができない。これは環境変数まわりで特に困った(後述)。
その他、loader 内のエラーがうまくクライアント側に伝わらないような挙動もあり、Remix でのエラーハンドリングは難しいな…という印象が残った。
ローディング
ローディングは defer / Suspense という仕組みが用意されており、loader 内で時間のかかるデータを待たずに一旦レスポンスを返しつつ、データが取得できるまでブラウザ側で代わりに表示するコンポーネントを指定できる。これ自体は非常に便利なのだが、Search params を変更したときはローディング状態にならないという仕様になっている。
今回作るような検索アプリでは URL に Search params として検索クエリを持つのが一般的だが、クエリを変更し Search params を変更しても Suspense はローディング状態にならため、ページ遷移が反映されていないように見えてしまう(最初のページロードだけローディングが出る形になる)。
対策として、上記コメントにある通り navigation の状態を見ることでローディングかどうかを自前でも判定することにした。うまく動くようになったものの、結局 Suspense の機能の外側でローディング中の文字を出す羽目になり、一体何をやっているのかという気持ちに。
環境変数
SSR ではサーバー側には環境変数があるがクライアント側は持っていないので、安易に process.env
を使うとクライアント側で死ぬ。そこで Remix では loader で env を返すことを推奨されている。
ただこれだと前述の通りエラーハンドリングで問題になる。ErrorBoundary で表示されるエラーは loader がエラーになった場合も含まれるので、 loader を ErrorBoundary で呼ぶことはできず環境変数を取得する術がない。
どうすればいいかというと、上記 Thread にある通り Root Route など別の(基本的にエラーにならないであろう)上位 loader で必要な process.env を返すように記述して子 Route から読みに行くか、Root Route 内でで Env を Context に追加するかといったところ。 どちらでも動くことを確認したけど、今回は React で慣れている Context を使うようにした。
Bootstrap
今回も Bootstrap を使ってスタイリングを行ったが、 SSR で Vanilla な bootstrap を使おう*1とするといくつかハマりポイントがあった。
Bootstrap の初期化
Bootstrap の CSS は import すればサーバーでもクライアントでも動くが、JavaScript はサーバーでは動かず、 import "bootstrap"
すると document
がなくエラーになる。
Bootstrap のコードはクライアント側だけで動いてくれればいいので、クライアントのみにバンドルされるコードである entry.client.tsx
内で import することにした。
Bootstrap の提供する API をコンポーネントで使う
その後、 Component 内で Bootstrap が提供する JavaScript コードを onClick 内で使いたいという場面が出てきた。ただ、 (今回使おうとしていた) Bootstrap の Collapse はコード上で評価した瞬間に document にアクセスするので、クライアント上でしか評価しないようにしないとエラーを吐いてしまう。
これに関しても *.client.tsx
の仕組みを使い、以下のようにして解決した。
bootstrap.client.tsx
内で必要なものの import だけを行い即座に export するようにする。その export されたものをコンポーネント側で Import する- サーバー側では Import すると undefined になる。クライアント側では Bootstrap の Collapse が得られる
import { Collapse } from "bootstrap"; export { Collapse };
onClick
などクライアントでしか動かないコード上で使う
その他、 bootstrap-select は BS5 で使えなくなっていて react-select に置き換えたりした。ただ react-select は emotion を使っていて SSR と相性が悪いのでそんなにおすすめはできない感(できるのだが *2、微妙 *3 *4) ...。 Downshift がいいらしいけど style 書くのめんどくさくてやめた。
…といった感じで、初めての SSR というのもあって、SSR 自体のつまづきポイントも含めて Remix のつまづきポイントは結構あった。まあ慣れてくるとどう対応すればいいのかもわかってくるけど、薄さに惹かれたわりには考えることは多かったなと思う。
最近の技術を触るのは楽しかったし、結果爆速なアプリケーションができたので満足はしている。食わず嫌いしている Nextjs や何もわからない Hono とかもまた触ってみたい。
*1:react-bootstrap だと React に寄ったコードになってしまい、後々のスタイルの流用がしづらい
*2:https://emotion.sh/docs/ssr