Blazor meets JavaScript 12.02.2024, 00:00 Uhr

Die Blechtrommel

Kommunikation zwischen Blazor und JavaScript: mühsam, aber möglich.
(Quelle: dotnetpro)
Ein Klassiker des World Wide Web ist Atwood’s Law [1]. Formuliert wurde es von Jeff Atwood, unter anderem als Mitgründer von Stack Overflow und von Discord bekannt. Sein „Gesetz“ besagt: Jede Anwendung, die (theoretisch) mit JavaScript entwickelt werden kann, wird es irgendwann auch. Das war 2007, und etwa elf Jahre später versuchte Microsoft mit Blazor, dies gewissermaßen zu widerlegen: Ist es doch möglich, komplett ohne JavaScript (oder TypeScript) ­eine Webanwendung zu erstellen, gar eine Single Page Application. Natürlich stimmt das nicht so ganz, natürlich läuft im Browser Skriptcode, etwa um eine Verbindung mit dem Server aufrechtzuerhalten oder um WebAssembly zu initialisieren, aber Microsoft hat hier tatsächlich eine Nische gefunden: Insbesondere Unternehmensanwendungen von .NET-affinen Teams mit JavaScript-Aversion werden gerne mit Blazor umgesetzt, ganz ohne eigenen JavaScript-Code.
Es gibt jedoch immer wieder Szenarien, in denen dennoch Skriptcode notwendig ist. Ein typisches Beispiel ist der Zugriff auf JavaScript-APIs, um clientseitige Informationen (etwa die aktuelle Position, den Netzwerkzustand oder die Bildschirmauflösung) an den Server beziehungsweise an die Blazor-Anwendung zu übermitteln. Für diese JavaScript-Inter­operabilität gibt es eine Reihe von Ansätzen, die zu unterschiedlichen Zeitpunkten in Blazor mit übernommen wurden. Wir setzen auf die aktuelle Long-Term-Support-Variante von Blazor als Ausgangsbasis und werfen einen Blick auf die verschiedenen Wege, von Blazor aus JavaScript-Code aufzurufen oder von JavaScript aus auf C#-Code zugreifen zu können. Das alles ist teilweise etwas mühsam und verwendet ein nicht unbedingt intuitives API, aber letzten Endes funktioniert es wie gewünscht. Legen wir los!

Blazor ruft JavaScript

Der Aufruf von JavaScript aus dem .NET-Code der Blazor-Anwendung heraus ist in wenigen Schritten erledigt. Dreh- und Angelpunkt unserer Implementierung ist die Schnittstelle IJSRuntime, definiert im Namespace Microsoft.JSInterop. Über diese wird der JavaScript-Code zugänglich gemacht, und sie kann sehr einfach per Dependency Injection in eine Blazor-Komponente eingebaut werden:

@inject IJSRuntime JSRuntime
Die Schnittstelle selbst unterstützt nur zwei Methoden:
  • InvokeAsync(), die – wie der Name schon andeutet – asynchron (JavaScript-)Code ausführt. Diese Methode ist generisch; der angegebene Typ ist der des erwarteten Rückgabewerts.
  • InvokeVoidAsync(), womit JavaScript-Code ohne Rückgabewert aufgerufen wird. Dementsprechend fehlt auch die Angabe des Rückgabetyps.
Dass überhaupt auf asynchrone Methoden gesetzt wird, ist per se kein Muss, ermöglicht aber einen einheitlichen Ansatz unabhängig vom Render-Modus. Wenn etwa Blazor Server (beziehungsweise: @rendermode InteractiveServer) zum Einsatz kommt, führt an einem asynchronen Aufruf kein Weg vorbei, weil ja die clientseitigen Informationen über die bestehende SignalR-Verbindung an den Server transportiert werden müssen.
Ein kleines Beispiel zeigt die JavaScript-Interoperabilität auf schnelle Art und Weise. Die JavaScript-Methode window.prompt() öffnet ein modales Fenster mit einem Texteingabefeld. Der Wert, der in diesem Texteingabefeld eingetragen wird, ist gleichzeitig der Rückgabewert der Methode. Dies können wir auch von Blazor aus aufrufen. Ausgangspunkt ist das folgende simple UI:

@page "/prompt"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime
<h3>Prompt</h3>
<button @onclick="PromptForName">Prompt</button>
<p>Name: <span>@Name</span></p>
@code {
  protected string Name = string.Empty;
}
Die Eigenschaft Name wird an das <span>-Element gebunden, sodass dieses sich jedes Mal verändert, wenn der Wert sich ändert. Eine Schaltfläche ruft eine (noch nicht imple­mentierte) C#-Methode PromptForName() auf, in der sich dann der Code der JavaScript-Interoperabilität befindet. Die JavaScript-Runtime wird bereits per @inject-Attribut verfügbar gemacht.
In eben dieser Methode PromptForName() kommt jetzt InvokeAsync() zum Einsatz. Der erste Parameter ist der aufzurufende Code, genauer der Name der Funktion, die ausgeführt werden soll. Etwaige weitere Parameter können im zweiten Argument von InvokeAsync() angegeben werden. In JavaScript würde beispielsweise folgender Code das gewünschte Ergebnis liefern:

window.prompt("What's your name?");
Aus Blazor heraus benötigen wir also offensichtlich folgenden Aufruf:

protected async Task PromptForName()
{
  Name = await JSRuntime.InvokeAsync<string>(
    "window.prompt", new[] { "What's your name?" });
}
Der Rückgabewert des aufgerufenen Codes, also das Ergebnis von window.prompt(), ist dann gleichzeitig der Rückgabewert von InvokeAsync(). Das ist normalerweise ein String, aber auch wenn die Rückgabe null ist (etwa, wenn das modale Fenster mit der ESC-Taste geschlossen wird), gibt es keine Exception, sondern die Eigenschaft Name wird auf eine leere Zeichenkette gesetzt. Bild 1 zeigt in drei Schritten den Ablauf des Beispiels visuell.
Daumenkino: Vorher ... (Bild 1a)
Quelle: Autor
Daumenkino: ... Eingabe ... (Bild 1b)
Quelle: Autor
Daumenkino: ... Nachher (Bild 1c)
Quelle: Autor
Natürlich sollten Sie sich davor hüten, JavaScript-Code – gar noch als „Magic String“ – geradewegs in den C#-Code zu integrieren. Üblicherweise steckt der Code, der aufgerufen werden soll, in einer JavaScript-Datei. Etwas abhängig davon, welche Version von .NET zum Einsatz kommt, und gegebenenfalls auch noch davon, welche der unterschiedlichen Templates Sie einsetzen, ist es wichtig, diesen Code am richtigen Ort zu integrieren. Beim „Blazor Web App“-Template von .NET 8 etwa ist die Datei Components/App.razor diejenige mit dem HTML-Rahmen. Dies ist hierbei der vorgegebene <body>-Bereich:

<body>
  <Routes />
  <script src="_framework/blazor.web.js"></script>
</body>
Diese Datei ist prädestiniert für den Einbau zusätzlicher Java­Script-Dateien, direkt vor dem schließenden <body>-Tag. Hier ein möglicher Ansatz für eine JavaScript-Funktion, die dann von C# aus aufgerufen werden kann:

function promptForName(name, defaultText) {
  return prompt(name, defaultText || "");
}
Diese Funktion unterstützt jetzt auch den zweiten Parameter von window.prompt(), nämlich den standardmäßig im Textfeld angezeigten Wert. So sähe dann der zugehörige C#-Aufruf via Blazor Interop aus:

Name = await JSRuntime.InvokeAsync<string>(
  "promptForName", new[] { "What's your name?", 
  "Der Chefredakteur" });
Etwaige Fehler tauchen ganz klassisch in der Browser-Konsole beziehungsweise in den Web Dev Tools auf. Die Anwendung läuft aber in den meisten Fällen weiter. Standardmäßig sind die Fehlermeldungen nicht sehr aussagekräftig, aber die Exception in der Kon­sole verrät auch, wie Sie an zusätzliche Details herankommen. Bild 2 zeigt eine solche Meldung, übrigens absichtlich ausgelöst, ­indem in den Namen der Java­Script-Funktion ein Tippfehler eingebaut worden ist.
Satz mit X: Mit Tippfehlern wird’s wohl nix (Bild 2)
Quelle: Autor

JavaScript ruft Blazor

Bei JavaScript-Funktionen, die einen Rückgabewert haben, funktioniert der gezeigte Ansatz schon recht gut. Wie steht es aber bei anderen Szenarien, etwa bei Methoden, die nur Callbacks liefern? Diese sind nicht in .NET transformierbar. Allerdings gibt es in fast allen Fällen einen guten Workaround. Nehmen wir als Beispiel die Geolocation-Features in allen modernen Browsern, die die aktuelle Position des Clients ermitteln. Das am häufigsten dafür verwendete JavaScript-API sieht wie folgt aus:

navigator.geolocation.getCurrentPosition(
  onSuccess, onError, options)
Diese Funktion gibt nicht direkt die Position zurück, denn deren Ermittlung kann etwas dauern (etwa, bis per GPS die Position zuverlässig genug berechnet worden ist). Sie arbeitet auch nicht mit JavaScript-Promises, sondern erwartet Callbacks (in C# würde man sagen: Delegates), die ausgeführt werden, wenn entweder erfolgreich eine Position ermittelt worden ist (erstes Argument), oder wenn ein Fehler aufgetreten ist (zweites Argument, etwa: Der Client hat den Zugriff auf die Position verweigert). Im dritten Argument können noch ein paar Konfigurationsoptionen angegeben werden, etwa bezüglich der Genauigkeit der Messung.
Die Callbacks von getCurrentPosition() sind aktuell in Blazor nutzlos; wir müssen sie also in JavaScript selbst implementieren. Doch wie kommen die Werte dann zurück an den Server? Dies erledigt der zweite Bestandteil, nämlich der Aufruf einer C#-Methode aus JavaScript heraus.
Damit das überhaupt funktioniert, muss die Methode öffentlich und statisch sein und mit [JSInvokable] gekennzeichnet werden. Der Aufruf selbst ist etwas ulkig: Der JavaScript-Code von Blazor stellt automatisch ein JavaScript-Objekt ­namens DotNet zur Verfügung. Dieses besitzt eine Methode namens invokeMethodAsync(). Damit gelingt der Ruf hinein in C#, allerdings ist dabei ein präzises Vorgehen gefordert. Hier ­zunächst eine einfache Oberfläche inklusive Ausgabe für etwaige Fehlermeldungen:

@page "/position"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime
<h3>Position</h3>
<button @onclick="GetPosition">Get Position</button>
<div class="text-danger">@Message</div>
Beim Klick auf die Schaltfläche soll eine JavaScript-Funktion aufgerufen werden, die den Geolocation-Prozess im Browser startet. Wie das funktioniert, wissen wir schon; diesmal erfolgt die Umsetzung mittels InvokeVoidAsync(), wir erhalten ja dieses Mal wie erläutert keine verwertbare Rückgabe:

protected async Task GetPosition()
{
  await JSRuntime.InvokeVoidAsync("getCountry");
}
Die JavaScript-Funktion getCountry() ruft dann navigator.geolocation.getCurrentPosition() auf und definiert die Callback-Funktionen. Das Ziel ist es jetzt, in diesen den Rückweg zum C#-Code zu schaffen und beispielsweise die ermittelten Client-Koordinaten der Blazor-Anwendung zur Verfügung zu stellen. Ein Fall für die Methode DotNet.invokeMethodAsync()! Jetzt kommt die bereits angesprochene Umsicht bei diesem Aufruf zum Tragen. Der Methodenname steckt erst im zweiten Parameter; im ersten Parameter steht die Assembly, in der die Methode steckt. Hier müssen Sie exakt darauf achten, welcher Namensraum durch das Projekt und gegebenenfalls auch die Klasse der Blazor-Komponente vorgegeben ist. Das Beispielprojekt heißt dnp.BlazorJs, was dann gleichzeitig auch der Namensraum ist. Alle Parameter ab Nummer 3 werden an die C#-Methode weitergereicht. Hier ein Aufruf, der die (C#-)Methode GetCountry() aufrufen soll und dabei den Längen- und Breitengrad übergibt. Diese stecken bei dem Wert, den der Success-Callback von getCurrentPosition() erhält, in den Eigenschaften coords.longitude und coords.latitude.

DotNet.invokeMethodAsync("dnp.BlazorJs", "GetCountry", 
  data.coords.longitude, data.coords.latitude)
Doch damit nicht genug. Einfach einmal angenommen, die – noch nicht implementierte – C#-Methode GetCountry() würde den zu den Koordinaten passenden Ländernamen zurückgeben. Wie können wir JavaScript-seitig darauf zugreifen? DotNet.invokeMethodeAsync() verwendet hierzu Promises, so kommen wir an die gewünschten Daten. Hier ein vollständiger Aufruf von getCountry(), inklusive Geolocation-API von JavaScript, Rückruf nach Blazor und Ausgabe des dann von C# gelieferten Wertes:

function getCountry() {
  navigator.geolocation.getCurrentPosition(
    (data) => {
      DotNet.invokeMethodAsync("dnp.BlazorJs", 
        "GetCountry", data.coords.longitude, 
        data.coords.latitude)
        .then(country => alert(country));
    }
  )
}
Zurück zu Blazor. Dort wollen wir aus den gelieferten Koordinaten das zugehörige Land ermitteln. Beziehungsweise nicht wir, sondern wir setzen auf ein Open-Source-Projekt. Auf NuGet findet sich das Paket Wibci.CountryReverseGeocode [2], das genau das erledigt. Leider gibt es einen kleinen Haken: Zwar wird .NET Standard unterstützt, aber es kommt ein inzwischen geändertes API zum Einsatz, weswegen der Code einen Fehler wirft. Interessanterweise funktioniert aber der entsprechende Code auf GitHub [3], der womöglich einen aktuelleren Stand hat. Dieser muss also ins Projekt integriert werden, dann steht die Funktionalität zur Verfügung.
Die Methode GetCountry() erhält, wie im obigen Code zu sehen, zwei Parameter: den Längen- und den Breitengrad, den das Geolocation-API ermittelt hat. Diese werden dann an Wibci.CountryReverseGeocode übergeben. Das Ergebnis ist ein Ländername, der dann zum Rückgabewert der C#-Methode wird:

[JSInvokable]
public static string GetCountry(double longitude, double 
    latitude)
{
  var geocodeService = 
    new CountryReverseGeocodeService();
  var geolocation = new GeoLocation { 
    Longitude = longitude, Latitude = latitude };
  var country = geocodeService.FindCountry(geolocation);
  return country.Name ?? "N/A";
}
Und damit funktioniert die komplette Strecke: Der Klick auf die Schaltfläche ruft C#-Code auf, der per JavaScript-Interop die clientseitige Funktion getCountry() auslöst. Diese zapft das Geolocation-API des Browsers an, um dann wiederum die C#-Methode GetCountry() aufzurufen. Diese bedient sich bei Wibci.CountryReverseGeocode, um aus den Koordinaten das zugehörige Land zu ermitteln, und gibt das Ergebnis wieder an den JavaScript-Code zurück, der den Wert abschließend anzeigt. Ein ziemliches Hin und Her. Bild 3 zeigt den Ablauf des Beispiels im Browser.
Herkunftsnachweis: Geolocation erlauben, Land erhalten (Bild 3a)
Quelle: Autor
Herkunftsnachweis: Geolocation erlauben, Land erhalten (Bild 3b)
Quelle: Autor
Damit ist das Gros der Arbeit geschafft, doch wie sieht es mit dem Fehler-Callback von getCurrentPosition() aus? Rein JavaScript-seitig ist es simpel, beispielsweise wäre Folgendes denkbar:

navigator.geolocation.getCurrentPosition(
  (data) => { /* ... */ },
  (error) => { 
     DotNet.invokeMethodAsync("dnp.BlazorJs", 
       "ShowError", error.message);
  }
)
Die zugehörige C#-Methode hat zunächst folgende Signatur – [JSInvokable]-Attribut, öffentlich, statisch:

[JSInvokable]
public static void ShowError(string error) {}
Der letztgenannte Punkt, nämlich das static, ist allerdings ein Problem. Mangels Instanz haben wir keinen direkten Zugriff auf eine Member-Variable der Blazor-Komponente, es ist ­also nicht direkt möglich, die Fehlermeldung in der Oberfläche auszugeben. Allerdings gibt es noch eine zusätzliche Hilfsmethode im JavaScript-Interop-Support von Blazor, die uns hier weiterhelfen kann.
Der .NET-Typ, dem die JavaScript-Variable DotNet in etwa entspricht, ist DotNetObjectReference, ebenfalls im Namespace Microsoft.JSInterop hinterlegt. Mit der Methode DotNetObjectReference.Create() ist es möglich, einen solchen Wert zu erzeugen. Der Clou: Als Parameter geben Sie eine Klasseninstanz an (am einfachsten: this). Die DotNetObjectReference zeigt dann auf diese Instanz, und damit können auch Instanzmethoden aufgerufen werden.
Und wie erhält JavaScript Zugriff auf diese Referenz? Genau, wieder über JavaScript Interop und InvokeAsync() beziehungsweise InvokeVoidAsync(). Die Implementierung der C#-Methode GetPosition() ändert sich wie folgt:

protected async Task GetPosition()
{
  var DotNet = DotNetObjectReference.Create(this);
  await JSRuntime.InvokeVoidAsync("getCountry", DotNet);
}
In der aufgerufenen JavaScript-Methode ist die Referenz das erste Argument. Im Fehlerfall bei der Positionsbestimmung mit Geolocation wird dann die Methode ShowError() aufgerufen, und zwar über die Instanz! Die Angabe eines Namespace ist logischerweise nicht mehr notwendig.
Im Erfolgsfall kommt weiterhin die statische Referenz (DotNet) zum Einsatz.

function getCountry(DotNetInstance) {
  navigator.geolocation.getCurrentPosition(
    (data) => { /* ... */ },
    error) => { 
      DotNetInstance.invokeMethodAsync(
        "ShowError", error.message);
    }
  )
}
Die C#-Methode ShowError() muss jetzt nicht mehr statisch sein und kann deshalb auch auf Modelleigenschaften zugreifen, die dann an die Oberfläche gebunden werden. Wichtig ist nur noch, dass Blazor mitgeteilt wird, dass sich ein Wert geändert hat; dies erledigt die Methode StateHasChanged().

[JSInvokable]
public void ShowError(string error)
{
  Message = error;
  StateHasChanged();
}
Bild 4 zeigt das Ergebnis, wenn der ­Client den Zugriff auf die Position ­unterbindet. Die entsprechende, von dem JavaScript-API gelieferte und an Blazor übergebene Fehlermeldung erscheint in der Oberfläche.
Datenschutz: Ohne Geolocation keine Positionsbestimmung (Bild 4)
Quelle: Autor

Neue Kommunikation ab ­Blazor 7

Zusammen mit der Veröffentlichung von .NET 7 (und damit von ASP.NET Core 7 und letztendlich Blazor 7) wurde ein zweiter Ansatz eingeführt, um zwischen .NET und JavaScript zu vermitteln. Wieder wird über Attribute gearbeitet, aber es sind einige Extra-Schritte notwendig. Primär gedacht ist dieser Weg für (Blazor-)Bibliotheken, die damit Teile der Implementierung für JavaScript zugänglich machen können. Wir stellen im Folgenden die vorherigen Beispiele auf den neuen Ansatz um und sehen dabei auch ein wenig die Stellen, die etwas mühsam sind. Wichtig ist: Kein Schritt darf vergessen werden, sonst gibt es Fehlermeldungen, die nicht immer aussagekräftig sind. Bild 5 zeigt ein typisches – und hoffentlich abschreckendes – Beispiel: Ursache dieser Meldung ist es, dass sich im JavaScript-Code ein Aufruf mit await befunden hat, obwohl die zugehörige Methode nicht als asynchron markiert war. Aus der Fehlermeldung allein ist der Grund nicht wirklich ersichtlich.
Danke für nichts: Eine nur bedingt hilfreiche Fehlermeldung (Bild 5)
Quelle: Autor
Aber zurück zum alternativen Ansatz für die JavaScript-­Interoperabilität in Blazor. Voraussetzung ist zunächst, dass Blazor WebAssembly zum Einsatz kommt. Theoretisch funktioniert der Weg auch in einer Anwendung, die mit der Vorlage „Blazor Web App“ erstellt wurde und dann @rendermode InteractiveWebAssembly enthält, aber hier hakt es noch an einigen Stellen. Ein besserer (und zuverlässigerer) Startpunkt ist damit die dedizierte WebAssembly-Vorlage. Mit Blazor Server funktioniert dieses Feature nicht – beim zuvor vorgestellten Interop-Mechanismus war das noch anders.
Außerdem ist es erforderlich, dass wir in der Projektdatei (.csproj) das Feature einschalten, das später ein Mapping von JavaScript-Funktionen auf C#-Methoden erlaubt. In der ersten <PropertyGroup> in der Datei fügen wir folgendes Mark­up hinzu:

<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
Als Nächstes stellen wir sicher, dass wir drei zusammenge­hörige Dateien haben (Komponente ist dabei natürlich ein Platzhalter):
  • Komponente.razor – die eigentliche Razor-Komponente, in unserem Fall die „Seite“ der Blazor-Anwendung, die Java­Script-Interop verwendet.
  • Komponente.razor.cs – die partielle Code-behind-Klasse für die Komponente. Dort verknüpfen wir JavaScript und C#.
  • Komponente.razor.js – diese JavaScript-Datei enthält den Code, der von C# aufgerufen werden soll beziehungsweise der C# aufruft.
Bei Verwendung dieses Namensschemas zeigt Visual Studio die Dateien auch automatisch zusammengefasst an, wie in Bild 6 zu sehen.
Gruppenkuscheln: Razor-, C#- und JavaScript-Datei bilden eine (visuelle) Einheit (Bild 6)
Quelle: Autor
Beginnen wir in der JavaScript-Datei. Dort hinterlegen wir eine Funktion, die später von C# aus getriggert werden soll. Die Datei wird später als Modul geladen, wir müssen somit die Funktion exportieren:

export function promptForName(name, defaultText) {
  return prompt(name, defaultText || "");
}
Weiter geht es in der C#-Datei. Der Name der Klasse entspricht dem Namen der Razor-Komponente. In dieser Klasse legen wir eine Methode an, die dieselbe Signatur besitzt wie die JavaScript-Methode, und zwar mit den Modifiern internal static partial (das ist für die spätere automatische Codegenerierung wichtig). Über das neue Attribut [JSImport] verbinden wir die JavaScript-Funktion mit der C#-Methode. Dazu benötigen wir zwei Parameter: den Namen der JavaScript-Funktion sowie den Namen des geladenen JavaScript-Moduls (dazu gleich mehr). Häufig ist dieser Modulname gleich dem Komponentennamen, aber das ist keine Pflicht. Hier die komplette JavaScript-Datei:

using System.Runtime.InteropServices.JavaScript;
namespace dnp.BlazorWasmJs.Pages
{
  public partial class Prompt
  {
    [JSImport("promptForName", "Prompt")]
    internal static partial string 
      JSPromptForName(string name, string? placeholder);
  }
}
Ein Aufruf der C#-Methode JSPromptForName() sorgt also dafür, dass die JavaScript-Funktion promptForName() ausgeführt wird. Dies erledigen wir in der Razor-Datei. Ein Klick auf eine Schaltfläche triggert wieder den Aufruf des modalen Warnfensters; die Eingabe dort binden wir an die Oberfläche:

page "/prompt"
@using System.Runtime.InteropServices.JavaScript
<h3>Prompt</h3>
<button @onclick="PromptForName">Prompt
  </button>
<p>Name: <span>@Name</span></p>
@code {
  protected string Name = string.Empty;
  protected void PromptForName()
  {
    Name = JSPromptForName(
    "What's your name?", "James Bond");
  }
}
Ein Puzzlestück fehlt allerdings noch. Das JavaScript-Modul muss noch geladen werden. Ein naheliegender Zeitpunkt im Lebenszyklus einer Blazor-Komponente ist das Ereignis Ini­tialized. Deswegen fügen wir der Razor-Datei folgenden Code hinzu, direkt in den @code-Block:

protected override async Task OnInitializedAsync()
{
  await JSHost.ImportAsync("Prompt",
    "../Pages/Prompt.razor.js");
}
Der erste Parameter für ImportAsync() ist der gewünschte Modulname (der dann entsprechend im [JSImport]-Attribut auch so verwendet werden muss), der zweite der Speicherort der Datei. Damit haben wir diesen Teil der Anwendung erfolgreich portiert; sie funktioniert wieder so wie mit dem anderen Interoperabilitätsansatz.
Bei genauerer Betrachtung fällt womöglich auf, dass unsere eigene JavaScript-Funktion promptForName() alle Parameter im Wesentlichen an die eingebaute Java­Script-Funktion window.prompt() durchreicht. In solchen Fällen ist es auch möglich, beim Anlegen des C#-Methodenrumpfs ­direkt das „Original“ zu verwenden. Über den Bezeichner globalThis können wir auf den globalen JavaScript-Kontext verweisen; globalThis.window.prompt wäre also eine Referenz auf die entsprechende Java­Script-Funktion. Folgender Code in der C#-Datei legt ­eine zugehörige C#-Methode an:

[JSImport("globalThis.window.prompt")]
internal static partial string WindowPrompt(
  [JSMarshalAs<JSType.String>] string message, 
  [JSMarshalAs<JSType.String>] string? placeholder);
Sie sehen hier auch noch die explizit angegebenen Datentypen in der Deklaration, von Visual Studio automatisch eingefügt (aber in unserem Fall obsolet). An dieser Stelle können Sie explizite Datenkonvertierungen vornehmen, wenn Sie bestimmte JavaScript-Typen auf bestimmte .NET-Typen mappen möchten, etwa bei Zahlenwerten.
Nach dieser Deklaration benötigen wir das JavaScript-Modul und seine Registrierung nicht mehr; der beim Klick auf die Schaltfläche auszuführende C#-Code sieht dann so aus:

protected void PromptForName()
{
  Name = WindowPrompt("What's your name?", "James Bond");
}
Damit ist der Weg von C# hin zu JavaScript geschafft. Der Rückweg ist etwas mühsamer. Dies sehen wir am Beispiel der JavaScript-seitigen Positionsbestimmung, die dann auf der C#-Seite eine Ermittlung des zugehörigen Landes anstößt. Diesmal beginnen wir mit der Razor-Seite. Dort passieren im Wesentlichen drei Dinge:
  • Beim ersten Rendern importieren wir die zugehörige Java­Script-Datei; zu deren Inhalt kommen wir später. Wir könnten auch wieder auf das Initialized-Ereignis lauschen.
  • Wieder gibt es eine Schaltfläche; bei Klick darauf wird ­eine Methode namens JavaScriptGetCountry() aufgerufen. Der Name deutet es schon an: Diese C#-Methode ist auf ­eine JavaScript-Funktion gemappt, auch dazu gleich mehr.
  • In der Oberfläche wird eine Eigenschaft namens Country ausgegeben. Ziel ist es also, diese Property mit dem entsprechenden Land zu füllen.

@page "/position"
@using System.Runtime.InteropServices.JavaScript
<h3>Position</h3>
<button @onclick="GetPosition">Get Position</button>
<div class="text-info">@Country</div>
@code {
  protected override async Task OnAfterRenderAsync(
      bool firstRender)
  {
    if (firstRender)
    {
      await JSHost.ImportAsync(
        "Position",
        "../Pages/Position.razor.js");
    }
  }
  protected void GetPosition()
  {
    JavaScriptGetCountry();
  }
}
Zwischen der C#-Klasse und der JavaScript-Datei wird jetzt etwas Pingpong gespielt. Beginnen wir im C#-Teil. Die in der Razor-Komponente erwähnte Methode JavaScriptGetCoun­try() ist hier angelegt. Erneut sorgt [JSImport] dafür, dass die Verbindung zum JavaScript-Modul hergestellt wird. Wichtiges kleines Detail: Was wir hier machen, funktioniert nur in einem Browserkontext, was der Compiler auch (nichtkritisch) anmeckert. Mit dem Attribut [SupportedOSPlatform] geben wir an, dass wir genau diesen Anwendungsfall erwarten.

[SupportedOSPlatform("browser")]
public partial class Position
{
  [JSImport("getCountry", "Position")]
  internal static partial void JavaScriptGetCountry();
  // ...
}
Die Aufgabe der JavaScript-Funktion getCountry() ist es jetzt, per Geolocation-API die aktuelle Position zu ermitteln und dann an Blazor weiterzugeben. Ersteres haben wir schon gemacht, Letzteres ist neu. Der Zugriff auf den Blazor-Part erfolgt über Exporte in der Assembly der Anwendung. Die Syntax hierfür sieht wie folgt aus: Wir holen uns wieder eine Art JavaScript-Referenz auf die .NET-Runtime und erhalten damit Verweise auf alles, was in der Assembly der Anwendung für den JavaScript-Zugriff freigeschaltet, quasi exportiert wird. Wichtig ist es hier, den korrekten Assembly-Namen anzugeben, meist der Projektname plus .dll. So funktioniert es in unserem Beispiel:

const { getAssemblyExports } = 
  await globalThis.getDotnetRuntime(0);
var exports = await getAssemblyExports(
  "dnp.BlazorWasmJs.dll");
Ergebnis: Über die Variable exports kommen wir an alle freigegebenen C#-Methoden heran. Angenommen, unsere C#-Klasse würde eine Methode namens SetCountry() anbieten, dann könnte ein Aufruf wie folgt aussehen:

exports.dnp.BlazorWasmJs.Pages.Position.SetCountry(
  data.coords.longitude, data.coords.latitude);
Damit können wir das JavaScript-Modul fertigstellen. Eine kleine Falle steckt aber noch in unserem Vorgehen: Der Aufruf von getAssemblyExports() muss mit await erfolgen, aber die exportierte JavaScript-Funktion kann nicht asynchron sein. Wir behelfen uns, indem wir innerhalb der Funktion ­eine weitere, asynchrone Funktion anlegen und diese dann aufrufen. Hier der vollständige Code, der am Ende Längen- und Breitengrad an SetCountry() übergibt:

export function getCountry() {
  async function getPosition() {
    const { getAssemblyExports } = 
      await globalThis.getDotnetRuntime(0);
    var exports = 
      await getAssemblyExports("dnp.BlazorWasmJs.dll");
    navigator.geolocation.getCurrentPosition(
      (data) => {
        exports.dnp.BlazorWasmJs.Pages.Position
          .SetCountry(
          zdata.coords.longitude, data.coords.latitude);
      }
    )
  }
  getPosition();
}
Nun fehlt eigentlich nur noch die SetCountry()-Methode, die in die C#-Datei gehört. Diese markieren wir mit dem Attribut [JSExport], denn nur dann steht sie JavaScript zur Verfügung. In ihr ermitteln wir wie gehabt das zu den Koordinaten zugehörige Land und binden es an die Klasseneigenschaft Coun­try, die wir schon in der Razor-Datei in Verwendung gesehen haben:

public static string Country { get; set; } = 
  string.Empty; 
[JSExport]
public static void SetCountry(double longitude, 
    double latitude)
{
  var geocodeService = 
    new CountryReverseGeocodeService();
  var geolocation = new GeoLocation { 
    Longitude = longitude, Latitude = latitude };
  var country = geocodeService.FindCountry(geolocation);
  Country = country?.Name ?? "N/A";
  // TODO: In der Oberfläche ausgeben
}
Der TODO-Eintrag in der Methode weist aber schon auf ein Problem hin: Die Methode SetCountry() ist statisch; anhand des Aufrufs haben Sie bereits gesehen, dass das so sein wird. Wie aber können wir Blazor dann mitteilen, dass sich der Wert von Country geändert hat? StateHasChanged() steht uns nur innerhalb einer Instanzmethode zur Verfügung. Wir müssen also einen kleinen Umweg gehen. Folgender Ansatz erscheint sinnvoll: Wir legen ein Ereignis an, das ausgelöst werden soll, wenn sich der Wert von Country ändert. Der Typ des Ereignisses ist ein Delegate:

protected delegate void OnCountryDetected();
private static event OnCountryDetected? 
  OnCountryDetectedOccurred;
Der Clou: Auf Ereignisse können wir reagieren und beispielsweise im OnInitialized-Handler dann StateHasChanged() aufrufen!

protected override void OnInitialized()
{
  OnCountryDetectedOccurred += () =>
  {
    StateHasChanged();
  };
}
Zurück zum TODO-Kommentar von oben: Den ersetzen wir durch ein Auslösen des Handlers für unseren selbst angelegten Event:

OnCountryDetectedOccurred?.Invoke();
Das war das letzte Puzzlestück. Die Beispielanwendung mit der Länderbestimmung nutzt jetzt [JSImport] und [JSExport]. Ganz schön viel Aufwand, aber wie immer gilt: Gelingt es einmal, gelingt es immer wieder.
Dieser auf Attributen basierende Ansatz schafft eine schöne Spiegelung von JavaScript-Funktionen hin zu C#-Methoden und zurück, erfordert aber einen kleinen Umweg, um von statischen Methoden hin zu Komponenteninstanzen zu gelangen. Der herkömmliche Weg von JavaScript-Interop benötigt mehr „magische Strings“ und ein etwas umständlicheres API, bietet damit aber direkten Zugriff auf Instanzen und erleichtert so die Datenbindung an das UI.
Dokumente
Artikel als PDF herunterladen
Downloads
Projektdateien herunterladen


Das könnte Sie auch interessieren