はじめに
チャットアプリのメッセージ一覧、ECサイトの商品一覧、ログビューアーのレコード表示。件数が数百、数千と増えていくと、決まって画面がカクつき始めます。原因はシンプルで、DOMに要素を積み上げすぎているからです。
「表示されていない要素までブラウザに描画させているのが悪い」とわかっていても、自分で仮想スクロールを実装するのは意外と骨が折れます。可変サイズの要素、スクロール位置の復元、iOSでの挙動差異……。こうした面倒事を丸ごと引き受けてくれるのが、今回紹介するVirtuaです。
読むより触った方が早いと思います。件数や高さを変えながらVListの挙動をその場で確認できるサンプルを用意したので、先に動きを見たい方はこちらからどうぞ。
Virtuaとは
Virtuaは、React・Vue・Solid・Svelteに対応した仮想スクロール(バーチャルリスト・グリッド)コンポーネントです。「ゼロコンフィグで高速・軽量(約3kB)」を掲げており、要素の高さを指定しなくても自動で計測しながら描画してくれます。
主な特徴
- ゼロコンフィグ - 各アイテムの高さや幅を事前に指定しなくても、実際のサイズを自動計測しながら仮想化してくれます
- 軽量 - gzip圧縮後で約3kBと非常に小さく、バンドルサイズへの影響を最小限に抑えられます
- マルチフレームワーク対応 - React・Vue・Solid・Svelteそれぞれ向けのパッケージが用意されており、同じ思想のAPIで使えます
- 実運用向けの作り込み - 水平/垂直スクロール、RTL対応、ウィンドウ全体をスクロールコンテナにする
WindowVirtualizer、無限スクロール、スクロール位置の復元など、実際のプロダクトで必要になる機能が一通り揃っています
インストール
npm、yarn、pnpmのいずれからでもインストールできます。
# npm
npm install virtua
# yarn
yarn add virtua
# pnpm
pnpm add virtua
Virtuaのサンプルを動かす
VirtuaのVListは、子要素の高さを1つも指定しなくても、実際のDOMサイズを自動計測しながらスクロール領域を計算してくれます。下のサンプルでは件数と行の高さ(ランダムかどうか)を切り替えられるので、値を変えてVListのスクロールが常になめらかなままか確認してみてください。件数欄を50000のような大きな数にしても、DOMに積まれる要素数はほぼ変わらないはずです。
要点となるコードは次のとおりです。子要素の高さを個別に指定しなくても、VListが自動でスクロール位置を計算します。
import { VList } from 'virtua'
function App() {
return (
<VList style={{ height: 400 }}>
{items.map((item, i) => (
// 高さを指定しなくても、実際のDOMサイズをVirtuaが自動計測する
<div key={i} style={{ height: item.height }}>
{item.text}
</div>
))}
</VList>
)
}
実際に動かせるものが下です。「件数」を書き換えるとリストの行数が、「高さをランダムにする」のチェックを外すと全行が同じ高さになります。どちらの状態でもスクロールが引っかからないことが体感できるはずです。
件数を増やしてもスクロールが重くならないのは、VListが画面内に見えている行だけをDOMに描画し、それ以外は仮想的に扱っているためです。この「高さ未指定でも自動計測」という挙動が、Virtuaを他の仮想スクロールライブラリと比べて導入しやすくしているポイントです。
基本的な使い方
最もシンプルな例として、Reactで1000件のリストを仮想スクロールさせてみます。
import { VList } from "virtua";
function App() {
return (
<VList style={{ height: 600 }}>
{Array.from({ length: 1000 }).map((_, i) => (
<div key={i} style={{ padding: 8, borderBottom: "1px solid #eee" }}>
Row {i}
</div>
))}
</VList>
);
}
高さを指定していない子要素でも、Virtuaが実際のサイズを自動計測してスクロール位置を計算してくれます。可変サイズの要素が混在するリストでも、特別な設定は不要です。
Vueの場合はコンポーネント名がVListのまま、テンプレート構文で利用できます。
<script setup>
import { VList } from "virtua/vue";
const items = Array.from({ length: 1000 }, (_, i) => i);
</script>
<template>
<VList :data="items" #default="{ item }" style="height: 600px">
<div class="row">Row {{ item }}</div>
</VList>
</template>
実践的なユースケース
無限スクロールのチャットログ
チャットアプリのように、上にスクロールすると過去ログを追加取得したいケースでは、VListのonScrollと組み合わせて実装します。
import { useRef, useState, useCallback } from "react";
import { VList, VListHandle } from "virtua";
type Message = { id: number; text: string };
function ChatLog({ initialMessages }: { initialMessages: Message[] }) {
const [messages, setMessages] = useState(initialMessages);
const [loading, setLoading] = useState(false);
const listRef = useRef<VListHandle>(null);
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
const older = await fetchOlderMessages(messages[0]?.id);
setMessages((prev) => [...older, ...prev]);
setLoading(false);
}, [loading, messages]);
return (
<VList
ref={listRef}
style={{ height: 500 }}
onScroll={(offset) => {
if (offset < 100 && !loading) {
loadMore();
}
}}
>
{messages.map((m) => (
<div key={m.id} style={{ padding: 8 }}>
{m.text}
</div>
))}
</VList>
);
}
// APIから過去のメッセージを取得する想定の関数
async function fetchOlderMessages(beforeId?: number): Promise<Message[]> {
const res = await fetch(`/api/messages?before=${beforeId ?? ""}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
return res.json();
}
上方向にスクロールして古いメッセージを先頭に追加しても、Virtuaはスクロール位置がガクッとズレないよう自動で補正してくれます。これは自前実装だと地味に苦労するポイントなので、非常にありがたい機能です。
実際にスクロール位置が保持される様子を動かして確認できるサンプルが下です(外部APIの代わりにsetTimeoutでメッセージ取得を疑似的に再現しています)。リストの一番上までスクロールするとonScrollが発火して過去メッセージが先頭に追加されますが、そのタイミングでも表示中の行がズレないことが確認できます。
onScrollのコールバックに渡ってくるoffsetが先頭からの距離です。この値としきい値(サンプルでは100)を比較してloadMoreを呼ぶだけで、無限スクロールの読み込みトリガーが実装できます。しきい値を大きくすると、より早いタイミングで追加読み込みが走るようになります。
ページ全体をスクロールさせたい場合
一覧をカード型グリッドの中の一部として使いたい、つまりページ全体のスクロールに追従させたい場合はWindowVirtualizerを使います。
import { WindowVirtualizer } from "virtua";
function ArticleList({ articles }: { articles: { id: number; title: string }[] }) {
return (
<WindowVirtualizer count={articles.length}>
{(i) => (
<article key={articles[i].id} style={{ padding: 16 }}>
<h2>{articles[i].title}</h2>
</article>
)}
</WindowVirtualizer>
);
}
VListが独自のスクロールコンテナを持つのに対し、WindowVirtualizerはブラウザのウィンドウそのものをスクロール領域として扱います。ブログ記事一覧やLPのような、ページ全体がスクロールするレイアウトに向いています。
実際にWindowVirtualizerを動かせるサンプルが下です。記事数を増やしてページ(このプレビュー領域)をスクロールしてみてください。VListのように専用のスクロールコンテナを用意しなくても、ページ全体のスクロールに追従して仮想化が効いていることが分かります。
WindowVirtualizerにはcountプロパティで総件数を渡し、子要素は(index) => ReactNodeという関数の形で渡します。記事数を5000のような大きな値にしても、実際にDOMへ描画されるのは画面に見えている範囲だけなので、スクロールの重さは変わりません。
まとめ
Virtuaは、仮想スクロールという「実装は理解できても、いざ自分で書くと沼にハマりやすい」領域を、ゼロコンフィグかつ軽量に解決してくれるライブラリです。
- 可変サイズの要素でも自動計測してくれる
- React・Vue・Solid・Svelteとフレームワークを問わず同じ思想で使える
- 無限スクロールやウィンドウスクロールなど実運用に必要な機能が揃っている
大量データを扱うリストやテーブルでパフォーマンスに悩んでいるなら、まずはVListを差し込むところから試してみてはいかがでしょうか。体感できるほどスクロールが軽くなるはずです。
