Repository navigation
WriteOnceComponents de
🌐 Diese Seite auf: English · Português · Español · Français · Deutsch
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.
| 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 |
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
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:
// `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.
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.
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 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).
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 derIconRegistryin C#).
Siehe DesignSystem.
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.
Seit 0.2.0-preview.13
TextRun.Destination, denn ein Link mitten in einem Absatz hat sonst nirgends Platz:
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.
Spanswird heute im Web realisiert. Photon realisiertLink(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.
Seit 0.2.0-preview.13
Eine Seite auf /docs/{slug} liest den Slug aus ihrem eigenen Kontext:
[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")inBuild,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 aus
Buildgelesen zu werden. In preview.13 hatte die Runtime keinenRouteValues-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.
Seit 0.2.0-preview.13
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
SetStatevon 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
SetStatevon 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.
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:
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.
🏁 Hier starten
🏗️ Architektur
- Architekturüberblick
- Write-once-Komponenten
- Deklarative Oberfläche
- Paketarchitektur
- Komponenten
- Styling
- Lokalisierung
- Analytics und GTM
📱 Write-once
- Photon-Engine
- Design System
- Fähigkeiten
- Speicher
- Formulare
- Code-Editor
- Markdown
- Mermaid
- E-Mail-Rendering
⚙️ Kompilierung
⚡ Runtime
🔌 Server
🎨 Ökosystem
🚀 Entwicklung