Blazor, TypeScript and JavaScript isolation
Since .NET 5 you can use CSS isolation to create component scoped CSS files. Did you know something similar was introduced for JavaScript as well?
Plenty of examples can be found online to get this working in your Blazor app (albeit most of them are really simple and just rehashes from the original docs sample). But what if the basis for your JavaScript is TypeScript? Good samples are a lot harder to find then. In fact I did not find any sample where multiple modules where being used. This post outlines the steps I took to get exactly this up and running.
JavaScript Isolation
Let's discuss regular .NET 5 JavaScript isolation first. (From the docs:) Blazor enables JavaScript isolation in standard JavaScript modules. This provides the following benefits:
- Imported JavaScript no longer pollutes the global namespace.
- Consumers of a library and components aren't required to import the related JavaScript
For example, the following JavaScript module exports a JavaScript function for showing a browser prompt:
export function showPrompt(message) {
return prompt(message, 'Type anything here');
}
Add the preceding JavaScript module to a .NET library as a static web asset (wwwroot/exampleJsInterop.js
) and then import the module into the .NET code by calling InvokeAsync
on the IJSRuntime
service. The service is injected as js (not shown) for the following example:
var module = await js.InvokeAsync<IJSObjectReference>(
"import", "./_content/{LIBRARY NAME}/{PATH UNDER WWWROOT}/exampleJsInterop.js");
The "import
" identifier is a special identifier used specifically for importing a JavaScript module. Specify the module using its stable static web asset path: ./_content/{LIBRARY NAME}/{PATH UNDER WWWROOT}
. The path segment for the current directory (./
) is required in order to create the correct static asset path to the JavaScript file. Dynamically importing a module requires a network request, so it can only be achieved asynchronously by calling InvokeAsync
. The {LIBRARY NAME}
placeholder is the library name. The {PATH UNDER WWWROOT}
placeholder is the path to the script under wwwroot.
IJSRuntime
imports the module as a IJSObjectReference
, which represents a reference to a JavaScript object from .NET code. Use the IJSObjectReference
to invoke exported JavaScript functions from the module:
public async ValueTask<string> Prompt(string message)
{
return await module.InvokeAsync<string>("showPrompt", message);
}
So, the basics are covered. Now what?
In my previous article I mentioned the BlazorFluentUI library and promised to write about using it. A lot has happened since then and I am now one of the maintainers of this library! I'll certainly discuss it in more depth later, but that will have to wait until after this article. I'm mentioning this library now, because it is using quite a bit of TypeScript and requires the consumers of the library to manually include the resulting JavaScript in their application (with a <script>
tag that uses the static web asset path described above)
Getting from TypeScript to the JavaScript is actually a process of manually compiling, bundeling uglifi-ing and minify-ing the files after each change to one of them. It's not good for your develoment flow, error prone and is so not 2021! I thought automating the process and combining that with the JavaScript isolation functionality at the same time would make for a great enhancement. It would not only deliver the 2 benefits mentioned in the docs, but also make the developer's life easier.
Automating the TypeScript compilation
To have the JavaScript(s) being created after a change when compiling the project is actually not that hard. Main thing you need to do is to add the Microsoft.TypeScript.MSBuild
NuGet package to your project(s). After you have done that, there are two ways you can setup the flow:
- Use the project properties to setup the TypeScript Build config:
- Add a tsconfig.json file in your project with the needed configuration:
{ "compilerOptions": { "alwaysStrict": false, "noImplicitAny": false, "noEmitOnError": true, "removeComments": false, "sourceMap": false, "target": "ES6", "module": "ES2015"
"outFile": "wwwroot/blazorFluentUI.js" //"allowJs": true }, "include": [ "wwwroot/**/*.ts" ], "exclude": [ //"wwwroot/blazorFluentUI.js", "node_modules" ] }
Note: Once you go with option 2, option 1 won't work anymore. The project properties screen will tell you it detected a tsconfig.json file and disables all TypeScript Build project properties.
As I described earlier, the manual proces compiled all the TypeScript files into one JavaScript file. Of course I wanted to have that with the automatic compilation too and as can be seen in the tsconfig.json
above, that is quite simple to achieve with the "outfile
" option. The functionality in the original TypeScript files are organized into namespaces, so combining them does not lead to problems with overlapping class- and/or function names (as with C# namespaces).
However, compiling into one outfile does not work together with the "ES2015" module option (more on why we need that specific variant below). Only "AMD" or "System" modules are valid choices then. We are certain that we need the module option, because the description of the JavaScript isolation specifically mentions JavaScript modules. When using one of those 2 options, I indeed got one file containing all the JavaScript but none of the functions that have an "export
" statement in the TypeScript files get the "export
" statement in the resulting JavaScript files. And having the "export" statement is key to getting the isolation working!
I spent a considerable amount of time of trying many different (if not all) combinations of "target
", "module
" and even the "lib
" option to try to get the "export
" statements to appear in the Javascript files. None of them worked. Binging it and and reading through pages of StackOverflow did not help me solve this. On top of that, loading the JavaScript files generated with the "AMD" or "System" option with a regular <script>
tag resulted in errors like "define not defined" in the browser console. Conclusion: to get this to work I needed to use the "ES6" / "ES2015" combo.
I was about to give up on this whole isolation operation. But being the OSD (Obsesive Stubborn Developer) that I am, I didn't. I decided to try to let go off the "outFile" option. After all, why not just create a Javascript file for every TypeScript file and combine them later in a different way (for example by using the Bundler & Minifier VS extension)? Now I could use the desired target/module combination of "ES6" and "ES2015".
To my dismay, the resulting separate JavaScript files still did not contain the required "export
" statements. So the problem was not being caused by the combining of files into one. I could however load the JavaScript files with <script>
tags now without any errors again, so small win there.
I kept investigating and stumbled onto 'JavaScript Isolation in Blazor Components' on meziantou's site. He mentiones that you do not need to have JavaScript functions exposed in the global scope (window) anymore with isolation (as standard ES modules work that way). I experimented with removing the conflicting lines from the TypeScript files but that did not suffice. Still no "export
"s where needed.
I finally decided to dig a little bit deeper into the TypeScript specification and found the Namespaces and Modules page. It finally hit me when I read
Namespaces are a TypeScript-specific way to organize code. <...> Unlike modules, they can span multiple files, and can be concatenated using
--outFile.
As I was not using the "outFile" option anymore, maybe I didn't need the namespaces anymore either. I removed the namespaces from all the TypeScript files, added import statements to be able to call into other modules (instead of using "/// <reference>
" syntax to connect it all together) and tried compilation again. Lo and behold, I finally got the "export
"s from within the TypeScript files to show up in the JavaScript files too!! Now, this might all seem perfectly logical if you are a TypeScript/JavaScript ninja but I think we can confidently establish that I am not one of them (yet!).
Update on the target/module combo
I was using the ES6/ES2015 combination for target and module because of the library supporting IE11 / Legacy Edge for Blazor Server in earlier versions. With .NET 5 however, the ASP.NET Team decided to drop Blazor Server support for these browers (finally!) . This means we don't have to support these in the library anymore as well. We can therefore now safely 'upgrade' the compiler output to generate ES2020 compliant JavaScript. Gives us some nice performance and language optimizations.
Using the generated JavaScripts from Blazor
Changing the BlazorFluentUI source to use the isolation method of doing JavaScript interop was not so hard. In general I followed these steps (based on the docs part above):
- Insert variables to point to the script and store the module
private const string BasePath = "./_content/BlazorFluentUI.CoreComponents/baseComponent.js"; private IJSObjectReference? baseModule;
- Do the import part
baseModule = await JSRuntime!.InvokeAsync<IJSObjectReference>("import", BasePath);
If you are using Blazor prerendering, don't do this inOnInitializedAsync
. You'll get errors. Instead do this inOnAfterRederAsync
. - Replace all the
JSRuntime.Invoke...
calls withbaseModule.Invoke...
When going through the solution to make these changes, I found that in some classes there where interop call to different namespaces. In those cases I just neede to do the first 2 steps twice (different variables for the different js files) and do the replacements with the right module (step 3) extra carefully. Now I was finally ready to run the solution with JS isolation. And most things actually just worked as before. This was expected of course as I did not change any of the functionality of the underlying script, just the organisation. I did however run into another pitfall...
When you need to call functionality from another module inside your current module, you need to import it with a statement in your TypeScript file like import * as FluentUIBaseComponent from './baseComponent' (assuming the modules are in the same folder). This compiles to JavaScript without errors in Visual Studio. When running the application I saw inexplicable errors that the application would try to load/import baseComponent and could not find it. I found that this is solved by actually referencing to the file in the import statement (i.e import * as FluentUIBaseComponent from './baseComponent.js'). After making these final changes the library is now successfully using the JavaScript isolation. Using the library became easier as the consumers don't have to add convoluted <script> tags to their application anymore, making developer's lives a tiny bit easier again.
Check out the BlazorFluentUI source on GitHub if you like to see or learn more: BlazorFluentUI/BlazorFluentUI: Port of FluentUI/Office Fabric React components and style to Blazor (github.com)
Hope this helps.
Comments
Comments are closed