仮想スクロールを実装することで改善できます。仮想スクロールとは、見た目は要素を全部表示しますが、DOMは必要な分だけ描画する方式です。
ASP.NET Coreアプリケーションを作成します。ファイルは以下です。
以下のコードを記述します。
namespace RazorPagesVirtualScroll
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
if (app.Environment.IsDevelopment()) {
app.UseDeveloperExceptionPage();
}
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
//JSONを出力するAPIエンドポイントを追加
app.MapGet("/api/items", () => Results.Json(DemoData.CreateItems()));
app.Run();
}
}
}
@page
@model RazorPagesVirtualScroll.Pages.IndexModel
@{
}
<!-- スタイルシートの読み込み -->
<link rel="stylesheet" href="~/index.css" />
<h1>仮想スクロール デモ</h1>
<!-- テーブルヘッダー行 -->
<div class="header-row">
<span class="col-id">ID</span>
<span class="col-category">カテゴリ</span>
<span class="col-name">商品名</span>
<span class="col-price">価格</span>
<span class="col-stock">在庫</span>
</div>
<!-- 仮想スクロールコンテナ -->
<!-- spacer: 全アイテム分の高さを確保してスクロールバーを生成する -->
<!-- viewport: 表示範囲のアイテムだけを描画する領域 -->
<div id="virtual-scroll-container" class="virtual-scroll-container">
<div id="spacer" class="virtual-scroll-spacer"></div>
<div id="viewport" class="virtual-scroll-viewport"></div>
</div>
<script>
(function () {
// 各アイテム行の高さ (px)
const ITEM_HEIGHT = 40;
// 表示範囲の上下に追加描画するバッファ行数
const BUFFER = 5;
// DOM 要素の取得
const container = document.getElementById('virtual-scroll-container');
const spacer = document.getElementById('spacer');
const viewport = document.getElementById('viewport');
// API から取得した全アイテムを保持する配列
let allItems = [];
// API からアイテムデータを取得する
async function loadItems() {
const response = await fetch('/api/items');
allItems = await response.json();
// spacer の高さを全アイテムの合計高さに設定し、スクロールバーを生成する
spacer.style.height = (allItems.length * ITEM_HEIGHT) + 'px';
render();
}
// 現在のスクロール位置に応じて表示範囲のアイテムだけを描画する
function render() {
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
// 表示開始インデックスを計算 (バッファ分を上に拡張)
let startIndex = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER;
if (startIndex < 0) startIndex = 0;
// 表示終了インデックスを計算 (バッファ分を下に拡張)
let endIndex = Math.ceil((scrollTop + containerHeight) / ITEM_HEIGHT) + BUFFER;
if (endIndex > allItems.length) endIndex = allItems.length;
// viewport の位置を開始インデックスに合わせてオフセットする
viewport.style.top = (startIndex * ITEM_HEIGHT) + 'px';
// 表示範囲のアイテムの HTML を組み立てる
let html = '';
for (let i = startIndex; i < endIndex; i++) {
const item = allItems[i];
html += '<div class="item-row">'
+ '<span class="col-id">' + item.id + '</span>'
+ '<span class="col-category">' + item.category + '</span>'
+ '<span class="col-name">' + item.product_name + '</span>'
+ '<span class="col-price">¥' + item.price.toLocaleString() + '</span>'
+ '<span class="col-stock">' + item.stock + '</span>'
+ '</div>';
}
// viewport に HTML を反映する
viewport.innerHTML = html;
}
// スクロールイベントで再描画を実行する
container.addEventListener('scroll', render);
// 初期データの読み込み
loadItems();
})();
</script>
namespace RazorPagesVirtualScroll
{
public class ListItem
{
public int id { get; set; }
public string category { get; set; } = "";
public string product_name { get; set; } = "";
public int price { get; set; }
public int stock { get; set; }
}
}
namespace RazorPagesVirtualScroll
{
public static class DemoData
{
public static List<ListItem> CreateItems()
{
var categories = new[] { "果物", "野菜", "飲料", "菓子", "乳製品", "肉類", "魚介類", "調味料", "冷凍食品", "パン" };
var products = new Dictionary<string, string[]>
{
["果物"] = ["りんご", "みかん", "バナナ", "ぶどう", "いちご", "桃", "梨", "柿", "メロン", "スイカ"],
["野菜"] = ["トマト", "きゅうり", "にんじん", "じゃがいも", "たまねぎ", "キャベツ", "ほうれん草", "ピーマン", "なす", "大根"],
["飲料"] = ["緑茶", "コーヒー", "オレンジジュース", "牛乳", "炭酸水", "紅茶", "スポーツドリンク", "ミネラルウォーター", "りんごジュース", "ココア"],
["菓子"] = ["チョコレート", "クッキー", "ポテトチップス", "キャンディ", "せんべい", "ケーキ", "プリン", "ゼリー", "ドーナツ", "アイスクリーム"],
["乳製品"] = ["ヨーグルト", "チーズ", "バター", "生クリーム", "スキムミルク", "カッテージチーズ", "練乳", "クリームチーズ", "サワークリーム", "モッツァレラ"],
["肉類"] = ["鶏もも肉", "豚バラ肉", "牛ロース", "合い挽き肉", "鶏むね肉", "豚ロース", "牛バラ肉", "ラム肉", "ソーセージ", "ベーコン"],
["魚介類"] = ["サーモン", "マグロ", "エビ", "イカ", "タコ", "サバ", "アジ", "ホタテ", "カニ", "ブリ"],
["調味料"] = ["醤油", "味噌", "塩", "砂糖", "酢", "みりん", "料理酒", "ケチャップ", "マヨネーズ", "ソース"],
["冷凍食品"] = ["冷凍餃子", "冷凍ピザ", "冷凍うどん", "冷凍チャーハン", "冷凍コロッケ", "冷凍枝豆", "冷凍たこ焼き", "冷凍パスタ", "冷凍ハンバーグ", "冷凍シュウマイ"],
["パン"] = ["食パン", "クロワッサン", "メロンパン", "カレーパン", "フランスパン", "あんパン", "ベーグル", "ロールパン", "デニッシュ", "蒸しパン"],
};
var random = new Random(42);
var items = new List<ListItem>();
int id = 1;
foreach (var category in categories)
{
foreach (var product in products[category])
{
items.Add(new ListItem
{
id = id++,
category = category,
product_name = product,
price = random.Next(50, 3000),
stock = random.Next(0, 200)
});
}
}
return items;
}
}
}
.virtual-scroll-container {
height: 600px;
overflow-y: auto;
border: 1px solid #ccc;
position: relative;
width: 800px;
}
.virtual-scroll-spacer {
width: 100%;
}
.virtual-scroll-viewport {
position: absolute;
left: 0;
right: 0;
}
.item-row {
display: flex;
align-items: center;
height: 40px;
box-sizing: border-box;
border-bottom: 1px solid #eee;
padding: 0 12px;
font-size: 14px;
}
.item-row:hover {
background-color: #f0f8ff;
}
.item-row .col-id { width: 60px; }
.item-row .col-category { width: 100px; }
.item-row .col-name { width: 260px; }
.item-row .col-price { width: 100px; text-align: right; }
.item-row .col-stock { width: 100px; text-align: right; }
.header-row {
display: flex;
align-items: center;
height: 40px;
padding: 0 12px;
font-weight: bold;
background-color: #4a90d9;
color: #fff;
font-size: 14px;
width: 800px;
box-sizing: border-box;
}
.header-row .col-id { width: 60px; }
.header-row .col-category { width: 100px; }
.header-row .col-name { width: 260px; }
.header-row .col-price { width: 100px; text-align: right; }
.header-row .col-stock { width: 100px; text-align: right; }
以下のコードでJSONを返すAPIを定義しています。大量の項目をJSONで受け取り、そのうち必要な部分のみを画面に表示する動作です。
app.MapGet("/api/items", () => Results.Json(DemoData.CreateItems()));
JSONは、DemoData.cs の CreateItems() メソッドで作成されます。
JSONの出力は以下の形式です。
[{"id":1,"category":"果物","product_name":"りんご","price":2020,"stock":28},{"id":2,"category":"果物","product_name":"みかん","price":420,"stock":104},{"id":3,"category":"果物","product_name":"バナナ","price":546,"stock":52},{"id":4,"category":"果物","product_name":"ぶどう","price":2187,"stock":102},{"id":5,"category":"果物","product_name":"いちご","price":562,"stock":152},{"id":6,"category":"果物","product_name":"桃","price":742,"stock":51},
......]
JSONはページ表示時にJavaScriptでリクエストされます。
/apt/items のURLにアクセスしてJSONデータを取得します。
// API からアイテムデータを取得する
async function loadItems() {
const response = await fetch('/api/items');
allItems = await response.json();
// spacer の高さを全アイテムの合計高さに設定し、スクロールバーを生成する
spacer.style.height = (allItems.length * ITEM_HEIGHT) + 'px';
render();
}
スクロール時にrender関数が呼び出されます。
スクロール位置に対して見えるコンテンツのHTMLだけをビューポートに描画しています。
行の高さは40pxと想定し、表示範囲の上下のバッファ領域は5行としています。
スクロール量を行の高さで割り、バッファ分を差し引く、または、加算して、表示する範囲の最初の行と最後の行のインデックスを求めます。
ビューポートのtopの値に開始インデックスに行の高さを掛けた値を設定し、開始インデックスから画面に描画します。
HTMLを整形して画面に表示しています。今回の例ではTableタグを利用するのではなく、span タグを利用して表組を作成しています。
// 各アイテム行の高さ (px)
const ITEM_HEIGHT = 40;
// 表示範囲の上下に追加描画するバッファ行数
const BUFFER = 5;
// 現在のスクロール位置に応じて表示範囲のアイテムだけを描画する
function render() {
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
// 表示開始インデックスを計算 (バッファ分を上に拡張)
let startIndex = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER;
if (startIndex < 0) startIndex = 0;
// 表示終了インデックスを計算 (バッファ分を下に拡張)
let endIndex = Math.ceil((scrollTop + containerHeight) / ITEM_HEIGHT) + BUFFER;
if (endIndex > allItems.length) endIndex = allItems.length;
// viewport の位置を開始インデックスに合わせてオフセットする
viewport.style.top = (startIndex * ITEM_HEIGHT) + 'px';
// 表示範囲のアイテムの HTML を組み立てる
let html = '';
for (let i = startIndex; i < endIndex; i++) {
const item = allItems[i];
html += '<div class="item-row">'
+ '<span class="col-id">' + item.id + '</span>'
+ '<span class="col-category">' + item.category + '</span>'
+ '<span class="col-name">' + item.product_name + '</span>'
+ '<span class="col-price">¥' + item.price.toLocaleString() + '</span>'
+ '<span class="col-stock">' + item.stock + '</span>'
+ '</div>';
}
// viewport に HTML を反映する
viewport.innerHTML = html;
}
ページ表示時にloadItems()関数を呼び出し、JSONを取得します。
また、addEventListenerメソッドを呼び出し、枠がスクロールされたらrender関数を呼び出すイベントを追加しています。
(function () {
/* 中略 */
// スクロールイベントで再描画を実行する
container.addEventListener('scroll', render);
// 初期データの読み込み
loadItems();
})();
プロジェクトを実行し、アプリケーションルートのURLにアクセスします。下図のページが表示されます。
枠にスクロールバーが表示され枠内がスクロールできます。
一番下までスクロールした状態です。
以下の指示でコードを生成できます。Claude Opus 4.6, Visual Studioのエージェントモードで実行しています。
表組をTableタグで実装した場合の例です。
@page
@model RazorPagesVirtualScroll.Pages.IndexTableTagModel
@{
}
<!-- スタイルシートの読み込み -->
<link rel="stylesheet" href="~/index-table-tag.css" />
<h1>仮想スクロール デモ (テーブルタグ版)</h1>
<!-- 仮想スクロールコンテナ -->
<div id="virtual-scroll-table-container" class="virtual-scroll-table-container">
<table>
<thead>
<tr>
<th class="col-id">ID</th>
<th class="col-category">カテゴリ</th>
<th class="col-name">商品名</th>
<th class="col-price">価格</th>
<th class="col-stock">在庫</th>
</tr>
</thead>
<tbody id="table-body">
<!-- 上部スペーサー行: 表示範囲より上の行の高さを確保する -->
<tr id="spacer-top"><td colspan="5"></td></tr>
<!-- 表示範囲の行はスクリプトで挿入される -->
<!-- 下部スペーサー行: 表示範囲より下の行の高さを確保する -->
<tr id="spacer-bottom"><td colspan="5"></td></tr>
</tbody>
</table>
</div>
<script>
(function () {
// 各アイテム行の高さ (px)
const ITEM_HEIGHT = 40;
// 表示範囲の上下に追加描画するバッファ行数
const BUFFER = 5;
// DOM 要素の取得
const container = document.getElementById('virtual-scroll-table-container');
const tableBody = document.getElementById('table-body');
const spacerTop = document.getElementById('spacer-top');
const spacerBottom = document.getElementById('spacer-bottom');
// API から取得した全アイテムを保持する配列
let allItems = [];
// API からアイテムデータを取得する
async function loadItems() {
const response = await fetch('/api/items');
allItems = await response.json();
render();
}
// 現在のスクロール位置に応じて表示範囲のアイテムだけを描画する
function render() {
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
// 表示開始インデックスを計算 (バッファ分を上に拡張)
let startIndex = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER;
if (startIndex < 0) startIndex = 0;
// 表示終了インデックスを計算 (バッファ分を下に拡張)
let endIndex = Math.ceil((scrollTop + containerHeight) / ITEM_HEIGHT) + BUFFER;
if (endIndex > allItems.length) endIndex = allItems.length;
// 上部スペーサーの高さを設定 (表示範囲より上の行分)
spacerTop.style.height = (startIndex * ITEM_HEIGHT) + 'px';
// 下部スペーサーの高さを設定 (表示範囲より下の行分)
spacerBottom.style.height = ((allItems.length - endIndex) * ITEM_HEIGHT) + 'px';
// 既存の表示行を削除する (スペーサー行は残す)
const oldRows = tableBody.querySelectorAll('tr.item-row');
for (const row of oldRows) {
row.remove();
}
// 表示範囲のアイテム行を組み立てて挿入する
const fragment = document.createDocumentFragment();
for (let i = startIndex; i < endIndex; i++) {
const item = allItems[i];
const tr = document.createElement('tr');
tr.className = 'item-row';
tr.innerHTML =
'<td class="col-id">' + item.id + '</td>'
+ '<td class="col-category">' + item.category + '</td>'
+ '<td class="col-name">' + item.product_name + '</td>'
+ '<td class="col-price">¥' + item.price.toLocaleString() + '</td>'
+ '<td class="col-stock">' + item.stock + '</td>';
fragment.appendChild(tr);
}
// スペーサー行の間にアイテム行を挿入する
tableBody.insertBefore(fragment, spacerBottom);
}
// スクロールイベントで再描画を実行する
container.addEventListener('scroll', render);
// 初期データの読み込み
loadItems();
})();
</script>
.virtual-scroll-table-container {
height: 600px;
overflow-y: auto;
border: 1px solid #ccc;
width: 800px;
}
.virtual-scroll-table-container table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.virtual-scroll-table-container thead {
position: sticky;
top: 0;
z-index: 1;
}
.virtual-scroll-table-container thead th {
height: 40px;
padding: 0 12px;
font-weight: bold;
background-color: #4a90d9;
color: #fff;
font-size: 14px;
box-sizing: border-box;
text-align: left;
}
.virtual-scroll-table-container thead th.col-price,
.virtual-scroll-table-container thead th.col-stock {
text-align: right;
}
.virtual-scroll-table-container tbody td {
height: 40px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
border-bottom: 1px solid #eee;
}
.virtual-scroll-table-container tbody td.col-price,
.virtual-scroll-table-container tbody td.col-stock {
text-align: right;
}
.virtual-scroll-table-container tbody tr:hover td {
background-color: #f0f8ff;
}
.virtual-scroll-table-container .col-id { width: 60px; }
.virtual-scroll-table-container .col-category { width: 100px; }
.virtual-scroll-table-container .col-name { width: 260px; }
.virtual-scroll-table-container .col-price { width: 100px; }
.virtual-scroll-table-container .col-stock { width: 100px; }
実行結果は下図です。
プロジェクトを実行し、アプリケーションルートのURLにアクセスします。下図のページが表示されます。
枠にスクロールバーが表示され枠内がスクロールできます。
一番下までスクロールした状態です。
以下の指示でコードを生成できます。Claude Opus 4.6, Visual Studioのエージェントモードで実行しています。
このセクションでは、RazorPagesのプロパティを利用してページモデルを利用して、ページのJavaScriptではFetchしない方式を紹介します。
@page
@model RazorPagesVirtualScroll.Pages.IndexNoFetchModel
@{
}
<!-- スタイルシートの読み込み -->
<link rel="stylesheet" href="~/index.css" />
<h1>仮想スクロール デモ (Fetch なし)</h1>
<!-- テーブルヘッダー行 -->
<div class="header-row">
<span class="col-id">ID</span>
<span class="col-category">カテゴリ</span>
<span class="col-name">商品名</span>
<span class="col-price">価格</span>
<span class="col-stock">在庫</span>
</div>
<!-- 仮想スクロールコンテナ -->
<div id="virtual-scroll-container" class="virtual-scroll-container">
<div id="spacer" class="virtual-scroll-spacer"></div>
<div id="viewport" class="virtual-scroll-viewport"></div>
</div>
<script>
(function () {
// 各アイテム行の高さ (px)
const ITEM_HEIGHT = 40;
// 表示範囲の上下に追加描画するバッファ行数
const BUFFER = 5;
// DOM 要素の取得
const container = document.getElementById('virtual-scroll-container');
const spacer = document.getElementById('spacer');
const viewport = document.getElementById('viewport');
// ページモデルから埋め込まれた JSON データを取得する
const allItems = @Html.Raw(Model.DataJson);
// spacer の高さを全アイテムの合計高さに設定し、スクロールバーを生成する
spacer.style.height = (allItems.length * ITEM_HEIGHT) + 'px';
// 現在のスクロール位置に応じて表示範囲のアイテムだけを描画する
function render() {
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
// 表示開始インデックスを計算 (バッファ分を上に拡張)
let startIndex = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER;
if (startIndex < 0) startIndex = 0;
// 表示終了インデックスを計算 (バッファ分を下に拡張)
let endIndex = Math.ceil((scrollTop + containerHeight) / ITEM_HEIGHT) + BUFFER;
if (endIndex > allItems.length) endIndex = allItems.length;
// viewport の位置を開始インデックスに合わせてオフセットする
viewport.style.top = (startIndex * ITEM_HEIGHT) + 'px';
// 表示範囲のアイテムの HTML を組み立てる
let html = '';
for (let i = startIndex; i < endIndex; i++) {
const item = allItems[i];
html += '<div class="item-row">'
+ '<span class="col-id">' + item.id + '</span>'
+ '<span class="col-category">' + item.category + '</span>'
+ '<span class="col-name">' + item.product_name + '</span>'
+ '<span class="col-price">¥' + item.price.toLocaleString() + '</span>'
+ '<span class="col-stock">' + item.stock + '</span>'
+ '</div>';
}
// viewport に HTML を反映する
viewport.innerHTML = html;
}
// スクロールイベントで再描画を実行する
container.addEventListener('scroll', render);
// 初期描画
render();
})();
</script>
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace RazorPagesVirtualScroll.Pages
{
public class IndexNoFetchModel : PageModel
{
public string DataJson { get; set; } = "";
public void OnGet()
{
var items = DemoData.CreateItems();
DataJson = JsonSerializer.Serialize(items);
}
}
}
DataJsonプロパティに表示する項目のJSONのデータを代入して、ページ側で、JSONのデータをページモデルから取得して表示する動作になります。
実行結果は下図です。先の実行結果と同じ動作になります。
Claude Opus 4.6, Visual Studio Agent モードで以下のプロンプトを実行してコード生成できます。