Sin da .NET 5 esiste per Blazor il componente Virtualize che permette di caricare “a pezzi” una lunga lista di dati e visualizzarla come se fosse unica. Si possono trovare tonnellate di esempi di codice in giro per la rete che spiegano come utilizzarlo, ma a volte serve andare un po’ più in là di quello che è l’utilizzo di base.
Virtualize: utilizzo base
Abbiamo accennato ad un fantomatico utilizzo di base di Virtualize, ma cos’è? Apriamo VS2022 e creiamo un progetto Blazor Webassembly ASP.NET Core Hosted. Quello del template classico, con il WeatherForecast che ci piace tanto. Come framework scegliamo .NET 7
Facciamo subito qualche modifica di base per vedere cosa succede. Nel WeatherForecastController, esiste un metodo GET che restituisce 5 oggetti per volta. Come prima modifica gliene facciamo restituire qualcuno in più:
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 50000).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
Fatto questo possiamo lanciare il progetto e vedere cosa succede:

Se avete un buon libro o magari avete bisogno di andare in bagno, beh… questo è un buon momento perché prima di vedere i nostri dati passerà parecchio tempo. Decisamente non è una buona soluzione.
Introduciamo il nostro oggetto Virtualize in questo modo:
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
<Virtualize Items="forecasts" Context="forecast">
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
</Virtualize>
</tbody>
</table>
E se facciamo partire il nostro progettino i dati saranno caricati in tempi un po’ più umani. Sul mio PC ci mette dai 3 ai 4 secondi. Per 50mila records potrebbe essere accettabile.
Rompiamo gli schemi
Ma cosa succede se avessimo un milione di records da visualizzare? Beh… sarebbe esattamente la stessa cosa di quanto avviamo visto prima senza il Virtualize.
Personalmente stavo cercando di utilizzare il Virtualize per visualizzare un file proveniente da una macchina utensile che ha al suo interno proprio un milione di linee. Con .NET 5 questo non era possibile perché Virtualize non riusciva a gestire correttamente questa mole di dati, ma con .NET 7 finalmente sembra che il problema sia risolto.
Visualizziamo un milione di righe
Per fare questo dobbiamo crearci un nuovo progetto sempre Blazor Webassembly ASP.NET Core Hosted ma questa volta partiamo dal template vuoto.
Dato che la struttura di progetto sarà diversa dalla precedente, inizierei spiegando le varie parti.
Ristrutturiamo il backend
Iniziamo dal backend. Creiamo un’API per poter rispondere alla chiamata HTTP che farà il front end quando vorrà caricare i dati.
public static class EndpointAPI
{
private const string CodeLinesEndpoint = "/get-text/{skip}/{count}";
public static IEndpointRouteBuilder AddTextProviderApiEndpoints(this IEndpointRouteBuilder app)
{
_ = app.MapGet(CodeLinesEndpoint, GetGasBlowerStateApi);
return app;
}
public static IResult GetGasBlowerStateApi(int skip, int count, TextProviderStorage textProvider)
{
var actualState = textProvider.GetList(skip, count).ToArray();
return Results.Ok(actualState);
}
}
Ed andremo a richiamarla nel file Program.cs
in questo modo:
app.AddTextProviderApiEndpoints();
così da sfruttare la dependency injection.
Come possiamo vedere dal codice, creiamo un TextProviderStorage
che ci permetterà di aprire il nostro file e fornirne il contenuto. Il TextProviderStorage
sarà fatto in questo modo:
public class TextProviderStorage
{
private List<GCodeLine> list = new();
public TextProviderStorage() => ReadFile();
private async void ReadFile()
{
int counter = 0;
var f = await File.ReadAllLinesAsync("FileSystemResources/LongProgram.nc");
if (f is not null)
{
foreach (var line in f)
{
list.Add(new GCodeLine() { LineNumber = counter++, CodeLine = line });
}
}
}
public List<GCodeLine> GetAllList()=> list.ToList();
}
Il file LongProgram.nc
lo troverete nel repository che accompagna il codice di questo articolo.
Ristrutturiamo il front end
Così come abbiamo fatto per il backend, ora ci preoccuperemo del front end con qualche approfondimento in più per il Virtualize.
Iniziamo con il creare un servizio che si occuperà di effettuare le chiamate HTTP verso il backend in questo modo:
public class Service
{
private HttpClient http;
public Service(HttpClient http)
{
this.http= http;
}
public async Task<List<GCodeLine>> GetItems()
=> await http.GetFromJsonAsync<List<GCodeLine>>("/get-all-text");
}
Questa classe Service
andremo a farla creare alla dependency injection in questo modo:
builder.Services.AddScoped<Service>();
direttamente nel file Program.cs
del nostro progetto Client. La creeremo Scoped perché essa utilizza un HttpClient
che è un servizio scoped.
A questo punto siamo pronti per ristrutturare la nostra pagina razor. Ci ricreiamo una tabella con il nostro Virtualize
@page "/"
@using BlazorVirtualizationList.Shared
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using BlazorVirtualizationList.Client.Services
@inject Service Service
<table class="table">
<thead>
<tr>
<th>Code</th>
</tr>
</thead>
<tbody>
<Virtualize Items="lines" Context="gCodeLine">
<tr>
<td>@gCodeLine.LineNumber @gCodeLine.CodeLine</td>
</tr>
</Virtualize>
</tbody>
</table>
Mentre nella sezione @code i dati saranno i seguenti
@code {
List<GCodeLine> lines = new();
protected override async Task OnInitializedAsync()
{
lines = await Service.GetItems();
}
}
Ora avviando l’applicazione vedremo chiaramente che… non è cambiato nulla 🙁
Questa struttura diversa dall’esemprio di base però, ci permette di modificare correttamente le parti in gioco.
Analizziamo il componente Virtualize
Fortunatamente è possibile vedere il sorgente di quasi tutti gli applicativi e frameworks, e dunque anche del componente Virtualize. Ci accorgiamo subito che contiene tre render fragments
/// <summary>
/// Gets or sets the item template for the list.
/// </summary>
[Parameter]
public RenderFragment<TItem>? ChildContent { get; set; }
/// <summary>
/// Gets or sets the item template for the list.
/// </summary>
[Parameter]
public RenderFragment<TItem>? ItemContent { get; set; }
/// <summary>
/// Gets or sets the template for items that have not yet been loaded in memory.
/// </summary>
[Parameter]
public RenderFragment<PlaceholderContext>? Placeholder { get; set; }
che possiamo sfruttare per visualizzare al meglio la nostra pagina.
Continuando ad analizzare il componente, vediamo che ci sono due proprietà che possiamo utilizzare: ItemsProvider e OverscanCount.
/// <summary>
/// Gets or sets the function providing items to the list.
/// </summary>
[Parameter]
public ItemsProviderDelegate<TItem>? ItemsProvider { get; set; }
/// <summary>
/// Gets or sets a value that determines how many additional items will be rendered
/// before and after the visible region. This help to reduce the frequency of rendering
/// during scrolling. However, higher values mean that more elements will be present
/// in the page.
/// </summary>
[Parameter]
public int OverscanCount { get; set; } = 3;
La prima è un delegate che possiamo “agganciare” ad una nostra funzione di recupero dati, la seconda ci servirà per impostare quante linee in più vogliamo che vengano caricate per avere un effetto di continuità di scrolling.
Quindi concentriamoci sulla funzione di recupero dati.
private async ValueTask<ItemsProviderResult<GCodeLine>> LoadData(ItemsProviderRequest request)
{
GCodeLine[] gcodes;
loading = false;
return new ItemsProviderResult<GCodeLine>(gcodes, totalLines);
}
private async Task<GCodeLine[]> GetItems(ItemsProviderRequest request)
{
return await Service.GetItems(request);
}
Senza entrare troppo nel dettaglio, l’oggetto ItemsProviderRequest passato alla funzione viene fornito direttamente dal Virtualize al momento della chiamata e conterrà uno StartIndex e un Count che ci serviranno per suddividere la chimamta di recupero dati. Nel metodo GetItems del nostro Service, gli passeremo direttamente questo oggetto di request in modo che sia il Service a gestirsi il recupero tramite l’API. Modifichiamo il Service in questo modo:
public async Task<GCodeLine[]> GetItems(ItemsProviderRequest request)
=> await http.GetFromJsonAsync<GCodeLine[]>("/get-text/" + request.StartIndex + "/" + 100);
A questo punto anche l’API deve essere modificata e lo faremo in questo modo:
private const string CodeLinesEndpoint = "/get-text/{skip}/{count}";
public static IEndpointRouteBuilder AddTextProviderApiEndpoints(this IEndpointRouteBuilder app)
{
_ = app.MapGet(CodeLinesEndpoint, GetCodeLinesApi);
return app;
}
public static IResult GetCodeLinesApi(int skip, int count, TextProviderStorage textProvider)
{
var actualState = textProvider.GetList(skip, count).ToArray();
return Results.Ok(actualState);
}
Sfruttando Skip e Count potremo recuperare solo una parte dei dati senza sovraccaricare il canale HTTP che dovrà rispondere al front end.
Al TextProviderStorage aggiungeremo un metodo per il recupero parziale dei dati:
public List<GCodeLine> GetList(int skip, int count)=> list.Skip(skip).Take(count).ToList();
Il momento della verità
E siamo giunti al momento della verità. Facciamo partire il tutto e… Pochi attimi dopo avremo la nostra lista bella e pronta a schermo.

Una delle cose più antipatiche quando si edita un file di programma per una macchina utensile è quello di andare immediatamente alla fine del programma per leggere o modificarne il contenuto. Se premiamo il tasto “Fine” sulla tastiera vedremo immediatamente disponibile a schermo l’ultima riga del file.
Direi che le performaces siano accettabili.
Potrete trovare il codice in questo repository.