Unyablog.

のにれんのブログ

検索システムのフロントを SSR・Remix で作り直した

かなり昔に Elasticsearch ベースの検索システム(Heineken)を作っていた。

Elasticsearch で部内 Wiki 検索高速化 - Speaker Deck

特に更新せず数年動かしていたのだけど、サーバーの置き換えに伴って Kubernetes に置きたいよねという話になり、ついでに Elasticsearch も新しくしたいよね、となった結果、現状のフロントエンドだと最新の Elasticsearch では動かないということがわかった。

nonylene.hatenablog.jp

フロントエンドの改修が必要なわけだが、ここでフロントエンドの構成を見ると…

  • FlowType
  • create-react-app
  • PureComponent
  • Bootstrap 3

古すぎる!絶対アップデート難しいし触りたくない技術しかない。

フロントまわりの構成を変えたいとずっと思っていた(フロントから Elasticsearch に直接アクセスする構成のやめたかった)こと、また SSR あんまり触ってなかったことがあり、1から作り直すことにした。

~~ここから2年弱が経過~~

フレームワークの選定

React を Vanilla で使うことは Webpack 頑張ればできるのだけど、最近は何かしらのフレームワークを使うことが主流らしい。

ja.react.dev

SSR もやりたいし、はじめは Next.js で作ろうと思っていた…のだけど、 Next.js は色々聞いてると考えることが多くて大変だな…という印象になっていた。検索アプリなので SSG は不要だし、キャッシュも静的ファイルの範囲内で十分だし、 fetch の override とかしないでほしい。もっと軽量なものが使いたい。自分は薄いフレームワークが好きなのです。

そこで Remix を使うことにした。 Remix は SSG はやらなくて SSR 専門だし、React router がベースなので馴染みもあるし、 Web standards はいいぞってトップページで主張していて好感度高い。超薄くはないけどまあ SSR するならこんなもんでしょう。

remix.run

Remix で実装した

ということで数日かけて Remix で Heineken のフロントエンドを再実装した。今まではブラウザが fetch で直接 Elasticsearch にアクセスする構成だったが、 Remix のサーバーが Elasticsearch に問い合わせつつ SSR をして、 react-select などフロントで一部だけ描画する感じになった。

github.com

置き換えた感想としては、

描画が早い!!

あまりパフォーマンスのことを考えずに実装しても Elasticsearch の検索含めて 100ms 程度で描画されるようになった。SSR がすごいという話もあるし、各種コンポーネントのデータを並列で取得しているのも見ごたえあるし、パフォーマンスをあまり何も考えていない実装でそれを実現できているのが偉い。

結構ファーストビューの速さって体感に大きく関わるんだなと実感した。

細かいところ

Remix は外部ナレッジこそ少なめなものの、公式ドキュメントやチュートリアルが充実していて、 SSR 初心者でも割とスムーズに書くことができた。

とはいえ、色々思ったところ・つまづいたところはあったので書いていく。

Route

Remix では routes 以下にファイルを配置すると自動的にファイル名がパスとして認識され、そこで定義したコンポーネントにルーティングされる。SPA でルーティング書くの意外と面倒なので、これはなかなか便利だった。React router を内部で使っているんだろうけど、実際に意識することは全然なく、良い感じにやってくれている。

ファイル名ベースとディレクトリ名ベース両方使えるのも便利で、自分はディレクトリベースの route 指定を主に行うようにした。ディレクトリの中にそのパス関係のコードを全部置くことで Code colocation が上がってわかりやすい。

remix.run

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 といった感じになった。

remix.run

URL や Nested routes にはこだわらず、どのようなデータをサーバーから取得したいか(loader がどのようなデータを返すのか)、で Route を分けるのが並列性を考えるとよいのかもしれない。

エラーハンドリング

Remix のエラーハンドリングは Route の中に ErrorBoundary という関数を定義して、そこでエラー内容を通常のコンポーネントの代わりに描画するというものだが、このやり方がなかなか難しかった。

remix.run

例えば検索フォームの Route を考える。ここで遷移(Search params の変更)が起きたときに loader で何かおかしなことがあればエラーを出したいのだが Route ファイル内に ErrorBoundary を定義して単純にエラーを出すとフォームが丸ごとエラー表示に置き換わるのでフォームの修正や再送信ができなくなる。

かといって通常の Route と同じ Component を流用して描画を行うのも難しい。ErrorBoundary 内では loader から得られるデータが使えないので描画に必要なデータを得ることができない。これは環境変数まわりで特に困った(後述)。

その他、loader 内のエラーがうまくクライアント側に伝わらないような挙動もあり、Remix でのエラーハンドリングは難しいな…という印象が残った。

github.com

ローディング

ローディングは defer / Suspense という仕組みが用意されており、loader 内で時間のかかるデータを待たずに一旦レスポンスを返しつつ、データが取得できるまでブラウザ側で代わりに表示するコンポーネントを指定できる。これ自体は非常に便利なのだが、Search params を変更したときはローディング状態にならないという仕様になっている。

github.com

今回作るような検索アプリでは URL に Search params として検索クエリを持つのが一般的だが、クエリを変更し Search params を変更しても Suspense はローディング状態にならため、ページ遷移が反映されていないように見えてしまう(最初のページロードだけローディングが出る形になる)。

対策として、上記コメントにある通り navigation の状態を見ることでローディングかどうかを自前でも判定することにした。うまく動くようになったものの、結局 Suspense の機能の外側でローディング中の文字を出す羽目になり、一体何をやっているのかという気持ちに。

github.com

環境変数

SSR ではサーバー側には環境変数があるがクライアント側は持っていないので、安易に process.env を使うとクライアント側で死ぬ。そこで Remix では loader で env を返すことを推奨されている。

remix.run

ただこれだと前述の通りエラーハンドリングで問題になる。ErrorBoundary で表示されるエラーは loader がエラーになった場合も含まれるので、 loader を ErrorBoundary で呼ぶことはできず環境変数を取得する術がない。

github.com

どうすればいいかというと、上記 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 することにした。

super-heineken/app/entry.client.tsx at fdcdc2b4cdec48190296b513cdbacf3291ea4ab7 · kmc-jp/super-heineken · GitHub

Bootstrap の提供する APIコンポーネントで使う

その後、 Component 内で Bootstrap が提供する JavaScript コードを onClick 内で使いたいという場面が出てきた。ただ、 (今回使おうとしていた) Bootstrap の Collapse はコード上で評価した瞬間に document にアクセスするので、クライアント上でしか評価しないようにしないとエラーを吐いてしまう。

stackoverflow.com

これに関しても *.client.tsx の仕組みを使い、以下のようにして解決した。

remix.run

  • 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

*3:https://github.com/emotion-js/emotion/issues/2800

*4:https://github.com/JedWatson/react-select/issues/3590