React cache() で Next.js の Per-request Caching が実現できるのはなぜか
Next.js App Router では、リクエスト単位で処理をキャッシュする機構が存在し、ドキュメント上にも Per-request Caching として説明があります。
ひとつは、fetch() による Automatic fetch() Request Deduping で、単一リクエスト内で同一の fetch()リクエストが存在した場合、重複を排除し、最低限の実行としてくれます。これには特に設定などは必要なく、一定条件(GET かどうか、など)を満たしていれば自動的に最適化されます。
詳細は ↓ をご覧ください。
そしてもう一つ、React が提供する cache() 関数を実行することでも同様にリクエスト単位でのキャッシュが実現できます。
これは Automatic fetch() Request Deduping では対処できないケースで活躍し、DB からの直接のデータ取得や、GraphQL の実行も最適化できます。
cache() を呼ぶだけで Per-request Caching になるのはなんで?
筆者は Next.js や React の実装の詳細な部分までは明るくはなく、ひとつ疑問がありました。
cache() 関数は React が提供するものですが、これを呼ぶだけで Next.js リクエストスコープが実現できるのはどういうことなのでしょう?
なんだかモヤモヤしてしまうので、コードを追って仕組みを確認してみましょう。
最初に結論
AsyncLocalStorage でいい感じにやってる
cache() 自体のコードを追う
cache() 関数は、React 内の ReactCache.js に定義されています。
色々と処理をしていますが、基本は次の内容だと思われます。
-
dispatcher
(ReactCurrentCache.current) から Map を取得する - Map にキャッシュがあればそれを利用。なければ関数を実行してキャッシュに記録
ReactCurrentCache.current
の設定箇所を探してみると、ReactFlightServer#createRequest()
に行き着きます。
設定される実体は DefaultCacheDispatcher
のようです。
Map の取得のために呼ばれている関数を辿ると
-
getCacheForType()
-
resolveCache()
resolveRequest()
-
と、ReactFlightServer#resolveRequest()
が大本であり、requestStorage.getStore()
というコードにたどり着きます。
requestStorage
自体は、実行される環境に応じたパッケージによって設定されます。
Next.js App Router の場合 react-server-dom-webpack/server.edge
が利用されます。
これに相当する React 側の Config は packages/react-server/src/forks/ReactFlightServerConfig.dom-edge-webpack.js
が該当するようです。
(Rollup でのビルド時に dom-edge-webpack
などのファイル名に応じて、対応する Config が利用されるようです。)
というわけで、どうやら cache()
関数では、AsyncLocalStorage
で管理される Map をもとにキャッシュ管理をするようです。
AsyncLocalStorage
AsyncLocalStorage
は Node.js で利用可能な API で、これを利用すると非同期操作・Promise Chain を含む一貫の処理内で閉じたコンテキストを生成し、外部から干渉されることなく状態管理を行えます。
AsyncLocalStorage#run()
経由でコールバックを実行すると、第一引数として与えた Store はコンテキスト内からのみアクセス可能となります。
では、この run()
はどこで呼ばれているのでしょうか?
Next.js のレンダリング側から追う
Next.js App Router ではレンダリング時に renderToReadableStream
を実行します。これは、 renderHTML
→ renderToHTMLOrFlight
経由で実行されます。
renderToReadableStream
の実体を見てみると、ReadableStream
の start
メソッドのタイミングで startWork
関数が実行されていることがわかります。
そしてこの startWork
を見てみると..
requestStorage.run
がありました!!
このとき第一引数に渡される request
オブジェクトは、renderToReadableStream
内での createRequest
で生成され、cache()
関数で利用される request.cache
に対して空の Map を指定しており、これがキャッシュとして利用されます。
まとめ
長くなりましたが、整理すると次のような流れのようです。
- Next.js App Router でリクエストごとのレンダリング時に
renderToReadableStream
が呼ばれる -
renderToReadableStream
内でAsyncLocalStorage#run()
を実行し、キャッシュ用の Map がコンテキスト内部で利用可能になる -
cache()
実行時は、AsyncLocalStorage
経由で Map を取得しキャッシュを管理する
AsyncLocalStorage
をフル活用している感じですね。
むずかしかった...
Discussion