eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsArchitektur
Write-once-Komponenten (gemeinsame Architektur)
Edit this page
13 min read
Das zentrale Versprechen der Multi-Target-Geschichte von eQuantic.UI: Komponenten werden einmal geschrieben, in C#, gegen ein abstraktes Vokabular, und je Ziel realisiert: DOM + CSS im Web, GPU-Pixel über Photon auf Native. Nicht "zwei ähnliche APIs", sondern buchstäblich dieselbe Klasse.
Diese Seite ist der Architekturüberblick; der Komponentenkatalog lebt auf der Seite Komponenten, und wie man einen Baum SCHREIBT (Factories statt new, auch für Ihre eigenen Komponenten) auf Deklarative Oberfläche.
Die Schichten
Paket
Rolle
Hängt ab von
eQuantic.UI.Primitives
Die gemeinsame Grundlage: Farben, Design-Tokens, typisierte Styles, abstrakte Knoten, Komponentenmodell
nichts (null)
eQuantic.UI.Components
Die Write-once-Komponentenbibliothek
nur Primitives
eQuantic.UI.Web
Web-Realisierer: abstrakter Baum → HtmlElement/DOM + CSS (SSR) + generiertes Stylesheet
Core, Primitives
eQuantic.UI.Native.Components
Native-Realisierer: abstrakter Baum → Photon-Display-List
Primitives, Engine
TS-Runtime (@equantic/runtime)
Lowering im Client (Hydration) + eingebettete Bibliotheksmodule
nichts
Das abstrakte Vokabular
Struktur: Box (+BoxStyle: Farbe, Deckkraft, Transform, Aspect-Ratio, Hover-/Focus-Diffs), Row/Column (Flex, dem der Gap gehört, + Wrap/RunGap), Grid (+GridTrack, der CSS-Grid-Zwilling), Stack/Positioned (z-Reihenfolge + Layer), AdaptiveNode (Fenstergrößenklassen, null Listener), ScrollView (+Pinned), Overlay (Viewport-Ebene, Modal-Flag), Anchored (schwebende Panels: Platzierungen, Scrim beim Tippen außerhalb, MatchAnchorWidth, OpenOnHover). Inhalt: Text (Typo-Rollen), TextEntry, Icon (kuratierte + jede Glyphe aus einem Pack), Image, Spinner, Markdown (ein Dokument, gerendert als das Design System), Mermaid (Diagramme, die es selbst zeichnet). Interaktion und Bewegung: Pressable (Trefferkontrakt ≥48dp unter einem Finger, 24dp unter einem Zeiger), Link (echte Anker im Web, die Navigationsnaht des Hosts auf Native), DragDismiss (die Sheet-Gestik), LoopMotion, Presence (Ein-/Ausgangsbewegung). Dazu die Wertetypen (EdgeInsets, SizeValue Hug/Fill/Fixed/WindowMinus, CornerRadii, ColorToken als Hell-/Dunkel-Paare, StyleDiff, Transform2D) und das Komponentenmodell (StatelessComponent, StatefulComponent + SetState, moduslose ComponentContext).
Styling hat keine CSS-Ebene. Siehe die atomare Style-Engine in DesignSystem: Jede Style-Deklaration wird zu einer deduplizierten atomaren Klasse, byte-identisch zwischen SSR (C#) und Hydration (TS).
Layout-Parität ist ein Kontrakt: Die Flex-Engine in C# (Native) und das CSS-Flex (Web) implementieren dieselbe Spezifikation: Flexible verteilt den Rest nach Gewicht, der Abschneidekontrakt (Text schrumpft bis zur Ellipse, bevor er Geschwister schiebt), Stretch füllt nur Automatisches.
Seit 0.2.0-preview.6
Responsive Bereiche: Flexible mit einer Basis
Flexible(child, flex, basis, shrink) ist das CSS-Tripel flex: grow shrink basis. Die Basis ist die Größe, gegen die ein umbrechendes Elternteil Zeilen bricht, und sie ist es, die ein responsives Layout überhaupt ausdrückbar macht:
1
2
3
4
5
// `Wrap` ist eine init-Eigenschaft, daher hier die Form Konstruktor + Initialisierer:
// die Factories deckeln Konstruktoren ab, nie eine zweite API.
var panes = new Row(gap: Space.S4) { Wrap = true, Width = SizeValue.Fill };
panes.Add(Flexible(EditorPane(), flex: 1, basis: 440));
panes.Add(Flexible(PreviewPane(), flex: 1, basis: 380));
Breit genug für beide Basen teilen sich die Bereiche eine Zeile und den Rest nach Gewicht. Zu schmal, nimmt jeder eine eigene Zeile und wächst, um sie zu füllen, statt bei 440 zu bleiben, während der Rest der Zeile leer liegt. Dieser zweite Teil ist ein echter Durchgang, kein Nebeneffekt des Zeilenumbruchs: Der Rest geht nach Gewicht an die Wachsenden, von einer überlaufenden Zeile wird den Schrumpfenden nach Basis gewichtet Platz zurückgenommen (so, wie CSS das skaliert), und nichts unterschreitet den Min-Content-Boden.
Eine Basis von 0, die Voreinstellung, ist das historische Verhalten: Das Kind bringt nichts Eigenes mit und wird rein aus seinem Gewicht dimensioniert. shrink: 0 weigert sich, Platz zurückzugeben, also bricht eine Zeile, die nicht passt, um, statt sich zu quetschen.
Wie dieselbe Klasse beide Ziele erreicht
Native: PhotonHost entfaltet Build() während des Layouts inline, und der Realisierer gibt Zeichenbefehle aus.
Web, Server (SSR): Der WebRealizer senkt den Baum zu HtmlElements ab. Farben werden zu light-dark(#l, #d), damit das DOM moduslos bleibt (Theme-Wechsel = color-scheme). Core-Seiten können Write-once-Teilbäume über die VisualNodeComponent-Brücke einbetten, und eine StatefulComponent aus Primitives mit [Page] ist eine vollständige Seite (der Server überbrückt das automatisch).
Web, Client: eqc (der C#-nach-JS-Compiler) transpiliert dieselben Quellen; das Lowering der Runtime (lowerVisualNode) spiegelt den WebRealizer Regel für Regel, und die Hydration-Parität wird durch byte-exakte Style-Strings erzwungen, die kreuzweise gepinnt und sowohl in der C#- als auch in der TS-Suite behauptet werden.
Ein Fehler, ein Teilbaum
Jedes Ziel entfaltet eine Komponente durch DIESELBE Naht, und diese Naht ist eine Grenze: Wirft Build, wird der Teilbaum der fehlerhaften Komponente durch eine eingefasste Fläche ersetzt, und alles ringsum rendert weiter. Eine Seite, die kein Elternteil hat, das sie einfassen könnte, trägt dieselbe Grenze an ihrer eigenen Rendernaht, also ist eine kaputte Seite ein Panel, nie ein leeres Dokument.
vorher
jetzt
Server (SSR)
500 für die ganze Anfrage
200, Panel anstelle des Teilbaums
Browser
der Mount warf, die Wurzel nie geschrieben
Panel, die Geschwister interaktiv
Fenster (Photon)
der Frame kam nie an
Panel, die App präsentiert weiter
Das Panel ist aus dem Vokabular gebaut, also ist es auf einer Seite und in einem Fenster dieselbe Fläche. In der Entwicklung nennt es die Komponente und zitiert den Wurf; in der Produktion sagt es nur, dass ein Abschnitt nicht angezeigt werden konnte, denn eine Ausnahmemeldung ist für den geschrieben, der den Code geschrieben hat, und kann eine Id, einen Pfad oder eine Query nennen. In beiden Fällen erreicht der Fehler das Log des Hosts über ComponentBoundary.Report: Eine Grenze, die nur verschluckt, tauscht einen lauten Absturz gegen einen stillen. Seit 0.2.0-preview.49 armiert der Photon-Host ihn standardmäßig (Ihr Logger, wenn Sie einen haben, sonst stderr), und eine Zuweisung, die vor dem Start der App erfolgt, gewinnt (??=): Ein Report, den Sie schon verdrahtet hatten, bleibt Ihrer, und eine Prüfung, die nach seiner wörtlichen Marke sucht, findet sie weiterhin.
Nichts wird erinnert. Eine Komponente, die aufhört zu werfen (ein erneuter Versuch, neue Props, ein Hot Reload), baut im nächsten Durchgang einfach wieder.
Seit 0.2.0-preview.55
Eine Komponente, deren Build sie selbst zurückgibt, oder zwei, die einander zurückgeben, wird genauso eingefasst. Jeder Realisierer entfaltet, was Build zurückgab, also lief ein Zyklus zurück in Build, bis der Stack überlief, und .NET lässt niemanden eine StackOverflowException fangen: Die Anfrage, der Frame oder das Fenster gingen mit ihr. Die Naht zählt nun, wie tief Komponenten schachteln, und jenseits von 64 behandelt sie den Gang als einen, der nie endet: dasselbe Panel, derselbe Eintrag in ComponentBoundary.Contained, derselbe Report. Ein endlicher Baum, der tiefer geschachtelt ist, sieht von innen identisch aus, also nennt das Panel, was es beobachtet hat (eine Komponente, die nach 64 Entfaltungen noch Komponenten baut), statt es einen Zyklus zu nennen.
Der eigene KONTRAKT einer Komponente ist kein Fehler zur Renderzeit und gehört nicht hierher: Formulieren Sie ihn dort, wo der Fehler geschrieben wird (init => field = value.Count > 3 ? throw … : value), damit die Autorin eine Ausnahme an der Zeile bekommt, die falsch war, statt einmal pro Frame ein rotes Panel.
Die Bibliothek wird von der Runtime bereitgestellt
Die transpilierten Module von eQuantic.UI.Components werden innerhalb von runtime.js ausgeliefert, in der CI byte-gepinnt gegen die lebende eqc-Ausgabe, und Anwendungen importieren sie aus @equantic/runtime. Selbst geschriebene Write-once-Komponenten brauchen keine Verdrahtung: Sie leben in der Anwendung und fließen durch den normalen Seiten-Scan (eqc erkennt die Komponentenformen von Primitives, einschließlich der mit direktem SetState).
Generiert, nie handgeschrieben
Jeder Design-System-Wert auf dem Client wird aus der einzigen C#-Quelle generiert und byte-gepinnt:
•
PhotonCssGenerator → das normative Stylesheet (Custom Properties, Typo-Rollen-Klassen, Elevation, Bewegung).
•
DesignSystemTsGenerator → design-system.generated.ts (Tokens, Theme, Button-Größentabelle).
•
IconTsGenerator → icons.generated.ts (Glyphen-Pfaddaten aus der IconRegistry in C#).
Die Bibliothek: eine Bibliothek, einmal geschrieben
Es gibt genau EINE Komponentenbibliothek. Buttons, Karten, Eingaben, Navigation, Overlays, Listen, die recycelnde ListView, das Spreadsheet-Gitter, der Code-Editor. Der vollständige Katalog, gruppiert und beschrieben, steht auf der Seite Komponenten.
Jede Komponente wird mit Pins des Web-Realisierers, nativen Goldens (hell+dunkel), gepinnten transpilierten Fixtures, die in vitest laufen, und dem lebenden Showroom ausgeliefert (/ und /shared im DefaultUIDashboard: SSR + Hydration-Identität + Interaktion, Ende zu Ende von der Playwright-Suite verifiziert). Systeme, die neben der Bibliothek ausgeliefert werden: das Bewegungssystem für Zustandsübergänge (Presence Ein-/Ausgang), die Zeiger-Pipeline (Hover, Ziehen zum Verwerfen mit Slop/Cancel/Glide), der Scroll-Compositor (echtes Anheften, über Pinned) und verankerte Overlays.
Ein Link mitten im Satz
Seit 0.2.0-preview.13
TextRun.Destination, denn ein Link mitten in einem Absatz hat sonst nirgends Platz:
1
2
3
4
5
6
7
8
9
new Text("", TypeRole.BodyM)
{
Spans =
[
new TextRun("Read the "),
new TextRun("getting started") { Destination = "/docs/start", Weight = FontWeight.SemiBold },
new TextRun(" guide."),
],
}
Ein Link um den ganzen Text macht den gesamten Absatz zu einem Link. Eine Row aus Texts bricht zwischen Runs statt zwischen Wörtern, also bricht ein Satz mit drei Code-Spans an den Spans um, und genau darum muss gemischte Betonung von Anfang an ein Text mit Spans sein. Zwischen diesen beiden war ein Link mitten im Satz nicht ausdrückbar, und bei Prosa sind das die meisten Absätze.
Ein Run kann auch eine andere GRÖSSE haben als die Prosa um ihn herum: StyleOverride am Run, dieselbe Notluke, die Text.StyleOverride ist. Inline-Code mit 13.5 in einem Absatz mit 16 ist der Fall, und der Run behält die Zeilenbox des Absatzes: Eine eigene line-height würde darüber und darunter eine Lücke öffnen, und das ist eine Zeile, kein Run. Seit 0.2.0-preview.15.
Ein Text ist selbst nie inline: Im Web ist er ein Block, wo auch immer er sitzt, mit seiner eigenen Zeilenbox in der Höhe, die sein Typo-Stil nennt (seit 0.2.0-preview.61). Als Inline-Span in einer Box mit Padding saß er auf der Zeile der Box, deren Strut die Textschrift der Seite ist, und ein 10/15-Label in einer Pille kam 26.5px hoch heraus, wo seine Teile 21 ergeben. Als Block nimmt er die Breite, die sein Elternteil ihm gibt, wie der Text von Flutter unter einer engen Breite, also liest sich seine Ausrichtung über diese Breite. Ein Satz ist ein Text mit Spans: mehrere Texts in einem HtmlElement nehmen je eine Zeile.
Die FORM ist universell: Ein NSAttributedString von Apple trägt ein .link-Attribut auf einem Bereich, ein Spannable von Android trägt einen URLSpan. Ein Inline-Link ist überall ein Run mit einem Attribut und kann kein eigener Knoten sein, denn ein eigener Knoten ist gerade das, was die Zeile bricht.
Der NAME folgt derselben Regel. Apple sagt link, Android sagt URLSpan, HTML sagt href, also ist href das Wort eines Ziels, und die abstrakte Schicht spricht die Sprache keines Ziels (Pressable, nicht "Button"). Es heißt Destination, auf Link wie auf TextRun: was der Wert IST, nämlich eine Route oder eine URL, in einem Wort, das keinem der drei Ziele gehört.
UMBENANNT IN 0.2.0-PREVIEW.13, von Href, in derselben Version, in der es zuerst erschien. Ein Breaking Change, genommen solange er eine Zeile pro Anwendung ist und nicht erst, wenn es Anwendungen zu brechen gibt.
Spans wird heute im Web realisiert. Photon realisiert Link (es sammelt Link-Regionen und testet sie auf Treffer, wobei es das Ziel an die Navigationsnaht der Shell übergibt), aber nicht das Ziel je RUN, also zeichnet ein Absatz mit Inline-Links dort als sein Text, ohne Verlinkung. Was fehlt, ist das Hit-Testing je Run, nicht die Bedeutung.
Die Route, auf der eine Seite liegt
Seit 0.2.0-preview.13
Eine Seite auf /docs/{slug} liest den Slug aus ihrem eigenen Kontext:
1
2
3
4
5
6
7
8
9
10
11
12
13
[Page("/docs/{slug}")]
public sealed class DocPage : StatelessComponent, IServerPrefetch
{
private Doc? _doc;
[ServerOnly]
public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
=> _doc = await services.GetRequiredService<IDocs>()
.FindAsync(RouteValues.Current.Param("slug"), cancellationToken);
public override VisualNode Build(ComponentContext context) =>
Text(_doc?.Title ?? "Not found", TypeRole.Heading);
}
•
context.Route.Param("slug") in Build, RouteValues.Current.Param("slug") in einem Prefetch (der läuft, bevor es einen Kontext gibt).
•
context.Route.Query("page") für den Query-String.
•
Nie null; ein Name ohne Treffer antwortet null.
Davor war der einzige Weg dorthin IHttpContextAccessor. Das funktioniert, und es kostet die Seite genau das, was sie write-once macht: Eine Komponente, die von ASP.NET weiß, ist eine Komponente, die nicht auf Photon laufen kann. Einen Routenparameter zu lesen ist das Gewöhnlichste, was eine Seite tut, und wenn der Weg dazu webspezifisch ist, dann gilt "write-once" für das Visuelle und nicht für die Seite.
Die Route wird vor dem Prefetch armiert, und das ist der Teil, auf den es ankommt: Eine Seite auf /docs/{slug} lädt anhand des Slugs, also kommt eine Route, die nach dem Prefetch ankommt, nach der einzigen Frage, für die sie da war.
Pro Anfrage, auf einem AsyncLocal wie jeder andere Wert pro Anfrage, denn SSR rendert gleichzeitige Anfragen auf gemeinsam genutzten Instanzen, und ein Slug in einem Feld würde das Dokument eines Besuchers einem anderen aushändigen.
BRAUCHT 0.2.0-PREVIEW.14, UM AUSBuild gelesen zu werden. In preview.13 hatte die Runtime keinen RouteValues-Export, also starb eine Seite, die ihn außerhalb von [ServerOnly] nannte, bei der Hydration an "does not provide an export named 'RouteValues'", während SSR weiter mit 200 und korrektem Markup antwortete, und genau darum sah es aus, als wäre nichts kaputt. Ihn aus einem [ServerOnly]-Prefetch zu lesen funktioniert auf beiden.
In den Baum eintreten und ihn verlassen
Seit 0.2.0-preview.13
1
2
3
4
5
6
7
8
9
10
11
12
public sealed class LiveRates : StatefulComponent
{
private IDisposable? _subscription;
private NetworkState _network;
protected override void OnMount() =>
_subscription = _status.Subscribe(state => SetState(() => _network = state));
protected override void OnUnmount() => _subscription?.Dispose();
public override VisualNode Build(ComponentContext context) => …;
}
Ein Konstruktor kann nicht die Stelle sein, an der gespeicherter Zustand geladen, ein Gerät abonniert oder eine Anfrage gestartet wird: Der Reconciler baut in jedem Durchgang frische Instanzen und behält die aufbewahrte, also wird alles, was ein Konstruktor startet, erneut für eine Instanz gestartet, die dann weggeworfen wird. OnMount läuft einmal, auf der Instanz, die bleibt.
OnUnmount kommt mit ihm statt nach ihm, denn ohne das Zweite ist jeder Mount ein Leck.
Drei Dinge, die man wissen sollte:
•
Geliefert, wenn der Durchgang schließt, nicht mitten im Build. Ein Hook, der im Reconcile liefe, liefe, während sein eigenes Elternteil noch baut, also würde ein SetState von dort einen halb fertigen Baum mutieren.
•
Das Abgehende unmountet, bevor das Eingehende mountet: die Reihenfolge, die zwei Komponenten erlaubt, eine exklusive Ressource über einen Tausch hinweg zu teilen.
•
Es bedeutet nicht "die Pixel existieren". Photon hat kein DOM, aus dem Geometrie gelesen werden könnte, also könnte ein Hook, der das verspricht, nicht write-once sein. Es bedeutet: Diese Komponente ist im Baum, ihr Build ist kurz davor gezeigt zu werden, und ein SetState von hier plant den nächsten Durchgang, statt mit dem aktuellen zu kämpfen.
Eine Seiten-WURZEL hat kein Elternteil, das sie mountet, also tut es die Fläche: PhotonHost nach seinem ersten Frame, die Web-Basis bei Mount/Hydrate.
Einen Baum ohne Server testen
Eine Komponente ist ein Wert, also kostet es ein Assert und keinen Prozess, über das zu urteilen, was sie rendert. Der Web-Realisierer ist öffentlich, und die eigenen Tests des Frameworks nutzen genau dies:
1
2
3
4
5
6
7
using eQuantic.UI.Primitives;
using eQuantic.UI.Web;
var html = HtmlRenderer.RenderNode(
WebRealizer.Lower(new PriceTag(1999), PhotonTheme.Instance).Render());
html.Should().Contain("R$ 19,99");
Drei Schritte, weil jeder eines tut: Lower(node, theme) realisiert den Baum, .Render() macht aus dem realisierten Element einen HtmlNode, und HtmlRenderer.RenderNode(...) schreibt diesen Knoten als Markup. Bei .Render() zu stoppen gibt Ihnen den Knoten, was nützlich ist, wenn die Behauptung die Struktur betrifft (.Tag, .Attributes) und nicht den Text, und das wollen die meisten Tests des Frameworks selbst. Ohne Style-Senke bleiben die Styles inline, und das ist, was man in einem Test will: Die atomare Klassenform ist für die Seite, und über einen Inhaltshash zu urteilen heißt, über ein Build-Artefakt zu urteilen.
Zwei Dinge, die das über Geschwindigkeit hinaus bringt. Ein Test kann DENSELBEN Knoten für beide Realisierer rendern und vergleichen, was jeder mit ihm gemacht hat, und so wird der Layout-Paritätskontrakt gehalten. Und eine Komponente, die wirft, wird von der Grenze gefangen, also lässt ein kaputter Teilbaum sein eigenes Assert scheitern, statt die Suite mitzunehmen.