サイトをSPA化した感想とか

#42
2022.8.28

丸一日かけて、ようやくこのサイトをSPA(Single Page Application)化、すなわち他のページにリロードなしで移動できるようにしました。私としては、SPA化するのは実装コストが上がるばっかりでやりたくは無かったのですが、ページ遷移アニメーションや検索機能を付けようと思ったら、SPA化するのが近道であるということでしてみました。技術的には、今までは単にReactでSSRしていただけのものから、新たにブラウザ側でhydrateするようにしました。

実は、ReactでSSR+SPAのブログサイトを構築したのはこれが初めてではありません。過去2度ほど、同じような試みをしているのです。ですがそれらのサイトは、このサイトほどのスケールにする前に拡張を諦めてしまいました。そういう意味では、今回のSPA化はそのリベンジのようなものです。

SSR+SPAでブログサイトをゼロから構築する、というのはやはり簡単なことではないと思います。実際、今回のSPA化でもかなり色々な壁にぶち当たりました。ということで、ReactのSSR+SPAの難しさやその解決策、まだ解決できてない問題について考えてみようと思います。


問題1. Isomorphicに書かなくてはならない

JavaScriptのドメインにおけるIsomorphic(Universal)とは、サーバーとブラウザで同じコードを走らせることを指します。ReactでSSR+SPAをする場合には、サーバー上でHTMLを描画する時と、ブラウザ上でHTMLを描画する時には、全く同じコード、コンポーネントを利用します。そして、hydrateするためにサーバー上での描画結果とブラウザ上での描画結果をちょうど一致させる必要があります。

この同じコードを走らせる、というのが簡単に見えて簡単ではありません。まず、サーバー上にはDOM APIがありません。今回はサーバー上とブラウザ上でXMLを操作する必要がありました。幸いにもjsdomという、Node.js上でDOM APIを利用できるライブラリがあります。しかし、ライブラリがあるだけでは問題は解決しません。Reactのコンポーネントなどでは特に、サーバー上とブラウザ上で全く同じコードを走らせる必要があります。なので、どちらの環境でも同じインターフェースで使えなくてはなりません。サーバー上でjsdomをインポートしてしまえば、ブラウザ上でもjsdomがバンドルされてしまうのです。私はサーバ上ではjsdom、ブラウザ上ではネイティブのDOM APIを使いたかったのですが、ちょうどいいライブラリがありませんでした。というわけで自作したのが@k0michi/isomorphic-domです。このライブラリはただのプロキシなのですが、package.jsonの作成に手こづりました。package.jsonをうまく設定することで、Node.js上とブラウザ上で異なるコードを同じインターフェースで提供することができるのですが、exports, browser, main, moduleフィールドの挙動の理解でかなり骨が折れました。なんとか動く設定にすることはできたのですが、はっきり言って理解しきれていません。なぜエントリポイントを指定するフィールドがこんなにもあるのでしょうね?

Node.jsのドキュメントを見る限り、mainは昔からあるエントリポイントを指定するフィールドで、Node.js v12で新たに導入されたのがexportsらしいです。exportsは条件付きエクスポートができます。moduleに関しては、ES Moduleのエントリポイント指定ですが、Node.jsは無視しているようです。browserに関しては、https://github.com/defunctzombie/package-browser-field-specに詳しく載っています。

とまあ、こんな感じで、サーバーとブラウザで同じコードを走らせるには、時にはパッケージを自作したりしなくてはならず、険しい道のりとなっています。


問題2. ブラウザの機能を再実装する必要がある

SPAにするということは、即ち、ブラウザが持つページ遷移機能を自前で(あるいはライブラリを用いて)実装する必要があるということです。ReactにはReact Routerというライブラリがあるので、全てを自前で実装する必要はありません。しかしReact Routerはv6になってAPIが大きく変わっているので、ブラウザが提供する遷移機能に頼った方が幾分簡単なのは確かです。

ページ遷移に関しては、React v18になってから、かなり簡単にできるようになりました。というのは、concurrentに描画できるようになったからです。以前にブログを作っていた際にはそんな便利なものはなく、historyオブジェクトを自前で操作するなどしていて、バグまみれになっていました。更に、useTransitionを使うことで、遷移前のページを表示したままにしておくことが簡単にできます。React Routerでも、こんな具合で遷移時にuseTransitionできます。

const [isPending, startTransition] = useTransition();
const handleClick = useLinkClickHandler(props.href);

return (
  <a href={props.href} onClick={e => {
    e.preventDefault();

    startTransition(() => {
      handleClick(e);
    });
  }}>{props.children}</a>
);

React RouterとReact v18のお陰で、簡単にルーティングを実装できるようになったことは喜ばしい限りです。しかし、これらが用意する機能を使うだけではまだ不足しています。例えば、ブラウザでページを戻る、あるいは進んだ時のスクロールの位置は、これらのライブラリだけではうまくいきません。このサイトでも、まだこのスクロールの問題は手付かずのままです。


問題3. 記事データをいかに描画するか

SSR+SPAなブログシステムであれば、例えばMarkdownなんかで記事を書いて、それをReactで描画する、というアーキテクチャになるかと思います(私はMarkdownで書いていませんが)。このアーキテクチャに生じる一つの問題が、どうやって記事データをReactで描画するのかという点です。MarkdownをHTMLに変換するライブラリを使って、生成したHTMLをdangerouslySetInnerHTMLで描画するのが一番簡単でしょう。しかし、ReactでdangerouslySetInnerHTMLを使うと、XSSなどの問題はともかく、例えば記事内の要素(画像やコードなど)をインタラクティブにすることが困難になります。

この問題を解決するためには、Markdownを直接的に、あるいは間接的にReactのVDOMツリーに変換する必要があります。しかし、このVDOMツリーへの変換というのが、私の経験上結構厄介です。確かに、なんらかのフォーマットをVDOMツリーに変換できるライブラリというのは存在します。例えば、rehype-reactなどです。

Markdownで記事を書いていると仮定しましょう。この場合、ブラウザ上でどのようにこのMarkdownを描画できるでしょうか。考えられるいくつかの方法は、

  1. Markdownをブラウザ上でパースしてVDOMに変換
  2. サーバー上でHTMLに変換しておき、それをパースしてVDOMに変換
  3. サーバー上でJSONに変換しておき、それをパースしてVDOMに変換

のいずれかでしょう。1番目はシンプルですが、ブラウザ用のスクリプトにMarkdownのパーサーをバンドルする必要があります。ブラウザ上にはDOMParserとJSON.parseがあるので、2番目と3番目の選択肢を選ぶならば、わざわざパーサーをバンドルする必要がありません。でもどちらにせよ、パースしたデータをVDOMに変換する処理を用意する必要があります。

私が前に作ったブログシステムではMarkdownで書けるようにしていたのですが、今回はXMLベースの記事データを採用しました。なぜXMLなのか。それは、拡張性、互換性、表現力が高いと感じたからです。Markdownはパーサーによって機能の差異があったりするのですが、XMLならどの言語であっても等しくパースできるでしょうし、独自のタグ、属性を追加し放題ですし、DOMParserのお陰で、ブラウザ上でも簡単に利用できてしまいます。Markdownは広く使われている割には、互換性が高くないので厄介なのです。

といった感じで、SPAを最大限活用するならば、記事データをVDOMに変換することが不可欠で、スタティックなHTMLを描画するのよりはだいぶ難易度が高くなります。


SSR+SPAなブログ構築で問題となる点を3つほど挙げてみました。はっきり言って、ブログというのは比較的静的なものであるので、わざわざブログをSPAにする意味というのはどこら辺なのか、という話です。それこそ本当に遷移アニメーションと検索機能くらいではないでしょうか?ぶっちゃけ自己満足でしかありません。まあブログ自体自己満足の権化みたいなものなので、それをどこまで持っていくかという話でなのでしょうが。

今回は割とまともなSPAを作れたので満足してはいます。しかし、遷移アニメーションに関してはほとんど実装できていないも同然です。実際には、ページ遷移時にコンテンツの部分がフェードイン/アウトするようなエフェクトを作ったのですが、挙動が不安定なのでお蔵入りとなっています。まだまだReact v18を使いこなせていません。


Simple is the best.

今回自力でSPAが実装できたことは喜ばしいことなのかもしれませんが、どんどんサイトジェネレーターのコードが複雑化することを危惧しています。約6ヶ月前にこのサイトを作った時には、なんのJavaScriptのコードもないような、それこそ一昔前のウェブサイトのアーキテクチャだったのです。ところが、今私のサイトはそれよりもはるかに複雑化しました。しかし私はシステムを必要以上に複雑化させたくないのです。そして私は必要以上にライブラリに依存したくありません。そのシステムを私自身が掌握している、という状態が望ましいと私は勝手に思っています。オッカムの剃刀です。

SPA化によって過剰に複雑化している感が否めません。依存関係が増えると、バージョンによるAPIの変更や非互換性が発生するもので、システムを維持する負担が徐々に大きくなっていきます。一回作って終わり、ではなく、システムをずっと維持していくのは簡単なことではありません。

メンテナンス性能で言えば、シンプルで理解しやすいソリューションの方が優れているはずです。