Web サイトっぽい SPA に必要なブラウザナビゲーションのエミュレートなど

Web サイトっぽい SPA に立ち向かう

大分前の話ですが、Node学園 20時限目 今回もdots!!!!! - connpassClient Side of なんちゃらfresh.tv としてお話した内容のうち、Web サイトっぽい SPA に関してだけこだわりを再抽出して書き留めます。

本件は、ページ全体のスクロールや頻繁なナビゲーションを伴わず、1画面におさまるレスポンシブな Web アプリを作っている場合はあんまり関係がありません。画面内の局所的な状態更新は、コンポーネントの責務分割やら何やらの設計なので実は別の話です。

総じて、Web サイトっぽいくせに大人の事情で Web ブラウザのネイティブなナビゲーションを積極的に破壊しにいくときの心構えです。


特に意味はありませんがコメダコーヒーのモーニングの様子です


URL が変わっても最低限レンダリングできるまで画面更新を遅延させる

画面遷移に必要なのは、 URL が更新されても次の画面を最低限レンダリングできるデータが取得できるまで既存の表示を更新してしまわないことです。Web ブラウザを観察すると、少なくともナビゲーション先の HTML が取得されるまではそのようになっているはずです。

// 架空のコード、こうあってほしい雰囲気
router.addEventListener('changeRoute', () => {
  await justRouting(location.path); // ルーティングに伴う Controller 的な処理
  updatePageContent(); // 表示中の内容を破棄して、新しい画面に更新する処理
});

よって、このような順序で処理が行われていることが望ましくなります。loading indicator を出すにしても、真っ白な画面ではなく表示中の既存画面のなかで分かるようにしたほうが良いでしょう。

ナビゲーションに伴うスクロール位置の制御もレンダリングまで耐える

スクロール位置の制御は、forward なら scrollTo(0, 0) であり、backward なら scrollTo(recorded.x, recorded.y) です。特に backward については、前の画面がレンダリングされ直したあとのタイミングで実行されるような配慮が必要です。

画面内の中腹 x=0, y=2000, から遷移してきたと仮定して、その画面に戻るとき URL の更新と同時に scrollTo(recorded.x, recorded.y) しても、y=2000 に相当するコンテンツの高さが復元されていない可能性がある(画面の表示に必要な Controller 的処理に非同期が含まれている可能性がある)ため正しい挙動にはなりません。つまり、画面がレンダリングされたあとのタイミングで制御が実行されなければ正しいスクロール位置に戻ってこられない、ということになります。

// 架空のコード、こうあってほしい雰囲気
router.addEventListener('changeRoute', () => {
  await justRouting(location.path); // ルーティングに伴う Controller 的な処理
  updatePageContent(); // 表示中の内容を破棄して、新しい画面に更新する処理
  adjustScroll(); // スクロール位置の制御を実行する
});

updatePageContent() のプロセスに非同期処理が混入しうるならば、内部でレンダリング完了イベントが発行されたときにスクロール位置を制御するなり、難しいようであれば setTimeoutrequestAnimationFrame をうまく使うなりしてイベントループの向こう側に渡ります。

ナビゲーションが forward か backward かは、すべての <a> を監視してれば分かるので、あとはスクロール位置の制御をアプリケーションのライフサイクルにシコシコ組み込みます。だいたいこのような雰囲気になるはずです。

ブラウザバックのための状態キャッシュを考える

ルーティング時の Contoller 処理の中には、戻る/進むという概念はなく URL が変更されるたび愚直に処理を実行して、画面のレンダリングを試みます。このままではブラウザバックの度にサーバーへのデータ取得などが実行されて処理上の無駄が生まれてしまいます。

そこで人類は前画面の状態が保持されるように何か工夫を講じるわけですが、だいたい次の2択に近いところへ収束するように思います。(ServiceWorker とかでドラスティックにキャッシュ機構を築くケースを今回は除外します)

  1. Android の Activity スタックのように、前の画面状態をまるまる保存しておく
  2. ルーティング時の Controller 的処理にデータキャッシュを効かせる機構を入れておく

上記の 2 であれば、次のようなイメージです。

// Controller 的な処理中に popState とキャッシュを確認して分岐
Promise
  .all(isPopStateAndCached(context) ? [/* noop */] : [
    executeAction(fetchFooFromApi),
    executeAction(fetchBarFromApi)
  ])
  .then(() => done())

その画面を表示するために必要なデータキャッシュが既にあるかは、現在のセッション中にこのページを表示したことがあるかないかを URL 単位で保持しておけば比較的ラクです。

親玉がサーバにあるデータの鮮度に気を遣う場合

状態というか保持しているキャッシュデータにも大雑把に2通りあって、親玉がサーバにあるデータ(DB レコード)の状態と、画面固有の UI の状態です。UI の状態は Flux でいえば Store にあってもいいですし、Component ツリーをまるまる保持するなら、それらの state として保持しっぱなしであっても構いません。

一方で、親玉がサーバにあるデータの状態は、鮮度に気を遣わなければならないこともあります。たとえば、今いる画面Aと前いた画面Bに、同じデータを表現している箇所があるとします。このとき画面Aでユーザ自らによる操作でデータの更新が発生すると、前いた画面Bで表示されているデータもブラウザバックしたときには更新されていて欲しくなるときがあります。あるんです。

前述した「Android の Activity スタックのように、前の画面状態をまるまる保存しておく」は、画面間の状態の整合性で困ることがあります。状態の親玉がサーバーサイドにあるようなデータは Store で一元管理しておくほうが画面 A で発生した更新を、ブラウザバックで再表示された画面 B にも伝播させやすいでしょう。

親玉がサーバにあるデータの状態といっても「新着記事一覧」みたいなのは、ブラウザバックは最初に表示したときの状態をまんま再現(本当の最新状態である必要は無い、つーか変わったら困る)すれば良いと思います。細かいところはプロダクトの仕様次第ということで。

現場からは以上です

色々書きましたがWeb サイトっぽいプロダクトなら変に複雑性を抱えまなくて良い気がするので、素直にサーバーサイドレンダリングでサブリソースのキャッシュをちゃんと効かせたハイパフォーマンス Web サイトを作れば良いと思います。はい。

今回述べたような課題は世間的に需要があまりないのか、既存のライブラリでは解決できず毎回泥臭く実装してしまっているため、誰かビューティフルな解法を各エコシステムに配給してたもれ。('A`)ノシ

最後に Speaker Deck の公開 embed を貼っておきます。HTML 版はこちら