Quand on parle de performance web, on pense souvent au rendu, aux appels API, au temps serveur ou au poids des pages. Pourtant, une partie très concrète de l’expérience utilisateur se joue aussi dans la gestion des fichiers statiques : CSS, JavaScript, images, polices, fichiers issus de l’isolation CSS, scripts de composants et assets provenant de Razor Class Libraries.

Le problème est classique : je veux que le navigateur mette les fichiers en cache pour accélérer les chargements, mais je veux aussi être certain qu’une nouvelle version de mon application ne réutilise pas une ancienne version d’un fichier JavaScript ou CSS. C’est exactement le rôle du fingerprinting.

Avec Blazor et .NET 10, je m’appuie sur le pipeline d’assets statiques fourni par ASP.NET Core : MapStaticAssets, @Assets[...], ImportMap et, dans certains cas, StaticWebAssetFingerprintPattern. L’objectif n’est pas de bricoler des suffixes de version à la main. Je préfère laisser le framework calculer une empreinte à partir du contenu réel des fichiers, puis servir des URLs stables côté code mais uniques côté navigateur.

Ce que le fingerprinting règle concrètement

Le fingerprinting consiste à intégrer une empreinte du contenu dans l’URL ou le nom final d’un asset. Si le fichier ne change pas, son URL fingerprintée ne change pas. Si le fichier change, l’empreinte change aussi, donc le navigateur demande naturellement la nouvelle ressource.

flowchart LR
    A["Fichier source : app.css"] --> B["Build / publish .NET 10"]
    B --> C["URL fingerprintée"]
    C --> D["Cache navigateur long et immutable"]
    A2["app.css modifié"] --> B2["Nouveau build"]
    B2 --> C2["Nouvelle URL fingerprintée"]
    C2 --> E["Le navigateur recharge uniquement ce qui a changé"]

C’est important parce que les deux stratégies naïves posent problème :

  • sans cache, chaque navigation recharge trop de fichiers ;
  • avec un cache long mais sans fingerprinting, une mise en production peut laisser des utilisateurs avec de vieux scripts.

Avec le fingerprinting, je peux accepter un cache agressif, parce que l’URL devient la version.

La base dans mes applications Blazor

Dans mes projets Blazor récents, je démarre par MapStaticAssets. C’est le point d’entrée qui permet à ASP.NET Core de servir les assets connus au moment du build avec les optimisations prévues : fingerprinting, en-têtes de cache, ETag, type de contenu et compression au build/publish.

Exemple volontairement court dans Program.cs :

var app = builder.Build();

app.UseHttpsRedirection();
app.MapStaticAssets();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode();

app.Run();

Quand l’application est simple, je garde cette forme. Si je veux éviter de faire passer les requêtes d’assets dans le reste du pipeline, je peux aussi court-circuiter :

app.MapStaticAssets().ShortCircuit();

Je ne le mets pas partout par réflexe. Je le fais surtout quand le comportement est clair : mes fichiers statiques publics n’ont pas besoin d’un traitement middleware supplémentaire.

Pourquoi j’évite les chemins codés en dur

Dans le HTML d’un composant racine ou d’un layout, je préfère utiliser @Assets[...] plutôt qu’un chemin brut. Le code reste lisible, mais Blazor peut résoudre l’URL fingerprintée réelle.

<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["Appliman.Client.styles.css"]" />

Même logique pour un fichier provenant d’une Razor Class Library :

<link rel="stylesheet" href="@Assets["_content/Appliman.Ui/components.css"]" />

C’est une habitude que j’aime bien parce qu’elle me force à penser en asset logique côté application, pas en nom final généré côté build.

flowchart TD
    A["Composant App.razor"] --> B["Reference logique vers app.css"]
    B --> C["Collection des assets Blazor"]
    C --> D["URL finale avec fingerprint"]
    D --> E["Reponse HTTP avec cache longue duree"]

Mon usage personnel dans les projets

Dans mes projets, j’ai généralement trois familles d’assets :

  • les fichiers globaux de l’application, comme app.css ou des scripts communs ;
  • les assets de composants, souvent liés à l’isolation CSS ou à une Razor Class Library ;
  • quelques modules JavaScript pour l’interop quand un composant Blazor doit piloter une API navigateur.

Je cherche à garder ces assets proches du code qui les utilise. Par exemple, si un composant vient d’une librairie UI interne, ses styles et scripts restent dans cette librairie. L’application consommatrice référence ensuite les assets via _content/..., sans recopier les fichiers dans son propre wwwroot.

flowchart LR
    App["Application Blazor"] --> AppCss["wwwroot/app.css"]
    App --> RCL["Razor Class Library"]
    RCL --> CssIso["CSS isolation"]
    RCL --> JsModule["Module JS du composant"]
    AppCss --> Build["Static web assets manifest"]
    CssIso --> Build
    JsModule --> Build
    Build --> Browser["Navigateur : URLs fingerprintées"]

Ce modèle m’aide au moment des déploiements. Si je modifie seulement un composant, je ne veux pas invalider mentalement tout le cache du site. Je veux que le framework produise de nouvelles URLs uniquement pour les fichiers qui ont vraiment changé.

ImportMap pour les modules JavaScript

Dès qu’il y a des modules JavaScript, ImportMap devient important. Dans une Blazor Web App, je le place dans le <head> du composant racine.

<head>
    <ImportMap />
    <link rel="stylesheet" href="@Assets["app.css"]" />
</head>

L’idée est simple : le navigateur sait résoudre les imports vers les fichiers réellement fingerprintés. Je garde donc des imports propres côté code, pendant que le build s’occupe des URLs finales.

Un exemple très court d’interop :

var module = await JS.InvokeAsync<IJSObjectReference>(
    "import", "./features/orders.js");

Si j’ai des modules supplémentaires à fingerprint, je peux déclarer un pattern dans le .csproj :

<ItemGroup>
  <StaticWebAssetFingerprintPattern Include="JSModule"
                                    Pattern="*.mjs"
                                    Expression="#[.{fingerprint}]!" />
</ItemGroup>

Je l’utilise seulement quand j’ai une vraie raison : modules .mjs, conventions particulières, ou besoin de rendre explicite une famille de fichiers.

Ce que je surveille en production

La promesse du fingerprinting fonctionne bien si je respecte quelques règles simples.

D’abord, je ne référence pas les fichiers générés à la main. Si je vois un nom avec hash directement copié dans un composant, je considère que c’est une odeur de code. Le code applicatif doit référencer le fichier logique, pas le résultat du build.

Ensuite, je sépare les assets publics des fichiers qui ne doivent pas l’être. wwwroot et les assets statiques d’une librairie sont faits pour être servis au client. Ce n’est pas un endroit pour déposer de la configuration sensible.

Enfin, je garde en tête que MapStaticAssets est conçu pour les assets connus au build ou au publish. Si je dois servir des fichiers ajoutés sur disque après le déploiement, par exemple des fichiers déposés par un utilisateur ou un système externe, je bascule sur UseStaticFiles avec une configuration explicite.

app.UseStaticFiles(new StaticFileOptions
{
    RequestPath = "/uploads"
});

Dans ce cas, je ne mélange pas les deux sujets : les assets applicatifs passent par MapStaticAssets, les fichiers dynamiques ou externes passent par un middleware configuré pour ce besoin.

Le scénario de déploiement que je vise

Le comportement que je veux obtenir est le suivant :

sequenceDiagram
    participant U as Utilisateur
    participant B as Navigateur
    participant S as Application Blazor

    U->>B: Ouvre l'application v1
    B->>S: Demande app.css fingerprinté
    S-->>B: app.abc.css + Cache-Control immutable
    U->>B: Revient plus tard
    B-->>B: Réutilise le cache
    U->>B: Ouvre l'application après déploiement v2
    B->>S: Demande la nouvelle URL app.def.css
    S-->>B: Nouveau fichier, ancien cache ignoré naturellement

Ce schéma résume pourquoi j’aime cette approche. Je n’ai pas besoin de demander aux utilisateurs de vider le cache. Je n’ai pas besoin d’ajouter des ?v=42 partout. Je laisse le contenu décider de l’URL.

Conclusion

Dans Blazor avec .NET 10, je vois la gestion des assets comme une partie du contrat de déploiement. Ce n’est pas seulement un détail frontend. C’est ce qui permet de livrer une nouvelle version sans casser l’expérience des utilisateurs déjà passés sur le site.

Mon approche reste volontairement simple :

  • MapStaticAssets pour les assets applicatifs connus au build ;
  • @Assets[...] pour éviter les chemins finaux codés en dur ;
  • ImportMap pour les modules JavaScript ;
  • UseStaticFiles uniquement pour les cas qui sortent du pipeline d’assets statiques ;
  • aucune stratégie manuelle de versionnement tant que le framework sait faire le travail proprement.

C’est une mécanique discrète, mais elle évite beaucoup de problèmes au moment où l’application passe de mon poste de développement à la production.

Sources