Next.js App Router 遷移の仕組みと実装
Next.jsのv13.4が発表され、App RouterがStableになりました。App Routerは発表以来着実に実装が進んでおり、最近もServer ActionやParallel Routesなどの新機能が次々と発表されています。
当然ながらこれらの話題はフレームワーク利用者目線の話題が多いのですが、本稿はApp Routerがどう実装されているのか、筆者の興味のままに遷移処理周りを中心に調査したまとめ記事になります。知っておくと役に立つ点もあるかと思うので、参考になれば幸いです。
Next.jsの遷移とprefetch挙動
Next.jsの遷移を理解するには、まずprefetch挙動について知る必要があります。今回は調査用のデモとして、pages
とapp
それぞれで同じようなページをいくつか用意しました。
pages
で実装したページ
app
で実装したページ
これらで従来のpages
とapp
の挙動を比較していきたいと思います。
pages
の挙動
pages
の場合、静的ファイルは積極的にprefetchされます。
SSGは当然ながら静的ファイルを作成するので、prefetchの対象となります。具体的にはJSファイルや画像、getStaticProps
などの実行結果をシリアライズしたJSONなどです。
一方でSSRページの場合、getServerSideProps
の結果だけはリンクが実際に押下された時に取得されます。具体的にはhttp://localhost:3000/_next/data/Czj63Y47_EGJEXlNGwfI6/pages/example_dynamic.json
のようなURLでfetchしてJSONを取得します。そのため、押下直後のリクエスト発火からレスポンスを受け取るまでの間は、直前の画面が表示されることになります。
以下で言うと、1~4まではずっと直前の画面が表示されていることになります。
- あらかじめJSファイルなどはprefetchされる
- SSRページに遷移するリンク押下
-
getServerSideProps
の結果をfetch開始 - ↑のレスポンスをJSONで受け取る
- ページ遷移
app
の挙動
一方でApp RouterにはgetServerSideProps
はなく、Server Componentsのレンダリング時にデータ取得が行われます。
export default async function Products() {
const res = await fetch("https://dummyjson.com/products");
const data = await res.json();
return (
...
);
}
App Routerはこのような、従来のgetServerSideProps
でやっていたようなAPIへのfetch処理を含むページなどの場合も積極的にprefetchします。
App Routerによるprefetch時のURLはページとまったく同じhttp://localhost:3000/app/example_dynamic
という形ですが、RSC: 1
というhttpヘッダーを含み、このヘッダーがあるときは文字通りReact Server Components(略してRSC)としてレスポンスされます。ブラウザのURLアドレスバーで上記URLを入力すると、当然ブラウザのデフォルトヘッダーにRSC: 1
はついていないのでhtmlとしてレスポンスされます。
このように、App Routerは積極的にprefetchを行うことで、従来のpages
とは違い直前の画面が表示されて待たされると言うことがほぼなく、即座に遷移が発生するような体験がデフォルトになります。もちろん、prefetchはOFFにすることが可能で、OFFの際にはpages
同様fetch完了までは直前の画面が表示されますが、デフォルト挙動が変更されたのは大きな方針変更と言えるかと思います。
App Routerのprefetchはさらに細く仕様が存在しますが、これらは後述します。
pages
とapp
の境界を越える際の挙動
pages
とapp
は共存可能です。仕様も実装も大きく異なるRouterを持ったこれらのページを、どうやってNext.jsは共存させているのでしょう?
これを実現するのは単純な仕組みで、pages
とapp
の境界を越える時にはSPA遷移ではなくMPA(=Multi Page Application)遷移、つまりJS制御による擬似遷移ではなくブラウザのデフォルト挙動の遷移を行うことで実現しています。これによりそれぞれのRouterが、互いの影響を考慮する必要がほとんどなくなります。
pages
からapp
への段階的リリースを伴うマイグレーションを検討している方は、移行段階においてSPA遷移が一部失われることを認識しておくと良いかと思います。
App Routerは遷移体験の改善を目論む
積極的なprefetchによって実現されるのは高いパフォーマンスです。具体的には新たにCore Web Vitalsに追加されたINP(Interaction To Next Paint)が改善されたSPA遷移を提供することを目指していると考えられます。前述の通り、従来のpages
では画面押下直後は画面が応答していないように見えてしまうことから、INP的に優れた体験とは言えませんでした。App Routerでは積極的なprefetchによって、この問題を解決しようとしていると考えられます。
一方でこの積極的なprefetchは、BFFの裏側にあるAPIサーバーやデータベース負荷を高めてしまうことにつながるであろうことは明白です。個人的見解を含みますが、App Routerはこの負荷的な懸念を、Cache戦略を持ってカバーしているというスタンスなのではないかと思います。fetch単位・page単位のrevalidateを使いこなすことで、ユーザーにとってのパフォーマンスだけでなく負荷軽減も見込めます。
App Routerの遷移実装
App Routerの遷移やprefetchの仕様について見てきましたが、ここからはこれらの実装を追ってみたいと思います。
App Routerの状態管理
App Routerは内部的に状態管理をuseReducer
を使って作成した、Reduxもどきで管理しています。ここであえて「Reduxもどき」と表現したのは、Redux Devtoolsと連携しているためです。Redux Devtoolsを入れていると、App Routerの状態管理がRedux Devtoolsによって可視化されます。
ただし、Promise
やReactElement
をStateに含んでいるため、reducerを介さず非同期に更新されたり見づらかったりするので、Redux DevtoolsでApp RouterのStateを見るときは参考程度にすることをお勧めします。
prefetch
アクション
next/link
から提供されるLink
コンポーネントはvisible、hover、touchStartイベント時にrouter.prefetch
を呼び出します。
router.prefetch
は外部URLやBot判定をしたのち、prefetch
アクションをdispatchします。
prefetch
アクションのreducerでは、fetchを発行しますが意図的にawaitしておらず、StateにそのままPromise
を保持します。具体的には、StateのprefetchCache: Map<string, PrefetchCacheEntry>
でurlとCacheを管理しており、このPrefetchCacheEntry
内のdata
がReturnType<typeof fetchServerResponse> | null
となっています。
実際にStateにPromiseを詰めているのは以下です。
遷移時はこのStateに保持したPromiseが解決済みなら値を取り出すような形で実装されています。
React Flight
prefetch
アクションによって作成されるこのPromiseの実態は、ReturnType<typeof fetchServerResponse>
の通りサーバーへのfetchであり、前述の通りページURLにRSC: 1
などいくつかのヘッダーを含めたGETリクエストです。このServer ComponentsのGETリクエストのレスポンスボディは、FlightやReact Flightと呼ばれる独自のデータフォーマットで表現されます。
1:HL["/_next/static/css/5cc6c563bf8ab1da.css",{"as":"style"}]
0:[[["",{"children":["app",{"children":["example_static",{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],"$L2",[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/5cc6c563bf8ab1da.css","precedence":"next.js"}]],["$L3",null]]]]
4:I{"id":"7846","chunks":["272:static/chunks/webpack-6365542cc30a6aab.js","769:static/chunks/8e422d1d-436056157c89b00f.js","365:static/chunks/365-6e63437f7129d097.js"],"name":"","async":false}
5:I{"id":"6650","chunks":["272:static/chunks/webpack-6365542cc30a6aab.js","769:static/chunks/8e422d1d-436056157c89b00f.js","365:static/chunks/365-6e63437f7129d097.js"],"name":"","async":false}
6:I{"id":"7371","chunks":["371:static/chunks/371-975f2f5092fc69c3.js","980:static/chunks/app/app/example_dynamic/page-2bccbb99522030af.js"],"name":"","async":false}
2:[["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","div",null,{"className":"flex min-h-screen flex-col items-center justify-between p-24","children":["$","div",null,{"className":"w-full max-w-5xl","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":false,"childProp":{"current":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","app","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":false,"childProp":{"current":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","app","children","example_static","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":false,"childProp":{"current":[["$","main",null,{"children":[["$","h1",null,{"className":"mb-4 text-3xl font-extrabold text-gray-900 dark:text-white md:text-5xl lg:text-6xl","children":[[["$","span",null,{"className":"text-transparent bg-clip-text bg-gradient-to-r to-emerald-600 from-sky-400","children":"`app`"}],"Â "],"example_static"]}],["$","p",null,{"className":"text-lg font-normal text-gray-500 lg:text-xl dark:text-gray-400","children":"This is an example page."}],["$","div",null,{"className":"mt-10","children":[["$","h2",null,{"className":"mb-4 text-xl font-extrabold text-gray-900 dark:text-white md:text-4xl lg:text-4xl","children":"Links"}],["$","ul",null,{"className":"list-decimal pl-5","children":[["$","li",null,{"children":["$","$L6",null,{"href":"/app","className":"underline","children":"/app"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/app/example_static","className":"underline","children":"/app/example_static"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/app/example_dynamic","className":"underline","children":"/app/example_dynamic"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/pages","className":"underline","children":"/pages"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/pages/example_static","className":"underline","children":"/pages/example_static"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/pages/example_dynamic","className":"underline","children":"/pages/example_dynamic"}]}]]}]]}]]}],null],"segment":"__PAGE__"},"styles":[]}],"segment":"example_static"},"styles":[]}],"segment":"app"},"styles":[]}]}]}]}]}],null]
3:[[["$","meta",null,{"charSet":"utf-8"}],["$","title",null,{"children":"Create Next App"}],["$","meta",null,{"name":"description","content":"Generated by create next app"}],null,null,null,null,null,null,null,null,["$","meta",null,{"name":"viewport","content":"width=device-width, initial-scale=1"}],null,null,null,null,null,null,null,null,null,null,[]],[null,null,null,null],null,null,[null,null,null,null,null],null,null,null,null,[null,[["$","link",null,{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"any"}]],[],null]]
FlightはStreamingを意識した仕様のため、1行ずつ読むようなフォーマットになっています。先頭部分のみ省けば、JSON配列っぽく読めると思います。なんとなくコンポーネント情報やprops、childrenの情報などが見て取れます。おそらく$
はコンポーネントを指しているように思うのですが、正確な仕様は不明です。
ちなみにFlightの仕様とかを探してみたのですが、筆者には見つけることができませんでした。大抵はreact-server-dom-webpack
によってFlightのレスポンスを実現しているようですが、ここやReactのRFCにもFlightの仕様書などはなさそうでした。
VercelにはReactコアチームのメンバーが多数いるので、この辺の仕様書はコアチーム内部に閉じてるのかもしれません。筆者が知らないだけで公開されてたらすいません、ご教示ください。
追記1: 以下に仕様がありました。koichikさんありがとうございます!
追記2: React Server ComponentsやFlightの仕組みについては以下の記事が詳しく解説してくれています。use client
ディレクティブが登場するより前なので少し情報は古い部分もありますが、興味のある方はご一読ください。
navigage
アクションと遷移判定
さて、Link押下時には、navigate
アクションが発火します。navigate
はいくつかのStateを更新して、遷移を指示するフラグや次のページのtree
を算出します。
具体的にはまず、prefetchした結果を元に遷移方法を決定します。prefetch結果がFlightでない場合、pages
配下のページへの遷移と判定し、MPA遷移となります。他にも外部URLの場合やprefetch失敗時にMPA遷移となります。これはStateのpushRef.mpaNavigatio
がtrue
に変更され、Router
コンポーネント内で読み取られてlocation.assign
が呼ばれることで実現しています。
app
内での遷移の場合、Stateのtree
が更新され、InnerLayoutRouter
に渡されます。
このInnerLayoutRouter
ではtree
に基づき順番にsubtree
が解決されていきます。
childNode.subTreeData
にはInnerLayoutRouter
が含まれているので、一段深ぼったtree
を新たなProviderに渡すことで、再起的にInnerLayoutRouter
を呼び出し、順々にLayoutを解決していきます。
Intercepting Routes
InnerLayoutRouter
はLayout順に解決されていくわけですが、この際、Parallel RoutesやIntercepting Routesが解決された状態でtree
などは更新されます。特にIntercepting Routesは、同じURLでも遷移元次第でUIが異なるので「どこから遷移しようとしているか」をどこかで判定しているわけです。
序盤で作成したデモにParallel RoutesとIntercepting Routesとを定義してみます。ここでは/app/feed
から/app/posts
への遷移に対して、Interceptするようにしています。
これでprefetchの中身を見てみると、少々中身が異なることが確認できました。
0:[["children","app",["app",{"children":["posts",{"children":[["id","999","d"],{"children":["__PAGE__",{}]}]}]}],null,null]]
非intercept時のprefetch
0:[["children","app","children","feed","preview","(..)posts",["(..)posts",{"children":[["id","999","d"],{"children":["__PAGE__",{}]}]}],null,null]]
/app/feed
からintercept時のprefetch
Intercepting Routesは内部的にはrewriteの一種として実装されています。prefetch時にヘッダーに付与されるNext-Url
が正規表現と一致するか検証するようなrewriteです。Next-Url
は遷移元のURLパターンが含まれるので、これにより特定のURLから特定のURLへの遷移をInterceptingしているというわけです。
next build
すると.next
直下にroutes-manifest.json
というJSONファイルで出力され、Intercepting Routesを含むrewriteの情報はこのJSONに記載されます。少々長いのでだいぶ省略しますが、中を見てみると以下のような記述が見つかります。
{
"version": 3,
...
"rewrites": {
"beforeFiles": [
{
"source": "/app/posts/:id",
"destination": "/app/feed/(..)posts/:id",
"has": [
{
"type": "header",
"key": "Next-Url",
"value": "\\/app\\/feed(?:\\/(.*))?[\\/#\\?]?"
}
],
"regex": "^/app/posts(?:/([^/]+?))(?:/)?$"
}
],
"afterFiles": [],
"fallback": []
}
}
next start
で立ち上がるNext.jsのサーバーは、このroutes-manifest.json
を元に幾つかのルーティングを決定しています。
今回筆者が調査したのはここまでです。Intercept周りまでなんとなく実装のイメージは掴めてきたので、満足しました。
感想
今回はNext.jsのApp Routerの遷移周りの実装を調査してみました。筆者はまだApp Routerをプロダクションで使ったことはなく、ちょっと試してみたりドキュメント読んだりしてたくらいだったので、仕組みをちゃんと追おうと思うと多くの学びや発見がありました。
特にServer Component周りについてはまだまだ理解が薄いことに気づきました。精進しようと思います。
あとStateに直接Promise
やReactElement
を持っているのは結構驚きました。直接画面にprefetch結果が反映されるわけではないので、多くのユースケースとは異なるからこういうやり方なのかもしれません。
余談: ブラウザバック体験と履歴keyについて
あとたまたま目に入って気づいたのですが、pages
と変わらずApp Routerでもhistory.state
を管理しているので、開発者がhistory.state
を利用するとおそらく上書きされてしまいます。
つまり、開発者が自前で履歴を管理する手段はないわけです。
詳しくは以下の記事を参照いただきたいのですが、筆者はブラウザバック体験を損ねないため、履歴に紐づく状態管理が必要なケースは多いと考えています。
辛うじてpages
では履歴のkeyがhistory.state
に含まれていましたが、今回はなさそうです。そもそも、pages
でもこれを参照してしまうのは内部実装に依存するので、本当は避けたいところです。結局Navigation API同様、Next.jsが履歴のkeyを提供するのが最もシンプルだと思って提案しているのですが、音沙汰ないのが現状です。
実はこれを直訴するためにVercel meetupにも参加して発表したりもしたのですが、変わらず。。。
明確に拒否されてるわけでもないですしブラウザバック体験はやはり重要な体験なはずなので、今後も迷惑にならない程度に、めげずに提案し続けてみようかと思います。
Discussion