Trog.NET Blazor Typescript Interop

ScreenShot

Table of Contents

  1. Create Blazor Project
  2. Implement JavaScript Interop
    1. Call JavaScript Browser API
    2. Call Embedded JavaScript
    3. Call Isolated JavaScript
  3. Debugging JavaScript
  4. Implement TypeScript Interop
    1. Call Isolated TypeScript
    2. Setup Webpack Build Pipeline
    3. Call Webpack TypeScript
    4. Call NPM TypeScript
  5. Interop Software Design

Home https://trog.net
Document https://trog.net/Articles/BlazorTSInterop
GitHub https://github.com/warrengb/BlazorTSInterop
Demo https://trog.net/BlazorTSInterop

ScreenShotBlazor is a formative addition to the .NET stack for building .NET Core SPA MVVM websites in Wasm coded in C#. Blazor is an attractive alternative to Angular, React, Vue and other JavaScript SPA website architectures for the .NET developer.
Blazor MAUI, a continuation of Xamarin with Blazor webview is another great addition to the .NET stack that completes a .NET developer ecosystem for device and browser applications.

ScreenShot TypeScript is a superset of JavaScript for application-scale development featuring strong types and geared for object-oriented programming.
TypeScript transpiles to JavaScript. Going forward in this article, in the scope of interop, JavaScript and TypeScript implies the result of transpiled TypeScript to JavaScript.
Using Typescript will benefit Blazer interop code designs. Especially in the are of structural design patterns like facades, adapters and bridges.

ScreenShotInterop is an interface between a higher level coding language to a lower level language, typically the native language of the platform.
Data elements and procedures can be interchanged between the two languages. Blazor out of the box uses interop to communicate with the browser.
The browser executes Wasm code one-way requiring JavaScript interop to communicate back to the browser function. Hence Blazor C# requires JavaScript interop.
Blazor .NET libraries for the browser are JavaScript interop wrappers. For example: the Blazor version of C# WebSocket class is an interop wrapper.

Part 1. Create Blazor Project

To start off we will just create a new Blazor app.

Create new Blazor WebAssembly App.

ScreenShot

Name it BlazerTSInterop in directory of your choice.

ScreenShot

Use .NET 5.0 client only, No security and no PWA.

ScreenShot

CTRL+F5 build and run in hot reload mode.

ScreenShot

Part 2. Implement JavaScript Interop

Before we get to Typescript, let's see how JavaScript interops.

1. Call JavaScript Browser API

Replace all of Index.razor contents with following code snippets respectfully.

@page "/"
@inject IJSRuntime JS
<h1>Hello, Interop!</h1>
<hr />@Message<hr />
<h4>JS Interop</h4>
<button class="btn btn-primary" @onclick="@Prompt">Prompt</button>
<hr>
@code {
    string Message { get; set; } = "";

    async void Prompt()
    {
        string answer = await JS.InvokeAsync<string>("prompt", "say what?");
        Message = "Prompt: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }
}

Save to run in hot reload mode and test.

ScreenShot


2. Call Embedded JavaScript

Create new JavaScript file.
Create new 'src' folder for JavaScript and Typescript files.
Create new 'wwwroot/src/script.js' file.

ScreenShot

Copy code to 'script.js'.

function ScriptPrompt(message){
    return prompt(message);
}

function ScriptAlert(message) {
    alert(message);
}

ScriptPrompt and ScriptAlert will be statically loaded and global.
Accessible to other JavaScript modules including isolated modules.

Notice the script methods call the browser API prompt and alert respectfully.

Add 'script.js' as static asset in 'Index.html' after 'webassemly.js'.

<body>
...
    <script src="_framework/blazor.webassembly.js"></script>
    <script src="src/script.js"></script>
...
</body>

Replace all of 'Index.razor' contents with following code snippets respectfully.
To add ScriptPrompt and ScriptAlert buttons with action method.

@page "/"
@inject IJSRuntime JS
@implements IAsyncDisposable
<h1>Hello, Interop!</h1><br />
<h4 style="background-color:aliceblue; padding:20px">JavaScript Interop</h4>
@Message<hr />
<button class="btn btn-primary" @onclick="@Prompt">Prompt</button>
<button class="btn btn-primary" @onclick="@ScriptPrompt">Script Prompt</button>
<button class="btn btn-primary" @onclick="@ScriptAlert">Script Alert</button><hr>
@code {
    string Message { get; set; } = "";

    async void Prompt()
    {
        string answer = await JS.InvokeAsync<string>("prompt", "say what?");
        Message = "Prompt: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }

    async void ScriptPrompt()
    {
        string answer = await JS.InvokeAsync<string>("ScriptPrompt", "ScriptPrompt say what?");
        Message = "Script Prompt: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }

    async void ScriptAlert()
    {
        await JS.InvokeVoidAsync("ScriptAlert", "Script Alert");
    }
}

Prompt demonstrates calling a browser API method.
ScriptPrompt and ScriptAlert demonstrate static JavaScript methods.

Run and test.

ScreenShot

3. Call Isolated JavaScript

Create new 'wwwroot/src/script.module.js' JavaScript file.

ScreenShot

Copy code to 'script.module.js'.

export function ModulePrompt(message) {
    return ScriptPrompt(message);
}

export function ModulAlert(message) {
    ScriptAlert(message);
}

Module methods demonstrates calling global script methods.

Note the 'export' method prefix.
This is ES module syntax to mark code as importable.
'export' is not used by global embedded script.js.

Replace all of 'Index.razor' contents with following code snippets respectfully to add Module buttons and methods.

@page "/"
@inject IJSRuntime JS
@implements IAsyncDisposable
<h1>Hello, Interop!</h1>
<br />
<h4 style="background-color:aliceblue; padding:20px">JavaScript Interop</h4>
@Message
<hr />
<button class="btn btn-primary" @onclick="@Prompt">Prompt</button>
<button class="btn btn-primary" @onclick="@ScriptPrompt">Script Prompt</button>
<button class="btn btn-primary" @onclick="@ScriptAlert">Script Alert</button>
<hr>
<button class="btn btn-primary" @onclick="@ModulelPrompt">Module Prompt</button>
<button class="btn btn-primary" @onclick="@ModulelAlert">Module Alert</button>
<hr>
@code {
    private IJSObjectReference module;
    string Message { get; set; } = "";

    string Version { get { return "?v=" + DateTime.Now.Ticks.ToString(); } }

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (module is not null) { await module.DisposeAsync(); }
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            module = await JS.InvokeAsync<IJSObjectReference>("import", "./src/script.module.js" + Version);
        }
    }

    async void ModulelAlert()
    {
        await module.InvokeVoidAsync("ModulAlert", "Modulel Alert");
    }

    async void ModulelPrompt()
    {
        string answer = await module.InvokeAsync<string>("ModulePrompt", "Module Prompt say what?");
        Message = "Module Prompt said: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }

    async void Prompt()
    {
        string answer = await JS.InvokeAsync<string>("prompt", "say what?");
        Message = "Prompt said: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }

    async void ScriptPrompt()
    {
        string answer = await JS.InvokeAsync<string>("ScriptPrompt", "ScriptPrompt say what?");
        Message = "Script Prompt said: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }

    async void ScriptAlert()
    {
        await JS.InvokeVoidAsync("ScriptAlert", "Script Alert");
    }
}

Isolated models support the IAsyncDisposable with the DisposeAsync to cleanup module resources when no longer needed.
Module is loaded after first render by the OnAfterRenderAsync method.
ModulePrompt demonstrates calling the static method ScriptPrompt.
ModuleAlert demonstrates calling another exported method from the same module.

Notice module appends an unique parameter Version tag when loaded:

...
string Version { get { return "?v=" + DateTime.Now.Ticks.ToString(); } }
...
module = await JS.InvokeAsync<IJSObjectReference>
                    ("import", "./src/script.module.js" + Version);
...

script.module.js avoids cached by unique param tag.

ScreenShot

This is a hack to bypass the browser cache which may stick during development.
To regain cache performance Version value can be replaced by an application release version number.
Which will then force a cache refresh once at first client run of new release.

Build and run.

ScreenShot

Part 3. Debugging JavaScript

Now is a good time to review debugging JavaScript from Visual Studio

Visual Studio may hesitate to attach to the Chrome debugger.
This issue not exclusive to Blazor.
More noticeable as the JavaScript code and symbols grow.
Here are some situations and workarounds that may help.

Set breakpoint in script.js as shown.
ScreenShot

Run application in debug mode F5.
The debugger is not attached if the breakpoint red circle is hollow.
ScreenShot

You can see the cached file in Script Document folders.
Click on file to see if cached contents are from a prior version.
ScreenShot

Try removing the breakpoint and re-apply.
The debugger may re-attach.
ScreenShot

While app is running, press CTR+Shift+I in browser to view developer tools.
Select src/script.js in Sources panel and set breakpoint at shown.
This will trigger Visual Studio debugger re-attachment to Chrome.
If this does not work, debugging in Chrome will suffice.
ScreenShot

Part 4. Implement TypeScript Interop

Let's proceed to TypeScript interop.

1. Call Isolated TypeScript

Create new 'wwwroot/src/hello.ts' TypeScript file.

ScreenShot

Copy code to 'hello.ts'.
Note class methods access ScriptAlert from embedded 'script.js'

declare function ScriptAlert(message:string);

export class Hello {

    hello(): void {
        ScriptAlert("hello");
    }
    static goodbye(): void {
        ScriptAlert("goodbye");
    }
}

export var HelloInstance = new Hello();

Include Microsoft.TypeScript.MSBuild from Nuget Package Manager.

ScreenShot

Set Version:ECMAScript, TSX:None, Module:ES2015 in Project/Properties/Typescript Build

ScreenShot

Replace all of 'Index.razor' contents with following code snippets respectfully to add Module buttons and methods.

@page "/"
@inject IJSRuntime JS
@implements IAsyncDisposable
<h1>Hello, Interop!</h1>
<h4 style="background-color:aliceblue; padding:20px">JavaScript Interop</h4>
@Message<hr>
<button class="btn btn-primary" @onclick="@Prompt">Prompt</button>
<button class="btn btn-primary" @onclick="@ScriptPrompt">Script Prompt</button>
<button class="btn btn-primary" @onclick="@ScriptAlert">Script Alert</button><hr>
<button class="btn btn-primary" @onclick="@ModulelPrompt">Module Prompt</button>
<button class="btn btn-primary" @onclick="@ModulelAlert">Module Alert</button><br /><br />
<h4 style="background-color:aliceblue; padding:20px">TypeScript Interop</h4><hr>
<button class="btn btn-primary" @onclick="@HelloAlert">Hello Alert</button>
@code {
    private IJSObjectReference module;
    private IJSObjectReference hello;
    string Message { get; set; } = "";

    string Version { get { return "?v=" + DateTime.Now.Ticks.ToString(); } }

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (module is not null) { await module.DisposeAsync(); }
        if (hello is not null) { await module.DisposeAsync(); }
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            module = await JS.InvokeAsync<IJSObjectReference>("import", "./src/script.module.js" + Version);
            hello = await JS.InvokeAsync<IJSObjectReference>("import", "./src/hello.js" + Version);
        }
    }

    async void ModulelAlert()
    {
        await module.InvokeVoidAsync("ModulAlert", "Modulel Alert");
    }

    async void ModulelPrompt()
    {
        string answer = await module.InvokeAsync<string>("ModulePrompt", "Module Prompt say what?");
        Message = "Module Prompt said: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }

    async void Prompt()
    {
        string answer = await JS.InvokeAsync<string>("prompt", "say what?");
        Message = "Prompt said: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }

    async void ScriptPrompt()
    {
        string answer = await JS.InvokeAsync<string>("ScriptPrompt", "ScriptPrompt say what?");
        Message = "Script Prompt said: " + (String.IsNullOrEmpty(answer) ? "nothing" : answer);
        StateHasChanged();
    }

    async void ScriptAlert()
    {
        await JS.InvokeVoidAsync("ScriptAlert", "Script Alert");
    }

    async void HelloAlert()
    {
        await hello.InvokeVoidAsync("HelloInstance.hello");
        await hello.InvokeVoidAsync("Hello.goodbye");
    }
}

Another module 'hello' has been added to load the JavaScript file 'hello.js' generated by 'hello.ts'. HelloAlert method demontrates calling a TypeScript class method 'goodbye' and object instance method 'hello'. These methods are using ScriptAlert function from embedded script 'script.js'

Build, Run and test Hello Alert.

ScreenShot

2. Setup Webpack Build Pipeline

Install recommended version of Node https://nodejs.org/en/
Right click on the 'wwwroot' folder and select popup menu item 'Open in Terminal'.

'Open in Terminal' is available in VS 2019 version 16.6 and above.
Alternitavly you can use any command line tool from the 'wwwroot' folder.

This opens a PowerShell terminal window in editor.

ScreenShot

Execute command below to create package.json

npm init -y

ScreenShot

Execute command below to install webpack and typescript tools

npm i ts-loader typescript webpack webpack-cli

ScreenShot

Add scripts entry "build": "webpack" in 'package.json'
Or replace 'package.json' with json contents below.

{
  "name": "wwwroot",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "three": "^0.130.1",
    "ts-loader": "^9.2.3",
    "typescript": "^4.3.5",
    "webpack": "^5.45.1",
    "webpack-cli": "^4.7.2"
  }
}

Create tsconfig.json in 'wwwroot' folder with contents below.

{
  "display": "Node 14",

  "compilerOptions": {
    "allowJs": true,
    "noImplicitAny": false,
    "noEmitOnError": true,
    "removeComments": false,
    "sourceMap": true,
    "lib": [ "es2020", "DOM" ],
    "target": "es6",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": "node"
  },
  "include": [ "src/**/*.ts" ],
  "exclude": [
    "node_modules",
    "wwwroot"
  ]
}


Create webpack.config.json in 'wwwroot' folder with contents below.

const path = require("path");

module.exports = {
    mode: 'development',
    devtool: 'eval-source-map',
    module: {
        rules: [
            {
                test: /\.(ts)$/,
                exclude: /node_modules/,
                include: [path.resolve(__dirname, 'src')],
                use: 'ts-loader',
            }
        ]
    },
    resolve: {
        extensions: ['.ts', '.js'],
    },
    entry: {
        index: ['./src/index']  
    },
    output: {
        path: path.resolve(__dirname, '../wwwroot/public'),
        filename: '[name]-bundle.js',
        library: "[name]"
    }
};

This script tells webpack to use ts-loader to transpile .ts files to .js.
For each entry [name] create a JavaScript library [name].
File [name]-bundle is genreated in the 'wwwroot/public' folder.
This script has one entry named 'index'.
Transpiles input file './src/index.ts' to output file './src/index.js'.
A second pass bundles './src/index.js' with dependency code and outputs to file '../wwwroot/public/index-bundle.js'

Add below section contents within ... to BlazorTSInterp.csproj file to invoke webpack prebuild.

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
...
  <Target Name="PreBuild" BeforeTargets="PreBuildEvent">
    <Exec Command="npm install" WorkingDirectory="wwwroot" />
    <Exec Command="npm run build" WorkingDirectory="wwwroot" />
  </Target>
...
</Project>

Microsoft.TypeScript.MSBuild process is no longer needed as it is bypassed the webpack typescript pre-build.

No harm done leaving it in for this demo.
Or you can select and delete to remove.

ScreenShot

3. Call Webpack TypeScript

Create new 'wwwroot/src/index.ts' TypeScript file.

ScreenShot

Copy code to 'index.ts'.
Index class is a Hello class wrapper.
Index module also exports Hello class and HelloInstance object.

import { Hello, HelloInstance } from './hello';
export { Hello, HelloInstance } from './hello'

export class Index {
    hello(): void {
        HelloInstance.hello();
    }
    static goodbye(): void {
        Hello.goodbye();
    }
}

export var IndexInstance = new Index()

Build CTRL+Shift+B creates 'index.js' and 'index_bundle.js' in the 'public' directory.

ScreenShot



Add 'index-bundle.js' as static asset in 'Index.html' after 'script.js'.

<body>
...
    <script src="_framework/blazor.webassembly.js"></script>
    <script src="src/script.js"></script>
    <script src="public/index-bundle.js"></script>
...
</body>

In 'Index.razor' html section add this line as last button.

<button class="btn btn-primary" @onclick="@BundleIndexHello">Bundle Index Hello</button>

In 'Index.razor' code section add this as last method.

async void BundleIndexHello()
{
    await JS.InvokeVoidAsync("index.IndexInstance.hello");
    await JS.InvokeVoidAsync("index.Index.goodbye");
}

Build and run.

ScreenShot

Bundle Index Hello button demonstrates calling Index class methods exported from 'index' library

In 'Index.razor' html section add this line as last button.

<button class="btn btn-primary" @onclick="@ReExportHello">ReExport Hello</button>

In 'Index.razor' code section add this as last method.

async void ReExportHello()
{
    await JS.InvokeVoidAsync("index.HelloInstance.hello");
    await JS.InvokeVoidAsync("index.Hello.goodbye");
}

Build and run.

ScreenShot

ReExport Hello button demonstrates calling Hello class methods exported from 'index' library

4. Call NPM TypeScript

In 'wwwroot' console execute command below to add threejs to package.json

npm i three

Create new 'wwwroot/src/cube.ts' file.
Copy code to 'cube.js'.

import * as THREE from 'three';

export class Cube {
    camera: THREE.PerspectiveCamera;
    scene: THREE.Scene;
    renderer: THREE.WebGLRenderer;
    cube: any;

    constructor() {
        this.camera = new THREE.PerspectiveCamera(75, 2, .1, 5);
        this.camera.position.z = 2;
        let canvas = document.querySelector('#cube') as HTMLCanvasElement;
        this.renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
        this.scene = new THREE.Scene();
        this.scene.background = null;
        const light = new THREE.DirectionalLight(0xFFFFFF, 1);
        light.position.set(-1, 2, 4);
        this.scene.add(light);

        const geometry = new THREE.BoxGeometry(1, 1, 1);
        const loadManager = new THREE.LoadingManager();
        const loader = new THREE.TextureLoader(loadManager);
        const texBlazor = loader.load('images/blazor.png');
        const texInterop = loader.load('images/interop.png');
        const texCircle = loader.load('images/tscircle.png');

        const matBlazor = new THREE.MeshPhongMaterial({ color: 0xffffff, map: texBlazor, transparent: false, opacity: 1 });
        const matInterop = new THREE.MeshPhongMaterial({ color: 0xffffff, map: texInterop, transparent: false, opacity: 1 });
        const matCircle = new THREE.MeshPhongMaterial({ color: 0xffffff, map: texCircle, transparent: false, opacity: 1 });
        const materials = [matBlazor, matInterop, matCircle, matBlazor, matInterop, matCircle];

        loadManager.onLoad = () => {
            this.cube = new THREE.Mesh(geometry, materials);
            this.scene.add(this.cube);
            this.animate();
        };
    }

    animate(time = 0) {
        time = performance.now() * 0.0005;
        this.cube.rotation.x = time;
        this.cube.rotation.y = time;
        this.renderer.render(this.scene, this.camera);
        requestAnimationFrame(this.animate.bind(this));
    }

    static Create(): void {
        new Cube();
    }
}

Create 'images' sub folder in 'wwwroot'.

ScreenShot

Download and add these to the 'images' folder.

ScreenShot ScreenShot ScreenShot ScreenShot

Add cube entry to webpack.config.js
Replace entry: section with snippet below.

    entry: {
        index: ['./src/index'],
        cube: ['./src/cube']
    },

Add 'cube-bundle.js' as static asset in 'Index.html'.

<script src="public/cube-bundle.js"></script>

Add cube canvas at end of 'Index.razor' html section.

<canvas id="cube"/>

Replace OnAfterRenderAsync in 'Index.razor' with below method with cube interop call.

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        module = await JS.InvokeAsync<IJSObjectReference>("import", "./src/script.module.js" + Version);
        hello = await JS.InvokeAsync<IJSObjectReference>("import", "./src/hello.js" + Version);
        await JS.InvokeVoidAsync("cube.Cube.Create");
    }
}

Build and run.

ScreenShot

Part 4. Interop Software Design

Leveraging TypeScript benefits interop software design. Typically Structural Design Patterns.

TypeScript transpiles to browser JavaScript ready to interop.
Blazor C# compiles to Wasm browser ready to execute.

 Blazor C# interface design aligns with TypeScript counterpart.

ScreenShot

 Blazor Wasm speaks to browser through interop ready JavaScript.

© Copyright 2021 Warren Browne