eQuantic.UI - ArchitektureQuantic.UI ist ein Komponenten-Framework für .NET, in dem eine Komponente einmal in C# geschrieben und auf zwei Arten von Ziel realisiert wird: im Web, als DOM und CSS, von ASP.NET Core ausgeliefert und im Browser von JavaScript getrieben, das der Build aus genau diesem C# kompiliert hat; und in nativen Fenstern und auf Telefonen, als Pixel, die die GPU-Engine Photon malt. Es gibt keine zweite Sprache zu lernen und keine externe Toolchain zu installieren: Der Compiler, der Bundler und die Engine kommen in den eigenen Paketen des SDK mit. C#-Komponenten, ein Vokabular
Das Diagramm oben ist ein mermaid `-Zaun: GitHub zeichnet es hier, und die Dokumentationsseite zeichnet DENSELBEN Zaun über die Mermaid-Komponente des SDK selbst. 1.
✅ 100% .NET: kein Node.js, kein npm und kein Vite auf der Maschine der Entwicklerin; die Toolchain ist eingebettet.
2.
✅ Einmal schreiben: eine Komponentenklasse, realisiert auf dem DOM und auf einer GPU-Display-List.
3.
✅ In sich geschlossen: ASP.NET Core liefert, rendert und kompiliert alles für das Web.
4.
✅ Vertraut: Routen sind Attribute, Dienste kommen aus IServiceCollection, Einstellungen aus appsettings.json; wer .NET entwickelt, kennt die Idiome schon.
5.
✅ Kein Plattformartefakt: kein Swift, kein Manifest, kein plist, kein CSS. Das SDK leitet aus C#-Deklarationen ab, was jedes Ziel braucht.
Schicht
Paket
Was sie hält
Darf referenzieren
Das Vokabular
eQuantic.UI.Primitives
die abstrakten visuellen Knoten (Box, Row, Text, Pressable, …), typisierte Styles und Design-Tokens, das Komponentenmodell, die Kontraktattribute
nichts
Die Bibliothek
eQuantic.UI.Components, eQuantic.UI.Charts
die Write-once-Komponenten (Button, Card, DataTable, BarChart, …), nur aus dem Vokabular zusammengesetzt
Primitives
Der Web-Realisierer
eQuantic.UI.Web
senkt einen Knotenbaum auf dem Server zu HtmlElement/DOM und atomarem CSS ab; dazu die DOM-Notluke (HtmlElement, DynamicElement, ClassBuilder)
Primitives
Der Web-Host
eQuantic.UI.Server
die ASP.NET-Core-Integration: SSR, Server Actions, Routing, Metadaten, Assets, Hot Reload
Web
Die Browser-Runtime
@equantic/runtime, von eQuantic.UI.Server aus dem TypeScript in eQuantic.UI.Runtime gebündelt
der TypeScript-Zwilling des Web-Realisierers, der Reconciler, der Zustand, der Router und die transpilierten Module der Bibliothek selbst
nichts
Der native Zweig
eQuantic.UI.Native.Engine (+ Metal, Vulkan, Reference), Native.Framework, Native.Components, Native.Hosting, die Shells
die GPU-Engine und ihre Backends, die Layout-Engine, der Photon-Realisierer und -Host, der Anwendungs-Builder und je eine Shell pro Plattform
Primitives, und einander nach unten
Der Compiler
eQuantic.UI.Compiler (eqc)
der Roslyn-gestützte Transpiler von C# nach TypeScript, den das Web-SDK ausführt
Codegen
Die SDKs
eQuantic.UI.Sdk, eQuantic.UI.Sdk.Native
MSBuild-SDKs: Ein Sdk="…"-Attribut bringt jedes Paket oben mit und verdrahtet den Build
nichts
Zwei Regeln halten die Tabelle zusammen. Das Vokabular referenziert nichts, also kann es zu jedem Ziel ausgeliefert werden, Browser-Bundle und Native AOT eingeschlossen. Ein Realisierer referenziert nie die Bibliothek, die er realisiert: Eine Komponente erreicht einen Realisierer als den Baum, den ihr Build erzeugt hat, also kennen Web, Email und Native.Components das Vokabular und nicht Components. Beide Regeln sind Tests im Repository (AssemblyLayeringTests), keine Konventionen.
Die Projektdatei einer Web-Anwendung nennt das SDK und nichts weiter, ohne jede PackageReference:
<Project Sdk="eQuantic.UI.Sdk">
<TargetFramework>net10.0</TargetFramework>
Die Version kommt aus global.json, so wie jedes MSBuild-SDK versioniert wird:
"eQuantic.UI.Sdk": "0.2.0-preview.52"
Sdk.props importiert Microsoft.NET.Sdk.Web, fügt die Framework-Pakete hinzu (Primitives, Code, Components, Charts, Web, Server, den Source-Generator und das eingebettete Bun-Paket für Betriebssystem und Architektur der bauenden Maschine), bringt die deklarative Oberfläche in jeder Datei in Reichweite (eQuantic.UI.Components.UI und eQuantic.UI.Charts.ChartsUI als statische Usings) und nimmt Pages/**/*.cs als die Komponenten auf, die der Build für den Browser kompiliert. Neben einem Quell-Checkout des Frameworks tauscht es jedes Paket gegen das entsprechende Projekt, so dass die Beispiele gegen den Baum bauen. Siehe Build-Ablauf. Auf das Wesentliche gekürzt liest sich diese Datei so:
<!-- eQuantic.UI.Sdk/Sdk/Sdk.props -->
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
<EnableEQuanticUICompilation>true</EnableEQuanticUICompilation>
<EQuanticOutputPath>wwwroot/_equantic/</EQuanticOutputPath>
<!-- die deklarative Oberfläche, in jeder Datei in Reichweite -->
<Using Include="eQuantic.UI.Components.UI" Static="true" />
<Using Include="eQuantic.UI.Charts.ChartsUI" Static="true" />
<!-- was der Build für den Browser kompiliert -->
<UIComponent Include="Pages/**/*.cs" Exclude="obj/**;bin/**" />
Microsoft.NET.Sdk.Web, die gewöhnliche Pipeline, durch CoreCompile (das C# der Anwendung)
ResolveBunPath das eingebettete Bun für diese Maschine, einmal aus seinem Paket entpackt
CompileEQuanticUI nach CoreCompile, und bevor die Static Web Assets definiert werden
1. eqc liest die Quellen des Projekts und die mitgelieferten Quellen der Bibliothek
2. Roslyn bindet sie. Das semantische Modell ist maßgeblich; ein nicht bindbarer Aufruf ist ein Build-Fehler
3. ein TypeScript-Modul je Seite oder Komponente
4. Bun bündelt jedes Modul; in Debug mit einer Source Map zurück zum C# (EQuanticSourceMaps)
CopyEQuanticRuntime die ausgelieferte runtime.js, aus dem Server-Paket
Ausgabe: wwwroot/_equantic/, als Static Web Assets aus dem definiert, was auf der Platte liegt,
damit Komprimierung, Manifeste und der Publish die Dateien dieses Builds sehen
Das TypeScript-Zwischenformat ist Absicht: Der ausgegebene Code ist lesbar, wird vom TypeScript-Compiler ein zweites Mal typgeprüft und für den Debugger des Browsers auf die C#-Zeile zurückgemappt. Der Bundler ist Bun, weil er eine einzige ausführbare Datei ist, die TypeScript ohne Konfiguration bündelt, und weil er als ein Paket je Betriebssystem und Architektur ausgeliefert wird (eQuantic.UI.Runtime.OsxArm64, .Win64, .Linux64, …), das das SDK auswählt. Niemand installiert ihn.
Das hier gibt eqc für eine der Komponenten der Bibliothek selbst aus, Divider, genau so, wie sie in runtime.js ausgeliefert wird. Das C#, aus dem sie kam, ist Divider.cs in eQuantic.UI.Components: dieselbe Klasse, dasselbe Build, die Enums auf ihre camelCase-Namen abgesenkt, der Objektinitialisierer als abschließendes props angekommen:
import { Box, BoxStyle, BuildContext, EdgeInsets, SizeValue, StatelessComponent } from "../runtime-exports";
export class Divider extends StatelessComponent {
declare leadingInset: any;
constructor(inset: any = 'none', axis: any = 'horizontal', props?: any) {
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';
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 Was ein Build schreibt
Größen werden hier nicht genannt, weil sie sich mit jeder Änderung bewegen: Die gzip-Größe der ausgelieferten Runtime ist ein Budget, das die Suite erzwingt (ServedRuntimeBudgetTests, gegen den Eintrag in tests/eQuantic.UI.Server.Tests/Budgets/served-runtime.json), und die CI berichtet die Größe jedes Seitenmoduls bei jedem Pull Request.
runtime.js
die Browser-Runtime: der Reconciler, die Events, der Zustand, die Server-Actions-Brücke, der Router UND die gemeinsame Komponentenbibliothek, deren transpilierte Module darin mitreisen. Die Seite erreicht sie über die Import Map der Shell als nacktes Modul @equantic/runtime, und der Server antwortet auf diese URL mit dem Bundle, das er einbettet; diese Datei sind dieselben Bytes, für Werkzeuge, die die Module außerhalb eines Servers laden (die Vorschau in VS Code liest sie)
<Component>.js, und .js.map in Debug
ein Modul je Seite oder Komponente, flach. Der Boot importiert das Modul der Seite bei der Navigation, also fällt das Lazy Loading je Route aus dem Modulgraph selbst heraus; es gibt keinen pages/-Ordner. eqc führt bun mit --splitting aus, also ist das, was zwei oder mehr Module teilen, ein Chunk, der nach einem von ihnen mit einem Inhaltshash benannt ist: NotFoundScreen-<hash>.js im Dashboard-Beispiel ist seine Konsolen-Shell, kein zweiter Typ dieses Namens. Die Map trägt das C#, aus dem das Modul kam, also ist EQuanticSourceMaps in Debug full und überall sonst none (external schreibt eine ohne das C#, für einen Fehlerberichter), und ein Publish nimmt nie eine mit
strings/<culture>.json
die Kulturkataloge, wenn die Anwendung .resx hat
icons/
die Icon-Größen der Anwendung und das Web-Manifest, wenn ein AppIcon deklariert ist
Die Styles sind keine Datei: Der Server schreibt sie in die Seite, das generierte Token-Sheet und die atomaren Klassen (<style id="eq-atomic">) darunter.
2.4 Server-Rendering und Hydration
Der Server rendert das erste HTML aus DEMSELBEN C#: WebRealizer senkt den Baum der Komponente zu HtmlElements ab, und die atomaren Style-Klassen, die er schreibt, hashen identisch zu denen, die das lowering.ts des Browsers schreibt, also vergleicht die Hydration Klassennamen und malt nichts neu. Eine Seite, die IServerPrefetch implementiert, bekommt ihre Daten auf dem Server VOR dem ersten Build geladen, und die Antwort reist mit der Seite zum Browser: Das erste Paint hat die Daten schon, und es gibt keinen Ladezustand zu entwerfen. Siehe Write-once-Komponenten für den Paritätskontrakt und Server-Integration für die Optionen der Pipeline. Dieselbe Komponentenklasse, von dotnet build zu einer gewöhnlichen .NET-Anwendung gebaut (Native AOT beim Publish), von Photon gehostet: PhotonApplication.CreateBuilder(args) … .Build().UseRoot<Shell>()
LayoutEngine Constraints gehen hinunter, Größen kommen herauf, Eltern setzen Positionen, alles in einem Durchgang
PhotonRealizer der ausgelegte Baum wird eine Display List: gerundete Boxen, Schatten, Text, Bilder, Icons
RHI ein Renderer über IRhiDevice; Metal bei Apple, Vulkan auf Windows und Android, überall ein CPU-Reference-Backend
Shell macOS, iOS, Android oder Windows: das Fenster, die Eingabe, die Textdienste, die Accessibility-Brücke, die Fähigkeiten
Text wird von der Plattform geformt (CoreText, DirectWrite, das Textlayout von Android) hinter einem einzigen ITextMeasurer-Kontrakt; Fähigkeiten (Kamera, Standort, Speicher, Biometrie, …) sind typisierte Schnittstellen, die die Shell implementiert und die eine Komponente über ihren Konstruktor oder GetService<T>() nimmt. Hot Reload ist dotnet watch: Die Runtime wendet die Änderung im Prozess an, und das Fenster zeichnet neu.
Eine Seite, die Daten braucht, sollte keinen Controller, kein DTO, kein fetch und keinen JSON-Kontrakt brauchen, die von Hand im Takt gehalten werden. Sie braucht eine Methode.
Ohne das verteilt sich dasselbe Feature über vier Stellen, die sich nur durch Disziplin einig sind:
public class TodoController : ControllerBase
public Task<Todo> AddTodo([FromBody] AddTodoRequest request) { /* … */ }
async function addTodo(title) {
const response = await fetch("/api/todos", { method: "POST", body: JSON.stringify({ title }) });
return await response.json();
public class TodoList : StatefulComponent
private List<Todo> _todos = [];
// Läuft auf dem SERVER. Der Client bekommt einen Aufruf, nicht den Körper.
public async Task<List<Todo>> LoadTodos()
using var db = new AppDbContext();
return await db.Todos.ToListAsync();
public async Task<Todo> AddTodo(string title)
using var db = new AppDbContext();
var todo = new Todo { Title = title };
await db.SaveChangesAsync();
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); // typsicher, für Sie serialisiert
SetState(() => _todos.Add(added));
public override VisualNode Build(ComponentContext context) =>
Column(gap: Space.S2, children: [.. _todos.Select(todo => TodoItem(todo))]);
Der Compiler ersetzt den Körper jeder [ServerAction] durch einen Aufruf an die Brücke:
return await this._serverActions.invoke("TodoList/LoadTodos", []);
app.UseServerActions() antwortet auf POST /api/_equantic/actions. Aufrufbar sind nur Methoden mit [ServerAction] (eine Whitelist, keine Konvention), und [Authorize] wird erzwungen, bevor die Methode läuft. Payload-Größe und Argumenttypen werden validiert; siehe Sicherheit. Eine Server Action ist Anfrage/Antwort; was der Server veröffentlicht, während eine Seite offen ist, erreicht sie über Server-Events. Auf der Leitung ist es ein POST je Aufruf, was zu wissen lohnt, wenn man den Netzwerk-Tab liest:
{ "actionId": "TodoList/LoadTodos", "arguments": [] }
{ "success": true, "result": [ { "id": 1, "title": "Buy milk" } ] }
Eine Komponente, die ein externes Skript oder Stylesheet braucht, deklariert es, und der Server injiziert die Tags einmal je Seite, dedupliziert:
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> tut dasselbe für eine Komponente, die Sie nicht geschrieben haben, registriert in der DI oder vom Assembly-Scan von AddUI() gefunden:
public class PrismAssetProvider : IComponentAssetProvider<CodeBlock>
public void ConfigureAssets(AssetBuilder assets) =>
assets.AddScript("https://cdn.example.com/prism.js");
// ausdrückliche Registrierung, wenn nicht der Scan sie finden soll
options.WithAssetProvider<PrismAssetProvider>();
Der Ablauf: ServerRenderingService geht den gerenderten Baum ab und sammelt beide Arten, AssetCollection dedupliziert nach Schlüssel (Stylesheets zuerst, dann Skripte), und die Tags landen im <head>. Eine Seite ohne jede Komponente, die Assets verlangt, bekommt keine. Siehe Assets. Eine Route ist ein Attribut auf der Seite, zur Build-Zeit entdeckt, also gibt es keine Routentabelle, die man im Takt mit den Seiten halten müsste:
[Page("/count")] // mehrere Routen, eine Seite
public class Counter : StatefulComponent { }
[Page("/user/{id:int}")] // ASP.NET-Routensyntax, Parameter eingeschlossen
public class UserProfile : StatefulComponent
public override VisualNode Build(ComponentContext context)
var id = context.Route.Param("id"); // was die Route sagte, ohne ein Ziel darin
var page = context.Route.Query("page"); // und der Query-String
[Page("/admin/dashboard")]
[Authorize(Roles = "Admin")]
public class AdminDashboard : StatefulComponent { }
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUI(options => options.ScanAssembly(typeof(Program).Assembly));
var app = builder.Build();
app.MapUI(); // jede [Page], dazu der SPA-Fallback
Im Browser übernimmt der Router der Runtime die Navigation ohne Reload: Ein Link ist ein echter Anker, und Navigator.Go(destination) ist der programmatische Zwilling für die Routen, die eine Komponente selbst zu nehmen beschließt. Auf Photon erreichen derselbe Link und derselbe Navigator die Navigationsnaht des Hosts.
├── MeineApp.csproj # <Project Sdk="eQuantic.UI.Sdk">
├── global.json # die SDK-Version
├── Program.cs # der ASP.NET-Core-Host
├── Pages/ # Komponenten mit [Page]
├── Components/ # Ihre eigenen Write-once-Komponenten
├── Assets/ # AppIcon.cs, *.svg → Vektoren zur Build-Zeit
├── Resources/ # .resx → strings/<culture>.json
└── _equantic/ # vom Build erzeugt; die .map-Dateien in Debug
├── Counter.js Counter.js.map
dotnet new install eQuantic.UI.Templates # einmal
dotnet new equantic-app -n MeineApp # eine Web-Anwendung
dotnet new equantic-native -n MeineApp # eine Photon-Anwendung
dotnet watch run # Hot Reload, Web oder Native
dotnet build # C# → TypeScript → JavaScript, SSR-fertig
dotnet publish -c Release # eine veröffentlichbare App; Native AOT für Photon
1. Sie bearbeiten Counter.cs
2. der Server, der die .cs der Anwendung beobachtet, führt eqc erneut aus, und Counter.js wird an
seiner Stelle neu geschrieben; dotnet watch wendet dieselbe Änderung unterdessen auf den
3. der Server schiebt ein Event auf /_equantic/hmr, Server-sent Events mit Herzschlag und Reconnect
4. der Browser holt das neue Modul und MOUNTET die Seite erneut, mit den Feldern, die die Seite
hielt; das alte Server-Markup wird nicht neu hydriert
5. auf Photon gibt es keinen Schritt 3: Die Runtime wendet die Änderung im laufenden Prozess an,
und der Host zeichnet beim nächsten Frame neu
Siehe Debugging für das Fehler-Overlay und die Source Maps, die auf die C#-Zeile zurückzeigen. 7. Im Vergleich zu WebForms
Aspekt
WebForms
eQuantic.UI
Zustand
ViewState in einem versteckten Feld
Zustand auf der Komponenteninstanz, vom Reconciler gehalten; der Server hält Daten, keinen Ansichtszustand
Rendering
servergeneriertes HTML bei jedem Postback
serverseitig gerendertes erstes Paint, dann ein Reconciler im Client
Aktualisierungen
Postback der ganzen Seite
an ihrem Platz, im Browser oder im Fenster
Serverlogik
Postback-Handler
[ServerAction]-Methoden, wie Methoden aufgerufen
Ziele
der Browser
der Browser, und native Fenster und Telefone aus derselben Klasse
Was es von WebForms behält, ist der Teil, der richtig war: Eine Komponente ist eine C#-Klasse mit Zustand und Handlern, und das Framework, nicht die Entwicklerin, entscheidet, wie sie auf den Bildschirm kommt.
public class Counter : StatefulComponent
// liest sich wie ein WebForms-Handler, und läuft im Browser, ohne 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. Technische Entscheidungen
•
TypeScript als Zwischenformat: lesbare Ausgabe, eine zweite Typprüfung und Source Maps zurück zum C#.
•
Bun, je Plattform eingebettet: eine Bundler-Binary je Betriebssystem und Architektur, als Paket geliefert, das das SDK auswählt; keine package.json, nichts installiert.
eQuantic.UI.Runtime.OsxArm64/ ein Paket je Betriebssystem und Architektur
├── tools/bun/bun-darwin.zip die Binary, beim ersten Build einmal entpackt
└── build/ die Props, die das SDK liest, um sie zu finden
•
Eine Style-Engine: typisierte C#-Styles senken sich im Web zu dedupliziertem atomarem CSS und auf Photon zu GPU-Malen ab; es gibt keine CSS-Ebene zu schreiben. Siehe Design System. •
Einmal schreiben, je Ziel realisieren: Das Vokabular ist geschlossen und liegt unter jedem Realisierer; die Realisierer sind der Erweiterungspunkt, nicht die Knoten. Die Abwägungen, die das kostet, gemessen, stehen im Architektur-Audit des Repositorys und in seinem Gegenstück, wie Flutter jede davon löst.