Blazor WASM JavaScript Superspeed

Zu den Anfängen von Blazor versuchte ich mich an einem QRCode Reader per WebCam und ZXing. Ich bin gescheitert, weil die Interop API zwischen Browser und .net code immer per JavaScript erledigt werden muss. Erschwerend kommt hinzu, das Microsoft für den Komfort eine automatische Datentyp Konvertierung, hier Marshalling genannt, nutzt und diese echt langsam ist.

Mit .net 8 Blazor Client (Web Assembly WASM) und AOT Kompilierung funktioniert es nun tatsächlich leidlich. Da mein Code älter ist nutze ich IJSRuntime als Interop Engine.

Dabei bietet Microsoft seit .net 7 direkten Zugriff auf die API Objekte per Pointer. Bisher habe ich dazu keine Performance Tests erstellt. In diesem Blazor Blog Artikel geht es erst mal um ein How To JSInterop.

JSImport/JSExport

Der Einstiegspunkt sind die beiden Attribute JSImport um eine JavaScript Funktion in C# direkt Verfügbar zu machen und JSExport um JavaScript zu erlauben eine C# Methode aufzurufen.

Da JavaScript nicht direkt per <script> in eine .razor Componente eingebettet werden darf, muss man einen der zwei Wege wählen.

Eine JavaScript Datei aus dem statischen Bereich (wwwroot) wird wiie üblich als Script Referenz eingebunden. In .net 8 wird das in app.razor durchgeführt, vorher gab es eine Razor Veiw für diesen Zweck.

wasmjs1

Der JavaScript Entwickler spricht dann von einer Verschmutzung des globalen Namensraums. Wer diesen sauber halten will, nutzt Module. Dabei wird je Blazor Komponente eine gleichnamige JS Datei hinzugefügt und per Code importiert.

wasmjs2 

Das ganze nennen die Redmonder JavaScript Isolation.

Programmieren wir ein wenig JavaScript, auch wenn es schwer fällt. Hier als staticjs.js in wwwroot global eingebunden.

   1:  window.JSLoggerStatic =function(msg) {
   2:      alert(msg);
   3:      console.log(msg);
   4:  }

Sämtliche Beispiel zeigen für die Anwendung des JS Import folgenden Code in einer extra statischen Klasse.

   1:   public static partial class Wrapper
   2:   {
   3:       [JSImport("globalThis.console.log")]
   4:       public static partial void Log(string message);

Dabei kommt den Keywords static und partial eine besondere Bedeutung zu. Nur dann wird auch ein JavaScript Proxy vom Compiler erzeugt.

Das eigentliche Import Attribut verweist auf eine JavaScript Methode. Um dem globalen Namensraum gerecht zu werden, wird das globalThis eingeführt. Die Alternative per JS Modul kommt ohne diesen aus, benötigt aber einen 2ten Parameter für den Modulnamen,

Achtung: benötigter Namensraum using System.Runtime.InteropServices.JavaScript;

Mein persönlicher Ehrgeiz zwang mich praktisch dazu, das ohne zusätzliche Klasse zu lösen. Direkt in der Page bzw Razor Komponente. Hat mich einiges Zeit gekostet, Das geht nur, wenn man eine Partial Klasse zur Blazor Komponente anlegt  (Screenshot 2). Wenn man dies direkt in der .razor Page anlegt, erzeugt der Compiler die benötigten JavaScript Proxy Klassen nicht.

   1:  namespace BlazorAppLab8WASM.Client.Pages
   2:  {
   3:      public partial class Home
   4:      {
   5:          [JSImport("globalThis.console.log")]
   6:          public static partial void Log1(string message);

Den Aufruf der Methode Log1, kann man dann doch direkt in der Blazor Komponente realisieren. Der Code zeigt rein zum Vergleich den Aufruf aus statischer Klasse und Page.

   1:   void click()
   2:   {  
   3:       Log1("direkt aus Page");
   4:       Wrapper.Log("aus Klasse");

Nun die Variante mit dem JavaScript Modul und JavaScript Isolation. Die JavaScript Datei liegt nun dort wo die Componente ruht und teilt sich den Namen (Sceenshot 2). Unterschied auch JS6 Schlüsselwort export.

export  function JSLogger(msg) {
      console.log(msg);
}

 

Um diese Funktion zu laden und bei bedarf auch ggf entladen zu können, wird bei Initialisierung der Komponente die JavaScript Datei importiert. JSHost existiert in der WASM Variante ohne zusätzlich etwas zu benötigen. Der erste Parameter ist ein beliebiger Name, über den das Modul später identifiziert werden kann.

wasmjs4

   1:   protected override async Task OnInitializedAsync()
   2:   {
   3:       await JSHost.ImportAsync("modul1",
   4:            "../Pages/Home.razor.js");

Der Import einer (oder dieser) JavaScript Methode (wieder in der razor.cs Datei) muss dann mit 2ten Parameter Modulnamen modul1 erfolgen.

 [JSImport("JSLogger", "modul1")]
 public static partial void LogMessage2(string msg);
 

Achtung: Beim Debug aus Visual Studio kommt es bei mir häufiger vor, das die JavaScript Dateien nicht aktualisiert werden. Abhilfe STRG F5 im Browser

Fehlt noch der Teil einen Callback von JavaScript nach C# auszuführen. Dieser kommt in der Praxis vor wenn man lang dauernde API Calls, wie zb getCurrentPosition ausführt. Die Callback Methode aus JS muss dann die Ergebnisse einer C# Methode übergeben.

Hinweis: bei geringsten Tippfehlen in den Namen von JS Methode, Modulnamen oder DLL erhält man in der F12 Browser Console die verrücktesten Fehlermeldungen.

Zunächst wird die JavaScript Methode erstellt, die nach .net ruft. Dies in der .razor.js Datei

   1:  export async function CallCsharp() {
   2:      const { getAssemblyExports } = await globalThis.getDotnetRuntime(0);
   3:      var exports = await getAssemblyExports("BlazorWASMJSBlog.Client.dll");
   4:     
   5:      console.log(exports.BlazorWASMJSBlog.Client.Pages.Home.CsharpFunction());
   6:  }

Da muss man ein bisschen was dazu sagen. Zeile 2 übernehmen sie einfach kommentarlos ohne große Fragen zu stellen.

In Zeile 3 ist es wichtig das geladenen Assembly exakt zu referenzieren. Das ist in diesem Fall unsere Anwendung aber wahrscheinlich öfter eine Library. So wird wiederum ein Proxy Objekt erstellt, das die eigentliche Methode in der Home.razor.cs antriggern kann. Dieser Aufruf wird in Zeile 5 gemacht und die Rückgabe in der Browser Console angezeigt. Damit sich wenigstens irgendwas tut.

Nun zum WASM .NET Code. per JS Export wird der Compiler angewiesen die Proxy Funktion zu erstellen, die im JavaScript Code Schnippsel vorher genutzt wurde.

wasmjs5

[JSExport] 
internal static string CsharpFunction()
{
    return DateTime.Now.ToString();
}

Zu guter Letzt (ich weis verwirrend) braucht es noch den Import für die JS Funktion CallCsharp in C#, genauer der home.razor.cs.

[JSImport("CallCsharp", "modul1")]
public static partial void CallJS(string msg);

 

wasmjs6

Praxisbeispiel Blazor WASM Javascript extrem

Legen Sie per Standard Template ein Blazor Projekt in Visual Studio an

wasmjs3

Ändern Sie die Home.razor aus dem Client Projekt anlog zu oben

Wenn Sie das erste Mal JSImport einbinden und dann kompilieren, erscheint eine Fehlermeldung ala

Schweregrad    Code    Beschreibung    Projekt    Datei    Zeile    Unterdrückungszustand
Fehler    SYSLIB1074    JSImportAttribute requires unsafe code. Project must be updated with '<AllowUnsafeBlocks>true</AllowUnsafeBlocks>'. For more information see
https://aka.ms/dotnet-wasm-jsinterop    BlazorWASMJSBlog.Client    C:\blazor\BlazorWASMJSBlog\BlazorWASMJSBlog\BlazorWASMJSBlog.Client\BlazorWASMJSBlog.Client.csproj    1    Aktiv

Beim Dioppelklick auf diese Meldung öffnet der Visual Studio Editor das Projekt File. Ergänzen sie das XML und speichern die Datei um den direkten Zugriff per Zeiger zu erlauben.

   <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
 </PropertyGroup>

 

Ein Eigenheit von Blazor ist das Prerendering. Das bedeutet, das in so einem Mixed Umfeld wie in diesem Projekt, zuerst die Componente am Server gerendert wird und dann nochmal im Client. Das verträgt sich mit der Nutzung der JavaScript Interop API nicht. Ein wen ist, das Prerendering per app.razor zu deaktivieren

@rendermode="@(new InteractiveWebAssemblyRenderMode(prerender:false))"

 

Dann fehlt noch ein Button und ein wenig Code um den initialen Call der JS Methode anzustoßen. Das geschieht ganz banal in der .razor Komponente.

   1:  <button @onclick="click">Call</button>
   2:  @code {
   3:      void click()
   4:      {
   5:          CallJS("hallo");
   6:      }
Kommentare sind geschlossen