🪟

巨大なテーブルコンポーネントを仮想スクロール化してブラウザのメモリ使用量を1/10にした話

2023/12/11に公開

はじめに

こんにちは!株式会社ログラスでエンジニアをしているd4te74です 🍷
ログラスではレポートと呼ばれる経営データ分析のための機能領域を改善するチームに所属しています。

この記事では、最近取り組んでいたレポート機能のフロントエンドのパフォーマンスチューニングとして行った「巨大なテーブルコンポーネントの仮想スクロール化」について書いていこうと思います。

※ Loglass は事業の予実を管理するサービスです。

レポート機能とは

レポート機能とは、Loglass 内に統合された部署や勘定科目などの経営データに対し集計・分析が行える機能で、ユーザー自身がそれらを自由度高く表形式に組み立てて、表示できるというものです。

以下の画像のような UI となっており、ユーザーはサイドメニューから見たいデータを列・行それぞれに設定後、指定したデータをテーブル形式で閲覧することができます。

レポート機能の概要については以下も併せてご覧ください。

https://prtimes.jp/main/html/rd/p/000000073.000052025.html

レポート機能のフロントエンドが抱えていた課題

複雑なデータ構造になっている経営データを瞬時に収集して自由な形式で閲覧できる便利な機能ですが、行や列は設定やデータ量によって容易に増大する可能性が高く、お客様によっては行の長さは数十万を超えることがありました。

フロントエンドではブラウザでメモリリークが発生しないようセルの表示上限を 1 万としていましたが、セル数が 1 万近くなるとブラウザのメモリ使用量は約 3.6GB まで跳ね上がり動作が重くなるなど課題がありました。

そのため、今後より多くの機能追加を予定しているフロントエンド側ではパフォーマンスのボトルネックを改善することになりました。

どのようなアプローチにしたのか

チューニング前に現状のボトルネックになっている箇所を把握するために調査を行い、その結果以下のことがわかりました。

  • 表示上限である 1 万セルが入った API Response のサイズは約 8MB
  • 表示上限である 1 万セルをテーブルで全て表示した時のメモリ使用量は約 3.6GB
  • テーブルで表示するオブジェクトを取得するための API 通信処理はそのままに一度改善前のテーブルコンポーネントをコメントアウトして非表示にした時のブラウザのメモリ使用量は約 100MB
  • テーブルで表示するオブジェクトを取得するための API 通信処理はそのままに画面に収まる範囲でセルを表示した場合のメモリ使用量は約 300MB

以上の結果から、テーブルで表示するセルの数を減らすことがブラウザの負荷軽減に対して最も有効であると判断し、タイトルにもある通りテーブルを仮想スクロール化することに決めました。
※ 仮想スクロールについては後述します

他にもセル数を減らす方法は無限スクロールやページネーションがありましたが以下の理由で不採用にしました。

  • 無限スクロール

    • スクロールにより DOM が増え続けることによって次第にブラウザが重くなるため課題が解決できない
    • API の Interface を改修する必要がありコストが高くなる
    • DOM が増え続けることの対策としてデータフェッチしながらスクロール方向から見て末端のデータを削除するなども検討しましたがこれもコストが高い
  • ページネーション

    • レポート機能のコンセプトから外れる
    • API の Interface にも改修が入るためコストが高い

仮想スクロール化の決め手は無限スクロールやページネーションより実装のコストが低く、かつ他の選択肢と比べても大きなデメリットがなかったことです。

※ 仮想スクロールは表示範囲のみレンダリングする技術であるため、Command + F でのブラウザ内文字列検索では検索対象が表示されているもののみに絞られるなどのデメリットはありますが、レポート機能での文字列検索のユースケースの優先度は高くないと判断されました。

仮想スクロールとは

仮想スクロールとは、ブラウザで見えている領域のみレンダリングする技術のことです。

大量のデータを一度に表示する際、レンダリングに時間がかかることでユーザー体験悪くなることがありますが、仮想スクロールでは見えている範囲+α のレンダリングを実行することでユーザー体験悪化を防ぎます。

chrome devtools で確認すると以下のような振る舞いとなります。

仮想スクロールライブラリ選定

Loglass のフロントエンドでは Next.js を採用しているため、React で動作する仮想スクロールライブラリを選定することになりました。

仮想スクロール導入によって以下既存のテーブルの振る舞いや見た目がデグレすることは避ける必要がありました。

  • テーブルを縦にスクロールする時、テーブルヘッダーは固定されていること
  • テーブルを横にスクロールする時、左端の列(画像の部署列・科目列)は固定されていること
  • 左端の列(画像の部署列・科目列)は列の幅を可変できること
  • セル内の数字は 3 点リーダーなどで省略したり改行できない
  • テーブルの値をコピー後、スプレッドシートや Excel ファイルにペーストした時、テーブル構造を維持したままペーストされること(= table タグで実装されていること)

そのため選定基準としてはブラウザのメモリ使用量が下がって体験が改善されていることだけでなく、既存の振る舞い・見た目が再現できており、かつ仮想スクロール導入後に機能開発の難易度が上がっていないことでした。

ライブラリの候補としては以下の 3 つがあり、それぞれ上記の基準を満たせるか否かを PoC を実装しながら調査していきました。

  • react-window
  • tanstack/table
  • react-virtuoso

react-window

https://github.com/bvaughn/react-window

react-window は、垂直または水平方向の 1 次元のデータを仮想スクロール化する List 系コンポーネントと、行と列を持つ 2 次元のデータを仮想スクロール化する Grid 系コンポーネントを提供するライブラリです。

今回の対応ではFixedSizeGridという Grid 系コンポーネントを使用して PoC の実装をしました。

しかし以下の issue にもある通り react-window は動的に高さが変わるリストや table タグとは相性が悪く、前述の振る舞いや既存の見た目を再現することが困難でした。

https://github.com/bvaughn/react-window/issues/60

table タグとの相性が悪いことから既存のテーブルコンポーネントを div で作り直すことも検討しましたが、table タグには、thead や tbody のデータをコピー&ペーストする際にテーブルの構造を保持してデータをペーストするという重要な特性があります。この特性は div タグでは再現できませんでした。

前述の既存の振る舞い ↓ にもある通り、Loglass のユーザーには経営企画業務を担当している方が多く、業務上レポートに表示された経営データをスプレッドシートや Excel ファイルへ直接コピー&ペーストするようなユースケースがあります。このユーザビリティを損なうリスクを考慮し、div タグへの移行は断念しました。

テーブルの値をコピー後、スプレッドシートや Excel ファイルにペーストした時、テーブル構造を維持したままペーストされること(= table タグで実装されていること)

tanstack/table

https://github.com/TanStack/table

https://tanstack.com/table/v8

tanstack/table はテーブルの作成を容易にする Headless UI ライブラリです。Headless UI なのでテーブル実装に必要な基本機能を備えながらデザインは独自に行うことができます。

https://tanstack.com/table/v8/docs/examples/react/virtualized-rows

仮想スクロール化や前述した振る舞いは意図した通りに実装できましたが、以下のように仮想スクロールさせたい DOM の上下に空の td を置いて計算した height を指定しているなど複雑で一見意図が分かりずらい実装をする必要がありました。

期待した機能は実現できたものの、後述する react-virtuoso と比較して実装難易度が高く、今後の変更の難易度が高かったため不採用となりました。

const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
  virtualRows.length > 0
    ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0)
    : 0;
<tbody>
  {paddingTop > 0 && (
    <tr>
      <td style={{ height: `${paddingTop}px` }} />
    </tr>
  )}
  {virtualRows.map(virtualRow => {
    const row = rows[virtualRow.index] as Row<Person>
    return (
      <tr key={row.id}>
        {row.getVisibleCells().map(cell => {
          return (
            <td key={cell.id}>
              {flexRender(
                cell.column.columnDef.cell,
                cell.getContext()
              )}
            </td>
          )
        })}
      </tr>
    )
  })}
  {paddingBottom > 0 && (
    <tr>
      <td style={{ height: `${paddingBottom}px` }} />
    </tr>
  )}
</tbody>

react-virtuoso

https://github.com/petyosi/react-virtuoso

react-virtuosoは List・Grid・Table などの仮想スクロールを実現するライブラリで、有名なコンポーネントライブラリであるMaterial UIでも 2023 年 1 月から採用されています。

Table であれば TableVirtuoso というコンポーネントが提供されており、以下のように thead に表示したいコンポーネントは fixedHeaderContent、tbody に表示したい値は itemContent に渡すだけで簡単テーブルの仮想スクロールを実現できます。

TableVirtuoso は fixedheaderContent や itemContent に tr, th, td が渡ってくることが前提となっているため、既存のテーブルコンポーネントに記述されていたスタイルや振る舞いをそのまま利用することができたため変更コストも少なく、かつ tfoot 表示やスクロール時の debounce 処理なども interface としてサポートしているため、今後の変更も容易だったため採用しました。

仮想スクロール導入に起因して起きた問題

仮想スクロール導入後、メモリ使用量やパフォーマンスの問題は大きく改善されましたが、スクロールのたびにテーブルの列の幅が動的に変わるという問題が起きました。

table タグの構造上、th や td に固定の width を指定しない場合、列の幅は列内部存在するコンテンツ(th や td)の幅で決定され、改修前のレポートのテーブルでは列の幅に固定の width を指定していませんでした。つまり列に存在する経営データ(数値)の length が一番長い th か td の width に合わせて列幅が設定されます。

仮想スクロール導入によって見えている範囲のみをレンダリングすることになったため、スクロールのたびに動的に保持する DOM が書き換わるようになったことで、列の幅も動的に変わるようになってしまったことが原因でした。

前述した通り、お客様の業務を第一に考えた結果「セル内の数字(経営データ)は 3 点リーダーなどで省略したり改行できない」という仕様を曲げることはできないため、どうにかして固定幅を指定して課題を解決する必要がありました。

どう解決したのか

以下は架空のコードです。
テーブルで仮想スクロールを適用したい tbody の中身は行の配列で、行はセルの配列を持っている多次元配列であることが多いかと思います。

// 行は複数のセルを持っている
const row = [10000, 20000, 30000];

// tbodyで表示したい行の配列
const rows = [row1, row2, row3]; // rowのような配列が複数入っている

前述の通り、レポートのテーブルでは td に経営データである数値を表示しており、その数値の文字列長で width が決定されているという状況で、列内で最も文字列長が大きい値を取り出し、文字列 * px 数の結果を列の幅に指定すれば良いと考えました。

そこで複数の行を表現する多次元配列を、

[
  [10, 200, 300],
  [1000, 2, 30000],
];

列の多次元配列に変換し、それぞれの列で最も文字列長が大きい値を取り出して px を掛け算する、といったロジックを実装することによって仕様はそのままに固定幅を列に指定することができました。

[
  [10, 1000],
  [200, 2],
  [300, 30000],
];

仮想スクロール導入によってどうなったのか

当初セル数が増えるほど体験が悪くなっていたレポート機能ですが react-virutoso による仮想スクロール導入によって表示上限である 1 万セルが API Response として渡ってきた場合でもメモリ使用量は約 350MB ほどと約 1/10 まで減らすことができました。

またこの改善により表示上限を 5 万セルに引き上げることができるなど、よりお客様にとっても価値のある改善をすることができました。

機能が豊富なテーブルの仮想スクロール化は難易度が高く、不確実性が高いため技術検証をしながら少しずつ進む作業でしたが、結果的に価値ある改善ができてよかったです。
そして同じような課題を抱えている方の参考になったら嬉しいです!

We Are Hiring

ログラスには他にも難しい技術課題がたくさんあり、一緒に立ち向かっていくエンジニアを募集しています!

https://job.loglass.jp/

今回の対応で参考にした資料

https://zenn.dev/zaki_yama/articles/react-window-tips
https://ant.design/components/table#components-table-demo-virtual-list
https://mui.com/material-ui/react-table/#virtualized-table
https://tanstack.com/table/v8
https://virtuoso.dev/

株式会社ログラス テックブログ

Discussion