ASP.NET Core Blazor 初探之 Blazor WebAssembly

最近Blazor热度很高,传说马上就要发布正式版了,做为微软脑残粉,赶紧也来凑个热闹,学习一下。

Blazor

Blazor是微软在ASP.NET Core框架下开发的一种全新的Web开发框架。Blazor利用WebAssembly使得开发者可以抛开JavaScript而使用优雅的C#来开发web单页应用。微软利用WebAssembly在浏览器里实现了一个.NET Runtime,任何.NET STANDARD 2.1的代码都可以在浏览器上运行,真的是屌炸了。Blazor强化了Razor模板引擎,并且借鉴了当前热门前端框架的优点,比如双向绑定技术,组件化,使前端开发敏捷高效。如果你对NG,VUE等框架熟悉那么很容易找到其中的共通点。

Blazor WebAssembly

Blazor 技术又分两种:

  • Blazor WebAssembly
  • Blazor Server

Blazor WebAssembly 是真正的SPA,页面的渲染在前端实现,可以实现真正的前后端分离设计。而Blazor Server可以认为是前者的服务端渲染版本,它使用SignalR实现了客户端的实时通讯,它的计算跟渲染都在服务端处理。本次咱先研究WebAssembly技术,因为我觉得它的应用前景可能更适合一般项目。废话不多说,直接开干吧,我们的目标还是完成一个标准的对学员进行CRUD的并且前后端分离的小项目。

安装Blazor WebAssembly模板

dotnet new -i Microsoft.AspNetCore.Components.WebAssembly.Templates::3.2.0-preview5.20216.8

因为Blzor WebAssembly还在预览阶段所以要手工安装模板,在控制台运行以上命令来安装最新的模板。

新建Blazor WebAssembly项目

打开vs找到Blazor的项目模板,就是那个特别像火影标志的那个图标。新建一个项目名叫BlazorWebAssemblyApp。点下一步,这里会让选是Blazor Server还是Blazor WebAssembly,不要选错了。
YmBx8P.md.png
先看一下项目结构:
YmrQQf.png
Blazor Webassembly的项目结构比较简单,跟Razor Page的项目结构比较类似。

新建ASP.NET CORE WebApi项目

我们的目标是打造一个前后端分离的项目,那么自然还要建一个Api项目。并且这个项目对外提供一个Student的Restful API。在vs里新建ASP.NET CORE WebApi项目,名为BlazorWebassemblyApisite。
为了演示方便,使用静态变量实现一个StudentRepository。

    public class Student
    {
        public int Id { get; set; }
        public string Name { get; set; }

        public string Class { get; set; }

        public int Age { get; set; }

        public string Sex { get; set; }
    }
    public interface IStudentRepository
    {
        List<Student> List();

        Student Get(int id);

        bool Add(Student student);

        bool Update(Student student);

        bool Delete(int id);
    }
 public class StudentRepository : IStudentRepository
    {
        private static List<Student> Students = new List<Student> {
                new Student{ Id=1, Name="小红", Age=10, Class="1班", Sex="女"},
                new Student{ Id=2, Name="小明", Age=11, Class="2班", Sex="男"},
                new Student{ Id=3, Name="小强", Age=12, Class="3班", Sex="男"}
        };

        public bool Add(Student student)
        {
            Students.Add(student);

            return true;
        }

        public bool Delete(int id)
        {
            var stu = Students.FirstOrDefault(s => s.Id == id);
            if (stu != null)
            {
                Students.Remove(stu);
            }

            return true;
        }

        public Student Get(int id)
        {
            return Students.FirstOrDefault(s => s.Id == id);
        }

        public List<Student> List()
        {
            return Students;
        }

        public bool Update(Student student)
        {
            var stu = Students.FirstOrDefault(s => s.Id == student.Id);
            if (stu != null)
            {
                Students.Remove(stu);
            }

            Students.Add(student);
            return true;
        }
    }

在Startup里注册这个Repository:

services.AddScoped<IStudentRepository, StudentRepository>();

实现StudentController用来暴露API:

[ApiController]
    [Route("[controller]")]
    public class StudentController : ControllerBase
    {
        private IStudentRepository _studentRepository;
        public StudentController(IStudentRepository studentRepository)
        {
            _studentRepository = studentRepository;
        }

        [HttpGet]
        public List<Student> Get()
        {
            return _studentRepository.List();
        }

        [HttpGet("{id}")]
        public Student Get(int id)
        {
            return _studentRepository.Get(id);
        }

        [HttpPost]
        public Student Post(Student model)
        {
            _studentRepository.Add(model);

            return model;
        }

        [HttpPut]
        public Student Put(Student model)
        {
            _studentRepository.Update(model);

            return model;
        }

        [HttpDelete("{id}")]
        public void Delete(int id)
        {
            _studentRepository.Delete(id);
        }
    }

因为我们的前后端项目会分两个网址部署,所以肯定需要配置CORS的问题:

 app.UseCors(config =>
            {
                config.AllowAnyOrigin();
                config.AllowAnyMethod();
                config.AllowAnyHeader();
            });

这样我们的后端API网站就完成了,接来下就是真正的Blazor环节了。

配置HttpClient与注入

让我们切换回BlazorWebAssemblyApp项目。我们的Blazor项目需要通过Http与API站点进行通信,所以肯定需要一个访问Http的类库。如果是JavaScript我们平时使用如axios等库,但是Blazor可以使用C#实现的HttpClient,在前端由C#发起Http请求,Cool!当然最后HttpClient发出的请求会还是会转换为浏览器的Fetch请求。Blazor项目支持依赖注入,这个用法跟ASP.NET Core项目的体验是一致的,通过IServiceCollection配置注入的生命周期:

builder.Services.AddTransient(sp => new HttpClient { BaseAddress = new Uri("https://localhost:6001") });

Blazor的注入同样分Transient、Scope、Singleton等生命周期。这里我们注册HttpClient为Transient,并且配置baseAddress为https://localhost:6001,这是ApiSite的地址。

实现学生列表(student/list)

因为新建成的项目会自动生成一些页面,为了减少干扰,先删掉点内容。
简化MainLayout.razor,删除一些不必要的东西:

@inherits LayoutComponentBase

<div class="main">
    <div class="content px-4">
        @Body
    </div>
</div>

删除Index.razor的内容,就留一个page指令:

@page "/"

新建Model文件夹,用来存放Student模型,这里其实可以把Api网站的Student模型提取出来,作为公共的定义模块,为了简单就直接定义一个一模一样的吧:

    public class Student
    {
        public int Id { get; set; }
        public string Name { get; set; }

        public string Class { get; set; }

        public int Age { get; set; }

        public string Sex { get; set; }
    }

新建一个student文件夹,在这个文件夹内新建一个List.razor文件:

@page "/student/list"

@using BlazorWebAssemblyApp.Model

@inject HttpClient Http

<h1>List</h1>

<p class="text-right">
    <a class="btn btn-primary" href="/student/add">Add</a>
</p>

<table class="table">
    <tr>
        <th>Id</th>
        <th>Name</th>
        <th>Age</th>
        <th>Sex</th>
        <th>Class</th>
        <th></th>
    </tr>
    @if (_stutdents != null)
    {
        foreach (var item in _stutdents)
        {
            <tr>
                <td>@item.Id</td>
                <td>@item.Name</td>
                <td>@item.Age</td>
                <td>@item.Sex</td>
                <td>@item.Class</td>
                <td>
                    <a class="btn btn-primary" href="/student/modify/@item.Id">修改</a>
                    <a class="btn btn-danger" href="/student/delete/@item.Id">删除</a>
                </td>
            </tr>
        }
    }

</table>

@code {
    private List<Student> _stutdents;

    protected override async Task OnInitializedAsync()
    {
        var students = await Http.GetFromJsonAsync<List<Student>>("/student");
        this._stutdents = students;
    }
}

这个文件大体上看跟RazorPages的页面差不多,Html主体使用razor语法渲染。但是还是有很大的不同,让我们从头开始一个个的解释:

@page "/student/list"

@page指令指示这个页面的路由,当用户访问/student/list时就会路由到这个页面

@using BlazorWebAssemblyApp.Model

@using指令不多说了,引用namespace,这个跟Razor Pages是一样的

@inject HttpClient Http

@inject指令从字面看就很容易理解,注入。上面的意思就是注入HttpClient对象,并且命名为Http。后面就可以使用这个Http对象了,当然前提是在Program里注册好。

@code {
    private List<Student> _stutdents;

    protected override async Task OnInitializedAsync()
    {
        var students = await Http.GetFromJsonAsync<List<Student>>("/student");
        this._stutdents = students;
    }
}

@code指令指示这个scope里的内容为C#代码。虽然没有明确定义为class,但是显然这个代码块最后会被编译成一个类。这个类里的变量可以作为razor模板的数据源,可以进行绑定或者for循环。OnInitializedAsync方法为初始化方法,可以在这里处理一些初始化工作,比如我们这里就是通过一次Http请求获取学生的列表数据。如果是同步方法请使用OnInitialized。这个文件的结构看起是不是很像VUE的单文件组件,笑哭。
让我们运行一下吧:
YmIGvQ.md.png

实现新增学生页面(/student/add)

当点击列表页面的Add按钮的时候,需要导航至新增页面,导航直接使用a标签没有任何问题。

   <a class="btn btn-primary" href="/student/add">Add</a>

考虑到后面还有编辑页面,新增跟编辑页面整体是一样的,只是后台处理的逻辑不一样。既然Blazor支持组件化,那么这种重复的东西既然是封装为一个组件为好了。

封装Edit组件

我们把对学生信息编辑的功能抽象成一个组件叫做Edit。在student文件夹下新建一个component文件夹,在文件夹内新建Edit.razor文件:

@using BlazorWebAssemblyApp.Model

<div>
    <div class="form-group">
        <label>Id</label>
        <input @bind="Student.Id" class="form-control" />
    </div>
    <div class="form-group">
        <label>Name</label>
        <input @bind="Student.Name" class="form-control"/>
    </div>
    <div class="form-group">
        <label>Age</label>
        <input @bind="Student.Age" class="form-control" />
    </div>
    <div class="form-group">
        <label>Class</label>
        <input @bind="Student.Class" class="form-control" />
    </div>
    <div class="form-group">
        <label>Sex</label>
        <input @bind="Student.Sex" class="form-control" />
    </div>

     <button class="btn btn-primary" @onclick="TrySave">
         保存
     </button>

</div>

@code{

    [Parameter]
    public Student Student { get; set; }
    [Parameter]
    public EventCallback<Student> OnSaveCallback { get; set; }

    protected override Task OnInitializedAsync()
    {
        if (Student == null)
        {
            Student = new Student();
        }

        return Task.CompletedTask;
    }

    private void TrySave()
    {
        OnSaveCallback.InvokeAsync(Student);
    }
}

继续解释下这个文件:

数据绑定

<input @bind="Student.Id" class="form-control" />

使用@bind指令可以跟某个对象实现的属性实现双向绑定。@bind指令本质上是通过对value跟onchange这个属性的绑定配合来实现双向绑定,这个套路怎么那么熟悉?对了VUE也是这么干的,笑哭。@bind="Student.Id"翻译过来等效于:

<input value="@Student.Id"
    @onchange="@((ChangeEventArgs __e) => Student.Id = 
        __e.Value.ToString())" />

事件绑定

除了对数据的绑定,Blazor还支持对事件的绑定:

     <button class="btn btn-primary" @onclick="TrySave">
         保存
     </button>

@onclick="TrySave" 表示这个button的click事件指向TrySave这个方法。

组件属性

我们封装组件经常对外暴露属性,以便接受外部传入的数据,比如我们这个Edit组件就需要外部传入一个Student对象才能正常工作。

    [Parameter]
    public Student Student { get; set; }

我们在@code代码里的属性上打上[Parameter]标签。这里叫做Parameter,估计是为了跟C#里的属性(property)进行区分。这样的话,这个属性就可以接受父组件的传参,注意这个属性是单项数据流,组件内对Student修改并不会修改外部组件的数据源,这个也很VUE啊,笑哭。

组件事件

我们除了需要对外暴露属性,常常还需要对外暴露事件,用来通知外部组件。当外部组件接受到事件的时候可以进行相应的处理。比如这个Edit组件点击保存的时候并没有进行真正的保存操作,而是对外抛一个事件,当外部组件接受这个事件的时候进行真正的处理,比如是调用新增API还是更新API。

[Parameter]
    public EventCallback<Student> OnSaveCallback { get; set; }

我们在@code代码里的EventCallback事件上打上[Parameter]标签。这样外部组件就可以注册这个事件了。当我们在这个组件上点击保存的时候激发这个事件,并且把修改过的Student对象传递出去。

 OnSaveCallback.InvokeAsync(Student);

使用Edit组件

Edit组件封装完成了,让我们开始使用它。新建一个Add.razor文件,并且在这里使用Edit组件。组件的使用跟VUE等一样,使用一个自定义的Tag插入到html的里。

@page "/student/add"

@using BlazorWebAssemblyApp.Model

@inject HttpClient Http
@inject NavigationManager NavManager

<h1>Add</h1>

<Edit OnSaveCallback="OnSaveAsync"></Edit>

<div class="text-danger">
    @_errmsg
</div>

@code {

    private Student Student { get; set; }

    private string _errmsg;

    protected override Task OnInitializedAsync()
    {
        return base.OnInitializedAsync();
    }

    private async Task OnSaveAsync(Student student)
    {
        Student = student;

        var result = await Http.PostAsJsonAsync("/student", Student);

        if (result.IsSuccessStatusCode)
        {
            NavManager.NavigateTo("/student/list");
        }
        else
        {
            _errmsg = "保存失败";
        }
    }

}

Add.razor的逻辑很简单,接受Edit组件的保存事件,然后把Student通过Http提交到后台。

<Edit OnSaveCallback="OnSaveAsync"></Edit>

通过OnSaveCallback="OnSaveAsync"设置Edit组件的OnSaveCallback事件回调为OnSaveAsync方法。
当我们保存功能的时候,需要跳转到列表页面。Blazor提供了一个简单的导航框架:NavigationManager。NavigationManager是默认注册到IoC容器的,所以可以直接使用@inject注入到需要的地方:

@inject NavigationManager NavManager

调用NavigateTo方法进行页面跳转。

NavManager.NavigateTo("/student/list");

让我们运行一下看看吧:
YnDgXT.md.png

实现修改学生信息页面(/student/modify)

修改界面相对新增页面会多涉及一个知识点,url传参。当我们需要修改学生信息的时候,需要传递一个id参数过去,告诉页面需要修改哪一个学生。

@page "/student/modify/{Id:int}"

@using BlazorWebAssemblyApp.Model
@using BlazorWebAssemblyApp.Data

@inject HttpClient Http
@inject NavigationManager NavManager
@inject Store Store

<h1>Modify</h1>

<Edit Student="Student" OnSaveCallback="OnSaveAsync"></Edit>

<div class="text-danger">
    @_errmsg
</div>

@code {
    [Parameter]
    public int Id { get; set; }

    private Student Student { get; set; }

    private string _errmsg;

    protected override void OnInitialized()
    {
        Student = Store.GetStudentById(Id);
    }

    private async Task OnSaveAsync(Student student)
    {
        Student = student;

        var result = await Http.PutAsJsonAsync("/student", Student);

        if (result.IsSuccessStatusCode)
        {
            NavManager.NavigateTo("/student/list");
        }
        else
        {
            _errmsg = "保存失败";
        }
    }
}

@page指令配置的路由模板可以支持参数匹配

@page "/student/modify/{Id:int}"

我们在列表页面使用a标签进行跳转,url组合成/student/modify/1样式,其中1会匹配给属性Id,并且这里限制了Id的类型为int。

<Edit Student="Student" OnSaveCallback="OnSaveAsync"></Edit>

对Edit组件的使用,修改页面跟新增页面不同的是,修改页面需要传递一个Student对象到Edit组件内部,以便显示学员信息。

实现一个Store

修改页面显然需要显示学生当前的信息。我们通过url传递过来的参数只有id,那么需要一次Http请求去后台获取学生信息,这没什么问题。但是如果是SPA应用,其实学生的信息本身已经在列表页面了,对于那些不是高频更新的数据,我们没有必要每次都去数据库里获取最新的数据,况且即使你从数据库里获取到了最新的数据,也可能在你修改的过程中被别人修改。因为SPA跟传统的Web项目不同,它可以完整的维护状态,所以如果我们把列表的数据存起来,那么其他地方可以很方便直接在内存里查询到,高效又便捷。通常使用Angularjs的时候这种场景会使用一个单例的Service来完成。这里我也简单使用C#来实现一个Service来存储页面的数据,名称就借鉴一下VUE的Vuex吧,叫Store。

   public class Store
    {
        private  List<Student> _students;

        public  void SetStudents(List<Student> list)
        {
            _students = list;
        }

        public List<Student> GetStudents()
        {
            return _students;
        }

        public  Student GetStudentById(int id)
        {
            var stu = _students?.FirstOrDefault(s => s.Id == id);

            return stu;
        }
    }
builder.Services.AddSingleton<Store>();

这个service很简单,就是一个简单的class。使用List来存储学生列表信息,对外提供几个Set,Get方法来存储数据跟获取数据。这里我并没有手工实现为单例,直接在框架的容器上注册为单例生命周期。

改造列表页面

现在我们有了Store,所以当列表获取到数据后需要存储到Store里,这样我们在修改页面或者其他地方就能根据id直接获取数据了。

@inject Store Store

@code {
    private List<Student> _stutdents => Store.GetStudents();

    protected override async Task OnInitializedAsync()
    {
        var students = await Http.GetFromJsonAsync<List<Student>>("/student");
        Store.SetStudents(students);
    }
}

我们的改造完成了,运行一下看看吧:
YnTaRK.md.png

实现删除页面(/student/delete)

删除页面比较简单,使用前面的知识点轻松可以搞定。同样通过Url传递一个Id到删除页面,页面上获取学生数据后进行显示,并且提示用户是否确定删除这个学生信息。如果点击确定就调用删除API进行删除操作,如果点击取消则回退到前一页。为了增加乐趣,这里会增加C#跟JavaScript交互的内容。

@page "/student/delete/{id:int}"

@using BlazorWebAssemblyApp.Model
@using BlazorWebAssemblyApp.Data

@inject HttpClient Http
@inject Store Store
@inject NavigationManager NavManager
@inject IJSRuntime JSRuntime

<h1>Delete</h1>

<h3>
    确定删除(@Student.Id)@Student.Name ?
</h3>

<button class="btn btn-danger" @onclick="OnDeleteAsync">
    删除
</button>

<button class="btn btn-info" @onclick="OnCancel">取消</button>

@code {
    [Parameter]
    public int Id { get; set; }

    private Student Student { get; set; }

    protected override void OnInitialized()
    {
        Student = Store.GetStudentById(Id);
    }

    private async Task OnDeleteAsync()
    {
        var result = await Http.DeleteAsync("/student/" + Id);
        if (result.IsSuccessStatusCode)
        {
            NavManager.NavigateTo("/student/list");
        }
    }

    private void OnCancel()
    {
        JSRuntime.InvokeVoidAsync("history.back");
    }
}

IJSRuntime

当用户点击取消的时候我们需要回退到前一个页面,但是Blazor的NavigationManager并没有提供GoBack这种操作。这个我实在是想不明白,不管是WPF的导航框架、还是VUE的路由服务都有这种机制,以至于我还得通过JavaScript的能力去调用浏览器的原生后退功能来实现。Blazor中想要跟JavaScript交互需要注入JSRuntime对象:

JSRuntime.InvokeVoidAsync("history.back");

我们在取消按钮的事件代码里调用以上代码,这样就能顺利后退了。
YuZE5R.md.png

总结

通过以上,我们使用Blazor实现了一个简单的前后端分离的SPA。总体涉及了Blazor的几个重要知识点,比如:数据绑定,事件处理,封装组件,JavaScript交互等。其中每个知识点都可以再深入展开来写一篇。我们使用Blazor,在几乎没用JavaScript的情况下顺利的完成了一个SPA,总体感觉还是比较良好的。虽然不用JavaScript,但是显然它借鉴了热门JavaScript框架的一些特点,如果你有一点前端基础跟.NET基础很容易就能上手。但是,我不想在这神吹Blazor,毕竟它也没有到让人惊艳的地步,比如我熟悉Angular,熟悉VUE,说真的,目前来说,我没有什么动力切换到Blazor上来。如果Blazor早出现那么几年,或许一切都不一样了。但是,又要但是。。。但是我还是会学习Blazor,就像我当年学习Silverlight一样。没错,我就是那个被微软伤害两次(Silverlight,Windows Phone)依然待他如初恋的男人,笑哭。微软的东西虽然不流行,但是不代表它不先进,有的时候或许是过于先进。比如MVVM、双向绑定、前后端分离,这些概念都是当年Silverlight RIA应用早就有的。虽然Silverlight后来黄了,但是它里面的一些设计理念,开发模式并不落后,甚至是超前的。这些经验对后来我学习Angularjs,VUE来说有非常大的帮助,学起来得心应手,因为套路都是那个套路。所以哪天说不定WebAssembly大行其道,Blazor又成了开山鼻祖,学习它的经验一定是有用的。

最后demo的源码:BlazorWebAssemblyAppDemo

关注我的公众号一起玩转技术

posted @ 2020-05-09 00:18  Agile.Zhou  阅读(4498)  评论(12编辑  收藏