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