ページ遷移をJSで制御する場合のページバック時のユーザー体験の問題について解説します。続いてそれを解決するページキャッシュの方針について説明します。最後に、React Suspenseの仕組みを応用してページバック時の問題を解決する方法について説明します。
Copyrights(c) Henry, Inc. All rights reserved.React Suspenseを使って遷移体験を向上させるFrontend Night 〜アーキテクチャ設計編〜2023-09-27 @kobayang
View Slide
Copyrights(c) Henry, Inc. All rights reserved.自己紹介● 小林 直樹(こばやん)● 採用系ベンチャーで5年ほど● ヘンリーに今年の6月にジョイン● Webエンジニア● X: @kbys_02
Copyrights(c) Henry, Inc. All rights reserved.アウトライン● SPAのページバック時の課題● 前ページをキャッシュする● React Suspense と React Freeze● まとめお詫び: アーキテクチャ設計の話はほぼしません🙏
Copyrights(c) Henry, Inc. All rights reserved.ref: Next.jsで戻る厨を満たすrecoil-sync-next: https://zenn.dev/akfm/articles/recoi-sync-nextSPAのページバック時の課題前のページに戻る体験について● 通常の遷移の場合ブラウザキャッシュである程度復元する● ページコントロールをJSで行う(SPA的な遷移)場合、前のページに戻る時に状態がリセットされる● 戻る時の体験を気をつけないといけない
Copyrights(c) Henry, Inc. All rights reserved.SPAのページバック時の課題何がリセットされるか?● UIの状態○ スクロールの位置など● State○ コンポーネント内のState(useState)● 良くある例○ 一覧→個別→一覧に戻った時に振り出しに戻る(*)ref: (*) 最近のWebサイトで「記事一覧ページ」と「個別記事ページ」を行き来すると「記事一覧ページ」がふりだしに戻る問題について - 結城浩の連ツイ:https://rentwi.hyuki.net/?1576010373357965312
Copyrights(c) Henry, Inc. All rights reserved.● Next.js 上で動いている● 各ページへはNext.jsのrouterからSPA的に遷移前提: Henryの場合
Copyrights(c) Henry, Inc. All rights reserved.前提: Henryの場合● 各ページの行き来が頻繁に発生○ 診療で患者一覧<>患者カルテ○ 患者カルテ内をさらに行き来する...など● ページバックたびに状態がリセットされると不便
Copyrights(c) Henry, Inc. All rights reserved.SPAのページバック時の課題● 解決案○ URL Paramにより状態を持たせる?■ 解決できる範囲が局所的● 大きな変更なしで課題を解決したい
Copyrights(c) Henry, Inc. All rights reserved.SPAのページバック時の課題● ページ切り替え時に前ページをキャッシュする
Copyrights(c) Henry, Inc. All rights reserved.前ページをキャッシュする● ページ切り替え時に前ページをキャッシュする
Copyrights(c) Henry, Inc. All rights reserved.前ページをキャッシュするdisplay: none で非表示にしつつ残す● ページ切り替え時に前ページをキャッシュする
Copyrights(c) Henry, Inc. All rights reserved.const cache = new Map();const App = ({ key, Component }) => {const renderPages = useMemo(() => {if (!cache.get(key)) cache.set(key, Component);const elements = [];cache.forEach((C, k) => {elements.push();});return elements;}, [key, Component]);return <>{renderPages}>;};実装イメージ
Copyrights(c) Henry, Inc. All rights reserved.実装イメージconst cache = new Map();const App = ({ key, Component }) => {const renderPages = useMemo(() => {if (!cache.get(key)) cache.set(key, Component);const elements = [];cache.forEach((C, k) => {elements.push();});return elements;}, [key, Component]);return <>{renderPages}>;};ページ遷移に対してユニークなkeyとコンポーネントを要求Next.jsの場合については後述
Copyrights(c) Henry, Inc. All rights reserved.コンポーネントをkeyでキャッシュ実装イメージconst cache = new Map();const App = ({ key, Component }) => {const renderPages = useMemo(() => {if (!cache.get(key)) cache.set(key, Component);const elements = [];cache.forEach((C, k) => {elements.push();});return elements;}, [key, Component]);return <>{renderPages}>;};
Copyrights(c) Henry, Inc. All rights reserved.キャッシュされたコンポーネントを描画現在のkeyの時だけコンポーネント表示他は display: none実装イメージconst cache = new Map();const App = ({ key, Component }) => {const renderPages = useMemo(() => {if (!cache.get(key)) cache.set(key, Component);const elements = [];cache.forEach((C, k) => {elements.push();});return elements;}, [key, Component]);return <>{renderPages}>;};
Copyrights(c) Henry, Inc. All rights reserved.注: keyとComponentに何を使うか?Next.jsの場合● Component○ Next.jsのAppPropsのComponent○ ページが切り替わるたびに変わる● key○ Next.jsでは HistoryState に key が格納されている○ window.history.state.key から取得可能■ router objectからとりたい...■ App Routerだどkeyがない...(*)ref: (*) Add router.key to identify history: https://github.com/vercel/next.js/discussions/47242
Copyrights(c) Henry, Inc. All rights reserved.注: 実際は他にもキャッシュが必要● キャッシュするのはComponentだけではない○ Page Props○ Router Object○ 細かすぎる話になるのでここでは割愛
Copyrights(c) Henry, Inc. All rights reserved.問題点display: noneで大体動くが描画処理の重さに課題● display: noneしてもNode上には要素が存在● 遷移のたびに再描画の更新(reconcile)が走る● 履歴が深くなるたびに処理が重くなる
Copyrights(c) Henry, Inc. All rights reserved.● React Freeze (*)● React Suspense を使って描画状態を制御○ freeze の boolean改善策: React Freeze (Suspense) を使うimport { Freeze } from "react-freeze"ref: (*) software-mansion/react-freeze: https://github.com/software-mansion/react-freeze
Copyrights(c) Henry, Inc. All rights reserved.● 仕組みはシンプル● freeze中にPromiseをthrowしSuspendにする● より詳細は以前記事(*)を書いているので興味があれば● Suspendの間はReconcileされない性質を利用● 不要な描画処理をなくすことができるReact Freezeref: (*) React Suspenseで不要な描画処理をなくす: https://zenn.dev/kobayang/articles/8e06c77cec9359
Copyrights(c) Henry, Inc. All rights reserved.前ページをキャッシュするReact Freeze で Suspend 状態にする● ページ切り替え時に前ページをキャッシュする
Copyrights(c) Henry, Inc. All rights reserved.実装イメージ(with React Freeze)const cache = new Map();const App = ({ key, Component }) => {const renderPages = useMemo(() => {if (!cache.get(key)) cache.set(key, Component);const elements = [];cache.forEach((C, k) => {elements.push();)});return elements;}, [key, Component]);return <>{renderPages}>;};キャッシュされたコンポーネントを描画React Freezeを代わりに使う現在のkey以外をfreeze
Copyrights(c) Henry, Inc. All rights reserved.プロダクトに導入する● Freeze中のコンポーネントの副作用の確認● キャッシュを無限に増やさないようにコントロール● メモリ使用量の確認
Copyrights(c) Henry, Inc. All rights reserved.プロダクトに導入する● Freeze中のコンポーネントの副作用の確認○ 表示しているページの裏でキャッシュされているページも描画○ Suspend の間も useEffect は発火■ tips: useLayoutEffect は発火しない○ 副作用の見直し■ useEffectの発火条件やロジックに問題ないかを確認■ どうしても発火してほしくない副作用を useLayoutEffect にする
Copyrights(c) Henry, Inc. All rights reserved.プロダクトに導入する● キャッシュを無限に増やさないようにコントロール○ reconcile が起きないとはいえ、何もしないと無限に積まれる○ 雑にやるなら最大数を決めておくなど● ヘンリー特有の事情○ ナビゲーションの stack を管理している(*)■ Next.js の routeChangeComplete をハンドリングして管理○ 相互に行き来できるページ遷移を push → replace に変更■ replace によるSPA的な遷移の場合は key は変わらないため■ 無限に stack を増やさせないようにref: (*) ルーター自前実装の話: https://www.slideshare.net/KazushiKawamura/ss-252983036
Copyrights(c) Henry, Inc. All rights reserved.● メモリ使用量の確認○ Developer Tools の Memory から確認○ 50個ほどStackに積んで許容の範囲内であることを確認したプロダクトに導入する
Copyrights(c) Henry, Inc. All rights reserved.まとめ● SPAの場合特にページバック時の遷移体験が悪い● ページをキャッシュすることで状態のリセットを防ぐ● その際にReact Freezeを使うと再描画をなくせる● 副作用の考慮や無限にキャッシュするのを防ぐ仕組みが必要