Utiliser des thèmes en MVC
ComponentViewLocationExpander : un système de résolution de vues dynamique dans ASP.NET Core
Dans ASP.NET Core MVC, le IViewLocationExpander permet de modifier les emplacements où Razor cherche les fichiers .cshtml. C’est un mécanisme puissant, souvent sous-estimé, qui permet de transformer complètement la logique de rendu des vues.
Le rôle clé de PopulateValues
La méthode PopulateValues est souvent vue comme un simple point d’initialisation, mais son rôle est beaucoup plus important. Elle sert à injecter des valeurs contextuelles (thème, section, etc.), mais surtout à définir la base du cache de résolution des vues.Cela signifie que ces valeurs participent directement à la clé de cache utilisée par Razor pour mémoriser les chemins de vues résolus.
Autrement dit, PopulateValues ne sert pas seulement à transporter des données, elle garantit aussi que le chemin d’une vue est correctement recalculé lorsque le contexte change. Sans cela, Razor pourrait réutiliser un chemin de vue incorrect entre différents contextes (thème, section, etc.).
Une résolution de vues basée sur le contexte
Grâce à ce mécanisme, la recherche de vues devient dynamique :
le thème actif influence le chemin
une section métier (Blog, Shop, Admin) restructure les composants
les vues peuvent être partagées ou spécialisées selon le contexte
On ne parle plus simplement de fichiers statiques, mais d’un système de résolution contextuel.
Une architecture plus proche d’un CMS
Ce type d’approche permet de sortir du modèle classique MVC où :
1 composant = 1 dossier de vues et de passer à un modèle plus flexible
1 domaine métier = 1 ensemble de vues organisées et mutualisées
Cas d’usage avancés
Au-delà du simple theming ou multi-dossier, ce système ouvre la porte à des scénarios plus avancés.
A/B Testing de vues
On peut charger différentes versions d’une vue selon le contexte utilisateur.
Exemple : une version A pour certains utilisateurs et une version B pour d’autres.
Variantes par utilisateur ou profil
La résolution peut dépendre du rôle utilisateur, du profil ou des préférences.
dashboard différent pour utilisateurs premium
interface simplifiée pour invités
vues spécifiques pour administrateurs
Personnalisation multi-tenant
Chaque client peut avoir ses propres vues tout en partageant la même base de composants.
Feature flags au niveau UI
Une feature activée peut changer complètement la vue utilisée, sans modifier le composant.
Localisation avancée
Au-delà des traductions, on peut adapter la structure même des vues selon les régions.
Europe : interface dense
US : interface plus orientée marketing
Personnalisation par appareil
Les vues peuvent varier selon le type de device (mobile, desktop, tablette).
Conclusion
Le ViewLocationExpander n’est pas seulement un outil de gestion de chemins de vues. C’est un moteur de sélection de templates contextuels.
Avec PopulateValues, il devient possible de contrôler finement le thème, la structure applicative, la personnalisation utilisateur et des scénarios avancés comme le A/B testing ou le multi-tenant UI.
Plongeons dans un exemple de code concret
Ici un exemple que j'utilise sur ce site pour gérer des thèmes. Si cela ne tenait qu'à des CSS ce serait inutile d'utiliser un IViewLocationExpander. Dans mon cas j'ai quelques alignements de markup à faire.
Le premier cas de figure est de pouvoir pour chaque site utiliser un thème différent.
Le thème de ce site est Crolow. J'ai une série de vues qui ont été customisées. Donc quand je navigue sur une page du site, quand on charge une vue, la priorité est donnée d'abord aux vues se trouvant dans View/Themes/Crolow ensuite ce sera dans Shared/Components ou autres Views dépendant du fait que c'est une vue pour une page ou une vue d'un composant.
Pour gérer cela dans le PopulateValues je récupère le thème du site qui est défini dans le SiteContext que j'ai implémenté et l'ajoute dans les valeurs de contexte du ViewExpander : context.Values["Theme"]
2e cas de figure. Je regroupe les vues de différents composants dans un seul et même répertoire. Par défaut chaque composant à son répertoire dédié, ce qui est un peu lourd à maintenir.
Pour gérer cela dans le PopulateValues je récupère la section que j'ai définie dans mon ViewData du composant : context.Values["ComponentSection"]
using Crolow.Cms.Core.Context;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace Crolow.Cms.Core.Startup.Mvc
{
public class ComponentViewLocationExpander : IViewLocationExpander
{
public void PopulateValues(ViewLocationExpanderContext context)
{
context.Values["Theme"] = SiteContext.Current(context.ActionContext.HttpContext)?.SettingsModel?.Theme ?? "default";
// Extract section safely
if (context.ActionContext is ViewContext vctx)
{
var section = vctx.ViewData.ContainsKey("ComponentSection")
? vctx.ViewData["ComponentSection"]?.ToString()
: "";
context.Values["ComponentSection"] = section ?? "";
}
else
{
context.Values["ComponentSection"] = "";
}
}
public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
{
var theme = context.Values["Theme"];
var section = context.Values["ComponentSection"];
var newUrls = new List<string>();
if (!string.IsNullOrEmpty(section))
{
var raw = context.ViewName;
string componentName = "";
string viewName = "";
var parts = raw.Split('/', StringSplitOptions.RemoveEmptyEntries);
viewName = parts.Last();
/*** I still need some more options but not yet implemented ***
if (string.IsNullOrEmpty(componentName))
{
if (!string.IsNullOrEmpty(raw) && raw.StartsWith("Components/"))
{
// Expected: Components / LatestBlogs / LatestBlogs
componentName = parts.Length > 1 ? parts[1] : "";
viewName = parts.Length > 2 ? parts[2] : parts[1];
}
else
{
// fallback (rare but safe)
componentName = "";
viewName = raw;
}
}
***/
var path = string.IsNullOrEmpty(componentName)
? $"{section}/{viewName}"
: $"{section}/{componentName}/{viewName}";
newUrls = new List<string>
{
$"~/Views/Themes/{theme}/Components/{path}.cshtml",
$"~/Views/Themes/{theme}/Components/{path}",
$"~/Views/Themes/default/Components/{path}.cshtml",
$"~/Views/Themes/default/Components/{path}",
$"~/Views/Shared/Components/{path}.cshtml",
$"~/Views/Shared/Components/{path}",
};
}
else
{
newUrls = new List<string>
{
$"~/Views/Themes/{theme}/Components/{{0}}.cshtml",
"~/Views/Themes/" + theme + "/{0}.cshtml",
$"~/Views/Themes/Default/Components/{{0}}.cshtml",
"~/Views/Themes/Default/{0}.cshtml",
"~/Views/Themes/" + theme + "/{1}/{0}.cshtml",
"~/Views/Themes/Default/{1}/{0}.cshtml"
};
}
newUrls.AddRange(viewLocations);
return newUrls.ToArray();
}
}
}PopulateValues
PopulatesValues permet de définir la customisation que vous voulez effectuer. Dans cette fonction vous allez compléter le dictionnaire Context.Values avec les variantes que vous voulez définir. Ces valeurs peuvent ensuite être récupérées dans la fonction ExpandViewLocations. Ce Context.Values va aussi définir la clé utilisé pour mettre le résultat de la fonction ExpandViewLocations en cache.
Il est donc important de définir vos valeurs dans cette fonction. Si vous définissez un thème par exemple mais que vous récupérez la valeur dans la fonction ExpandViewLocations. Seulement un thème sera actif car la fonction ExpandViewLocations ne sera plus appelée pour un appel à la même vue.
ExpandViewLocations
Cette fonction vous permet de définir le routing pour lequel le ViewEngine va scanner pour retrouver la vue que vous voulez générer. Elle retournera donc une liste à scanner
En paramètre vous avez une liste d'Urls (viewLocations) déjà définies. L'exemple ici est fonctionnel pour une installation Umbraco CMS qui a déjà changé les Urls par défaut de MVC. Normalement les Url par défaut sont les suivantes (à vérifier) :
/Areas/{AreaName}/Views/{ControllerName}/{ViewName}.cshtml
→ /Areas/Admin/Views/Dashboard/Index.cshtml/Areas/{AreaName}/Views/Shared/{ViewName}.cshtml
→ /Areas/Admin/Views/Shared/Index.cshtml/Views/{ControllerName}/{ViewName}.cshtml
→ /Views/Home/Index.cshtml/Views/Shared/{ViewName}.cshtml
→ /Views/Shared/Index.cshtml
Si on veut étendre ce routing, on doit définir de nouvelles Urls. Normalement celles-ci prévaudront à celles déjà définies. Donc au lieu d'ajouter des Urls à celles passées en paramètres, nous allons créer une nouvelle liste et au final rajouter les Url existantes. Evidemment ce n'est pas obligatoire de la jouer ainsi, mais en règle générale cela se fera ainsi, à moins que l'on veuille instaurer son propre système de fallback si une vue n'a pas pu être résolue.