🏎️

React Server Components 対応の仮想スクロールライブラリを作りました

2023/07/13に公開

業務で仮想スクロールを使った改善に関わる機会があり、その辺りの知見を活かしつつ、個人的にやりたかったことなども盛り込みつつ、React 用の OSS を作成しました。
もしよろしければスターいただけると励みになります。

https://github.com/inokawa/virtua

使い方

以下のように通常の div などの場合とほぼ変わらない記述で、簡単に仮想スクロールによるパフォーマンス改善を導入することができます。
一般的に、仮想スクロールは単に表示するだけでもセットアップが面倒だったり、特に高さが未知もしくは可変の要素を表示した時にうまく動いてくれないものも多いですが、その辺りも自動的にハンドリングしてくれます。また各 component の bundle size も約 3kb(全体で約 4kb)なので導入し易いと思います。

import { VList } from "virtua";

export const App = () => {
  return (
    <VList style={{ height: 800 }}>
      {Array.from({ length: 1000 }).map((_, i) => (
        <div
          key={i}
          style={{
            height: Math.floor(Math.random() * 10) * 10 + 10,
            borderBottom: "solid 1px gray",
            background: "white",
          }}
        >
          {i}
        </div>
      ))}
    </VList>
  );
};

それ以外の点でも、overflow: scrolloverflow: autoを使った、通常の scrollable な要素との互換性を重視しています。なので、例えば以下のように外側に padding をつけたり、conditional に要素を表示したりといった、仮想スクロールだと地味に難しいケースも対応しています。

2024/03/21追記: 現在paddingはサポートされていません。代わりに、仮想化領域の上下に任意のheader/footerを配置できるようになっており、より広い形で同等のusecaseをサポートしています。

    <VList
      style={{
        width: 400,
        height: 400,
        padding: "80px 20px",
        background: "lightgray",
      }}
    >
      {children}
      {isLoading ? <div>loading...</div>}
    </VList>

React Server Components にも対応しています。本ライブラリは client component として mark されているので、Next.js の App Router などの RSC 環境でそのまま使用でき、children として server component を rendering することができます。
ただ、仮想スクロールを使いたい時は大量のデータを表示したいケースがほとんどだと思うので、これらを予め RSC で評価しておくのと client で評価するのとどちらがトータルでマシになるのか、ケースバイケースなのかもしれませんが…

// page.tsx in App Router of Next.js
import { VList } from "virtua";

export default async () => {
  return (
    <div>
      <div>This is Server Component</div>
      <VList style={{ height: 300 }}>
        {Array.from({ length: 1000 }).map((_, i) => (
          <div key={i} style={{ border: "solid 1px gray", height: 80 }}>
            {i}
          </div>
        ))}
      </VList>
    </div>
  );
};

あと vertical scroll だけでなく、horizontal scroll も可能です。vertical / horizontal 両方向の仮想化が可能な VGrid component も提供しています。 まだ実装途中ですが、react-virtualized の WindowScroller などのように window の scroll に応じて仮想化を行う component もリリース予定です

2023/07/22追記: リリースされました。

import { VGrid } from "virtua";

export const App = () => {
  return (
    <VGrid style={{ height: "100vh" }} row={1000} col={500}>
      {({ rowIndex, colIndex }) => (
        <div
          style={{
            border: "solid 1px gray",
            background: "white",
            padding: 4,
          }}
        >
          {rowIndex} / {colIndex}
        </div>
      )}
    </VGrid>
  );
};

react-virtualizedreact-windowreact-virtuosoreact-virtual (TanStack virtual) など、類似ライブラリとの違いが気になる方は星取表をご参照ください。

他にも、スクロール量に応じて要素を追加していく無限スクロールや、Chat 系 UI でよく使われる上方向へのスクロールなど、一般的なユースケースには概ね対応していると思います。Live Demoもご参考ください。

実装メモ

※あくまで現時点の内容であり、将来的に変わる可能性はあります。

仮想スクロールは使われ方も幅広く、パフォーマンスを改善する方法も色々思いつくかもしれませんが、何も考えずこれらを全て実装してしまうと、互いに絡み合い複雑化してメンテ不可能になってしまうことが予想されます。react-virtualized の開発が停滞して react-window がリリースされましたが、その react-window も v2 が未だリリースされていないのも、そのような難しさによると推測されます。なので、綺麗な設計をするというよりは、その時々で必要な機能が入れられ、かつ出来る限りメンテできる状態を保つ、というのを重要視しています。

一般的な仮想スクロールの原理をおさらいすると、現在のスクロール位置をリアルタイムで取得し、これと各要素のサイズからからユーザーに見えているだろう領域を予測し、該当領域の要素のみを描画する、以上になると思います。一番基本的な原理に限っていうとシンプルです。

なのですが、現実の Web アプリケーションでは、要素のサイズが描画するまで分からない状態で、要素のサイズを使った予測を行わないといけなかったり、ブラウザ API の不足から、スクロールの情報が不正確にしか取得できなかったり、そもそも DOM の付け外し起因でレイアウト再計算が発生するなど、仮想スクロールという機構自体のオーバーヘッドがあったりします。その辺りに上手く折り合いをつけ、仮想スクロールによるレンダリング高速化のメリットを得つつ、仮想スクロール未使用のスクロールのスムーズさに如何に近づけられるか、というのが各実装において試行錯誤されているところかと思います。

https://qiita.com/neer_chan/items/5ff1a82ed2fe121026d5

Architecture

本ライブラリは、scroll や resize などの DOM の各種 event を元に仮想スクロールの状態を算出、store の状態を更新し、この store の更新を React component が購読して view に反映する、大雑把にこのような構成になっています。

architecture

store が担う状態管理や、仮想化に関するロジックは基本的に React の外で実装しています。何故このような構成をとったかというと、仮想スクロールの実装には複数 state が絡む状態遷移が必要になるのですが、React の中に useState などを使ってこれを直接書いてしまうと、見通しが悪くなっていくことが想定されたからです。また、原理上特に React に依存するものではないため、あらかじめ責務を分離しておくことは、自身のモチベーションが React 以外のフレームワークに移ってしまった時や、(あまり考えにくいですが)React が廃れてしまった時にも移行し易く、リスクヘッジにもなるかなと思ったからです。

また、仮想化の状態自体は論理的に決定できるものなのですが、その状態の算出元となる DOM の event は信頼性が低かったり、ブラウザごとに差異があったりするため、それを吸収する層が一枚欲しかったというのもあります。例えば、ブラウザではスクロールが別スレッドで扱われている関係上、scroll event の発火タイミングもまちまちですし、後述するdirection: rtlのように値の正負が逆転したりするケースもありますし、iOS Safari など scroll の挙動が若干異なるブラウザがあったりします。

vertical scroll から派生させて、horizontal scroll や Grid、WindowScroller を実装することを考えると、event の変換部分を上手く差し替えるのが結果的にやり易かったのもあり、このようになっていった部分もあります。

Scroll handling

scroll 状態の取得には、一般的な仮想スクロールのように scroll event を使っています。初期実装で IntersectionObserver が使えないか試してはみたのですが、パフォーマンス面であまりメリットが感じられなかったのと、スクロールバーを大きくドラッグした時など、 viewport に交差せず一気に跨いでしまうケースで IntersectionObserver が発火せず、結局 scroll event のハンドリングが必要になってしまったのでやめました。

https://developer.mozilla.org/ja/docs/Web/API/IntersectionObserver
https://github.com/w3c/IntersectionObserver/issues/187

あとは scroll event はブラウザの負荷が高まっている時など間引かれるケースが確認されたため、wheel event を併用して安定して動作するようにしています。調査しきれていないですが、もしかした mobile browser では touch event など併用した方が良い可能性はあるかもしれません。

2023/11/23追記: mobile対応のためtouch eventの考慮を実装済みです。特にiOS対応では無いと厳しいです(あっても厳しい)。

Resize handling

要素のサイズをユーザーに指定させたくないので、サイズの自動測定も必要です。これは ResizeObserver を使用して実装しています。

他の仮想スクロールライブラリは要素サイズの自動測定が opt-in、ないし補助的な扱いのものが多いのに対して、本ライブラリは自動測定をベースに組み立てており、サイズ変化時の補正量の算出、要素サイズの予測など、幅広く活用しています。意外と対応されていないエッジケースである、サイズ未測定の要素に対して scrollTo した時に正確にスクロールするための位置補正にも応用されています。

一番広く実装されているResizeObserverEntry.contentRectを使う場合、要素の border や margin が取れないので工夫が必要です。

https://developer.mozilla.org/ja/docs/Web/API/ResizeObserver

Component API

本ライブラリは、react-virtual などのような hook 方式ではなく component としてライブラリを実装しています。bundle size や汎用性などを考えると、hook 方式の API が良さそうに思えるかもしれません。ただ、仮想スクロールを実現できる markup は複雑であり、かつ実装によってパフォーマンスにも影響を及ぼします。今回はセットアップの煩雑さをできる限りライブラリ側で吸収したかったため component 方式を選択しました。

また、仮想スクロールのパフォーマンス最適化には React、JS、DOM、CSS など複数レイヤーのチューニングが必要になることが想定されます。これをライブラリ側で最大限かけ、かつ将来的に、CSS の content-visibility や React の Offscreen など、新しいチューニング方法を漸進的に取り込んでいくにも、component 方式がやり易いと考えました。

https://developers.cyberagent.co.jp/blog/archives/28778/

https://react.dev/blog/2022/06/15/react-labs-what-we-have-been-working-on-june-2022#offscreen

component 方式の類似ライブラリは render prop を採用しているものが多いですが、children を採用しています(VGrid を除く)。メモリ使用量が増えるなどのデメリットはあると思いますが、ReactElement の参照が変わることによる rerendering を抑えられるというメリットの方を優先しました。結果的に RSC 対応もやり易かったです (仮に render prop を使う場合、どう対応するのが良いんでしょうかね…)。

2023/11/23追記: 最終的にrender propも実装しました。雑に仮想化したい場合やRSCと連携したい場合はchildrenを、メモリ使用量や起動速度を切り詰めたい場合はrender propを活用してください。

State management

大方針として、state に入れる状態は最低限にとどめ、state の組み合わせで算出可能な値は state に入れず、cache を併用しつつ selector 的に導出するようにしています。

state との接続部分は、 useState -> useReducer -> useState -> useSyncExternalStore -> useState と書き直して、最終的に useState に落ち着いています。

最初は useState でスタートしたのですが、各 state が互いに影響し合っているためすぐに reducer で構造化したくなり、useReducer に置き換えました。
React 18 以降、同じ値で state 更新が行われた場合に re-render をスキップする機構が、useReducer から削除されてしまっています。これによって余計な re-render が発生してしまうことが確認されたため、reducer の仕組みは維持したまま、React との接続部分を useState で書き換えました。

https://github.com/facebook/react/pull/22445

re-render を更に減らしていくにあたり、例えば redux のように外部 store から selector で最小限の scope で state 更新を適用するような構成に変更したくなり、useSyncExternalStore を使って、先に説明したような構成に書き換えました。
上記の課題は解消され、また useSyncExternalStore は state の同期更新を行う API であるため、副次的に state の画面への反映も早くなりパフォーマンスも改善されたように見えた…のですが、実装を進めていくと、逆に同期更新を行うことで更新頻度が高くなりすぎてしまい、パフォーマンスに問題が出るケースも見つかってきました。

useSyncExternalStore は同期更新専用の API ですが、useState でも flushSync を使えば同期更新を行うことはできます。逆に、useSyncExternalStore には concurrent rendering 時に行われる state 更新の batch 処理は適用されず、useTranstion や startTransition を使って更に更新の priority を下げることも出来ません。
これだと今後の改善が難しいと判断し、構成を維持したまま接続部のみ再度 useState への書き直しを行いました。

https://github.com/facebook/react/issues/25191#issuecomment-1237456448

useState だと render 中に state 更新を行った場合に、返り値を破棄し新しい state で再度 render を実行してくれる機能 (eager bailout) があると思うのですが、これを useSyncExternalStore で行うと何故かエラーになってしまったのも書き換えの理由ではありました。

https://github.com/urql-graphql/urql/issues/2309

2023/11/23追記: このあと再度useReducerに移行しています。stateの依存する値がモードによって変わるなど、何をstateに何をcomputed fieldにすべきかの判断が難しくなってきたため、stateはReactに更新を通知するだけの役割とし、値の冪等性はcacheなどでReactに頼らず保証する形に変更しました。tanstack virtualと比較的近い構成かもしれません。

Reverse scroll & rtl

スクロール方向を反転させる方法は、現在採用している JS で値を調整する方法以外にも、CSS で反転させる方法があります。
具体的には、flex-direction: row-reverseもしくはflex-direction: column-reverse  style を当てることで、スクロール方向を逆転させ、終点からスクロールを開始させることができます。

これで実現できれば話は簡単なのですが、試した感じ、scrollbar をドラッグした時に scroll event が発火するのですが、Safari でだけ、何故か取得できる scrollTop の値が必ず 0 になる現象に見舞われました。また、古いブラウザだと DOM 構造によってスクロールが動作しないバグがありました。これらの理由から、まだあまり熟れていない印象を受けたため、採用には至りませんでした。
https://github.com/philipwalton/flexbugs/issues/108

また、direction: rtl style を当てることでもスクロール方向が左右反転します。rtl 時の対応は react-window を参考にしています。
scrollLeft は通常正の値が返却されてきますが、rtl style 配下では負の値が返ってきます。この動作は現在標準化されているようで、現行のブラウザのみを対象にする場合は問題ないのですが、古いブラウザだとブラウザごとに挙動差異があるようです。例えば Chrome v85(2020 年にリリース)より以前の Chrome では正の値が返ってきたりします。

https://github.com/othree/jquery.rtl-scroll-type

未対応ですが(仮想化の需要があるかもよく分かりませんが)、調べた感じ writing-mode で縦書きにした場合も似たような問題がありそうな予感がしています。

https://note.com/eveningmoon_lab/n/na85e167f500b

Test

Jest の JSDOM 環境が使える範囲は Jest を使ってテストしています。ただ、JSDOM 環境だと scroll event が実装されておらずスクロール周りをテストすることがほぼ不可能であり、これらは特にリグレッションしやすいので、ブラウザ上で動く e2e でカバーするようにしています。

ブラウザごとに微妙に挙動差異があるので、Playwright を使って Chromium、Firefox、WebKit の 3 ブラウザで e2e を動かしています。これでだいぶ安心ではあるのですが、Windows や iOS Safari でだけ発生する挙動が見つかっていたりするので、どうしても手動テストはゼロにはできないかな…という印象です。

https://playwright.dev/

Documentation

できる限りメンテに工数を割きたくなかったため、 TSDoc をコード中に書き、ここから typedoc を使って API ドキュメントを自動生成しています。例えば VSCode で使われている Monaco Editor のドキュメントもこの方法で生成されていると思います。

https://tsdoc.org/
https://github.com/TypeStrong/typedoc

デフォルトの HTML ではなく Markdown として出力したかったので、 typedoc-plugin-markdown を併用しています。

https://github.com/tgreyuk/typedoc-plugin-markdown

Build

@babel/plugin-transform-react-pure-annotations を使用して、memo()forwardRef()といった関数実行に/*#__PURE__*/を付与して treeshake されるようにしています。

本 plugin は@babel/preset-reactに含まれているため、上記対応をせずともユーザー側で付与していて問題ないケースもあります。ただ、近年は SWC など babel がツールチェインに含まれないことも増えていると思うので、ライブラリ側で annotation を付与してしまった方が良いのかなと思っています。

https://github.com/babel/babel/tree/main/packages/babel-plugin-transform-react-pure-annotations

細かい話としては、 Terser を使って internal な property name は全て minify をかけています。
https://zenn.dev/link/comments/ef520d6f852340

JSX の変換先はcreateElementではなくreact/jsx-runtimeを使用しています。React の OSS は未だにcreateElementを使って publish されているものも多いですが、こちらが将来的に改善されていく可能性は低いと思うので、出来れば乗り換えた方が良いと思います。導入されたのは React 17 ですが、16.14.015.7.00.14.10にまでバックポートされているようです。
https://zenn.dev/uhyo/articles/react17-new-jsx-transform

これから

機能追加

ベースは大体安定してきたので、実際のユースケースを拾って、足りない機能を実装していきたいです。
とりあえず直近優先して検討しているのは、window scroll による仮想化の対応、iOS Safari などの mobile browser 対応強化、Slack などのような bi-directional infinite scroll 対応、辺りでしょうか…

2023/11/23追記: 上記全て対応済みです。特にReactでSlackやTwitterのようなWebアプリを実装する場合、react-virtuosoなどと比べても第一選択肢になり得るクオリティにはなっていると思うので、よろしくお願いいたします。

あとはreact-lazyloadのような lazyload component を、離散値を扱う仮想スクロールの変形として捉えて実装してみるのもありなんじゃないかなという気もしています。
もし要望などあればお願いします。

パフォーマンス改善

API を出来る限り変えずに、内部実装を抜本的に見直してパフォーマンスを上げていきたいです。

これが一番重要だとは思うのですが、一番難しい… そもそも仮想スクロールという仕組み自体がトレードオフの塊と言いますか、あちらを立てればこちらが立たないという状況が生じやすいです。基本的には、スクロール中に出来る限り何も処理を行わないことがパフォーマンス向上に繋がる印象です。

例えば Android の RecyclerView や React Native 用の OSS である FlashList などに実装されているような、instance の 再利用を DOM で実装する、DOM Recycling と呼ばれる手法があります。Element の絶対数が減ったり、そもそも appendChild や removeChild の実行自体もオーバーヘッドが大きいため、これらが無くなることよりパフォーマンスの改善が見込まれますが、一方、実装も複雑化することが予想され、また Element を使い回す実装になる関係上、アクセシビリティに悪影響が出る可能性が懸念されます。

https://github.com/Shopify/flash-list
https://developer.chrome.com/blog/infinite-scroller/

また、ブラウザではスクロールが別スレッドで非同期に処理されているため、scroll event で得られるスクロール位置と、実際の現在のスクロール位置が異なる場合があるという問題があります。一応、スクロールにより発生した scroll event を JS 側が受け取る構成ではなく、wheel event などの変化量を JS 側からスクロールスレッドに適用する形でスクロールすれば、ラグを軽減することが出来るらしいです。多分 Monaco Editor や古い CodeMirror に実装されていると思うのですが、それはそれで単に実装しただけだと native のスクロールを超えるパフォーマンスを出すのは難しい気がしています。

https://twitter.com/devongovett/status/1175132626601963520?lang=ja

https://engineering.monday.com/our-journey-to-understand-scrolling-across-different-browsers/

アクセシビリティ周りの改善

近年では、例えば navigation 前後のスクロール位置の保持が難しい、といったアクセシビリティ上の問題を考慮して、敢えて仮想スクロールや無限スクロールを避けるケースもあると思います。これらについても技術で解決できる部分は解決していきたいです。

2023/11/23追記: スクロール位置復元用のAPIを実装済みです。その他アクセシビリティについては、ユーザー側でのrole付与など、よりmarkupの自由度を高められるような構成変更を検討中です。

https://www.webaxe.org/infinite-scrolling-and-accessibility/
https://www.digitala11y.com/ok-aria-rolefeed-is-here-its-not-ready-for-prime-time/

React 以外のフレームワークへの対応

仮想化の仕組み自体は React の API に出来る限り依存していない作りにしているため、Vue Solid Svelte などへの対応も可能であると思っています。ニーズと余力があれば対応したいです。
もしご興味ある方がいれば、粗くても構わないので PR いただけると助かります。

Discussion