Un pattern per gestire il pre-rendering in componenti Blazor complessi

di Marco De Sanctis, in ASP.NET Core,

Nel corso dell'articolo sul pre-rendering in Blazor (https://www.aspitalia.com/articoli/asp.net-core5/blazor/abilitare-gestire-prerendering-applicazioni-blazor-webassembly.aspx), abbiamo visto come uno degli aspetti a cui dobbiamo prestare maggiore attenzione è che la pagina possa esistere in due contesti differenti, sul client e sul server. Pertanto, quando un nostro componente presenta delle dipendenze esterne, dobbiamo assicurarci che esse abbiano senso in entrambi i casi.

A dimostrazione di questo fatto, nel corso dell'articolo abbiamo visto come la pagina FetchData.razor vada implementata in modo da supportare i due diversi contesti di esecuzione, mascherando l'uso di HttpClient:
https://www.aspitalia.com/articoli/asp.net-core5/blazor/abilitare-gestire-prerendering-applicazioni-blazor-webassembly-p-4.aspx#title_1

Ma gestire questo dualismo nel caso di pagine complesse può essere non banale, pertanto cercheremo di presentare un pattern che possiamo applicare in modo da minimizzare lo sforzo implementativo.

Immaginiamo allora di avere una pagina, chiamata PlayerList.razor, simile alla seguente:

@page "/players"
@inject HttpClient Http
@inject NotificationsManager Notifications
@implements IDisposable

...altro codice qui...

<Virtualize @ref="_virtualize" Context="player" ItemsProvider="GetPlayers" ItemSize="220">
   <ItemContent>
     .. altro codice qui ..
     <button OnClick="@(async e => await this.DeletePlayerAsync(player.Id))">
        Delete
     </button>
   </ItemContent>
</Virtualize>


@code {
    public Virtualize<Player> _virtualize;

    protected override async Task OnInitializedAsync()
    {
        this.Notifications.PlayerUpdatedAsync += LoadDataAsync;
    }

    private async ValueTask<ItemsProviderResult<Player>> GetPlayers(ItemsProviderRequest request)
    {
        var response = await this.Http.GetFromJsonAsync<PlayersResponse>(
          $"/api/players?start={request.StartIndex}&count={request.Count}");

        return new ItemsProviderResult<Player>(response.Players, response.TotalCount);
    }

    private async Task LoadDataAsync()
    {
        await _virtualize.RefreshDataAsync();

        this.StateHasChanged();
    }

    private async Task DeletePlayerAsync(int id)
    {
        var response = await this.Http.DeleteAsync($"/api/players/{id}");
        response.EnsureSuccessStatusCode();

        await this.LoadDataAsync();
    }

    public void Dispose()
    {
        this.Notifications.PlayerUpdatedAsync -= LoadDataAsync;
    }
}

La pagina gestisce una lista di giocatori, tramite il componente Virtualize, sfrutta l'infinite scroll per il load progressivo degli elementi, e permette di eliminarne alcuni alla pressione di un button. Senza dilungarci più di tanto nei dettagli, possiamo notare come essa presenti diverse dipendenze e interazioni non proprio banali:

  • Abbiamo una prima dipendenza esterna da HttpClient, che viene utilizzata per effettuare il refresh del contenuto del componente Virtualize;
  • c'è una seconda dipendenza da una nostra classe custom, chiamata NotificationsManager, che espone un evento PlayerUpdatedAsync, sollevato quando un altro utente apporta una modifica a qualcuno degli elementi;
  • all'inizializzazione, sottoscriviamo questo evento per scatenare un refresh del componente Virtualize, la cui reference è mantenuta in un field _virtualize;
  • abbiamo un metodo DeletePlayerAsync, invocato al click di un button, che usa HttpClient per invocare una API ed eliminare un record tramite una chiamata Delete;
  • la pagina implementa IDisposable, così da rimuovere la sottoscrizione a PlayerUpdatedAsync.

Come possiamo riorganizzare il codice in modo da supportare il pre-rendering?

L'idea è di creare un'interfaccia unica, che chiameremo IPlayersListPageController, specifica solo per la nostra pagina PlayerList, con il solo scopo di astrarre la logica di tutte queste interazioni:

public interface IPlayersListPageController : IDisposable
{
    Virtualize<Player> Virtualize { get; set; }

    ValueTask<ItemsProviderResult<Player>> GetPlayers(ItemsProviderRequest request);

    Task DeletePlayerAsync(int id);

    event Action DataHasChanged;
}

Come possiamo notare nel codice in alto, abbiamo replicato tutti i metodi e gli elementi di cui la nostra pagina iniziale ha bisogno:

  • una proprietà per manterere la reference all'oggetto Virtualize;
  • i due metodi per recuperare l'elenco dei player ed eliminare un player;
  • l'evento DataHasChanged.

A questo punto, possiamo riscrivere la pagina iniziale in questo modo:

@page "/players"
@inject IPlayersListPageController PageController
@implements IDisposable

...altro codice qui...

<Virtualize @ref="this.PageController.Virtualize" Context="player" 
            ItemsProvider="this.PageController.GetPlayers" ItemSize="220">
   <ItemContent>
     .. altro codice qui ..
     <button OnClick="@(async e => await this.PageController.DeletePlayerAsync(player.Id))">
        Delete
     </button>
   </ItemContent>
</Virtualize>


@code {
    protected override async Task OnInitializedAsync()
    {
        this.PageController.DataHasChanged += StateHasChanged;
    }

    public void Dispose()
    {
        this.PageController.DataHasChanged -= StateHasChanged;
    }
}

Ora il nostro codice di pagina è più pulito, perché presenta una sola dipendenza, e i componenti Virtualize e Button fanno riferimento direttamente ai suoi membri. L'unico codice rimasto è quello per sottoscrivere e cancellare l'evento DataHasChanged, in modo da forzare il refresh del componente con StateHasChanged.

A questo punto possiamo implementare IPlayersListPageController in versione BlazorWebAssembly in una classe specifica, in cui replicheremo grossomodo il codice presente nella prima versione di PlayerList.razor:

internal class WasmPlayersListPageController : IPlayersListPageController
{
    private NotificationsManager _notifications;
    private HttpClient _http;

    public WasmPlayersListPageController(NotificationsManager notifications, HttpClient http)
    {
        _notifications = notifications;
        _http = http;

        _notifications.PlayerUpdatedAsync += this.LoadDataAsync;
    }

    public Virtualize<Player> Virtualize { get; set; }

    public event Action DataHasChanged;

    private async Task LoadDataAsync()
    {
        await this.Virtualize.RefreshDataAsync();

        this.DataHasChanged?.Invoke();
    }

    public async Task DeletePlayerAsync(int id)
    {
        var response = await _http.DeleteAsync($"/api/players/{id}");
        response.EnsureSuccessStatusCode();

        await this.LoadDataAsync();
    }

    public void Dispose()
    {
        _notifications.PlayerUpdatedAsync -= this.LoadDataAsync;
    }

    public async ValueTask<ItemsProviderResult<Player>> GetPlayers(ItemsProviderRequest request)
    {
        var response = await _http.GetFromJsonAsync<PlayersResponse>(
          $"/api/players?start={request.StartIndex}&count={request.Count}");

        return new ItemsProviderResult<Player>(response.Players, response.TotalCount);
    }
}

Il beneficio di questo approccio - oltre alla maggiore testabilità della nostra logica di pagina - è che ora, se vogliamo supportare il pre-rendering, non dobbiamo più implementare diverse classi, una per ogni dipendenza: ci basterà infatti creare una ServerPlayersListPageController, in cui ci limiteremo a importare solo quelle di cui abbiamo effettivamente bisogno, e a riscrivere esclusivamente i metodi che hanno senso in caso di pre-rendering:

internal class ServerPlayersListPageController : IPlayersListPageController
{
    private MyContext _context;

    public ServerPlayersListPageController(MyContext context)
    {
        _context = context;
    }

    public Virtualize<Player> Virtualize { get; set; }

    public event Action DataHasChanged;

    public Task DeletePlayerAsync(int id)
    {
        throw new NotSupportedException();
    }

    public void Dispose()
    {
    }

    public async ValueTask<ItemsProviderResult<Player>> GetPlayers(ItemsProviderRequest request)
    {
        IQueryable<Player> result = _context.Players.Where(....);


        return new ItemsProviderResult<Player>(
            await result.ToListAsync(), 
            await _context.Players.CountAsync());
    }
}

Nel nostro caso, per esempio, sono sparite le dipendenze da HttpClient (siamo sul server, quindi nessuna chiamata Http) e NotificationManager, visto che, trattandosi di un rendering statico, non ha senso sottoscrivere alcuna notifica.

Anche il metodo di DeletePlayerAsync non ha implementazione, dato infatti che non verrà mai invocato lato server.

L'unica logica che è rimasta è una dipendenza da un DbContext di Entity Framework Core, che sfruttiamo nel metodo GetPlayers per recuperare l'elenco di Player nella fase di pre-rendering.

Conclusioni


Come già detto, una pagina che supporti il pre-rendering ha la responsabilità di fare in modo che le sue dipendenze funzionino sia lato WebAssembly che lato server. Tuttavia, quando le dipendenze sono molteplici e le interazioni complesse, supportare questo requisito può richiedere uno sforzo implementativo non banale.

In questo script abbiamo presentato un possibile pattern riutilizzabile per gestire queste complessità in maniera standardizzata, creando un unico PageController che astragga tutti i servizi necessari all'interfaccia.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Approfondimenti

I più letti di oggi