RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

React-Virtualを活用したフロントエンドパフォーマンスチューニングの話

こんにちは! RevCommのフロントエンドエンジニアの楽桑です。

フロントエンドパフォーマンスチューニングを経験した方ならご存じのとおり、レンダリング効率は常に重要です。データをスピーディかつ効率的に画面に表示することは、フロントエンド最適化の核心です。

本記事では、すでにリリースされているプロジェクトにおいて、コードの変更を最小限に抑えつつ、効果的なテーブルパフォーマンスチューニングをどのように実施するかをご紹介します。

背景

僕が担当しているプロジェクトでは、システム内に配置された2つのインタラクティブなテーブルがあります。

これらのテーブルは、ユーザーが操作するディバイダーによって高さが調整される設計になっています

このような設計は、ユーザーによりよいコントロールを提供する一方で、データ量が増加するとパフォーマンスに影響を与える可能性があります。特に、ディバイダーの動きがスムーズでなくなると、全体のユーザー体験が損なわれます。

この問題を解決するために、どのようにフロントエンドのパフォーマンスを最適化し、ユーザーインターフェースの応答性を保つかを探求します。

技術選択

テーブルパフォーマンスチューニングにおいて、テーブルのバーチャル化は一般的に用いられる手法です。

このアプローチでは、従来のテーブル描画方法とは異なり、ユーザーのビューポート(画面に表示されている範囲)に現れる行のみを描画することに焦点を置いています。

この手法を用いることで、一度に描画される行の数を減らすことができます。これは、スクロール時の描画コストを低減する効果も持ち合わせています。

dev.to

ただし、今回のアプローチでは基本的な実装のみを採用し、動的な行の描画の最適化は次段階の課題として残します。

ライブラリ

ライブラリとして候補に上がったのは React-VirtualizedReact-WindowReact-VirtualReact-Table 4つのライブラリです。

今回は、すでに実装されQAも完了しているテーブルコンポーネントにバーチャルスクロールを追加することが目標です。できるだけ軽量な実装を望んでいるため、React TableReact-virtualized といったライブラリを使用する方法も考慮しましたが、いずれにせよ既存のコンポーネントをカスタマイズする必要があるため、一旦見送ることにしました。

その結果、React-WindowReact-Virtual の2つの選択肢が残ります。今回は React-Virtual を選択しました。既存のコンポーネントをそのまま使用し、ライブラリが提供するhooksを用いて実装できるためです。これにより、実装コストをかなり抑えることができました。

ただし、このアプローチのデメリットとして、テーブル内でバーチャル化を行うためにテーブルヘッダーを適切に表示する必要があり、表示行の前後の余白高さを計算する必要が生じます。結果としてJavaScriptの計算コストが増加します。

実装

React-Virtualが提供したHook
 // The virtualizer
 const rowVirtualizer = useVirtualizer({
   count: 10000,
   getScrollElement: () => parentRef.current,
   estimateSize: () => 35,
 })

count: テーブル全体のサイズです。

getScrollElement: スクロール対象のElementを指定します。

estimateSize: 各テーブル行の予想高さを設定します。

これらの3つのパラメータに加えて、よく使用されるのは以下の2つです:

overscan: ビューポートの前後に予め描画する行数を指定します。

horizontal: trueに設定すると、水平方向に対してのスクロールが有効になります。

useVirtualizedTableを用いて実装したHook
export const useVirtualizedTable = ({
  tableSize,
  scrollable,
}: VirtualizedTableProps) => {
  // The virtualizer
  const rowVirtualizer = useVirtualizer({
    count: tableSize,
    getScrollElement: () => scrollable.current,
    estimateSize: () => TABLE_ROW_HEIGHT,
    overscan: 5,
  });

  const items = rowVirtualizer.getVirtualItems();

  // Calculate the space before and after the virtual items
  const [before, after] =
    items.length > 0
      ? [
          notUndefined(items[0]).start - rowVirtualizer.options.scrollMargin,
          rowVirtualizer.getTotalSize() -
            notUndefined(items[items.length - 1]).end,
        ]
      : [0, 0];

  const totalSize = rowVirtualizer.getTotalSize() + TABLE_HEADER_HEIGHT;

  return { items, totalSize, before, after };
};

Table 前後の余白高さ計算

const before = notUndefined(items[0]).start -  rowVirtualizer.options.scrollMargin,
const after = rowVirtualizer.getTotalSize() - notUndefined(items[items.length - 1]).end,

beforeは表示中のアイテムの最初の要素の上部からスタート位置までの高さからスクロールマージンを差し引いた値です。

afterはバーチャルスクロール全体の高さから、表示中の最後のアイテムの下部のエンド位置までの高さを差し引いた値です。

このアプローチでは、現在ビューポートに表示されている行の実データのみを描画し、その他のスクロール可能な範囲は空白のtrタグ 要素で埋められています。これにより、現在のビューに対する描画負荷を最小限に抑えつつ、ユーザーにスムーズなスクロール体験を提供することが可能になります。(画像のように、最初と最後のtrタグは高さのみのダミータグになります)

それを適用した実際のコード
// Hook展開
const { items, totalSize, before, after } = useVirtualizedTable({
  tableCount: body.length,
  scrollable: parentRef,
});

// 高さをテーブル全体に適用
<table
  css={css`
    table-layout: fixed;
    height: ${totalSize}px;
    width: 100%;
    border-collapse: separate;
    border-spacing: 0;
  `}
>
  ....
</table>;

// itemの展開適用、beforeとafterを表示中のRow前後の行に高さ適用
{
  before > 0 && (
    <tr
      css={css`
      height: ${before}px;
    `}
    />
  );
}
{
  items.map((virtualItem) => (
    <TableDataRow
      key={virtualItem.index}
      whiteSpace="nowrap"
      data={body[virtualItem.index].data} // Indexを使ってDataをマッピング
    />
  ));
}
{
  after > 0 && (
    <tr
      css={css`
      height: ${after}px;
    `}
    />
  );
}

テーブルのバーチャルスクロール時のヘッダー幅の動的変更

テーブルでバーチャルスクロールを使用している場合、もしヘッダーが固定されていなければ、表示中の行の中で最も幅が大きいものに合わせてヘッダーがレンダリングされます。つまり、スクロール中に最も幅が広い行がマウントまたはアンマウントされるたびに、テーブルの全体の幅が変動してしまうことになります。

対策:

この問題を解決するために、ヘッダーの幅を固定し、テーブル全体の幅が変動しないように設定することが一つの対策となります。

ただし、ヘッダを固定することにより、新しい項目を追加するたびに、幅の値を追加する必要があります。幅計算の関数を作成することもおすすめです。

パフォーマンス計測

パフォーマンスレポート

対応前 対応後

画像1・2はパフォーマンスチューニング前後のテーブルの高さ変更時の実行時間を表しています。

画像1のようにレンダリング時間は実行時間の多くを占めており、およそ16000msになってます。そして、スクリプティング時間は5000msになっています。この2つで、高さ移動時の実行時間のおよそ71%を占めています。

一方、画像2ではレンダリング時間が7000msと半分程度になっています。そのかわり、スクリプティング時間は9000msくらいになりましたが、全体に占める実行時間は71%から52%へと19ポイント (26%) 縮小したことがわかります。

終わりに

本記事では、リリース済みのプロジェクトにおけるテーブルのパフォーマンスチューニングに取り組みました。主な目標は、変更量を最小限に抑えつつ、効率的なコードを実現することでした。結果として、全体のコード実行時間を約26%削減することに成功しました。

今後の課題としては、スクリプティングの計算量を可能な限り抑えることを目指しています。一つの案として、非表示のデータ行の動的描画コスト最適化を検討していく予定です。