eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArchitecture
eQuantic.UI - Architecture
Edit this page
11 min read
eQuantic.UI est un framework de composants pour .NET dans lequel un composant s'écrit une seule fois, en C#, et se réalise sur deux sortes de cibles : le web, en DOM et CSS servis par ASP.NET Core et pilotés dans le navigateur par du JavaScript que le build a compilé depuis ce même C# ; et des fenêtres et téléphones natifs, en pixels peints par le moteur GPU Photon. Il n'y a pas de second langage à apprendre ni de chaîne d'outils externe à installer : le compilateur, le bundler et le moteur voyagent dans les paquets du SDK lui-même.
TypeScript
Composants C#, un vocabulaire
eqc
Bun embarqué
wwwroot/_equantic
SSR ASP.NET Core
Navigateur
Build .NET, AOT
Shell Photon
Fenêtre / téléphone
Le diagramme ci-dessus est une clôture mermaid ` : GitHub le dessine ici, et le site de documentation dessine la MÊME clôture via le composant Mermaid du SDK lui-même.
Cette page est la carte. Composants write-once explique en profondeur le vocabulaire partagé et les réalisateurs, Photon le moteur natif, Compilateur et Flux de build la pipeline web étape par étape, et Architecture des paquets ce que porte chaque paquet. Le dépôt garde les audits mesurés à côté du code : docs/README.md en est l'index.
Principes fondateurs
1.
✅ 100% .NET : pas de Node.js, de npm ni de Vite sur la machine de qui développe ; la chaîne d'outils est embarquée.
2.
✅ Écrire une fois : une classe de composant, réalisée sur le DOM et sur une display list GPU.
3.
✅ Autonome : ASP.NET Core sert, rend et compile tout pour le web.
4.
✅ Familier : les routes sont des attributs, les services viennent d'IServiceCollection, la configuration d'appsettings.json ; qui développe en .NET connaît déjà les idiomes.
5.
✅ Aucun artefact de plateforme : pas de Swift, pas de manifest, pas de plist, pas de CSS. Le SDK dérive ce dont chaque cible a besoin depuis des déclarations C#.
1. Les couches
Couche
Paquet
Ce qu'elle contient
Peut référencer
Le vocabulaire
eQuantic.UI.Primitives
les nœuds visuels abstraits (Box, Row, Text, Pressable, …), les styles typés et les tokens de design, le modèle de composants, les attributs de contrat
rien
La bibliothèque
eQuantic.UI.Components, eQuantic.UI.Charts
les composants write-once (Button, Card, DataTable, BarChart, …), composés uniquement à partir du vocabulaire
Primitives
Le réalisateur web
eQuantic.UI.Web
abaisse un arbre de nœuds en HtmlElement/DOM et CSS atomique sur le serveur ; aussi l'échappatoire DOM (HtmlElement, DynamicElement, ClassBuilder)
Primitives
L'hôte web
eQuantic.UI.Server
l'intégration ASP.NET Core : SSR, Server Actions, routage, métadonnées, assets, hot reload
Web
Le runtime du navigateur
@equantic/runtime, empaqueté par eQuantic.UI.Server depuis le TypeScript d'eQuantic.UI.Runtime
le jumeau TypeScript du réalisateur web, le réconciliateur, l'état, le routeur, et les modules transpilés de la bibliothèque elle-même
rien
La voie native
eQuantic.UI.Native.Engine (+ Metal, Vulkan, Reference), Native.Framework, Native.Components, Native.Hosting, les shells
le moteur GPU et ses backends, le moteur de layout, le réalisateur et l'hôte Photon, le constructeur d'application, et un shell par plateforme
Primitives, et les uns les autres vers le bas
Le compilateur
eQuantic.UI.Compiler (eqc)
le transpileur C# vers TypeScript fondé sur Roslyn que lance le SDK web
Codegen
Les SDK
eQuantic.UI.Sdk, eQuantic.UI.Sdk.Native
des SDK MSBuild : un attribut Sdk="…" amène tous les paquets ci-dessus et câble le build
rien
Deux règles tiennent le tableau. Le vocabulaire ne référence rien, il peut donc voyager vers toutes les cibles, bundle navigateur et Native AOT compris. Un réalisateur ne référence jamais la bibliothèque qu'il réalise : un composant arrive à un réalisateur sous la forme de l'arbre que son Build a produit, donc Web, Email et Native.Components connaissent le vocabulaire et pas Components. Les deux règles sont des tests dans le dépôt (AssemblyLayeringTests), pas des conventions.
2. La pipeline web
2.1 Le SDK
Le fichier projet d'une application web nomme le SDK et rien d'autre, sans aucun PackageReference :
1
2
3
4
5
<Project Sdk="eQuantic.UI.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
La version vient de global.json, comme se versionne tout SDK MSBuild :
1
2
3
4
5
{
"msbuild-sdks": {
"eQuantic.UI.Sdk": "0.2.0-preview.52"
}
}
Sdk.props importe Microsoft.NET.Sdk.Web, ajoute les paquets du framework (Primitives, Code, Components, Charts, Web, Server, le générateur de source, et le paquet Bun embarqué pour l'OS et l'architecture de la machine qui compile), met la surface déclarative à portée dans chaque fichier (eQuantic.UI.Components.UI et eQuantic.UI.Charts.ChartsUI en usings statiques), et inclut Pages/**/*.cs comme les composants que le build compile pour le navigateur. À côté d'un checkout des sources du framework, il remplace chaque paquet par le projet correspondant, si bien que les exemples compilent contre l'arbre. Voir Flux de build.
Réduit à ce qui compte, ce fichier se lit ainsi :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- eQuantic.UI.Sdk/Sdk/Sdk.props -->
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
<PropertyGroup>
<EnableEQuanticUICompilation>true</EnableEQuanticUICompilation>
<EQuanticOutputPath>wwwroot/_equantic/</EQuanticOutputPath>
</PropertyGroup>
<ItemGroup>
<!-- la surface déclarative, à portée dans chaque fichier -->
<Using Include="eQuantic.UI.Components.UI" Static="true" />
<Using Include="eQuantic.UI.Charts.ChartsUI" Static="true" />
<!-- ce que le build compile pour le navigateur -->
<UIComponent Include="Pages/**/*.cs" Exclude="obj/**;bin/**" />
</ItemGroup>
</Project>
2.2 Ce que fait dotnet build
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dotnet build
↓
Microsoft.NET.Sdk.Web, la pipeline ordinaire, jusqu'à CoreCompile (le C# de l'application)
↓
ResolveBunPath le Bun embarqué pour cette machine, extrait une fois de son paquet
↓
CompileEQuanticUI après CoreCompile, et avant la définition des static web assets
1. eqc lit les sources du projet et les sources livrées par la bibliothèque
2. Roslyn les lie. Le modèle sémantique fait autorité ; un appel non liable est une erreur de build
3. un module TypeScript par page ou composant
4. Bun empaquette chaque module ; en Debug, avec une source map vers le C# (EQuanticSourceMaps)
↓
CopyEQuanticRuntime le runtime.js servi, depuis le paquet Server
↓
Sortie : wwwroot/_equantic/, défini comme static web assets à partir de ce qui est sur le disque,
pour que la compression, les manifests et le publish voient les fichiers de ce build
L'intermédiaire TypeScript est délibéré : le code émis est lisible, typé une seconde fois par le compilateur TypeScript, et remappé vers la ligne de C# pour le débogueur du navigateur. Le bundler est Bun, parce que c'est un exécutable unique qui empaquette du TypeScript sans configuration, et qu'il est livré comme un paquet par système d'exploitation et architecture (eQuantic.UI.Runtime.OsxArm64, .Win64, .Linux64, …) que le SDK sélectionne. Personne ne l'installe.
Voici ce qu'eqc émet pour l'un des composants de la bibliothèque elle-même, Divider, exactement tel qu'il est livré dans runtime.js. Le C# dont il vient est Divider.cs dans eQuantic.UI.Components : la même classe, le même Build, les enums abaissés à leurs noms en camelCase, l'initialiseur d'objet arrivant comme le props final :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Box, BoxStyle, BuildContext, EdgeInsets, SizeValue, StatelessComponent } from "../runtime-exports";
export class Divider extends StatelessComponent {
declare inset: string;
declare axis: string;
declare leadingInset: any;
constructor(inset: any = 'none', axis: any = 'horizontal', props?: any) {
super();
if (inset !== undefined) this.inset = inset;
if (axis !== undefined) this.axis = axis;
if (this.inset === undefined) this.inset = 'none';
if (this.axis === undefined) this.axis = 'horizontal';
this.inset = inset;
this.axis = axis;
if (props && typeof props === 'object') Object.assign(this, props);
}
build(context: BuildContext) {
let line = new Box(new BoxStyle({ width: this.axis === 'vertical' ? SizeValue.fixed(1) : SizeValue.fill, height: this.axis === 'vertical' ? SizeValue.fill : SizeValue.fixed(1), background: context.theme.border }));
if (this.inset === 'none') return line;
let padding = this.inset === 'leading' ? new EdgeInsets(this.leadingInset ?? 16, 0, 0, 0) : EdgeInsets.symmetric(16, 0);
return new Box(new BoxStyle({ width: SizeValue.fill, padding: padding }), line);
}
}
2.3 Ce qu'un build écrit
Les tailles ne sont pas citées ici, puisqu'elles bougent à chaque changement : la taille gzip du runtime servi est un budget que la suite fait respecter (ServedRuntimeBudgetTests, contre l'enregistrement dans tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json), et la CI rapporte la taille du module de chaque page à chaque pull request.
Fichier
Ce que c'est
runtime.js
le runtime du navigateur : le réconciliateur, les événements, l'état, le pont des Server Actions, le routeur, ET la bibliothèque de composants partagée, dont les modules transpilés voyagent à l'intérieur. La page l'atteint comme le module nu @equantic/runtime via l'import map du shell, et le Server répond à cette URL avec le bundle qu'il embarque ; ce fichier, ce sont les mêmes octets, pour les outils qui chargent les modules hors d'un serveur (l'aperçu de VS Code le lit)
<Component>.js, et .js.map en Debug
un module par page ou composant, à plat. Le boot importe le module de la page à la navigation, donc le chargement paresseux par route découle du graphe de modules lui-même ; il n'y a pas de dossier pages/. eqc lance bun avec --splitting, donc ce que deux modules ou plus partagent est un chunk nommé d'après l'un d'eux avec un hash de contenu : NotFoundScreen-<hash>.js dans l'exemple du dashboard est son shell de console, pas un second type de ce nom. La map porte le C# dont vient le module, donc EQuanticSourceMaps vaut full en Debug et none partout ailleurs (external en écrit une sans le C#, pour un rapporteur d'erreurs), et un publish n'en emporte jamais
strings/<culture>.json
les catalogues de culture, quand l'application a des .resx
icons/
les tailles de l'icône de l'application et le manifest web, quand un AppIcon est déclaré
Les styles ne sont pas un fichier : le serveur les écrit dans la page, la feuille de tokens générée et les classes atomiques (<style id="eq-atomic">) comprises.
2.4 Rendu serveur et hydratation
Le serveur rend le premier HTML depuis le MÊME C# : WebRealizer abaisse l'arbre du composant en HtmlElements, et les classes de style atomique qu'il écrit hachent à l'identique de celles qu'écrit le lowering.ts du navigateur, donc l'hydratation compare des noms de classes et ne repeint rien. Une page qui implémente IServerPrefetch voit ses données chargées sur le serveur AVANT le premier build, et la réponse voyage vers le navigateur avec la page : le premier affichage a déjà les données, et il n'y a pas d'état de chargement à concevoir. Voir Composants write-once pour le contrat de parité et Intégration serveur pour les options de la pipeline.
3. La pipeline native
La même classe de composant, construite par dotnet build en une application .NET ordinaire (Native AOT à la publication), hébergée par Photon :
1
2
3
4
5
6
7
8
9
PhotonApplication.CreateBuilder(args) … .Build().UseRoot<Shell>()
↓
LayoutEngine les contraintes descendent, les tailles remontent, les parents positionnent, tout en une passe
↓
PhotonRealizer l'arbre mis en page devient une display list : boîtes arrondies, ombres, texte, images, icônes
↓
RHI un renderer au-dessus d'IRhiDevice ; Metal chez Apple, Vulkan sur Windows et Android, un backend Reference CPU partout
↓
Shell macOS, iOS, Android ou Windows : la fenêtre, l'entrée, les services de texte, le pont d'accessibilité, les capacités
Le texte est façonné par la plateforme (CoreText, DirectWrite, la mise en page de texte d'Android) derrière un seul contrat ITextMeasurer ; les capacités (caméra, localisation, stockage, biométrie, …) sont des interfaces typées que le shell implémente et qu'un composant prend par son constructeur ou avec GetService<T>(). Le hot reload, c'est dotnet watch : le runtime applique la modification dans le processus et la fenêtre se redessine.
4. Server Actions
4.1 Le problème
Une page qui a besoin de données ne devrait pas avoir besoin d'un contrôleur, d'un DTO, d'un fetch et d'un contrat JSON tenus à jour à la main. Elle a besoin d'une méthode.
Sans cela, la même fonctionnalité est répartie sur quatre endroits qui ne s'accordent que par discipline :
1
2
3
4
5
6
[ApiController]
public class TodoController : ControllerBase
{
[HttpPost("/api/todos")]
public Task<Todo> AddTodo([FromBody] AddTodoRequest request) { /* … */ }
}
1
2
3
4
async function addTodo(title) {
const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify({ title }) });
return await response.json();
}
4.2 La forme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Pages/TodoList.cs
[Page("/todos")]
public class TodoList : StatefulComponent
{
private List<Todo> _todos = [];
// Tourne sur le SERVEUR. Le client reçoit un appel, pas le corps.
[ServerAction]
public async Task<List<Todo>> LoadTodos()
{
using var db = new AppDbContext();
return await db.Todos.ToListAsync();
}
[ServerAction]
public async Task<Todo> AddTodo(string title)
{
using var db = new AppDbContext();
var todo = new Todo { Title = title };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return todo;
}
protected override async void OnMount() => await Load();
private async Task Load()
{
var loaded = await LoadTodos();
SetState(() => _todos = loaded);
}
private async Task Add(string title)
{
var added = await AddTodo(title); // typé, sérialisé pour vous
SetState(() => _todos.Add(added));
}
public override VisualNode Build(ComponentContext context) =>
Column(gap: Space.S2, children: [.. _todos.Select(todo => TodoItem(todo))]);
}
Le compilateur remplace le corps de chaque [ServerAction] par un appel au pont :
1
2
3
async loadTodos() {
return await this._serverActions.invoke("TodoList/LoadTodos", []);
}
4.3 Le pont
app.UseServerActions() répond à POST /api/_equantic/actions. Seules les méthodes marquées [ServerAction] sont appelables (une liste blanche, pas une convention), et [Authorize] est appliqué avant que la méthode ne tourne. La taille du payload et les types des arguments sont validés ; voir Sécurité. Une Server Action est requête/réponse ; ce que le serveur publie pendant qu'une page est ouverte lui parvient par les événements serveur.
Sur le fil, c'est un POST par appel, ce qui est bon à savoir quand on lit l'onglet réseau :
1
{ "actionId": "TodoList/LoadTodos", "arguments": [] }
1
{ "success": true, "result": [ { "id": 1, "title": "Buy milk" } ] }
4.4 Assets
Un composant qui a besoin d'un script ou d'une feuille de styles externe le déclare, et le serveur injecte les balises une fois par page, dédupliquées :
1
2
3
4
5
6
7
8
public class CodeBlock : StatelessComponent, IRequireAssets
{
public void ConfigureAssets(AssetBuilder assets)
{
assets.AddStylesheet("https://cdn.example.com/prism.css", id: "prism-theme");
assets.AddScript("https://cdn.example.com/prism.js");
}
}
IComponentAssetProvider<T> fait la même chose pour un composant que vous n'avez pas écrit, enregistré dans la DI ou trouvé par le scan d'assemblys d'AddUI() :
1
2
3
4
5
6
7
8
public class PrismAssetProvider : IComponentAssetProvider<CodeBlock>
{
public void ConfigureAssets(AssetBuilder assets) =>
assets.AddScript("https://cdn.example.com/prism.js");
}
// enregistrement explicite, quand ce n'est pas au scan de le trouver
options.WithAssetProvider<PrismAssetProvider>();
Le flux : ServerRenderingService parcourt l'arbre rendu en collectant les deux sortes, AssetCollection déduplique par clé (les feuilles de styles d'abord, puis les scripts), et les balises atterrissent dans <head>. Une page sans aucun composant demandeur d'assets n'en reçoit aucun. Voir Assets.
5. Routage
Une route est un attribut sur la page, découvert au build, donc il n'y a pas de table de routes à tenir au pas des pages :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Page("/counter")]
[Page("/count")] // plusieurs routes, une page
public class Counter : StatefulComponent { }
[Page("/user/{id:int}")] // syntaxe de route ASP.NET, paramètres compris
public class UserProfile : StatefulComponent
{
public override VisualNode Build(ComponentContext context)
{
var id = context.Route.Param("id"); // ce que la route a dit, sans aucune cible dedans
var page = context.Route.Query("page"); // et la query string
…
}
}
[Page("/admin/dashboard")]
[Authorize(Roles = "Admin")]
public class AdminDashboard : StatefulComponent { }
1
2
3
4
5
6
7
8
9
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUI(options => options.ScanAssembly(typeof(Program).Assembly));
var app = builder.Build();
app.UseStaticFiles();
app.UseServerActions();
app.MapUI(); // chaque [Page], plus le fallback SPA
app.Run();
Dans le navigateur, le routeur du runtime prend la navigation en charge sans rechargement : un Link est une vraie ancre, et Navigator.Go(destination) est le jumeau programmatique pour les routes qu'un composant décide de prendre lui-même. Sur Photon, le même Link et le même Navigator atteignent la couture de navigation de l'hôte.
6. Expérience de développement
6.1 Un projet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MonApp/
├── MonApp.csproj # <Project Sdk="eQuantic.UI.Sdk">
├── global.json # la version du SDK
├── Program.cs # l'hôte ASP.NET Core
├── Pages/ # les composants avec [Page]
│ ├── Home.cs
│ └── Counter.cs
├── Components/ # vos propres composants write-once
├── Assets/ # AppIcon.cs, *.svg → des vecteurs au build
├── Resources/ # .resx → strings/<culture>.json
└── wwwroot/
└── _equantic/ # généré par le build ; les .map en Debug
├── runtime.js
├── Counter.js Counter.js.map
└── Home.js Home.js.map
6.2 Commandes
1
2
3
4
5
6
dotnet new install eQuantic.UI.Templates # une fois
dotnet new equantic-app -n MonApp # une application web
dotnet new equantic-native -n MonApp # une application Photon
dotnet watch run # hot reload, web ou natif
dotnet build # C# → TypeScript → JavaScript, prêt pour le SSR
dotnet publish -c Release # une application publiable ; Native AOT pour Photon
6.3 Hot reload
1
2
3
4
5
6
7
8
1. Vous modifiez Counter.cs
2. le serveur, qui surveille les .cs de l'application, relance eqc et Counter.js est réécrit sur
place ; pendant ce temps dotnet watch applique la même modification au serveur en marche
3. le serveur pousse un événement sur /_equantic/hmr, des server-sent events avec battement et reconnexion
4. le navigateur récupère le nouveau module et MONTE la page à nouveau, avec les champs que la page
tenait ; l'ancien balisage du serveur n'est pas réhydraté
5. sur Photon il n'y a pas d'étape 3 : le runtime applique la modification dans le processus en
marche, et l'hôte redessine à la frame suivante
Voir Débogage pour l'overlay d'erreurs et les source maps qui pointent vers la ligne de C#.
7. Comparé à WebForms
Aspect
WebForms
eQuantic.UI
État
le ViewState dans un champ caché
l'état sur l'instance du composant, gardé par le réconciliateur ; le serveur garde des données, pas de l'état de vue
Rendu
du HTML généré au serveur à chaque postback
premier affichage rendu au serveur, puis un réconciliateur côté client
Mises à jour
postback de toute la page
sur place, dans le navigateur ou dans la fenêtre
Logique serveur
des handlers de postback
des méthodes [ServerAction], appelées comme des méthodes
Cibles
le navigateur
le navigateur, et des fenêtres et téléphones natifs depuis la même classe
Ce qu'il garde de WebForms, c'est la part qui était juste : un composant est une classe C# avec de l'état et des handlers, et c'est le framework, pas qui développe, qui décide comment il arrive à l'écran.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Counter : StatefulComponent
{
private int _count;
// se lit comme un handler WebForms, et tourne dans le navigateur, sans postback
private void Increment() => SetState(() => _count++);
public override VisualNode Build(ComponentContext context) =>
Row(gap: Space.S2, children: [
Text($"{_count}", TypeRole.Title),
Button("+", onPressed: Increment),
]);
}
8. Décisions techniques
•
TypeScript comme intermédiaire : une sortie lisible, une seconde vérification de types, et des source maps vers le C#.
•
Bun, embarqué par plateforme : un exécutable de bundler par OS et architecture, livré comme un paquet que le SDK sélectionne ; pas de package.json, rien d'installé.
1
2
3
eQuantic.UI.Runtime.OsxArm64/ un paquet par OS et architecture
├── tools/bun/bun-darwin.zip l'exécutable, extrait une fois au premier build
└── build/ les props que le SDK lit pour le trouver
•
Un seul moteur de styles : les styles C# typés s'abaissent en CSS atomique dédupliqué sur le web et en peinture GPU sur Photon ; il n'y a pas de plan CSS à écrire. Voir Design System.
•
Écrire une fois, réaliser par cible : le vocabulaire est fermé et se place sous tous les réalisateurs ; les réalisateurs sont le point d'extension, pas les nœuds. Les compromis que cela coûte, mesurés, sont dans l'audit d'architecture du dépôt et dans son pendant, comment Flutter résout chacun d'eux.