Blazor 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.
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.
Interop 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.
Create new Blazor WebAssembly App.
Name it BlazerTSInterop in directory of your choice.
Use .NET 5.0 client only, No security and no PWA.
CTRL+F5 build and run in hot reload mode.
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.
Create new JavaScript file.
Create new 'src' folder for JavaScript and Typescript files.
Create new 'wwwroot/src/script.js' file.
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.
Create new 'wwwroot/src/script.module.js' JavaScript file.
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.
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.
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.
Run application in debug mode F5.
The debugger is not attached if the breakpoint red circle is hollow.
You can see the cached file in Script Document folders.
Click on file to see if cached contents are from a prior version.
Try removing the breakpoint and re-apply.
The debugger may re-attach.
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.
Create new 'wwwroot/src/hello.ts' TypeScript file.
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.
Set Version:ECMAScript, TSX:None, Module:ES2015 in Project/Properties/Typescript Build
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.
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.
Execute command below to create package.json
npm init -y
Execute command below to install webpack and typescript tools
npm i ts-loader typescript webpack webpack-cli
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.
Create new 'wwwroot/src/index.ts' TypeScript file.
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.
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.
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.
ReExport Hello button demonstrates calling Hello class methods exported from 'index' library
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'.
Download and add these to the 'images' folder.
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.
TypeScript transpiles to browser JavaScript ready to interop.
Blazor C# compiles to Wasm browser ready to execute.
© Copyright 2021 Warren Browne