近日,终于有机会将大名鼎鼎的WebAssembly开发框架Blazor体验了一把。Blazor是一个套基于ASP.NET的WebAssembly前端开发框架,仅用C#语言无需javascript便可以开发一套完整的Web前端应用,听起来都觉得相当之Cool。个人觉得,Blazor WebAssembly并不算什么非常新鲜的的技术。早在2-3年前,网上就有不少大V在不遗余力地“安利”它。但是,开发者对于Blazor或者WASM应用的热情似乎一直不温不火。究竟Blazor有什么硬伤呢?带着这个好奇心,我终于创建了一个Blazor应用一探究竟。
创建项目
添加一个Blazor WebAssembly项目,也就是那种纯前端的项目模板
目录结构说明
应用程序入口(program.cs)
这东东对于所有.net开发者都不陌生了吧。我们通常在此为依赖注入容器做注册。例如:
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app"); // 注册前端根组件节点,类似Vue的app.mount('#app');
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddAntDesign(); // 注册 AntDesign Blazor组件库
builder.Services.AddHttpClient(); // 注册 HttpClientFactory
builder.Services.AddSingleton<LeanCloudService>(); // 注册自定义的类
Web静态资源
静态资源包括首页(index.html)、css、图片、字体、json数据等,这些内容与静态网页几乎一样。这里便不再展开赘述了。
- 我的应用使用了AntDesign组件库,因此在首页(index.html)增加了相关的样式以及js脚本的引用。
<link href="_content/AntDesign/css/ant-design-blazor.css" rel="stylesheet" />
<script src="_content/AntDesign/js/ant-design-blazor.js"></script>
全局引用
在_Imports.razor
中设置全局引用,那么其他页面就可省事很多了。比如我这里的AntDesign。
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using RM.Blazor.WASM
@using RM.Blazor.WASM.Shared
@using AntDesign
公共布局
根Blazor项目默认的布局不大一样:
- 使用了AntDesign的布局控件
- 使用了动态的菜单导航,内容从
./data/menu.json
配置文件获取。内容如下:
[
{
"key": "home",
"title": "首页",
"routerUrl": "",
"summary": ""
},
{
"key": "vote",
"title": "投票记录",
"routerUrl": "votelogs",
"summary": "HeiYan投票记录"
},
{
"key": "account",
"title": "用户账号",
"routerUrl": "useraccount",
"summary": "LeanCloud数据表TAccount管理"
}
]
- 使用PageHeader作为内页标题
MainLayout.razor
代码如下:
@inherits LayoutComponentBase
@inject HttpClient Http
<Layout Class="layout">
<Header>
<div class="logo" />
<Menu Theme="MenuTheme.Dark" @ref="mainMenu" Mode="MenuMode.Horizontal" DefaultSelectedKeys=@(new[]{"home"}) OnMenuItemClicked=@(OnMenuClick)>
@foreach (var menu in menus)
{
<MenuItem Key="@menu.Key" RouterLink="@menu.RouterUrl">@menu.Title</MenuItem>
}
</Menu>
</Header>
<Content Style="padding: 0 16px;">
<PageHeader Class="site-page-header" Title="@_title" Subtitle="@_summary" />
<div class="site-layout-content">
@Body
</div>
</Content>
<Footer Style="text-align: center; ">Ant Design ©2018 Created by Ant UED</Footer>
</Layout>
@code {
private MenuOption[]? menus = new MenuOption[] { };
private string _title = "首页";
private string _summary = "";
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
menus = await Http.GetFromJsonAsync<MenuOption[]>("data/menu.json");
}
public Task OnMenuClick(MenuItem e)
{
var opt = menus.Where(x => x.Key == e.Key).FirstOrDefault();
_title = e.Key == "home" ? opt.Title : "首页 / " + opt.Title;
_summary = opt.Summary;
return Task.CompletedTask;
}
Menu mainMenu;
public class MenuOption
{
public string? Key { get; set; }
public string? Title { get; set; }
public string? RouterUrl { get; set; }
public string? Summary { get; set; }
}
}
CRUD示例页面
这个是个标准的CRUD用户账号管理的界面。看着跟Vue的开发体验也差不多。需要留意的一个细节就是如果要做显示控件与变量双向绑定,必须要加上@bind-
才行,否者就是单向的。详细代码如下:
@page "/useraccount"
@using RM.Blazor.WASM.Services
@using RM.Blazor.WASM.Models
@using System.Text.Json;
@inject LeanCloudService Lean
<Button @onclick="OpenAdd">Add User Account</Button>
<br />
<Table @ref="table"
TItem="LCAccount"
DataSource="@listData"
Size="TableSize.Small"
Total="_total"
@bind-PageIndex="_pageIndex"
@bind-PageSize="_pageSize">
<PropertyColumn Property="c=>c.phone" />
<PropertyColumn Property="c=>c.points" />
<PropertyColumn Title="tickets" Property="c=>c.remark" />
<PropertyColumn Property="c=>c.status" />
<ActionColumn>
<Space>
<SpaceItem><Button OnClick="()=>Edit(context.id)">Edit</Button></SpaceItem>
<SpaceItem>
<Popconfirm Title="Are you sure delete this task?"
OnConfirm="()=>Delete(context.id)"
OkText="Yes"
CancelText="No">
<Button Danger>Delete</Button>
</Popconfirm></SpaceItem>
</Space>
</ActionColumn>
</Table>
<Drawer Closable="true" Placement="bottom" Height="600" Visible="showEdit" Title="@dialogTitle" OnClose="@(CloseEdit)">
<Template style="height:90%">
<Row Gutter="16">
<AntDesign.Col Span="12">
<Text>Name: @editItem.name</Text>
</AntDesign.Col>
<AntDesign.Col Span="12">
<Text>Phone: @editItem.phone</Text>
</AntDesign.Col>
</Row>
<br />
<Row Gutter="16">
<AntDesign.Col Span="12">
<Text>Points: @editItem.points</Text>
</AntDesign.Col>
<AntDesign.Col Span="12">
<Text>Tickets: @editItem.remark</Text>
</AntDesign.Col>
</Row>
<br />
<Row Gutter="16">
<AntDesign.Col Span="24">
<Text>Cookies</Text>
<TextArea Rows="10" Placeholder="Please enter cookie" @bind-Value="@editItem.cookies"></TextArea>
</AntDesign.Col>
</Row>
<br />
<Row>
<AntDesign.Col Span="12">
<Text>Status</Text>
<Switch @bind-Checked="@editStatus" />
</AntDesign.Col>
<AntDesign.Col Span="12">
<Button Type="primary" OnClick="EditSave">Submit</Button>
</AntDesign.Col>
</Row>
</Template>
</Drawer>
<Drawer Closable="true" Placement="bottom" Height="600" Visible="showAdd" Title="@dialogTitle" OnClose="@(CloseAdd)">
<Template style="height:90%">
<Row Gutter="16">
<AntDesign.Col Span="12">
<Text>Name: </Text>
<Input Placeholder="input account name" @bind-Value="@editItem.name" />
</AntDesign.Col>
<AntDesign.Col Span="12">
<Text>Phone: </Text>
<Input Placeholder="input phone number" @bind-Value="@editItem.phone" />
</AntDesign.Col>
</Row>
<br />
<Row Gutter="16">
<AntDesign.Col Span="12">
<Text>Password:</Text>
<Input Placeholder="input password" @bind-Value="@editItem.pwd" />
</AntDesign.Col>
<AntDesign.Col Span="12">
<Text>User Id</Text>
<Input Placeholder="input user id" @bind-Value="@editItem.userId" />
</AntDesign.Col>
</Row>
<br />
<Row Gutter="16">
<AntDesign.Col Span="24">
<Text>Cookies</Text>
<TextArea Rows="10" Placeholder="Please enter cookie" @bind-Value="@editItem.cookies"></TextArea>
</AntDesign.Col>
</Row>
<br />
<Row>
<AntDesign.Col Span="12">
<Text>Status</Text>
<Switch @bind-Checked="@editStatus" />
</AntDesign.Col>
<AntDesign.Col Span="12">
<Button Type="primary" OnClick="AddSave">Submit</Button>
</AntDesign.Col>
</Row>
</Template>
</Drawer>
@code {
ITable table;
int _pageIndex = 1;
int _pageSize = 10;
int _total = 0;
private List<LCAccount> listData;
LCAccount editItem = new LCAccount();
bool showEdit = false;
bool showAdd = false;
bool editStatus = false;
string dialogTitle = "Edit User Account";
string tableName = LCAccount.TABLE;
protected override async Task OnInitializedAsync()
{
await LoadData();
}
private async Task LoadData(){
listData = await Lean.FindAll<LCAccount>(tableName) ;
_total = listData.Count;
}
private void Edit(int id)
{
var item = listData.Where(x => x.id == id).FirstOrDefault();
if(item != null){
editItem = item.Clone();
editStatus = (editItem.status == 1);
showEdit = true;
dialogTitle = "Edit User Account";
}
}
private async Task EditSave()
{
if (editItem.id>0)
{
var data = new { cookies = editItem.cookies, status = editStatus ? 1 : 0 };
var res = await Lean.Update(tableName, JsonSerializer.Serialize(data), editItem.objectId);
if (res)
{
var item = listData.Where(x => x.id == editItem.id).FirstOrDefault();
item.cookies = editItem.cookies;
item.status = editStatus ? 1 : 0;
}
showEdit = false;
}
}
private async Task Delete(int id)
{
var item = listData.Where(x => x.id == editItem.id).FirstOrDefault();
if (item != null)
{
var res = await Lean.Delete(tableName, new string[] { item.objectId });
listData = listData.Where(x => x.id != id).ToList();
_total = listData.Count;
}
}
private void CloseEdit(){
showEdit = false;
}
private void OpenAdd(){
dialogTitle = "Add User Account";
editItem = new LCAccount();
editStatus = true;
showAdd = true;
}
private void CloseAdd(){
showAdd = false;
}
private async Task AddSave()
{
var data = new {
editItem.cookies,
status = editStatus ? 1 : 0 ,
editItem.pwd,
editItem.name,
editItem.phone,
remark= "",
points = 0,
editItem.userId
};
var res = await Lean.Insert(tableName, JsonSerializer.Serialize(data));
if (res)
{
await LoadData();
CloseAdd();
}
}
}
运行界面
- 列表页面
- 添加表单
总结
- 首先,开发过程来说是比较友好的,对于一个有ASP.NET项目开发经验的人来说上手还是相对平顺的,总体坑不算太多。AntDesign这类开源组件的支持,界面的表现力也是可圈可点,不逊于 vue/ react这些主流的框架。对于完全没有兴趣折腾前端技术的.NET开发者来说,或许是个不错的选择(我的demo代码里 0 javascript哦)。
- 接下来篇幅,我是准备用来吐槽的!
1、加载实在太慢了
首次加载页面loading时间超过90秒,浏览器在疯狂地加载一堆dll总共计11.2M。
这速度瞬间能把95%的人耐心给耗尽。个人觉得Blazor WebAssembly
技术来做互联网应用并非是它的用武之地,而在企业应用领域的表现也会差强人意,估计跟当年的Silverlight半斤八两。反而可能会在桌面应用领域能够找到其合适的定位。AntDesign的文档就是推荐大家这么玩的。
Winform+WebView2+Blazor WebAssembly,这样loading起来感觉就平顺很多。至少用户对于内容加载速度的心理预期,会大大降低。亲测之,果然非常顺滑!有机会的话,针对套解决方案我再补充一篇文章。
2、代码安全问题
Blazor WebAssembly是作为运行在浏览器的运行的客户端代码,起始就跟javascript一样,完全可以把编译后的dll下载到本地,然后用反编译工具进行逆向反编。
所以,跟发布服务端应用不用,想要确保代码安全,不被有心人窃取或窥视的话,有两招:
- NativeAOT发布直接弄成机器码,但是换取的代价就是发布的包容量可能会膨胀一倍之多
- 做代码混淆,将代码可读性降低,即便让人家反编译出来,也像天书一样难懂。
3、网络安全问题
Blazor WebAssembly应用中HttpClient请求是通过Ajax发送,因此简单的抓包就可以拿到整个请求内容。如果有涉及安全级别较高的接口请求,比如支付什么的,不建议直接在客户端写。而是通过服务端转接会更好。