.NET Wrappers for Using JavaScript Components in Blazor - WijmoBlazor
What is Blazor?
Blazor is Microsoft's new Web UI framework. It compiles code written in C# or other .NET languages to regular .NET assemblies, which are downloaded and run in a web browser using the WebAssembly based .NET runtime.
Blazor was designed to simplify the task of building single-page applications that run in any browser. It enables web developers to write .NET-based web apps that run client-side in web browsers using open web standards.
In Blazor, the .NET runtime itself is compiled to WebAssembly, but the .NET assemblies created from user code are not. They are interpreted by the runtime, which makes the current implementation rather slow (about 10/20 times slower than JavaScript). An ahead of time compiler may be added in a later development stage and that might improve things.
What is WijmoBlazor?
WijmoBlazor is a set of .NET classes that wrap Wijmo controls so they can be used in Blazor.
Wijmo has always provided interop/wrapper modules to interface with popular JavaScript frameworks React, Angular, and Vue. Wijmo includes FlexGrid, our famous JavaScript DataGrid. FlexGrid is also available as an Angular DataGrid, React DataGrid and Vue DataGrid.
When Blazor was announced, we immediately decided to implement WijmoBlazor, an experimental interop library that allows Blazor developers to use Wijmo in their apps. This is currently a research project as we gauge interest and adoption of Blazor.
You can get your copy of WijmoBlazor from our repo on Github or run our WijmoBlazor sample, which shows all the controls available in WijmoBlazor.
Wrappers vs Native Components
With every framework, there's a choice of implementing wrappers or "native" components. Native libraries are framework-specific by nature. This may in some cases allow them to provide better with the framework, but it also makes it harder to switch frameworks or develop applications that target multiple frameworks.
At GrapeCity, we decided to develop WijmoBlazor and to make it available to developers as a preview of Wijmo's support for Blazor. If you use Wijmo, you can use it in Blazor.
We are also researching native Blazor components, which we may release as a separate product by the time Blazor is officially released. If/when we release the native components, WijmoBlazor will remain will remain available as a public research project.
The main advantages of wrapper over native components are:
- Wrapper classes are simple to implement and maintain. For example, WijmoBlazor implements virtually all the controls in Wijmo core, including FlexGrid, FlexChart, Gauges, and all input controls. Any improvements or bug fixes we make to Wijmo are automatically propagated to WijmoBlazor.
- If you use multiple frameworks or switch frameworks, you will be using the same base components, so there will be no learning curve and the features will be the same.
- The components leverage all the rich HTML/CSS/JavaScript regular Web applications rely on. For example, you can use (and customize) all the theme and culture files that ship with Wijmo.
- The controls are implemented in JavaScript, which is currently a lot more performant than Blazor.
Of course, native components (implemented in .NET) also have potential advantages over wrappers:
- They offer better integration with Blazor.
- They minimize the need to use Blazor's JavaScript interop to switch between the .NET and JavaScript parts of the app.
- At some point in the future, they may be compiled directly to WebAssembly and that should improve their performance.
WijmoBlazor Architecture
Each component in WijmoBlazor wraps a specific Wijmo object. Some are controls, but there are also components that wrap classes used to implement complex properties and a few other special object types:
WijmoBlazor is based on the following class hierarchy:
Component |
Description |
WijmoObject | Abstract class that extends Blazor's ComponentBase class to provide support for creating and tracking Wijmo objects. Also provides basic lifecycle handlers and methods used to expose properties, methods, and events. |
Control | Abstract class that extends WijmoObject and provides methods for creating Wijmo controls. Control also defines basic properties and events shared by all controls (like IsEnabled, GotFocus, and LostFocus). |
FlexGrid, FlexChart, ComboBox, Gauge, etc. | Concrete implementation of actual controls. Each control class exposes its set of properties, events, and methods, and inherits members declared by its ancestor classes. |
MarkupProperty | Class that implements array and complex properties such as grid columns, chart series and axes, gauge ranges, etc. |
Series, Column, Range, etc | |
CollectionView | Class that wraps a Wijmo CollectionView so it can be used as a shared data source for multiple components on a page. |
Tooltip | Extender component that adds rich tooltips to other components and HTML elements. |
Component Lifecycle
Blazor components are instantiated using markup that looks like HTML (same as Angular, React, Vue, and most JavaScript frameworks).
For example:
<WJ.FlexGrid
IsReadOnly="true"
HeadersVisibility="WJ.HeadersVisibility.Column"
ItemsSource="@forecasts"/>
This markup creates a FlexGrid component and specifies some parameters.
The component has a host element that corresponds to an HTML element on the page. The parameters correspond to properties on the component (and on the underlying Wijmo control).
Blazor have lifecycle methods that WijmoBlazor (WB) uses to create and manage the corresponding Wijmo objects:
OnInit/OnInitAsync
These hooks are called when the component is created. WB does not use these hooks because at this point, the component has not been mounted or received any parameters, so there's nothing to do.
SetParametersAsync
This method is invoked when parameters are assigned to the component and when their values change. WB handles this by enumerating the parameters and calling the SetProp method to initialize/update the component properties:
// WijmoObject.cs
public override Task SetParametersAsync(ParameterCollection parameters)
{
foreach (Parameter p in parameters)
{
var value = p.Value;
var name = char.ToLower(p.Name[0]) + p.Name.Substring(1);
SetProp(name, value);
}
return base.SetParametersAsync(parameters);
}
The interesting thing about SetParametersAsync is that it gets called in two situations:
- Right after the markup has been processed. At this point the component has not been mounted yet, so there's no host element and no backing Wijmo object. Properties set at this point are cached by the component but not used in any other way.
- When a parameter changes value. For example, an array used as a data source may be loaded asynchronously and its value may be updated when the data is received, or a property may change as a result of user actions. In this case, the new property values are stored in the cache and assigned to the backing Wijmo object.
The SetProp method handles both cases:
protected void SetProp(string name, object value)
{
// if the value is the same, we're done
object oldVal;
if (_props.TryGetValue(name, out oldVal) && object.Equals(value, oldVal))
{
return;
}
// update the value in the cache
_props[name] = value;
// set property if the component is mounted
if (_jsRef != null)
{
Invoke<object>("setProp", _jsRef, name, value);
}
}
OnAfterRender
This hook gets called when all element and component references are ready. WB uses this hook to create the Wijmo objects wrapped by the component:
// WijmoObject.cs
protected override void OnAfterRender()
{
if (!_initialized) // do this only once
{
_initialized = true;
Initialize();
}
}
Initialize is a virtual method that is overridden by all classes that extend WijmoObject.
For example, the Control component implements Initialize as follows:
// Control.cs
protected override void Initialize()
{
var events = GetEventNames();
var netRef = DotNetObjectRef.Create(this);
_jsRef = Invoke<string>("initControl",
netRef, _host, _className, _props, events);
}
The code starts by retrieving a list of the events that have associated handlers (actions). This is an important optimization. Any events that are defined by the control but do not have any handlers attached will not be invoked through the JS interop.
Finally, the code invokes the initControl function, which creates the corresponding Wijmo object, initializes its properties, attaches the event handlers, and returns a reference to it so the component can communicate with it.
Dispose
The last lifecycle method of interest is Dispose, which disposes of the Wijmo object and removes it from the reference dictionary:
// WijmoObject.cs
public void Dispose()
{
Invoke<bool>("dispose", _jsRef);
}
Component Object Model
All WijmoBlazor components expose object models comprised of properties, events, and methods.
The next few sections explain how they are implemented.
Properties
Properties in WijmoBlazor components are implemented using the GetProp and SetProp methods implemented by the WijmoObject base class.
For example, the FlexGrid component implements the HeadersVisibility and StickyHeaders properties like this:
[Parameter]
public HeadersVisibility HeadersVisibility
{
get => GetProp<HeadersVisibility>("headersVisibility");
set => SetProp("headersVisibility", value);
}
[Parameter]
public bool StickyHeaders
{
get => GetProp<bool>("stickyHeaders");
set => SetProp("stickyHeaders", value);
}
The Parameter attribute enables the properties to be used as parameters in the markup. Parameter properties must have getters and setters.
The getters and setters specify the property type and map the property name casing (by convention, .NET uses Pascal/uppercase identifiers while JavaScript uses camel-case).
Events
Events are implemented as Action objects in Blazor components.
For example, the Popup component implements Showing and Shown events with this code:
namespace WJ
{
public class Popup : Control
{
// … properties and methods …
// declare events
[Parameter]
public Action<Popup, CancelEventArgs> Showing { get; set; }
[Parameter]
public Action<Popup> Shown { get; set; }
// raise events
override protected string RaiseEvent(string name, string args)
{
switch (name)
{
case "showing":
if (Showing != null)
{
var e = JsonSerializer.Deserialize<CancelEventArgs>(args);
Showing.Invoke(this, e);
return JsonSerializer.Serialize(e);
}
return string.Empty;
case "shown":
Shown?.Invoke(this);
return string.Empty;
// … other events …
}
// not our event, let base class handle it
return base.RaiseEvent(name, args);
}
}
First, the events are declared. They look like regular properties, with getters and setters and a Parameter attribute so they can be used in markup. The difference is they are all of type Action.
To raise the events, the component overrides the RaiseEvent method declared by the WijmoObject base class. The method checks the event name, de-serializes the event arguments, calls the appropriate action, and returns a serialized version of the arguments after the event has been handled.
If the event name does not correspond to any of events on the Popup component, the method falls back on the base class to handle events that belong to ancestor classes.
For example, the Showing event has arguments of type CancelEventArgs. If the event handler sets the Cancel property to true, the event will be canceled, and the Popup will not be displayed.
Here's a step by step description of the process:
- The Popup Wijmo control raises its showing event.
- The WB interop handler (attached when the control was created) serializes the event arguments and calls the InvokeRaiseEvent method in the WijmoObject class.
- The InvokeRaiseEvent method calls RaiseEvent, which is overridden by the Popup class as shown above, and returns the event's output parameters serialized as a string.
- The WB interop handler receives the event results and applies the changes to the parameters of the original JavaScript/Wijmo event.
And this is the actual implementation:
// wijmoBlazor.js
connectEventHandlers = function (netRef, obj, events) {
if (obj && events) {
events.forEach(event => {
obj[event].addHandler((s, e) => {
// Wijmo event has been raised with arguments "e"
// raise the event on the Blazor component
let args = JSON.stringify(this.marshallOut(e));
let result = netRef.invokeMethod('InvokeRaiseEvent', event, args);
// parse and interpret the event result
if (result && e instanceof wijmo.CancelEventArgs) {
result = JSON.parse(result);
// possibly cancel the event
e.cancel = result.Cancel;
}
});
});
}
}
Raising events requires some work to serialize the parameters and calls from the JavaScript to the .NET side of the app. The WijmoObject class tries to optimize this by only handling events that do have actions attached to them.
Methods
Most methods in WijmoBlazor map to methods in the underlying Wijmo object, invoked through the interop. For example, this is how the Popup component implements its Hide method:
public void Hide(object dialogResult = null)
{
Call<bool>("hide", dialogResult);
}
The Call method is sent to the underlying control via the interop as usual.
This simple mechanism is used for all synchronous methods in WijmoBlazor.
Things get more interesting when dealing with asynchronous methods.
For example, the Popup component has a ShowAsync method that shows the dialog and waits until the user closes it. The method is used like this:
<WJ.Popup @ref="@popup"
<PopupContent />
</WJ.Popup>
<button class="btn btn-primary" @onclick="@ShowPopupAsync">
Show Dialog
</button>
@code {
WJ.Popup popup;
async void ShowPopupAsync()
{
var result = await popup.ShowAsync();
Console.WriteLine("Popup closed with {0}", result);
}
}
The Popup component is declared with a ref attribute so we can refer to it in code. Clicking the button invokes the ShowPopupAsync method, which shows the popup, waits for the user to close it, and returns a result.
This is how the Popup component implements the ShowAsync method:
namespace WJ
{
public class Popup : Control
{
public async Task<object> ShowAsync()
{
_tcs = new TaskCompletionSource<object>();
var netRef = DotNetObjectRef.Create(this);
Invoke<object>("showPopupWithCallback", netRef, _jsRef, true);
return await _tcs.Task;
}
TaskCompletionSource<object> _tcs;
// method called by the showPopupWithCallback interop
[JSInvokable]
public void _PopupCallback(object result)
{
_tcs.SetResult(result); // finish the async task
}
The method creates a TaskCompletionSource object, invokes the "showPopupWithCallback" interop method, and awaits for the task to be completed.
The "showPopupWithCallback" method looks like this:
// wijmoBlazor.js
// show a popup dialog and invoke a callback when the dialog closes
showPopupWithCallback = function (netRef, key, modal) {
let obj = this.getObjRef(key);
if (obj instanceof wijmo.input.Popup) {
obj.show(modal, () => {
return netRef.invokeMethod('_PopupCallback', obj.dialogResult);
});
} else {
wijmo.assert(false, "Invalid host in call to Popup.show.");
}
}
The method shows the popup and provides a callback function that is invoked when the user closes the popup. The callback calls the _PopupCallback method on the Popup component, which sets the result on the TaskCompletionSource object which finishes the async task.
WijmoBlazor Component Types
Most WijmoBlazor components wrap Wijmo controls, including the FlexGrid and FlexChart. But there are a few other types of component worth mentioning. The next few sections discuss the Tooltip, CollectionView, and MarkupProperty components.
Tooltip
The Tooltip component allows developers to add contextual information to components. When users move the mouse over a component that contains a Tooltip, the additional content is displayed in a popup element.
To add a tooltip to a component, all you need is a little markup:
<button class="btn btn-primary" @onclick="@PrimesJS">
<WJ.Tooltip>
Calculate the first @n.ToString("n0") primes using <b>JavaScript</b>,
show the last prime and the time elapsed.
</WJ.Tooltip>
JavaScript
</button>
The Tooltip component does not wrap any Wijmo controls. Instead, it relies on a single instance of a Wijmo Tooltip class and calls its addTooltip to associate tooltips with HTML elements.
This is how the WijmoBlazor interop creates tooltips:
// wijmoBlazor.js
initTooltip = function (host, props, key) {
// hide host element
host.style.display = 'none';
// create shared instance of Tooltip object
if (!this.tooltip) {
this.tooltip = new wijmo.Tooltip();
}
// set properties (will apply to all tooltips)
wijmo.copy(this.tooltip, props);
// add tooltip to the host element
setTimeout(() => { // let the host's RenderFragment update first...
this.tooltip.setTooltip(host.parentElement, host.innerHTML);
});
// create and return reference to tooltip object
return key || this.saveObjRef(this.tooltip);
}
The tooltip can be styled using CSS. For example:
.wj-tooltip {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
color: white;
border: 2px solid white;
padding: 18px;
}
And this is the tooltip in action (Benchmark page of the WijmoBlazor sample):
CollectionView
The CollectionView component allows multiple components to share a common data source, so if the data is modified in one of them all the others show the changes automatically.
The WJ CollectionView page of the sample our WijmoBlazor sample provides a good example of this scenario. The page has a CollectionView that is used as a data source for a FlexGrid and a FlexChart. If users change the data by editing, sorting, or filtering the grid, the chart is automatically updated.
The page also has a CollectionViewNavigator that allows users to navigate through the pages exposed by the CollectionView (it specifies a page size of 20 items).
This is the markup:
@page "/collectionview"
@inject HttpClient Http
<h1>
Wijmo CollectionView
</h1>
…
<WJ.CollectionView @ref="@view"
SourceCollection="@countries"
PageSize="20"/>
<div class="row">
<div class="col-md-6">
<WJ.FlexGrid
ItemsSource="@view"
SelectionMode="WJ.SelectionMode.ListBox"
HeadersVisibility="WJ.HeadersVisibility.Column">
<WJ.FlexGridFilter />
</WJ.FlexGrid>
</div>
<div class="col-md-6">
<WJ.FlexChart
ItemsSource="@view"
BindingX="country">
<WJ.Series Binding="popK" Name="Pop (k)" />
<WJ.Series Binding="gdpB" Name="GDP ($billions)" />
<WJ.Series Binding="pci" Name="PCI ($/year/person)" />
</WJ.FlexChart>
</div>
</div>
<WJ.CollectionViewNavigator
View="@view"
HeaderFormat="Page {current:n0} of {count:n0}"
ByPage="true"/>
And here is the code block that loads the data from a JSON file:
@code {
WJ.CollectionView view; // shared CollectionView
CountryData[] countries; // raw data source
protected override async Task OnInitAsync()
{
countries = await Http.GetJsonAsync<CountryData[]>(
"sample-data/countries.json");
}
class CountryData
{
public string Country { get; set; }
public double Pop { get; set; }
public int PopK { get => (int)Pop / 1000; } // pop in thousands
public double Gdp { get; set; } // in millions
public double GdpB { get => (int)Gdp / 1000; } // GDP in billions
public double Pci { get => Gdp / Pop * 1e6; } // US$/year/capita
}
}
And this is the result (WJ CollectionView page of the WijmoBlazor sample):
The grid and chart on the page are connected. Changing the data on the grid causes the chart to update. Switching pages with the navigator control at the bottom of the page updates the grid and the chart.
Limitations, Caveats, and Pain Points
While writing the WijmoBlazor library, we came across a few issues that are worth mentioning.
Server versus Client-Side Models
Blazor was designed to run client-side in the browser on a WebAssembly-based .NET runtime (Blazor client-side) or server-side in ASP.NET Core (Blazor server-side).
The client-side hosting model has some pros and cons:
Client-side PROS |
Client-side CONS |
There's no .NET server-side dependency. The app is fully functioning after downloaded to the client. | The app is restricted to the capabilities of the browser. |
Client resources and capabilities are fully leveraged. | Capable client hardware and software (for example, WebAssembly support) is required. |
Work is offloaded from the server to the client. | Download size is larger, and apps take longer to load. |
An ASP.NET Core web server isn't required to host the app. | .NET runtime and tooling support is less mature. For example, limitations exist in .NET Standard support and debugging. |
The server-side hosting model also has pros and cons:
Server-side PROS |
Server-side CONS |
Download size is significantly smaller than a client-side app, and the app loads much faster. | Higher latency usually exists. Every user interaction involves a network hop. |
The app takes full advantage of server capabilities, including use of any .NET Core compatible APIs. | There's no offline support. If the client connection fails, the app stops working. |
.NET Core on the server is used to run the app, so existing .NET tooling, such as debugging, works as expected. | Scalability is challenging for apps with many users. The server must manage multiple client connections and handle client state. |
Thin clients are supported. For example, server-side apps work with browsers that don't support WebAssembly and on resource-constrained devices. | An ASP.NET Core server is required to serve the app. Serverless deployment scenarios aren't possible (for example, serving the app from a CDN). |
The app's .NET/C# code base, including the app's component code, isn't served to clients. |
WijmoBlazor can be used in both models, but there is a difference between them that is really important when dealing with wrapper components: Only the client-side model allows calling JavaScript interop methods synchronously.
This is important because it allows the component to retrieve property values from the underlying JavaScript object using a simple, synchronous method.
When running under the server-side model, getting property values becomes tricky. Property getters are always synchronous. Of course, we could add a GetPropertyAsync method, but that would not be convenient to use or nice to look at. The other option is to retrieve the property values from the cache. The challenge in this case keeping the values in the cache current without copying all the properties whenever an event is raised.
The current version of WijmoBlazor supports the client-side model very well, but the server-side model is only partially supported for now. We are working to improve this situation and are confident that future versions will handle both models equally well.
Debugging
Debugging is probably the most annoying limitation you will find when running Blazor apps in its current implementation.
There is some documentation available describing what is possible today and what isn't, but I had to resort to adding "Console.WriteLine" and "debugger" statements to my code, and even then debugging was not easy.
I really hope this will improve before Blazor is officially released.
JSON Serialization
The JavaScript interop is an important part of Blazor. In order to transmit data between the .NET and JavaScript parts of the app, Blazor serializes the data using JSON (the latest version uses the new System.Text.Json APIs available in .NET Core 3.0).
The serialization process works well for simple things like primitive values, POCO objects, and even lists of POCO objects. But it breaks down if you try to serialize complex objects like references to HTML entities, objects with circular references, etc.
Also, you must remember that Blazor's JSON serializer is a little opinionated about casing. The .NET side of the app uses .NET/Pascal casing, where variables start with uppercase characters. The JavaScript side of the app uses JavaScript/Camel casing, where variables start with lowercase characters.
For example, the FetchData page on our sample app loads data from a JSON file that looks like this:
[
{
"date": "2018-05-06",
"temperatureC": 1,
"summary": "Freezing",
"temperatureF": 33
}, …
This data is used to populate an array of WeatherForecast objects defined this way:
class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF { get; set; }
public string Summary { get; set; }
}
If you send the resulting array back to the JavaScript part of the app using Blazor's interop, you get JavaScript objects that look like this:
{
date: "2018-05-06T00:00:00",
temperatureC: 1
temperatureF: 33
summary: "Freezing"
}
As you can see, the variable names were automatically converted to JavaScript casing. That is a potential source of errors.
For example, if in this case you were to use "Summary" as a binding for a grid column or chart series, the binding would not work (you should use "summary" instead).
Also, note that dates are encoded as ISO strings. It is your (or in this case WijmoBlazor's) responsibility to convert those into JavaScript date objects.
I think life would be easier if the Blazor serializer worked like JavaScript's JSON object and preserved the original casing of property/key names.
Date Serialization
When implementing WijmoBlazor's InputTime and InputDateTime controls, we noticed that Blazor showed local date/time values, but the controls showed the UTC version of the date/time.
To fix that issue, we adjusted date values being marshalled in and out of the controls as follows:
// wijmoBlazor.js
marshallIn = function (name, val) {
// …
// convert strings into dates
if (wijmo.isString(val) && val.match(this.rxDate)) {
val = new Date(val);
// convert local time to UTC when marshalling in
val = new Date(val.getTime() + val.getTimezoneOffset() * 60000);
}
// …
return val
}
marshallOut = function (val) {
// …
// convert UTC to local time when marshalling out
if (val instanceof Date) {
val = new Date(val.getTime() - val.getTimezoneOffset() * 60000);
}
// …
return val
}
This worked, but it is very kludgy.
To make things even stranger, this issue only happens when running in client-side mode. When running in server-side mode, the conversion is not necessary (and must not be applied).
I hope someone fixes this at some point (or at least show me a better/cleaner way to make it work).
Event Debouncing
Debouncing is a common technique used to handle events that fire very often.
For example, if you want to perform lengthy searches as a user types, or update information about the current selection as the user scrolls down a grid, you will get tons of events, and you probably don't have to handle them all.
This article, by Rick Strahl, has a good discussion of this topic.
Our WijmoBlazor sample has a WijmoGrid page that shows a grid and allows the user to select multiple ranges.
As the selection changes, the grid displays an Excel-style summary with aggregate statistics:
Updating the summary every time the selection changes would be easy but inefficient, especially since calculating the summary requires JavaScript interop calls for each cell.
The sample avoids this by "debouncing" the event and handling it only 600ms after the last SelectionChanged event:
// WijmoGrid.razor
Timer _timer;
void SelectionChanged(WJ.FlexGrid grid, WJ.CellRangeEventArgs args)
{
Console.WriteLine("SelectionChanged Fired, range: {0}; selection: {1}",
args.Range, grid.Selection);
// use a timer to debounce the SelectionChanged event handler:
// we update aggregates 600ms after the last SelectionChanged event.
if (_timer == null)
{
_timer = new Timer(new TimerCallback((_) => UpdateAggregates()));
}
_timer.Change(600, Timeout.Infinite);
}
The code creates a System.Threading.Timer instance that triggers the UpdateAggregates method after 600ms. The timer is reset every time the SelectionChanged event is raised, so if the event is raised a bunch of times in quick succession, the aggregates are updated only once.
This simple change makes a huge difference in terms of application responsiveness.
The equivalent code in JavaScript would be something like this:
// debouncing in JavaScript
var timer = null;
grid.selectionChanged.addHandler((s, e) => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
this.updateAggregates(grid);
}, 600);
});
Blazor Conclusion
After working with Blazor for a couple of weeks, I find it to be a great new option for creating web applications.
It is powerful, easy to use, and it allows me to use C# which is a language I really like. Plus, it is extensible. Creating rich/powerful wrappers is easy.
I am pretty sure Blazor will not replace existing JavaScript frameworks. React, Vue, and Angular are popular for good reasons. They are compact, flexible, and fast. Plus, they all have rich eco-systems with mature tools, documentation, and examples. And they are all based on JavaScript/TypeScript, which still is the native language of the browser.
I do think Blazor will carve a niche for itself and will co-exist with the main JavaScript frameworks. It will be an extra option, especially attractive for developers and teams that currently use ASP.NET and Razor.
We are eagerly waiting for Blazor's official release and for any feedback you may have!
Happy coding!