vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【ASP.NET Core】Blazor を試す (Blazor Server)

はじめに

気になりつつも試せていなかった Blazor 。

ASP.NET Core Preview 7 でアップデートがたくさん入ったっぽいこともあり、試してみることにしました。

実際使うとすると、既存のプロジェクトに追加(置き換え)していくことになると思うので、 MVC プロジェクトを作り、そこに必要なファイルを追加していく形で進めます。

Blazor には Blazor Server と Blazor WebAssembly があるのですが、今回 PWA などを試さないこと、 Blazor Server の方がわかりやすい( Razor などを使ったプロジェクトに近いため)気がしたので、今回は Blazor Server を試します。

Environments

Base project

まず Empty テンプレートで ASP.NET Core のプロジェクトを作り、 Controller や Razor などを追加しておきます。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace BlazorSample
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();
        }
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseRouting();
            app.UseStaticFiles();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

HomeController.cs

using Microsoft.AspNetCore.Mvc;
using Models;

namespace Controllers
{
    public class HomeController: Controller
    {
        [Route("/")]
        public ActionResult Index()
        {
            return View("Views/Index.cshtml");
        }
    }
}

Product.cs

namespace Models
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
}

_ViewStart.cshtml

@{
    Layout = "_Layout";
}

_Layout.cshtml

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>@ViewData["Title"]</title>
    </head>
    <body>
        @RenderBody()
    </body>
</html>

Index.cshtml

@{
    ViewData["Title"] = "Home Page";
}
<h2>This is Index.cshtml</h2>

Blazor を追加する

下記の記事をもとに Blazor を使うのに必要な物を追加していきます。

middleware の追加、 Blazors SignalR hub のマッピング

Startup.cs

...
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddControllers();
        }
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseRouting();
            app.UseStaticFiles();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
                endpoints.MapBlazorHub();
            });
        }
...

_Imports.razor

@using System
@using System.Net.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using BlazorSample

BlzrSample.razor

<h1>Hello @Name!</h1>
<p>This is Blazor Components.</p>
<div>@SampleInfo</div>
<button @onclick="ShowConsole">Click</button>
@code {
    [Parameter]
    public string Name {get; set;}
    [Parameter]
    public string SampleInfo { get; set; }
    public string GetName(){
        var name = $"{Name} {DateTime.Now}";
        Console.WriteLine(name);
        return name;
    }
    public void ShowConsole()
    {
        SampleInfo = GetName();
        Console.WriteLine($"{SampleInfo}");
    }
}

Index.cshtml

@using BlazorSample.Views
@{
    ViewData["Title"] = "Home Page";
}
<h2>This is Index.cshtml</h2>
@(await Html.RenderComponentAsync<BlzrSample>(RenderMode.ServerPrerendered, new { Name = "BlazorSample" }))

Result

f:id:mslGt:20200815194613j:plain

ボタンをクリックすると div 要素の値が変わります。

IE11

いい加減フロントエンド関連のことを試すとき、まず IE を考えるのをやめたいところですが。。。

ともかく、 IE11 で開くと、 Blazor の要素は表示されるものの、ボタンをクリックしても動作しません。

対策は色々あると思いますが、その一つである Blazor.Polyfill を試します。

_Layout.cshtml

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>@ViewData["Title"]</title>
        <base href="/">
        <script type="text/javascript">
            if (/MSIE \d|Trident.*rv:/.test(navigator.userAgent)) {
                document.write('<script src="https://polyfill.io/v3/polyfill.min.js?features=Element.prototype.closest%2CIntersectionObserver%2Cdocument.querySelector%2Cfeatures=Array.prototype.forEach%2CNodeList.prototype.forEach"><\/script>');
                document.write('<script src="js/blazor.polyfill.min.js"><\/script>');
            }
        </script>
    </head>
    <body>
        @RenderBody()
    <script src="_framework/blazor.server.js"></script>
    </body>
</html>

base タグを忘れるとエラーになるので注意が必要です。

DOM 操作

どうも C#(Blazor) からは JavaScript で行うような DOM 操作を直接行うことはできないようです。
( @foreach などを使ってリストの数だけ DOM を生成する、とかはできますが)

@* 「Numbers」の要素を増減すると div の数が変わる *@
@foreach(var n in Numbers)
{
    <div>@SampleInfo</div>
}

ということで、そのような操作がしたい場合は JavaScript を呼び出す必要があります。

JavaScript とのやり取り

JavaScript を Blazor から呼び出す

IJSRuntime を使って JavaScript を呼び出すことができます。

普段 webpack や TypeScript を使っているため、それらを追加します。

Samples

package.json

{
    "dependencies": {
        "ts-loader": "^8.0.2",
        "tsc": "^1.20150623.0",
        "typescript": "^3.9.7",
        "webpack": "^4.44.1",
        "webpack-cli": "^3.3.12"
    }
}

webpack.config.js

var path = require('path');

module.exports = {
    mode: 'development',
    entry: {
        'blazorSample': './wwwroot/ts/blazor-sample.ts',
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: 'ts-loader',
                exclude: /node_modules/
            }
        ]
    },
    resolve: {
        extensions: [ '.tsx', '.ts', '.js' ]
    },
    output: {
        filename: '[name].bundle.js',
        path: path.resolve(__dirname, './wwwroot/js'),
        library: 'Page',
        libraryTarget: 'umd'
    }
};

blazor-sample.ts

export function callFromBlazor(name: string): string
{
    console.log("Hello Blazor");
    return `Hello Blazor ${name}`;
}

BlzrSample.razor

@inject IJSRuntime JSRuntime;
...
<button @onclick="CallFromBlazor">Click2</button>

@code {
...
    private async Task CallFromBlazor()
    {
        var result = await JSRuntime.InvokeAsync<string>("Page.callFromBlazor", SampleInfo);
        Console.WriteLine(result);
    }
}

JavaScript から C# の static メソッドを呼ぶ

C# の static メソッドは DotNet.invokeMethodAsync を使って呼び出すことができます。
まずは Blazor の型定義を追加します。

npm install --save @types/blazor__javascript-interop

blazor「__」javascript ??と気にならなくはないのですが、ほかにそれらしきものが見つからなかったので。

blazor-sample.ts

...
export function callStaticFromJs() {
    // 1. Assembly name, 2. Method name, 3. Arguments
    DotNet.invokeMethodAsync("BlazorSample", "CallStaticFromJs", "FromJS")
        .then(result => console.log(result));
}

BlzrSample.razor

...
<button onclick="Page.callStaticFromJs()">Click3</button>

@code {
...
    // must add JSInvokable and set public static method.
    [JSInvokable]
    public static async Task<string> CallStaticFromJs(string message)
    {
        Console.WriteLine($"Hello JS {message}");
        return await Task.FromResult("Hello static");
    }
...

JavaScript から C#インスタンスメソッドを呼ぶ

C#インスタンスメソッドを呼ぶには、まず C# のメソッド内でインスタンスを生成し、それを JavaScript に渡します。

HelloHelper.cs

using Microsoft.JSInterop;

public class HelloHelper
{
    public HelloHelper(string name)
    {
        Name = name;
    }
    public string Name { get; set; }
    // call from JavaScript
    [JSInvokable]
    public string SayHello() => $"Hello, {Name}!";
}

blazor-sample.ts

...
// argument: HelloHelper instance
export function callInstanceFromJs(dotnetHelper: any) {
    return dotnetHelper.invokeMethodAsync('SayHello')
      .then((r: any) => console.log(r));
}

BlzrSample.razor

@inject IJSRuntime JSRuntime;
...
<button @onclick="CallInstanceFromJs">Click4</button>

@code {
...
    public async Task CallInstanceFromJs()
    {
        using(var helperRef = DotNetObjectReference.Create(new HelloHelper("Js instance")))
        {
            await JSRuntime.InvokeAsync<string>(
                "Page.callInstanceFromJs", helperRef);
        }  
    }
...

今回は using 句を使っているわけですが、実際にはインスタンスの寿命の関係から生成するメソッドと破棄するメソッドは分けることになるのかな~と思っています。

サーバー側のメソッドを呼ぶ

では サーバー側のメソッドを呼ぶことはできるでしょうか。

と思ったら、実は Blazor でも DI が使えると( IJSRuntime も Inject されてましたね)。

ということで、サーバー側と同じように Service クラスなどを Inject すれば OK です。

IBlazorService.cs

using System.Threading.Tasks;
using Models;

namespace Services
{
    public interface IBlazorService
    {
        Task<Product> GetMessageAsync(string Name);
    }
}

BlazorService.cs

using System.Threading.Tasks;
using Models;
namespace Services
{
    public class BlazorService: IBlazorService
    {
        public async Task<Product> GetMessageAsync(string name)
        {
            return await Task.FromResult(new Product
            {
                Id = 2,
                Name = name,
            });
        }
    }
}

Startup.cs

...
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRazorPages();
        services.AddServerSideBlazor();
        services.AddControllers();
        services.AddScoped<IBlazorService, BlazorService>();
    }
...

BlzrSample.razor

@inject Services.IBlazorService _blazor
...
<button @onclick="CallServerside">Click5</button>

@code {
...
    public async Task CallServerside()
    {
        var result = await _blazor.GetMessageAsync(Name);
        if (result == null)
        {
            Console.WriteLine("Cannot get message");
            return;
        }
        Console.WriteLine($"Success ID: {result.Id} Name: {result.Name}");
    }
}

おわりに

以前 Razor も試しました が、動的に処理を実行できる分 Blazor の方が幅が広がりますね。

とはいえ DOM 操作など JavaScript でしかできない処理があるため、うまく住み分けしないと中途半端になってしまいそうですね。

WebAssembly の方も気になるところです。