Skip to content

Architecture de

Edgar Mesquita edited this page Oct 13, 2026 · 1 revision

eQuantic.UI - Architektur

🌐 Diese Seite auf: English · Português · Español · Français · Deutsch

eQuantic.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.

graph LR
  A[C#-Komponenten, ein Vokabular] --> B{eqc}
  B -->|TypeScript| C{Eingebettetes Bun}
  C --> D[wwwroot/_equantic]
  A --> E[ASP.NET Core SSR]
  E --> F((Browser))
  D --> F
  A --> G[.NET-Build, AOT]
  G --> H[Photon-Shell]
  H --> I((Fenster / Telefon))
Loading

Das Diagramm oben ist ein ```mermaid-Zaun: GitHub zeichnet es hier, und die Dokumentationsseite zeichnet DENSELBEN Zaun über die Mermaid-Komponente des SDK selbst.

Diese Seite ist die Landkarte. Write-once-Komponenten erklärt das gemeinsame Vokabular und die Realisierer in der Tiefe, Photon die native Engine, Compiler und Build-Ablauf die Web-Pipeline Schritt für Schritt, und Paketarchitektur, was jedes Paket mitbringt. Das Repository hält die gemessenen Audits neben dem Code: docs/README.md ist ihr Index.

Grundprinzipien

  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.

1. Die Schichten

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.


2. Die Web-Pipeline

2.1 Das SDK

Die Projektdatei einer Web-Anwendung nennt das SDK und nichts weiter, ohne jede PackageReference:

<Project Sdk="eQuantic.UI.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
</Project>

Die Version kommt aus global.json, so wie jedes MSBuild-SDK versioniert wird:

{
  "msbuild-sdks": {
    "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 -->
<Project>
  <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />

  <PropertyGroup>
    <EnableEQuanticUICompilation>true</EnableEQuanticUICompilation>
    <EQuanticOutputPath>wwwroot/_equantic/</EQuanticOutputPath>
  </PropertyGroup>

  <ItemGroup>
    <!-- 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/**" />
  </ItemGroup>
</Project>

2.2 Was dotnet build tut

dotnet build
    ↓
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 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 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.

Datei Was sie ist
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.


3. Die native 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.


4. Server Actions

4.1 Das Problem

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:

[ApiController]
public class TodoController : ControllerBase
{
    [HttpPost("/api/todos")]
    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();
}

4.2 Die Form

// Pages/TodoList.cs
[Page("/todos")]
public class TodoList : StatefulComponent
{
    private List<Todo> _todos = [];

    // Läuft auf dem SERVER. Der Client bekommt einen Aufruf, nicht den Körper.
    [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);   // 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:

async loadTodos() {
  return await this._serverActions.invoke("TodoList/LoadTodos", []);
}

4.3 Die Brücke

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" } ] }

4.4 Assets

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.


5. Routing

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("/counter")]
[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 { }
// 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();   // jede [Page], dazu der SPA-Fallback
app.Run();

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.


6. Entwicklungserfahrung

6.1 Ein Projekt

MeineApp/
├── MeineApp.csproj             # <Project Sdk="eQuantic.UI.Sdk">
├── global.json                 # die SDK-Version
├── Program.cs                  # der ASP.NET-Core-Host
├── Pages/                      # Komponenten mit [Page]
│   ├── Home.cs
│   └── Counter.cs
├── Components/                 # Ihre eigenen Write-once-Komponenten
├── Assets/                     # AppIcon.cs, *.svg → Vektoren zur Build-Zeit
├── Resources/                  # .resx → strings/<culture>.json
└── wwwroot/
    └── _equantic/              # vom Build erzeugt; die .map-Dateien in Debug
        ├── runtime.js
        ├── Counter.js  Counter.js.map
        └── Home.js     Home.js.map

6.2 Befehle

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

6.3 Hot Reload

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
   laufenden Server an
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
{
    private int _count;

    // 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.

Clone this wiki locally