Wprowadzenie
Jednym z problemów aplikacji stworzonej w Blazorze (WebAssembly) jest start aplikacji i potrzebny czas do jej pobrania i załadowania. Co powoduje, że już w najprostszej aplikacji użytkownik widzi ekran ładowania. W najnowszej wersji Blazora, która została opublikowana wraz z .NET 5 pojawiła możliwość prerenderowania aplikacji już po stronie serwera (to coś innego niż działanie Serwer Side). Dzięki czemu do przeglądarki leci już wygenerowany HTML, a co za tym idzie użytkownik szybciej widzi interfejs aplikacji. W tym wpisie pokaże Ci jak skonfigurować aplikację, jakie problemy niesie prerenderowanie aplikacji oraz jak je rozwiązać.
Aplikacja bez prerenderowania
Zanim przejdę do omawiania, w jaki sposób skonfigurować prerenderowanie w Blazorze, zobacz, jak zachowuje się aplikacja bez tego. W przykładzie wykorzystam prosty projekt, który jest domyślnie wygenerowany przy tworzeniu nowej aplikacji Blazor w WebAssembly. Już nawet w tej aplikacji, która uruchamia się lokalnie można zobaczyć Loading…

Gdy zajrzymy do żądań wysyłanych przez aplikację, to ładnie widać, że w pierwszym żądaniu serwer zwraca do przeglądarki prostego HTMLa, w którym znajduje się div z napisem „Loading…”, który następnie jest podmieniany i w nim jest renderowana aplikacja już po stronie przeglądarki:

Zobaczmy, w jaki sposób włączyć prerenderowanie aplikacji po stronie serwera, aby już w tym miejscu serwer zwracał wygenerowanego HTMLa.
Blazor Prerendering
Skonfigurowanie prerenderowania nie jest skomplikowane, wymaga wykonania kilku kroków. Liczę, że z czasem gotowe szablony w Visual Studio będą to miały skonfigurowane i nie będzie trzeba robić tego ręcznie.
Domyślnie do przeglądarki przesyłany jest plik index.html z folderu wwwroot z projektu aplikacji Blazora (WebAssembly) – projekt *.Client (* to nazwa podana w trakcie tworzenia projektu). W przypadku prerenderowania aplikacji należy wysyłać plik z poziomu aplikacji serwerowej – projekt *.Server.
Dlatego w folderze Pages projektu serwerowego dodajemy nowy plik (np. o nazwie _Host.cshtml), do którego przekopiowujemy zawartość z wcześniej wspomnianego pliku index.html. Za chwilkę ten plik będziemy zmieniali. W efekcie mamy coś takiego:

Skopiowaną zawartość musimy troszeczkę zmienić i ostatecznie wygląda ona tak:
@page | |
@using BlazorPreRendering.Client | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> | |
<title>BlazorPreRendering</title> | |
<base href="/" /> | |
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" /> | |
<link href="css/app.css" rel="stylesheet" /> | |
<link href="BlazorPreRendering.Client.styles.css" rel="stylesheet" /> | |
</head> | |
<body> | |
<component type="typeof(App)" render-mode="WebAssemblyPrerendered" /> | |
<script src="_framework/blazor.webassembly.js"></script> | |
</body> | |
</html> |
W stosunku do zawartości pliku index.html zmieniłem:
- dodanie dyrektywy page oraz using na początku pliku – linijka 1 oraz 2
- usunięcie dwóch elementów div z id app oraz blazor-error-ui – one tutaj już nie będę potrzebne
- dodanie komponentu typu App (projekt Client – dlatego był wcześniej potrzebny using) – linijka 17
- ustawienie trybu renderowania dla komponentu na WebAssemblyPrerendered – również linijka 17
Po skopiowaniu i zmianie zawartości w pliku _Host.cshtml musimy jeszcze zmienić konfigurację domyślnego pliku, który ma być wysyłany do przeglądarki. Robimy to w klasie Startup projektu serwerowego. W metodzie Configure jest fragment kodu, który konfiguruje punkty dostępowe aplikacji (wywołanie metody UseEndpoints). W niej zmieniamy startowy plik:
app.UseEndpoints(endpoints => | |
{ | |
endpoints.MapRazorPages(); | |
endpoints.MapControllers(); | |
//endpoints.MapFallbackToFile("index.html"); | |
endpoints.MapFallbackToPage("/_Host"); | |
}); |
Zakomentowana linijka 5 zawiera wcześniejszą zawartość. Natomiast w linijce 6 znajduje się nowa konfiguracja, która spowoduje, że domyślnie będzie wykonywany kod z pliku _Host.cshtml.
Aby cieszyć się prerenderowaniem musimy jeszcze wykonać jedną rzecz. W tym momencie aplikacja jeszcze nie wie, czym jest element component z pliku _Host.cshtml. Musimy zaimportować tag helper. Najlepiej zrobić to w pliku _ViewImports.cshtml, zawierającym importy, które będą automatycznie dorzucone do wszystkich stron.
Dlatego do folderu Pages dodajemy plik _ViewImports.cshtml i zawartość do niego (odpowiednio zmieniając nazwy przestrzeni nazw):
@using BlazorPreRendering.Server | |
@namespace BlazorPreRendering.Server.Pages | |
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers |
Po tych krokach możemy cieszyć się prerenderowaniem aplikacji.
Działanie prerenderowania
Możemy już uruchomić aplikację i zobaczyć zmiany. Po pierwsze nie widać już napisu Loading… (który przecież usunęliśmy) oraz widać, że aplikacja wyświetla się szybciej:

Nawet ciężko wyłapać moment ładowania aplikacji. Efekt prerenderowania aplikacji dużo lepiej widać w tym, co serwer przesyła do przeglądarki:

Serwer zwraca HTMLa, który jest wyświetlany w przeglądarce. Dzięki temu nie mamy problemu z długim startem aplikacji, jak to jest domyślnie.
Jeśli przełączymy się na zakładkę Console, to zobaczmy szereg błędów:

Spowodowane jest to tym, że domyślnie aplikacja po starcie szuka diva o id app, aby w nim wyświetlić aplikację. Oczywiście tego diva nie ma, bo usunęliśmy go wcześniej.
Aby naprawić błąd, wystarczy udać się do klasy Program projektu aplikacji klienckiej i w niej usunąć jedną linijkę – zakomentowana linijka numer 6:
public class Program | |
{ | |
public static async Task Main(string[] args) | |
{ | |
var builder = WebAssemblyHostBuilder.CreateDefault(args); | |
//builder.RootComponents.Add<App>("#app"); | |
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); | |
await builder.Build().RunAsync(); | |
} | |
} |
Po tym błędy z konsoli powinny zniknąć.
Problemy
Aplikacja sprawia wrażenie, że działa poprawnie. Możemy przechodzić pomiędzy stronami, działa licznik oraz pobieranie danych. Po wyrenderowaniu pierwszej strony, pozostałe już są obsługiwane przez WebAssembly po stronie przeglądarki. Ale jest jeden problem, który może być niewidoczny na pierwszy rzut oka.
Gdy wejdziemy na stronę Fetch Data (/fetchdata) i spróbujemy odświeżyć (F5) stronę, aby była ona wyrenderowana po stronie serwera, to otrzymamy błąd:

Jest on spowodowany tym, że po stronie serwera, gdzie teraz odbywa się renderowanie aplikacji, nie jest domyślnie zarejestrowany typ HttpClient w kontenerze dependency injection.
Najprostszym rozwiązaniem jest po prostu rejestracja typu HttpClient w klasie Startup projektu serwerowego (linijka 6):
public void ConfigureServices(IServiceCollection services) | |
{ | |
services.AddControllersWithViews(); | |
services.AddRazorPages(); | |
services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("https://localhost:44363/") }); | |
} |
Dla prostoty przykładu ustawiłem ręcznie bazowy adres, aby nie komplikować zbyt mocno przykładu, szczególnie, że to nie jest docelowe rozwiązanie.
W tym momencie działanie aplikacji nie jest do końca optymalne. Renderowanie strony Fetch data po stronie serwera powoduje, że serwer wysyła sam do siebie żądanie HTTP po dane do wyświetlenia w tabelce. Mamy zbędny narzut, który można spróbować wyeleminować.
Lepsze rozwiązanie problemu
Innym rozwiązaniem problemu jest próba opakowania kodu pobierającego dane w jakąś usługę, w tym przypadku interfejs, który następnie będzie miał różne implementacje. Wersja serwerowa wykonuje właściwą logikę (tutaj generowanie danych). Wersja kliencka wysyła żądanie HTTP do serwera, gdzie pod spodem jest używana implementacja serwerowa usługi.
Zacznę od dodania interfejsu IWeatherForecastService do projektu Shared. Dla przypomnienia projekt ten jest współdzielony między projektem serwerowym oraz klienckim. Więc to idealne miejsce na ten interfejs:
public interface IWeatherForecastService | |
{ | |
Task<WeatherForecast[]> GetForecastAsync(); | |
} |
Implementacja kliencka będzie zawierać tę samą logikę, która do tej pory znajdowała się na stronie FetchData:
public class WeatherForecastService : IWeatherForecastService | |
{ | |
private readonly HttpClient _httpClient; | |
public WeatherForecastService(HttpClient httpClient) | |
{ | |
_httpClient = httpClient; | |
} | |
public async Task<WeatherForecast[]> GetForecastAsync() | |
{ | |
return await _httpClient.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast"); | |
} | |
} |
Musimy jeszcze zmienić samą stronę FetchData, aby korzystała z nowej usługi. Zmiany są bardzo proste i sprowadzają się do trzech linijek (3, 4, 44):
@page "/fetchdata" | |
@using BlazorPreRendering.Shared | |
@using BlazorPreRendering.Shared.Services | |
@inject IWeatherForecastService Service | |
<h1>Weather forecast</h1> | |
<p>This component demonstrates fetching data from the server.</p> | |
@if (forecasts == null) | |
{ | |
<p><em>Loading...</em></p> | |
} | |
else | |
{ | |
<table class="table"> | |
<thead> | |
<tr> | |
<th>Date</th> | |
<th>Temp. (C)</th> | |
<th>Temp. (F)</th> | |
<th>Summary</th> | |
</tr> | |
</thead> | |
<tbody> | |
@foreach (var forecast in forecasts) | |
{ | |
<tr> | |
<td>@forecast.Date.ToShortDateString()</td> | |
<td>@forecast.TemperatureC</td> | |
<td>@forecast.TemperatureF</td> | |
<td>@forecast.Summary</td> | |
</tr> | |
} | |
</tbody> | |
</table> | |
} | |
@code { | |
private WeatherForecast[] forecasts; | |
protected override async Task OnInitializedAsync() | |
{ | |
forecasts = await Service.GetForecastAsync(); | |
} | |
} |
Na koniec wystarczy jeszcze zarejestrować usługę w kontenerze po stronie klienckiej. Robimy to w Program.cs (linijka 9):
public class Program | |
{ | |
public static async Task Main(string[] args) | |
{ | |
var builder = WebAssemblyHostBuilder.CreateDefault(args); | |
//builder.RootComponents.Add<App>("#app"); | |
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); | |
builder.Services.AddScoped<IWeatherForecastService, WeatherForecastService>(); | |
await builder.Build().RunAsync(); | |
} | |
} |
Dodanie usługi po stronie serwera
Po stronie serwera również dodajemy implementację WeatherForecastService. Zawiera ona logikę, która była wcześniej w WeatherForecastController:
public class WeatherForecastService : IWeatherForecastService | |
{ | |
private static readonly string[] Summaries = new[] | |
{ | |
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" | |
}; | |
public async Task<WeatherForecast[]> GetForecastAsync() | |
{ | |
var rng = new Random(); | |
return Enumerable.Range(1, 5).Select(index => new WeatherForecast | |
{ | |
Date = DateTime.Now.AddDays(index), | |
TemperatureC = rng.Next(-20, 55), | |
Summary = Summaries[rng.Next(Summaries.Length)] | |
}) | |
.ToArray(); | |
} | |
} |
Zmieniamy również kontroler, aby korzystał z usługi:
[ApiController] | |
[Route("[controller]")] | |
public class WeatherForecastController : ControllerBase | |
{ | |
private readonly ILogger<WeatherForecastController> _logger; | |
private readonly IWeatherForecastService _weatherForecastService; | |
public WeatherForecastController(ILogger<WeatherForecastController> logger, | |
IWeatherForecastService weatherForecastService) | |
{ | |
_logger = logger; | |
_weatherForecastService = weatherForecastService; | |
} | |
[HttpGet] | |
public async Task<IEnumerable<WeatherForecast>> Get() | |
{ | |
return await _weatherForecastService.GetForecastAsync(); | |
} | |
} |
A na samym końcu rejestracji usługi (linijka 7):
public void ConfigureServices(IServiceCollection services) | |
{ | |
services.AddControllersWithViews(); | |
services.AddRazorPages(); | |
services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("https://localhost:44363/") }); | |
services.AddScoped<IWeatherForecastService, WeatherForecastService>(); | |
} |
W zależności od tego, czy aplikacja wykonuje się po stronie serwera, czy przeglądarki, będzie korzystać z odpowiedniej usługi. Ale jak widać to podejście powoduje, że mamy trochę więcej pracy i całość się komplikuje.
Problem z OnInitializedAsync
Aktualne rozwiązanie ma jeszcze jeden dodatkowy problem, który widać poniżej:

Po załadowaniu strony następuje po chwili przeładowanie danych. Jest to spowodowane tym, że Blazor po załadowaniu aplikacji w przeglądarce uruchamia metodę OnInitializedAsync, która w tym przykładzie zawiera logikę ładowania danych. Więc w praktyce wykonuje się ona dwa razy. Raz po stronie serwera podczas prerenderowania strony oraz drugi raz już po załadowaniu aplikacji w przeglądarce. Co w tym przypadku wyraźnie widać, bo dane za każdym razem są losowe.
Niestety na tę chwilę nie jesteśmy w stanie rozwiązać tego problemu. Metoda musi się wykonać po obu stronach, aby wyrenderować HTML po stronie serwera, czy odtworzyć stan komponentu w przeglądarce.
Możemy się zastanowić, czy potrzebujemy wyrenderować tabelkę po stronie serwera. Bo w sumie powinno nam wystarczyć, że pozostałe elementy interfejsu użytkownika zostaną wyrenderowane po stronie serwera, więc i tak uzyskamy efekty szybkiego startu aplikacji. Dane do tabelki zostaną po chwili załadowane.
Niestety na tę chwilę nie znalazłem wbudowanego sposób na sprawdzenie, czy strona renderuje się na serwerze, czy w przeglądarce. Ale można to obejść poprzez dodanie prostej usługi PreRenderService:
public class PreRenderService : IPreRenderService | |
{ | |
public bool IsPreRendering { get; private set; } | |
public PreRenderService() | |
{ | |
} | |
public PreRenderService(IHttpContextAccessor httpContextAccessor) | |
{ | |
if (httpContextAccessor.HttpContext.Response.HasStarted) | |
{ | |
IsPreRendering = false; | |
} | |
else | |
{ | |
IsPreRendering = true; | |
} | |
} | |
} | |
public interface IPreRenderService | |
{ | |
bool IsPreRendering { get; } | |
} |
Usługa ma jedną właściwość IsPreRendering, w którym będzie informacja o tym, czy następuje prerenderowanie, czy nie. Informację ustawiamy po stronie serwera na podstawie kontekstu HTTP (drugi konstruktor). Natomiast po stronie klienta nie będziemy mieli kontekstu, więc wykona się pierwszy konstruktor, który zostawi domyślną wartość właściwości, czyli false.
Musimy jeszcze usługę zarejestrować. Po stronie serwera wygląda to tak (nowe linijki to 8 i 9):
public void ConfigureServices(IServiceCollection services) | |
{ | |
services.AddControllersWithViews(); | |
services.AddRazorPages(); | |
services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("https://localhost:44363/") }); | |
services.AddScoped<IWeatherForecastService, WeatherForecastService>(); | |
services.AddHttpContextAccessor(); | |
services.AddScoped<IPreRenderService, PreRenderService>(); | |
} |
Rejestrujemy HttpContextAccessor oraz samą usługę. Natomiast po stronie klienta tylko usługę (linijka 8):
public static async Task Main(string[] args) | |
{ | |
var builder = WebAssemblyHostBuilder.CreateDefault(args); | |
//builder.RootComponents.Add<App>("#app"); | |
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); | |
builder.Services.AddScoped<IWeatherForecastService, WeatherForecastService>(); | |
builder.Services.AddScoped<IPreRenderService, PreRenderService>(); | |
await builder.Build().RunAsync(); | |
} |
Na koniec musimy jeszcze wykorzystać usługę na stronie Fetch Data:
@page "/fetchdata" | |
@using BlazorPreRendering.Shared | |
@using BlazorPreRendering.Shared.Services | |
@inject IWeatherForecastService Service | |
@inject IPreRenderService PreRenderService | |
<h1>Weather forecast</h1> | |
<p>This component demonstrates fetching data from the server.</p> | |
@if (forecasts == null) | |
{ | |
<p><em>Loading...</em></p> | |
} | |
else | |
{ | |
<table class="table"> | |
<thead> | |
<tr> | |
<th>Date</th> | |
<th>Temp. (C)</th> | |
<th>Temp. (F)</th> | |
<th>Summary</th> | |
</tr> | |
</thead> | |
<tbody> | |
@foreach (var forecast in forecasts) | |
{ | |
<tr> | |
<td>@forecast.Date.ToShortDateString()</td> | |
<td>@forecast.TemperatureC</td> | |
<td>@forecast.TemperatureF</td> | |
<td>@forecast.Summary</td> | |
</tr> | |
} | |
</tbody> | |
</table> | |
} | |
@code { | |
private WeatherForecast[] forecasts; | |
protected override async Task OnInitializedAsync() | |
{ | |
if (PreRenderService.IsPreRendering == false) | |
{ | |
forecasts = await Service.GetForecastAsync(); | |
} | |
} | |
} |
Zamiany są w dwóch miejscach:
- linijka 5 – wstrzyknięcie PreRenderService
- linijka 45 – sprawdzenie właściwości IsPreRendering
W efekcie aplikacja poza tabelką ładnie renderuje się po stronie serwera, a następnie w aplikacji następuje pobranie danych i renderowanie tabelki:

Myślę, że jest to najlepsze rozwiązanie. Aplikacja wyświetla się użytkownikowi bez opóźnień. Natomiast pobranie danych i generowanie tabelki odbywa się tylko raz.
Przykład
Na githubie (https://github.com/danielplawgo/BlazorPreRendering) znajduje się przykład do tego wpisu. Każdy kolejny commit to poszczególne kroki realizowane w trakcie wpisu. Więc możesz wrócić do dowolnego momentu i się pobawić. Po pobraniu projektu można od razu go uruchomić. Nie trzeba konfigurować nic więcej.
Podsumowanie
Prerenderowanie aplikacji po stronie serwera spowoduje, że użytkownik będzie miał wrażenie, że aplikacja startuje szybciej. Ale jak widać potrzebujemy włożyć trochę dodatkowej pracy, aby osiągnąć ten efekt. Przy okazji musimy się zastanowić, w jaki sposób rozwiążemy problem z podwójnym wywołaniem metody OnInitializedAsync.
Z drugiej strony myślę, że warto o to zadbać, bo start aplikacji jest dużo przyjemniejszy dla użytkownika.
A co Ty o tym myślisz?
1 thought on “Blazor – prerendering”