Blazor
-
Blazor介绍
- 可以在服务器上运行客户端逻辑,并且客户端UI事件使用Signalr发送回服务器的一个实时框架
- Blazor官方介绍:ASP.NET Core Blazor | Microsoft Learn
- Blazor Server在 ASP.NET Core 应用中支持在服务器上托管 Razor 组件。 可通过 SignalR 连接处理 UI 更新。SignalR又是基于websocket的,网络环境不好会断掉,长时间不操作也会断掉。
- 网络的延迟会影响到Blazor的延迟问题
- 场景:并发不高,用户访问不大的情况。
- 适合调试,不需要下载整个运行时
- Blazor Server在 ASP.NET Core 应用中支持在服务器上托管 Razor 组件。 可通过 SignalR 连接处理 UI 更新。SignalR又是基于websocket的,网络环境不好会断掉,长时间不操作也会断掉。
-
- Blazor WebAssembly,用于使用 .NET 生成交互式客户端 Web 应用。
- 通过 WebAssembly(缩写为 ),可在 Web 浏览器内运行 .NET 代码。 WebAssembly 是针对快速下载和最大执行速度优化的压缩字节码格式。
- 类似于纯前端 ,且安全性能高
- 适合运行,会下载整个运行时
- 优点:
- 将应用程序加载到客户端设备后,UI交互反应快,因为应用程序的全部内容已下载到客户端设备或客户端
- 由于应用程序在客户端下载的,因此应用程序可以离线工作(除了对服务器的API进行请求)
- 缺点:
- 初始加载需要耗费时间。必须将应用程序二进制文件下载到客户端。下载时间取决于应用程序二进制文件的大小。一旦下载完成,第二次以后应用程序可以更快的加载。
- 界面切换的延时比较长,使用着就很难受。
- Blazor WebAssembly,用于使用 .NET 生成交互式客户端 Web 应用。
-
- 可以在网页->应用程序->Cache中看到要加载的二进制文件
- WebAssembly vs Server
-
WebAssembly(WASM)是一种二进制指令格式,可以让代码在Web浏览器中以接近本地速度执行。它旨在通过允许开发人员使用除JavaScript之外的其他语言编写代码(例如C ++,Rust和Go),从而使复杂的Web应用程序运行得更快,更高效。简单说,Dll首次加载的时候,是通过服务器下载到浏览器中,然后全部在浏览器中运行。
-
Server是一种计算机程序,它提供服务并响应客户端的请求。服务器通常运行在远程计算机上,与客户端通过网络进行通信,可以提供各种服务,例如Web服务、文件共享、数据库服务等。
-
因此,WebAssembly和服务器是不同类型的技术,它们服务于不同的目的。WebAssembly旨在提高Web应用程序的性能和效率,而服务器则旨在提供服务并响应客户端请求。在某些情况下,WebAssembly可以在服务器端使用,以提高服务器性能,但这不是其主要目的。
-
- Blazor官方创建:Blazor 教程 - 生成首个应用 (microsoft.com)
项目是在 Visual Studio 中创建和加载的。请使用解决方案资源管理器查看项目内容。
已创建多个文件,以为你提供可运行的简单 Blazor 应用。
-
Program.cs
是启动服务器以及在其中配置应用服务和中间件的应用的入口点。App.razor
为应用的根组件。Pages
目录包含应用的一些示例网页。BlazorApp.csproj
定义应用项目及其依赖项,且可以通过双击解决方案资源管理器中的 BlazorApp 项目节点进行查看。Properties
目录中的launchSettings.json
文件为本地开发环境定义不同的配置文件设置。创建项目时会自动分配端口号并将其保存在此文件上。
- Blazor程序的运行
- _Host.cshtml文件
-
- App.razor文件,这里主要是一个路由表的设置,并设置响应。
- 细节
- Titile占位符:_Layout.cshtml
-
- @RenderBody()
@RenderBody() 是 Blazor WebAssembly 应用程序的核心功能之一。它指定一个布局页面,并将内容插入到该页面中。
在 Blazor 应用程序中,可以创建多个页面,并且每个页面都可以使用特定的布局。这些布局页面包含了页面中重复的HTML和结构,例如头部、导航菜单和页脚等,以减轻开发者的代码量和工作量。
要在布局页面中显示具体的内容,我们需要使用 @RenderBody()
。它的作用就是定义主页面内容排版的区域,我们所编写的每个子页面在被呈现时,都会被加载到此处,并占据其所在位置。
路由
- 官方介绍:ASP.NET Core Blazor 路由和导航 | Microsoft Learn
- 通过 Router 组件可在 Blazor 应用中路由到 Razor 组件。
- 添加路由
- 通过AdditionalAssemblies添加多个路由(程序集)
1 2 3 4 5 6 7 | // 添加单个路由 <Router AppAssembly= "@typeof(App).Assembly" > </Router> // 在原来的路由基础上,添加多个路由 <Router AppAssembly= "@typeof(App).Assembly" AdditionalAssemblies= "new Assembly[]{typeof(App).Assembly}" > </Router> |
-
- 定义路由
- 可以给一个razor定义多个路由
- 定义路由
1 2 3 4 5 | // 方式一: @page "/blazor-route" // 方式二: @attribute [Route( "/blazor-route" )] |
-
- 路由报错
1 2 3 4 5 6 7 8 | <NotFound> @*页面标题*@ <PageTitle>Not found</PageTitle> @*布局绑定:布局页面*@ <LayoutView Layout= "@typeof(MainLayout)" > <p role= "alert" >Sorry, there's nothing at this address.</p> </LayoutView> </NotFound> |
-
- 路由参数
1 2 3 4 5 6 7 8 9 10 | // 必选参数 @page "/{text}" // 可选参数 @page "/{text?}" // 定义参数 @code { [Parameter] public string ? Text { get ; set ; } } |
-
- 路由约束
1 2 3 4 | // 必选类型 @page "/{text:int}" // 可选类型 @page "/{text:int?}" |
导航
使用 NavigationManager 来来管理 URI 和导航。常用方法:
成员 | 描述 |
Uri | 获取当前绝对URI |
BaseUri | 获取可在相对 URI 路径之前添加用于生成绝对 URI 的基 URI(带有尾部反斜杠)。 通常,BaseUri 对应于文档的 <base> 元素(<head> 内容的位置)上的 href 属性。 |
NavigateTo | 导航到指定 URI。 如果 forceLoad 为 true ,则:
replace 为 true ,则替换浏览器历史记录中的当前 URI,而不是将新的 URI 推送到历史记录堆栈中。 |
LocationChanged | 导航位置更改时触发的事件。 有关详细信息,请参阅位置更改部分。 |
ToAbsoluteUri | 将相对 URI 转换为绝对 URI。 |
ToBaseRelativePath | 给定基 URI(例如,之前由 BaseUri 返回的 URI),将绝对 URI 转换为相对于基 URI 前缀的 URI。 |
RegisterLocationChangingHandler | 注册一个处理程序来处理传入的导航事件。 调用 NavigateTo 始终调用处理程序。 |
GetUriWithQueryParameter | 返回通过更新 NavigationManager.Uri 来构造的 URI(添加、更新或删除单个参数)。 有关详细信息,请参阅查询字符串部分。 |
- 跳转
- 通过修改URI的方式进行跳转
1 2 3 4 5 6 7 8 9 10 | <button @oneclick=Goto></button> @inject NavigationManager NavigationManager @code{ private void Goto() { // 参数二:默认false,不会刷新,只是显示UI // true,会刷新UI,尽量少用,UI刷新多了,消耗性能 NavigationManager.NavigateTo( "/example/aidatd" , false ); } } |
- 拼接参数
1 2 3 | // 拼接参数 // /id?123 NavigationManager.GetUriWithQueryParameter( "id" , "123" ); |
配置
官方介绍:ASP.NET Core Blazor 配置 | Microsoft Learn
依赖注入
官方介绍:ASP.NET Core Blazor 依赖关系注入 | Microsoft Learn
基于ASP.NET Core中的依赖注入实现的
服务 | 使用的生存期 | 描述 |
HttpClient | 范围内 |
提供用于发送 HTTP 请求以及从 URI 标识的资源接收 HTTP 响应的方法。 Blazor WebAssembly 应用中 HttpClient 的实例由应用在 默认情况下,Blazor Server 应用不包含配置为服务的 HttpClient。 向 Blazor Server 应用提供 HttpClient。 HttpClient 注册为作用域服务,而不是单一实例。 |
IJSRuntime |
Blazor WebAssembly (运行到浏览器):单例 Blazor Server (运行到服务器):作用域 Blazor 框架在应用的服务容器中注册 IJSRuntime。 |
表示在其中调度 JavaScript 调用的 JavaScript 运行时实例。
试图在 Blazor Server 应用中将服务注入到单一实例服务时,请采用以下任一方法:
|
NavigationManager | Blazor WebAssembly(运行到浏览器) :单例
Blazor Server (运行到服务器):作用域 Blazor 框架在应用的服务容器中注册 NavigationManager。 |
包含用于处理 URI 和导航状态的帮助程序。 |
- 不能通过构造函数方式注入,只能通过@Inject注入对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // 创建对象类 public class DemoService { public string Demo { get ; set ; } } // 注入到服务 builder.Services.AddSingleton<DemoService>(); // 使用 @inject DemoService DemoService @code{ [Parameter] public string ? PageRoute{ get ; set ; } private void Show() { PageRoute = DemoService.Demo; DemoService.Demo = "调试" ; } } |
异常处理
官方介绍:处理 ASP.NET Core Blazor 应用中的错误 | Microsoft Learn
要定义错误边界,请使用 ErrorBoundary 组件来包装现有内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // ref:绑定组件 <ErrorBoundary @ ref = "errorBoundary" > <ChildContent> <button @onclick=Show></button> </ChildContent> <ErrorContent> <p>Nothing to see here right now, Sorry!</p> </ErrorContent> </ErrorBoundary> @code{ private ErrorBoundary? errorBoundary; private void Show() { errorBoundary.ErrorContent = builder => @<h1>异常页面</h1>; } } |
组件
- 组件类
- 组件的名称必须以大写字符开头
- .razor文件都可以称之为组件。
- 严格来说,继承自ComponentBase的都是组件
- 如果在组件中加了路由,它就是一个页面。
- 布局
- 组件继承自 LayoutComponentBase。 LayoutComponentBase 为布局内呈现的内容定义 Body 属性(RenderFragment 类型)。
@Body
在布局标记中指定呈现内容的位置。- MainLayout组件:应用的默认组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | // 创建组件AppBar @inject NavigationManager NavigationManager <button onclick= "Goto('/')" >首页</button> <button onclick= "Goto('/function')" >功能</button> @code { private void Goto( string href) { NavigationManager.NavigateTo(href); } } // 创建页面home @page "/home" <h1>home</h1> // 创建页面function @page "/function" <h1>function</h1> // 在MainLayout使用组件 <div class = "sidebar" > <AppBar/> </div> |
- 组件间传参
- 官方介绍:ASP.NET Core Blazor 级联值和参数 | Microsoft Learn
- 在使用Blazor时,避免不了要进行组件间通信,组件间的通信大致上有以下几种:
- 父、子组件间通信;
- 多级组件组件通信,例如祖、孙节点间通信;
- 非嵌套组件间通信。
- 父与子的通讯
- 父与子通信是最为简单的,直接通过数据绑定即可实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // 子组件 <div class = "bg-white p-3" style= "color: #000;" > <h3>Son</h3> <p>parent: @Value</p> </div> @code { [Parameter] public string Value { get ; set ; } } // 父组件 <div class = "bg-primary jumbotron text-white" > <h3>Parent</h3> <Son Value= "I'm from Parent1" ></Son> </div> |
-
- 子与父的通讯
- 子与父通信是通过回调事件实现的,通过将事件函数传递到子组件,子组件在数据发生变化时,调用该回调函数即可。
- 为Son组件基础上添加一个事件OnValueChanged,并在数据Value发生变化时执行该事件,通知父组件新数据。
- 子与父的通讯
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | // 子组件 <div class = "bg-white p-3" style= "color: #000;" > <h3>Son</h3> <button @onclick= "ChangeValue" >ChangeValue</button> <p>parent: @Value</p> </div> @code { [Parameter] public string Value { get ; set ; } [Parameter] public EventCallback< string > OnValueChanged { get ; set ; } private async Task ChangeValue() { string newValue = DateTime.Now.ToString( "o" ); if (OnValueChanged.HasDelegate) { await OnValueChanged.InvokeAsync(newValue); } } } // 父组件 <div class = "bg-primary jumbotron text-white" > <h3>Parent</h3> <p>@_value</p> <Son Value= "@_value" OnValueChanged= "@OnValueChanged" ></Son> </div> @code{ private string _value = "I'm from Parent2" ; private void OnValueChanged( string val) { _value = val; } } |
-
- 多级组件通讯(组、孙组件通讯)
- 正常是通过创建父节点中转实现祖-父-孙通信
- 但是当跨越多个层级的时候就比较麻烦,好在Blazor提供了“Cascading values and parameters”,中文翻译为级联值和参数。
- 级联值和参数是通过CascadingValue组件和CascadingParameter属性注解实现的。
- 祖先组件使用 Blazor 框架的 CascadingValue 组件提供级联值,该组件包装组件层次结构的子树,并向其子树中的所有组件提供单个值。
- 为了使用级联值,后代组件使用
[CascadingParameter]
特性来声明级联参数。 级联值按类型绑定到级联参数。
- 祖与孙的通讯
- CascadingParameter所声明的属性可以是private
- CascadingValue和CascadingParameter可以不指定Name,这时将会通过类型进行匹配。
- 多级组件通讯(组、孙组件通讯)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | // 孙组件 <div class = "bg-white p-3" style= "color: #000;" > <h3>Son</h3> <p>GrandValue: @GrandValue</p> </div> @code { /// <summary> /// Name参数必须与Name带有CascadingValue组件的属性匹配,如果我们没有注明Name,则会通过类型匹配一个最相似的属性 /// </summary> [CascadingParameter(Name = "GrandValue" )] string GrandValue { get ; set ; } } // 父组件(中间组件) <div class = "bg-primary jumbotron text-white" > <h3>Parent</h3> <Son></Son> </div> // 祖组件 <h3>Grand</h3> <p>GrandValue:@_grandValue</p> <CascadingValue Value= "@_grandValue" Name= "GrandValue" > <Parent/> </CascadingValue> @code { private string _grandValue = "GrandValue" ; } |
-
- 祖与孙的通讯多个参数
- 嵌套使用CascadingValue
- CascadingValue组件运行嵌套使用,可以在祖组件中嵌套CascadingValue,而孙组件中则只需要将所有的来自祖组件的参数使用CascadingParameter进行声明即可。需要注意的是,如果指定Name,请确保每个Name都是唯一的。
- 祖与孙的通讯多个参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | // 孙组件 <div class = "bg-white p-3" style= "color: #000;" > <h3>Son</h3> <p>GrandValue1: @GrandValue1</p> <p>GrandValue2: @GrandValue2</p> </div> @code { /// <summary> /// Name参数必须与Name带有CascadingValue组件的属性匹配,如果我们没有注明Name,则会通过类型匹配一个最相似的属性 /// </summary> [CascadingParameter(Name = "GrandValue1" )] string GrandValue1 { get ; set ; } [CascadingParameter(Name = "GrandValue2" )] string GrandValue2 { get ; set ; } } // 父组件(中间组件) <div class = "bg-primary jumbotron text-white" > <h3>Parent</h3> <Son></Son> </div> // 祖组件 <h3>Grand</h3> <p>GrandValue1:@_grandValue1</p> <p>GrandValue2:@_grandValue2</p> <CascadingValue Value= "@_grandValue1" Name= "GrandValue1" > <CascadingValue Value= "@_grandValue2" Name= "GrandValue2" > <Parent/> </CascadingValue> </CascadingValue> @code { private string _grandValue1 = "GrandValue1" ; private string _grandValue2 = "GrandValue2" ; } |
-
-
- 也可以使用Model类(不细讲)。CascadingValue可以是class,因此可以将所有的需要传递的参数使用一个class进行封装,然后传递到孙组件,孙组件使用同类型的class接收该参数即可。
- 孙与祖通讯
- 孙与祖通信与子与父通信一样,需要使用事件进行回调,这个回调方法也是一个参数,因此只需要将该回调也通过CascadingValue传递到孙组件中,当孙组件数据发生变化时调用该回调函数即可。
-
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | // 回调的封装 public class CascadingModel<T> { public CascadingModel() { } public CascadingModel(T defaultValue) { _value = defaultValue; } public Action StateHasChanged; private T _value; public T Value { get => _value; set { _value = value; StateHasChanged?.Invoke(); } } } // 孙组件 <div class = "bg-white p-3" style= "color: #000;" > <h3>Son</h3> <p>GrandValueModel-GrandValue: @CascadingModel.Value</p> <button @onclick= "ChangGrandValue" >Chang GrandValue</button> </div> @code { [CascadingParameter(Name = "GrandValue" )] CascadingModel< string > CascadingModel { get ; set ; } void ChangGrandValue() { CascadingModel.Value = "I'm Form self:" + DateTime.Now.ToString( "HH:mm:ss" ); } } // 父组件 <div class = "bg-primary jumbotron text-white" > <h3>Parent</h3> <Son></Son> </div> // 祖组件 <h3>Grand</h3> <p>GrandValue:@_cascadingModel.Value</p> <CascadingValue Value= "@_cascadingModel" Name= "GrandValue" > <Parent/> </CascadingValue> @code { private CascadingModel< string > _cascadingModel = new CascadingModel< string >( "GrandValue" ); protected override void OnInitialized() { _cascadingModel.StateHasChanged += StateHasChanged; base .OnInitialized(); } private void ChangeGrandValue() { _cascadingModel.Value = DateTime.Now.ToString( "o" ); } } |
-
- 非嵌套组件间通信
- 非嵌套组件也就是说在渲染树中,任一组件无法向上或向下寻找到另外一个组件,例如兄弟组件、叔父组件等。
- 最好的方式是使用服务注入。您可以创建一个服务,其中包含您要共享的状态或功能,并将其注入到需要访问该状态或功能的每个组件中。
- 非嵌套组件间通信
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | // 公共服务类 public class MyService { public event EventHandler< string > DataChanged; private string _data; public string Data { get => _data; set { if (_data != value) { _data = value; DataChanged?.Invoke( this , value); } } } } // 在 Startup.cs 中将该服务注册为单例服务。 services.AddSingleton<MyService>(); // 在需要访问该服务的组件中注入该服务。 @inject MyService MyServiceInstance // 在需要使用服务中的数据的组件中订阅 DataChanged 事件。 protected override void OnInitialized() { MyServiceInstance.DataChanged += OnDataChanged; } private void OnDataChanged( object sender, string data) { // 更新组件中的数据 StateHasChanged(); } public void Dispose() { MyServiceInstance.DataChanged -= OnDataChanged; } // 在修改服务中的数据的组件中更新数据。 MyServiceInstance.Data = "New data" ; |
数据绑定
官方介绍:ASP.NET Core Blazor 数据绑定 | Microsoft Learn
- Blazor中Razor组件通过一个名为@bind的HTML元素属性提供数据绑定(双向绑定)功能,数据绑定的对象可以为字段、属性或表达式值。
- @bind默认绑定的是元素的onchange事件,通过在组件中添加一个元素p可以看出效果,每当input离开focus或者回车时,p中的值才会更新
- 相应input的oninput事件,@bind通过指定event参数来在指定绑定属性或字段响应的事件,
- 如果要绑定字符串对象,需要在对象前@,其他对象可以不用加。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // 使用绑定 // 失去焦点时才触发 <div> <input @bind= "value1" /> </div> // 使用onchange方法 // 失去焦点时才触发 <div> <input value= "@value2" @onchange= "(e)=>{value2=e.Value.ToString();}" /> </div> // 事件绑定 // 值变化就触发 <div> <input @bind= "value3" @bind: event = "oninput" /> </div> <div style= "background-color: beige" > <h2>@value1</h2> <h2>@value2</h2> <h3>@value3</h3> </div> @code{ private string ? value1; private string ? value2; private string ? value3; } |
- 格式化字符串
- 数据绑定通过
@bind:format="{FORMAT STRING}"
使用单个 DateTime 格式字符串,其中{FORMAT STRING}
占位符是格式字符串。
- 数据绑定通过
1 2 3 4 5 6 7 8 | <p> <code>yyyy-MM-dd</code> format: <input @bind= "startDate" @bind:format= "yyyy-MM-dd" /> </p> @code{ private DateTime startDate = new DateTime(2020, 1, 1); } |
- 组件间的事件绑定
- 父组件到子组件
- 通过使用@bind-{PROPERTY}的形式,可以将属性值从父组件向下绑定到子组件,其中PROPERTY必须为组件参数,
- EventCallback<TValue> 必须以组件参数名称命名,并带有“
Changed
”后缀。 - EventCallback.InvokeAsync 调用与提供的参数进行绑定相关联的委托,并为已更改的属性调度事件通知。
- 父组件到子组件
1 2 3 4 5 6 7 8 9 10 11 12 13 | // 子组件 <input @bind= "DateTime" @bind:format= "yyyy-MM-dd HH:mm:ss" /> <p>@DateTime.ToString( "o" )</p> [Parameter] public DateTime DateTime { get ; set ; } = DateTime.Now; [Parameter] public EventCallback<DateTime> DateTimeChanged { get ; set ; } // 父组件中使用 <Son @bind-DateTime= "_dateTime" /> @code{ private DateTime _dateTime; } |
-
- 子组件到父组件
- 子组件向父组件传递数据,则必须使用相应的回调事件。
- 子组件到父组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | // 子组件 <input @bind= "DateTime" @bind:format= "yyyy-MM-dd HH:mm:ss" @oninput= "ChangeParentValue" /> <p>@DateTime.ToString( "o" )</p> @code{ [Parameter] public DateTime DateTime { get ; set ; } = DateTime.Now; /// <summary> /// 使用bind赋值,必须要有此事件:{property}Changed /// </summary> [Parameter] public EventCallback<DateTime> DateTimeChanged { get ; set ; } private async Task ChangeParentValue(ChangeEventArgs e) { var dateStr = e.Value.ToString(); await DateTimeChanged.InvokeAsync(DateTime.Parse(dateStr)); } } // 父组件 <div> <Son ChildEvents= "(e)=>MyEvent(e)" /> <p>@_Value</p> </div> @code{ private string _Value; private void MyEvent( object Value) { Console.WriteLine($ "Got the ChangeParentValue({Value})" ); _Value = Value.ToString(); } } |
组件生命周期
官方介绍:ASP.NET Core Razor 组件生命周期 | Microsoft Learn
- 生命周期是由于ComponentBase基类带来的
- 分为三个阶段:初始化阶段、运行中阶段和销毁阶段,其相关方法有10个,包括设置参数前、初始化、设置参数之后、组件渲染后以及组件的销毁,但是这些方法有些是重复的,只不过是同步与异步的区别。
- Blazor生命周期方法主要包括:
- 分为两个阶段
- 阶段一:只会执行一次
- 分为两个阶段
Js的调用必须在OnParametersSet完成的Render下面
-
-
- 阶段二:界面只要被再一次渲染,就会执行
-
1 | SetParametersAsync | 设置参数前(第一次触发) | 传入参数ParameterView,这个类主要存储一个字典。用来保存键值对。用来存储标记了ParameterAttribute特性的属性和值 |
2 | OnInitialized/OnInitializedAsync | 初始化 |
OnInitializedAsync可能会导致页面两次刷新。OnInitialized不会。 异步的时候,代码块中获取数据,数据还没有获取到的时候,不能直接将对象赋值到界面中去 |
3 | OnParametersSet/OnParametersSetAsync | 设置参数后 |
OnParametersSetAsync可能会导致两次触发 render-mode模式 = ServerPrerenderd ,会触发两次。
render-mode模式 = Server,只会触发一次 render-mode模式 = Static,就不会触发OnAfterRender/OnAfterRenderAsync |
4 | OnAfterRender/OnAfterRenderAsync | 组件渲染呈现后 | |
5 | ShouldRender | 判断是否渲染组件 | |
6 | Dispose | 组件删除前 | |
7 | StateHasChanged | 通知组件渲染 |
- 输出显示
- 在所有生命周期函数中,有以下需要注意的点:
- 前5种方法的声明都是virtual,除SetParametersAsync为public外,其他的都是protected。
- OnAfterRender/OnAfterRenderAsync方法有一个bool类型的形参firstRender,用于指示是否是第一次渲染(即组件初始化时的渲染)。
- 同步方法总是先于异步方法执行。
- Dispose函数需要通过使用@implements指令实现IDisposable接口来实现。
- StateHasChanged无法被重写,可以被显示调用,以便强制实现组件刷新(如果ShouldRender返回true,并且Blazor认为需要刷新);当组件状态更改时不必显示调用此函数,也可导致组件的重新渲染(如果ShouldRender返回true),因为其已经在ComponentBase内部的处理过程(第一次初始化设置参数时、设置参数后和DOM事件处理等)中被调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | @code{ // 1.设置参数 public override Task SetParametersAsync(ParameterView parameters) { return base .SetParametersAsync(parameters); } // 2.初始化组件 protected override void OnInitialized() { base .OnInitialized(); } // 3.判断是否渲染 protected override bool ShouldRender() { // true:渲染,false:不渲染 return base .ShouldRender(); } // 4.渲染后事件 protected override void OnAfterRender( bool firstRender) { if (firstRender) { // WebAssembly 可以在Initialized中使用Js互操,但是Server不行 // Server 在Initialized的时候并没有在浏览器中,所以使用Js互操会报错 // 可以在OnAfterRender判断 firstRender是否为第一次渲染,是就在里面写初始化逻辑 } // 强制刷新 // 同步 StateHasChanged(); // 异步 InvokeAsync(StateHasChanged); base .OnAfterRender(firstRender); } // 5.销毁事件 // 继承@implements IDisposable 得到组件销毁事件 public void Dispose() { } } |
- 渲染循序
从上到下,从外到内(优先将父组件先渲染,后再渲染子组件)
1 2 3 4 5 6 7 8 9 10 | // 由于先渲染父组件,所以Foos是空的。子组件再赋值,但是此时父组件已经渲染完成 <CascadingValue Value= "Foos" IsFixed = "true" > @*子组件,并且子组件会赋值Foos*@ <Foo2Component></Foo2Component> </CascadingValue> @ foreach ( var i in Foos) { <div>Comp @i</div> } |
因此我们可以将父组件中的代码,再包一层空的子组件,使得其同一级别,从上到下渲染。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // 子组件RenderTemplate @ChildContent @code{ [Parameter] public RenderFragment? ChildContent{ get ; set ; } } // 父组件重新使用 <CascadingValue Value= "Foos" IsFixed = "true" > @*子组件,并且子组件会赋值Foos*@ <Foo2Component></Foo2Component> </CascadingValue> <RenderTemplate> // 现在大家都是子组件,渲染从上到下 @ foreach ( var i in Foos) { <div>Comp @i</div> } </RenderTemplate> |
模板化组件
官方介绍:ASP.NET Core Blazor 模板化组件 | Microsoft Learn
- 模板化组件是接受一个或多个 UI 模板作为参数的组件,然后可将其用作组件呈现逻辑的一部分。
- 通过指定一个或多个 RenderFragment 或 RenderFragment<TValue> 类型的组件参数来定义模板化组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | // 定义组件TemplatePage <h1 style= "background-color:antiquewhite" >@Tab</h1> <div style= "background-color:antiquewhite" >@Content</div> <div>@Trailer</div> @code{ // 头部 [Parameter] public RenderFragment Tab{ get ; set ; } // 内容 [Parameter] public RenderFragment Content { get ; set ; } [Parameter] // 尾部 public RenderFragment Trailer { get ; set ; } } // 使用组件 <TemplatePage> <Tab> 这是标题内容 </Tab> <Content> 这是内容。。。。。 </Content> <Trailer> 这是尾部内容 </Trailer> </TemplatePage> |
- 在使用模板化组件时,可以使用与参数名称匹配的子元素来指定模板参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | // 定义组件 TemplatePage @typeparam TItem @ using System.Diagnostics.CodeAnalysis <table class = "table" > <thead> <tr>@Tab</tr> </thead> <tbody> @ foreach ( var item in Items) { if (RowTemplate is not null ) { <tr>@RowTemplate(item)</tr> } } </tbody> </table> @code{ // 头部 [Parameter] public RenderFragment Tab{ get ; set ; } // 行模板 [Parameter] public RenderFragment<TItem> RowTemplate { get ; set ; } // 内容 [Parameter, AllowNull] public IReadOnlyList<TItem> Items { get ; set ; } } // 使用,通过Context获取RenderFragment泛型对象TItem的实例,也就是这里的pet。也可以不写Context="",直接使用@context.就行 <TemplatePage Items= "pets" Context= "pet" > <Tab> <th>ID</th> <th>Name</th> </Tab> <RowTemplate> <td>@pet.PetId</td> <td>@pet.Name</td> </RowTemplate> </TemplatePage> @code { private List<Pet> pets = new () { new Pet { PetId = 2, Name = "Mr. Bigglesworth" }, new Pet { PetId = 4, Name = "Salem Saberhagen" }, new Pet { PetId = 7, Name = "K-9" } }; private class Pet { public int PetId { get ; set ; } public string ? Name { get ; set ; } } } |
- 模板化组件时,泛型套用模板化组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | // 定义组件 TemplateComponent @ if (Template != null ) { @Template(ChildContent) } else { @ChildContent } @code { [parameter] public RenderFragment<RenderFragment>? Template { get ; set ; } [parameter] [NotNull] public RenderFragment? ChildContent { get ; set ; } } // 使用 <TemplateComponent> <Template> <div>Header</div> <div>@Context</div> <div>Footer</div> </Template> <ChildContent> <div>Body</div> </ChildContent> </TemplateComponent> |
CSS隔离
官方介绍:ASP.NET Core Blazor CSS 隔离 | Microsoft Learn
- 在相同文件夹中创建一个
.razor.css
文件,该文件与组件的.razor
文件的名称相匹配。
-
<link>
元素将添加到从 Blazor 项目模板创建的应用
1 | <link href= "CssRazor.css" rel= "stylesheet" /> |
- 使用
1 | <Home></Home> |
动态呈现组件
- DynamicComponent 适用于呈现组件,而无需循环访问可能的类型或使用条件逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // 创建组件DataInfo <h3>DataInfo</h3> // 创建组件Home <h3>Home</h3> // 使用 @ using CssRazor <button @onclick= "Home" >Home</button> <button @onclick= "DataInfo" >DataInfo</button> <div> <DynamicComponent Type= "ComponentType" ></DynamicComponent> </div> @code{ private Type ComponentType = typeof (Home); private void Home() { ComponentType = typeof (Home); } private void DataInfo() { ComponentType = typeof (DataInfo); } } |
- 动态渲染并传递参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | // 创建组件DataInfo <h3>DataInfo:@Title</h3> @code { [Parameter] public string Title { get ; set ; } } // 创建组件Home <h3>Home:@HomeData</h3> @code { [Parameter] public string HomeData { get ; set ; } } // 使用 @ using CssRazor <button @onclick= "Home" >Home</button> <button @onclick= "DataInfo" >DataInfo</button> <div> <DynamicComponent Type= "ComponentType" Parameters= "Parameters" ></DynamicComponent> </div> @code{ private Type ComponentType = typeof (Home); public Dictionary< string , object > Parameters{ get ; set ; } private void Home() { Parameters = new Dictionary< string , object >(); Parameters.Add( "HomeData" , "HomeData123" ); ComponentType = typeof (Home); } private void DataInfo() { Parameters = new Dictionary< string , object >(); Parameters.Add( "Title" , "Title123" ); ComponentType = typeof (DataInfo); } } |
RenderFragment
Blazor RenderFragment 是一种特殊的委托,用于将 HTML 和组件呈现到页面上。RenderFragment 可以看作是一个代表可呈现内容的函数,它在组件渲染时被调用,并将可呈现内容作为参数传递给该函数。在 Blazor 组件中,RenderFragment 通常用于将子组件或其他可呈现内容呈现到页面上。
- RenderFragment 具有如下特点:
- RenderFragment 是一个委托类型,它接受一个参数,这个参数是一个 RenderTreeBuilder 对象。RenderTreeBuilder 是 Blazor 内部使用的一种用于构建渲染树的对象。
- 在 RenderFragment 中,可以使用 RenderTreeBuilder 对象来构建渲染树,以实现组件的呈现。
- RenderFragment 可以用于将组件作为子组件传递给其他组件。这是 Blazor 中组件间通信的一种方式,允许开发者在组件中嵌套其他组件,以实现更加复杂的界面。
- RenderFragment 还可以用于动态生成组件的呈现内容,例如根据数据源动态生成列表或表格等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | // 创建Home组件 @ using Microsoft.AspNetCore.Components.RenderTree @ using Microsoft.AspNetCore.Components.Rendering <h3>Home</h3> @ChildContent @code { [Parameter] public RenderFragment ChildContent{ get ; set ; } protected override void OnInitialized() { base .OnInitialized(); var builder = new RenderTreeBuilder(); builder.AddContent(0, this .ChildContent); var frame = builder.GetFrames().Array.FirstOrDefault(x => new [] { RenderTreeFrameType.Text, RenderTreeFrameType.Component }.Any(t => x.FrameType == t)); } } // 使用Home组件 <Home> <Counter/> </Home> |
模式(render-mode)
- 有两种渲染模式:
- 服务器预渲染(ServerPrerendered):将组件呈现静态html,并包含blazor服务器端应用程序的标记。当用户代理启动时,它使用该标记来引导Blazor应用程序。
- 在没渲染之前,localStorage和sessionStorage不可用
- 在刷新页面的时候,自定义身份验证状态提供程序类的“GetAuthenticationStateAsync”中会收到错误。因为应用程序无法处理将用于访问会话IJSRuntime
- 服务器(Server):为blazor服务器应用程序呈现标记。当用户代理启动时,它使用此标记来引导Blazor应用程序。
- Server用来解禁ServerPrerenderd
- 服务器预渲染(ServerPrerendered):将组件呈现静态html,并包含blazor服务器端应用程序的标记。当用户代理启动时,它使用该标记来引导Blazor应用程序。
Blazor Server 身份验证和授权
官方说明:ASP.NET Core Blazor 身份验证和授权 | Microsoft Learn
- 自定义CustomAuthenticationStateProvider
- Blazor Server 应用内置的 AuthenticationStateProvider (提供有关当前用户的身份验证状态的信息。)服务可从 ASP.NET Core 的
HttpContext.User
获取身份验证状态数据。 - 请勿在 Blazor Server 应用的 Razor 组件中直接或间接使用 IHttpContextAccessor/HttpContext。
- AuthenticationStateProvider 是 AuthorizeView 组件和 CascadingAuthenticationState 组件用于获取用户身份验证状态的基础服务。
- Blazor Server 应用内置的 AuthenticationStateProvider (提供有关当前用户的身份验证状态的信息。)服务可从 ASP.NET Core 的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | // 创建验证对象 public class UserSession { public string UserName { get ; set ;} public string Role { get ; set ;} } // 自定义身份认证 // 获取身份状态 public class CustomAuthenticationStateProvider : AuthenticationStateProvider { // Storage,用来村粗保护登陆用户的信息 private readonly ProtectedSessionStorage _sessionStorage; private ClaimsPrincipal _principal = new ClaimsPrincipal( new ClaimsIdentity()); public CustomAuthenticationStateProvider(ProtectedSessionStorage sessionStorage) { _sessionStorage = sessionStorage; } // 获取Authentication关联的List<ClaimsPrincipal> public override async Task<AuthenticationState> GetAuthenticationStateAsync() { try { // "UserSession": Storage的令牌的key var userSessionStorageResult = await _sessionStorage.GetAsync<UserSession>( "UserSession" ); var userSession = userSessionStorageResult.Success ? userSessionStorageResult.Value : null ; if (userSession == null ) { return await Task.FromResult( new AuthenticationState(_principal)); } var claimsPrincipal = new ClaimsPrincipal( new ClaimsIdentity( new List<Claim> { new Claim(ClaimTypes.Name, userSession.UserName), new Claim(ClaimTypes.Role, userSession.Role) }, "CustomAuth" )); return await Task.FromResult( new AuthenticationState(claimsPrincipal)); } catch { return await Task.FromResult( new AuthenticationState(_principal)); } } // 更新UserSession到Storage中 // 有值就更新,没值就清空 public async Task UpdateAuthenticationState(UserSession userSession) { ClaimsPrincipal claimsPrincipal; if (userSession != null ) { await _sessionStorage.SetAsync( "UserSession" , userSession); claimsPrincipal = new ClaimsPrincipal( new ClaimsIdentity( new List<Claim> { new Claim(ClaimTypes.Name, userSession.UserName), new Claim(ClaimTypes.Role, userSession.Role) })); } else { await _sessionStorage.DeleteAsync( "UserSession" ); claimsPrincipal = _principal; } // 将身份验证和声明一起传递到 NotifyAuthenticationStateChanged(Task.FromResult( new AuthenticationState(claimsPrincipal))); } } |
- 注入Authentication服务
1 2 3 4 5 6 | // 添加Storage服务 builder.Services.AddScoped<ProtectedSessionStorage>(); // 添加Authentication服务 builder.Services.AddAuthenticationCore(); // 添加自定义Authentication服务 builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>(); |
- 添加数据对象(源)以及注入数据服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | // 登陆实体对象 public class UserAccount { public string UserName { get ; set ; } public string Password { get ; set ; } public string Role { get ; set ; } } // Dto public class UserAccountDto { public string UserName { get ; set ; } public string Password { get ; set ; } } // 服务(数据源) public class UserAccountService { private List<UserAccount> _users; public UserAccountService() { _users = new List<UserAccount> { new UserAccount{UserName= "admin" ,Password= "admin" ,Role= "Administrator" }, new UserAccount{UserName= "user" ,Password= "user" ,Role= "user" }, }; } public UserAccount? GetByUserName( string userName) { return _users.FirstOrDefault(x=>x.UserName == userName); } } // 添加Server服务 builder.Services.AddSingleton<UserAccountService>(); |
- 资源授权
- 若要授权用户访问资源,请将请求的路由数据传递到 AuthorizeRouteView 的 Resource 参数。
- 需将将App组将(App.razor)中请求的路由组件改为<AuthorizeRouteView>
- 使用
App
组件中的 AuthorizeRouteView 和 CascadingAuthenticationState 组件来设置Task<
AuthenticationState>
级联参数。 - <NotAuthorized>组件表示权限认证失败
- <NotAuthorizing>组件表示权限认证中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | // ---------- Index.razor ---------- <CascadingAuthenticationState> <Router AppAssembly= "@typeof(App).Assembly" > <Found Context= "routeData" > <AuthorizeRouteView RouteData= "@routeData" DefaultLayout= "@typeof(MainLayout)" > <NotAuthorized> You are not authorized(This is a custom message) </NotAuthorized> <Authorizing> You are getting authorized ( This is a custom message) </Authorizing> </AuthorizeRouteView> <FocusOnNavigate RouteData= "@routeData" Selector= "h1" /> </Found> <NotFound> <PageTitle>Not found</PageTitle> <LayoutView Layout= "@typeof(MainLayout)" > <p role= "alert" >Sorry, there's nothing at this address.</p> </LayoutView> </NotFound> </Router> </CascadingAuthenticationState> |
- 获取权限并公开身份验证状态作为级联参数
- 定义类型为
Task<
AuthenticationState>
的级联参数来获取身份验证状态数据 - 使用
App
组件中的 AuthorizeRouteView 和 CascadingAuthenticationState 组件来设置Task<
AuthenticationState>
级联参数。 - AuthorizeView 组件公开了一个 AuthenticationState 类型的
context
变量(Razor 语法中的@context
),可以使用该变量来访问有关已登录用户的信息 - <Authorized>组件表示权限认证成功。常用于异步身份验证期间显示
- <NotAuthorized>组件表示权限认证失败
- 定义类型为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | // ---------- Index.razor ---------- @page "/" <AuthorizeView> <Authorized> <h1>Hello,@context.User.Identity.Name</h1> </Authorized> <NotAuthorized> <h1>Hello,Guest!</h1> </NotAuthorized> </AuthorizeView> <AuthorizeView> <Authorized> <br /><br /> <button class = "btn btn-outline-primary" @onclick= "DisplayGreetingAlert" >Display Greeting Alter</button> </Authorized> </AuthorizeView> @inject IJSRuntime js; @code{ [CascadingParameter] private Task<AuthenticationState> authenticationState{ get ; set ; } private async Task DisplayGreetingAlert() { var authState = await authenticationState; var message = $ "Hello {authState.User.Identity.Name}" ; await js.InvokeVoidAsync( "alter" , message); } } |
- 登出并撤销授权
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | // ---------- MainLayout.razor ---------- @inherits LayoutComponentBase <PageTitle>BlazorServerAuthenticationAndAuthorization</PageTitle> <div class = "page" > <div class = "sidebar" > <NavMenu /> </div> <main> <div class = "top-row px-4" > <a href= "https://docs.microsoft.com/aspnet/" target= "_blank" >About</a> <AuthorizeView> <Authorized> <a @onclick= "Logout" href= "javascript:void(0)" >Logout</a> </Authorized> <NotAuthorized> <a href= "/login" >Login</a> </NotAuthorized> </AuthorizeView> </div> <article class = "content px-4" > @Body </article> </main> </div> @ using BlazorServerAuthenticationAndAuthorization.Authentication; @inject AuthenticationStateProvider authStateProvider; @inject NavigationManager navManager @code { private async Task Logout() { var customAuthStateProvider = (CustomAuthenticationStateProvider)authStateProvider; await customAuthStateProvider.UpdateAuthenticationState( null ); navManager.NavigateTo( "/" , true ); } } |
- 基于角色和基于策略的授权
- AuthorizeView 组件支持基于角色或基于策略的授权 。对于基于角色的授权,请使用 Roles 参数。常用于NavMenu上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | // ---------- NavMenu.razor ---------- <div class = "top-row ps-3 navbar navbar-dark" > <div class = "container-fluid" > <a class = "navbar-brand" href= "" >BlazorServerAuthenticationAndAuthorization</a> <button title= "Navigation menu" class = "navbar-toggler" @onclick= "ToggleNavMenu" > <span class = "navbar-toggler-icon" ></span> </button> </div> </div> <div class = "@NavMenuCssClass nav-scrollable" @onclick= "ToggleNavMenu" > <nav class = "flex-column" > <div class = "nav-item px-3" > <NavLink class = "nav-link" href= "" Match= "NavLinkMatch.All" > <span class = "oi oi-home" aria-hidden= "true" ></span> Home </NavLink> </div> <AuthorizeView Roles= "Administrator,User" > <Authorized> <div class = "nav-item px-3" > <NavLink class = "nav-link" href= "counter" > <span class = "oi oi-plus" aria-hidden= "true" ></span> Counter </NavLink> </div> </Authorized> </AuthorizeView> <AuthorizeView Roles= "Administrator" > <Authorized> <div class = "nav-item px-3" > <NavLink class = "nav-link" href= "fetchdata" > <span class = "oi oi-list-rich" aria-hidden= "true" ></span> Fetch data </NavLink> </div> </Authorized> </AuthorizeView> </nav> </div> @code { private bool collapseNavMenu = true ; private string ? NavMenuCssClass => collapseNavMenu ? "collapse" : null ; private void ToggleNavMenu() { collapseNavMenu = !collapseNavMenu; } } |
[Authorize]
特性[Authorize]
特性在 Razor 组件中可用:常用于界面上- 仅对通过 Blazor 路由器到达的
@page
组件使用[Authorize]
。 授权仅作为路由的一个方面执行,而不是作为页面中呈现的子组件来执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | // ---------- FetchData.razor ---------- @page "/fetchdata" @ using BlazorServerAuthenticationAndAuthorization.Data @inject WeatherForecastService ForecastService @*允许管理员授权属性:锁整个界面*@ @attribute [Authorize(Roles = "Administrator" )] @ if (forecasts == null ) { <p><em>Loading...</em></p> } else { <table class = "table" > <thead> <tr> <th>Date</th> <th>Temp. (C)</th> <th>Temp. (F)</th> <th>Summary</th> </tr> </thead> <tbody> @ foreach ( var forecast in forecasts) { <tr> <td>@forecast.Date.ToShortDateString()</td> <td>@forecast.TemperatureC</td> <td>@forecast.TemperatureF</td> <td>@forecast.Summary</td> </tr> } </tbody> </table> } @code { private WeatherForecast[]? forecasts; protected override async Task OnInitializedAsync() { forecasts = await ForecastService.GetForecastAsync(DateOnly.FromDateTime(DateTime.Now)); } } |
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战