Blazor JavaScript Isolation; Side by Side .js with Component

Organize your JavaScript file for better code management in your Blazor application project. We'll look at how to create a JavaScript isolation for a Blazor component with both component and JavaScript files sitting side-by-side similar to CSS isolation naming convention.

Overview

Blazor JavaScript isolation was introduced in .NET 5 Release Candidate 1. Enabling developers to load and invoke ES modules. Unlike CSS isolation where you would create a component style as ComponentName.razor.css in the same location as the component, JavaScript isolation in Blazor works differently. At the time of writing, Blazor requires that all .js files be placed inside the wwwroot of your application. This limits the ability to better organize JavaScript files for components, making it more difficult to manage in large projects.

Let's dig into how to workaround some of these limitations and manage our JavaScript file side-by-side with its component in the same directory structure with rollup.js.

Rollup

Rollup is a module bundler for JavaScript which compiles small pieces of code into something larger and more complex, such as a library or application. It uses the new standardized format for code modules included in the ES6 revision of JavaScript.

Let's Code

Wire up your IDE of choice and create a new Blazor wasm application. Give it any name you like, I'm going with MyBlazorApp.

While we are going to have our component script in the same folder as our component, Blazor by convention doesn't run .js files outside the wwwroot folder. This is where Rollup comes in play. We'll use Rollup to bundle our script into the wwwroot folder and run it from there while still having our original .js file side-by-side with out component.

Let's setup our Rollup configuration for this workflow. Run the following command to install Rollup globally.

1npm install --global rollup
2
3// or
4yarn global add rollup

Open up a Terminal Window inside you project's root location and create a package.json file with the following command

1npm init
2
3// or
4yarn init

Let's install a couple of libraries for our Rollup configuration. Run the following command in the Terminal you opened previously.

1npm install --save-dev @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve @babel/core globby
2
3// or
4yarn add --dev @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve @babel/core globby

Create the rollup configuration file by creating a new file inside the root of your project with the name rollup.config.js. Add the following code inside the newly created file.

 1//rollup.config.js
 2
 3//Import the modules needed for our configuration
 4import globby from 'globby';
 5import babel from '@rollup/plugin-babel';
 6import commonjs from '@rollup/plugin-commonjs';
 7import nodeResolve from '@rollup/plugin-node-resolve';
 8import path from 'path';
 9
10export default {
11
12    input: globby.sync([ 'Shared/**/*.js', 'Pages/**/*.js' ]),
13    output: {
14
15        //The root folder for all bundled .js files
16        dir: './wwwroot/js/',
17
18        // bundle the files as ES modules
19        format: 'es',
20
21        entryFileNames: ( { facadeModuleId } ) => {
22
23            let root = path.resolve('.');
24            let filePath = path.parse(facadeModuleId.substr( -(facadeModuleId.length - root.length)+1));
25
26            return `${filePath.dir}/[name].js`;
27        }
28    },
29    plugins: [
30        nodeResolve(),
31        commonjs({
32            include: "node_modules/**"
33        }),
34        babel({
35            babelHelpers: 'bundled',
36        }),
37    ]
38};

Let's have a breakdown of what we're doing in the preceding code.

1input: globby.sync([ 'Shared/**/*.js', 'Pages/**/*.js' ]),

Rollup by default does not support globing for entry files, so here we're using the globby library to avoid specifying each file path that we want to bundle manually. We are interested in all .js files inside Shared and Pages folders and sub-folders in the project root.

1...
2entryFileNames: ( { facadeModuleId } ) => {
3
4    let root = path.resolve('.');
5    let filePath = path.parse(facadeModuleId.substr( -(facadeModuleId.length - root.length)+1));
6
7    return `${filePath.dir}/[name].js`;
8}
9...

In the preceding codes, we want each output file to retain the same directory structure as the entry file inside the wwwroot/js folder. That is Pages/Index.razor.js will be outputted to wwwroot/js/Pages/Index.razor.js. This structure can be maintained for subfolders as well.

We could easily achieve same goal as above by using output.preserveModules preserveModules: true option instead. The problem I have with this approach is that if we decide to use node module dependencies in our component .js file(s), rollup will preserve same folder structures (node_modules/**) for the node modules inside the wwwroot/js folder with other files and folder structures the node modules depend on. I prefer the approach we've taken because it keeps the output directory cleaner. You can go with whatever method that feels right for you, the final result will still work for this post.

Alright, our rollup configuration is sufficient for our need. Let's focus on writing some JavaScript code for our Blazor components. Create a new file Index.razor.js for the Index.razor component inside the Pages folder. If you are using Visual Studio, the newly created file will be a child of the Index.razor file in the Solution Explorer pane as shown in the image below.

Index.razor.js as Index.razor child

Put in the code below inside the Index.razor.js file.

1//Index.razor.js
2
3export function showMessage(message){
4  alert(message);
5}

As previously stated, Blazor does not support calling .js files outside the wwwroot folder. In order to invoke the showMessage function from the Index.razor component, we'll need to point the import to wwwroot/js/Pages/Index.razor.js. Rollup will take care of bundling any .js file inside Shared or Pages folders into the wwwroot/js folder for us.

Imaging if we have tens or more .js files that we'll need to invoke their functions, manually entering the file paths for those files will be tedious and a big maintenance issue. Moving components and their corresponding .js files will mean manually modifying these paths where they're imported in the components. To avoid this we are going to write an extension method for IJSRuntime that will help us structure the .js file path for a given component. Take the following code for example.

1// Instead of doing this
2var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/Pages/Index.razor.js");
3
4// We are going to do this
5var module = await JSRuntime.ComponentModule<Index>();

Create a new file Extensions.cs inside your project root and paste the following code

 1// Extensions.cs
 2
 3using System.Text;
 4using System.Threading.Tasks;
 5using Microsoft.AspNetCore.Components;
 6using Microsoft.JSInterop;
 7
 8namespace MyBlazorApp
 9{
10    public static class Extensions
11    {
12        public static async Task<IJSObjectReference> ComponentModule<T>(this IJSRuntime js)
13        where T : ComponentBase
14        {
15            var type = typeof(T);
16            var sb = new StringBuilder("./js/");
17
18            sb.Append(type.FullName.Remove(0, type.Assembly.GetName().Name.Length + 1).Replace(".","/"));
19            sb.Append(".razor.js");
20
21            var result = await js.InvokeAsync<IJSObjectReference>("import", sb.ToString());
22            return result;
23        }
24    }
25}

What we are doing in the code above is using the Component's FullName and Assembly.GetName().Name properties to structure the path of the component and then append .razor.js to point it to its JavaScript file. Once the file path is structured we import it by calling IJRuntime.InvokeAsync.

Open Pages/Index.razor file. We are going to modify this file to add codes to import and call the showMessage function. modify the file as shown below.

 1// Index.razor
 2
 3
 4@page "/"
 5
 6@inject IJSRuntime JS
 7@implements IAsyncDisposable
 8
 9<h1>Hello, world!</h1>
10
11Welcome to your new app. <br/>
12
13<button class="btn btn-primary" @onclick="@(async () => await ShowMessage())">Show Message</button>
14
15<SurveyPrompt Title="How is Blazor working for you?" />
16
17@code {
18    IJSObjectReference module;
19
20    protected override async Task OnAfterRenderAsync(bool firstRender)
21    {
22        if(firstRender){
23            module = await JS.ComponentModule<Index>();
24        }
25    }
26
27    async Task ShowMessage() => await module.InvokeVoidAsync("showMessage", "Hello world!");
28
29    async ValueTask IAsyncDisposable.DisposeAsync()
30    {
31        if (module != null)
32        {
33            await module.DisposeAsync();
34        }
35    }
36}

It's demo time. We need to bundle our JavaScript code before we'll be able to run the application. Go back to the Terminal Window opened previously and run the code below to get rollup bundle the .js file to wwwwroot/js/**.

1rollup --config

Once the bundling is done, run the Blazor project and click the Show Message button. You should see an alert box with the message Hello world! as below.

The example project for this post is available here

comments powered by Disqus