背景
先日Next.jsでとあるアプリを作っている際に、サーバサイドレンダリング(以下SSR)時のCPU負荷増大が見られた。
すごいざっくり言うと、下記サンプルのように大量のdiv
要素を描画する必要があるアプリケーションであった。

言うまでもないが、DOMの数が増えればSSR時のCPU負荷は上がる。
目的
SSR時のDOMの個数を少なくしSSR時の負荷を下げること。
実装方法検討
大きく分けると以下の3つである。
-
Intersection Observer
を使った方法 - Virtual Scrollを使った方法
- SSR時に描画範囲を決めてしまう方法
今回は、大きくソースコードをいじる必要がなく、カスタムフック1発で対応できる3つ目の方法を試してみた。
作ったもの
以下にソースコードを示す。
type UseOptiomizedArrayParams<T> = {
virtualHeight: number,
itemHeight: number,
items: T[]
}
/**
* サーバーサイドではfirstview分の高さ分の要素のみを、
* クライアントサイドは全ての要素を返すカスタムフック
*/
export const useOptimizedArray = <T>(params: UseOptiomizedArrayParams<T>): T[] => {
const [isClient, setIsClient] = useState(false)
// useEffectを用いることで、クライアント実行時はisClient = trueが保障される
useEffect(() => {
setIsClient(true)
}, [])
// クライアント実行時は全て返す
if(isClient) return params.items
// サーバ実行時のアイテムの個数は、 想定されるfirstviewの高さ / アイテム1個の高さ
const length = params.virtualHeight / params.itemHeight
// サーバ実行時は、想定高さ分の要素のみを返す
return params.items.slice(0, length)
}
実際の使い方は以下の通り。大きな配列にこのhooksをかますだけ。お手軽。
export function ListComponent() {
const array = [...Array(1000)]
const optimizedArray = useOptimizedArray({
items: array,
itemHeight: 30, // 要素の高さ
virtualHeight: 1000, // 1000もあれば大体のスマホデバイスは事足りる。
})
return <div>
{
optimizedArray.map(((_,i) => <div key={i} className="border-2 border-black"> {`item ${i}`}</div>))
}
</div>
}
想定質問集
SSR時とCSR時に結果が変わるけど、hydration missmatchは気にしなくていいの?
結論から言うとこのケースでは問題がない。useEffectによって要素が追加描画されるだけであり、HTMLレンダリングの結果が変わるわけでないため。
vertualHeight
は0とか小さい値じゃダメなの?小さければ小さいほどサーバ負荷が減りそうだけど?
0にしてしまうと、SSR時に何も描画されずブラウザは一度真っ白な状態を表示することになる。その後、useEffect
により要素が追加されるような挙動になる。値が小さい場合も同様で、要素の一部だけを表示してしまうので後方の要素が後から追加されるような挙動になる。
vertualHeight
を固定値以外で決める方法ないの?
Sec-Ch-Viewport-Height
というのがリクエストヘッダーにあるらしいが、今現在は残念ながらWeb標準にはなってない。ユーザエージェントから端末や高さを特定するのが良さそう。ただ、ある程度大きな値(4K想定でも2160px)を設定しておけば十分ではありそう。
謝辞
アイデアをくださったChatGPTさんには大変感謝します。