Blazor和Vue对比学习(小知识点-5):Blazor中C#和JS互操作(知识点有点大,超长文)
C#和JS互操作的基本语法是比较简单的,但小知识点特别多,同时,受应用加载顺序、组件生命周期以及参数类型的影响,会有比较多坑,需要耐心的学习。在C#中调用JS的场景会比较多,特别是在WASM模式下,由于WebAssembly的限制,很多时候,还是需要借助JS去控制DOM或BOM,比如WebStorage、WebGL、MediaCapture,还比如使用JS的图表库echart.js。反过来,在JS中调用C#的场景,就比较少见。所以,此章节关于"C#中调用JS” 的篇幅会多一些。
这个章节比较长,目录如下:
一、C#和JS互操作涉及到的类和接口
二、HTML/JS和Blazor/C#的加载顺序
三、在C#中调用JS
1、使用步骤
2、继续举些例子来熟悉InvokeVoidAsync和InvokeAsync<T>的使用
3、InvokeVoidAsync和InvokeAsync<T>方法的参数:引用类型、Blazor组织树节点引用、流式数据
四、在JS中调用C#
1、互操作的发起端
2、调用C#的静态方法
3、调用C#的实例方法及使用实例引用的注意事项
五、其它知识点
1、调用JS的异常处理、超时处理、中止执行
2、调用JS的模块化module方式,引用一个JS库
3、WASM模式下同步调用JS函数
4、Server模式下传输数据的大小限制
5、不要循环引用
一、C#和JS互操作涉及到的类、接口和方法
ASP.NET Core框架,在Microsoft.JSInterop命名空间下,定义了将近20个类和接口,用于C#和JS的互操作。虽然比较繁杂,但大体上可以分为:
1、C#调JS:JSRuntime / JSRuntimeExtensions / IJSRuntime,这是核心的几个类,用于在C#中异步调用JS,最常用。相对应,有一套同步操作类,仅限于WASM模式,比较少用
2、在C#中使用JS实例,JSObjectReference / JSObjectReferenceExtensions / IJSObjectReference
3、JS调C#:为C#方法进行特性标注的JSInvokableAttribute,如果是C#的静态方法,就可以直接在JS中调用
4、在JS中使用C#实例,DotNetObjectReference / DotNetObjectReference<T>,JS调用C#的实例方法时,需要将组件实例传入JS
5、其它:处理异常的JSException/JSDisconnectedException、参数中传输流式数据的DotNetStreamReference
二、JS和Blazor的加载顺序
1、HTML/JS和Blazor组件加载顺序,受预渲染、Blazor组件生命周期的等影响,是坑最多的地方,需要特别注意。如下图所示,注意4个坑的说明:
补充说明:
(1){NAME}.lib.module.js,称为JS初始值。其中NAME为项目的名称,要遵循命名约定。JS初始值主要有两个回调函数可以使用,如下图所示,可以看一下,在Server和WASM模式下,回调什么时候执行,以及框架调用时,传入的参数options、extensions和blazor分别是什么?
(2)Server模式下,修改预渲染的方式。修改文档【Pages/_Host.cshtml】的以下代码:
<component type="typeof(App)" render-mode="ServerPrerendered" /> //开启服务端预渲染
<component type="typeof(App)" render-mode="Server"/> //关闭服务端预渲染
2、如何引用JS代码
(1)添加JS的位置,Pages/_Layout.cshtml(Server模式);wwwroot/index.html(WASM模式)
(2)引用JS代码方式主要有两种,一是直接在script标签里写代码,二是以JS文件的方式,推荐JS文件方式,和HTML中使用JS差不多,此处不详述
(3)引用JS的具体位置也有很多,推荐在body标签里,【blazor.server|webassembly.js】之后。如下所示(前缀波浪号~指WEB根目录):
(4)JS代码组织方式(以下为推荐方式):
- 在web根文件夹下新建js文件夹,所有应用级的JS文件都放在这个文件夹下,引用位置为“~/js/***.js”
- 组件级的JS文件(特定组件专用),在组件同目录下创建同名的JS文件,如Index.razor-->Index.razor.js,VisualStudie会自动将JS文件组织到同名的razor文件下
- JS函数建议类似命名空间的组织方式,可以有效防止变量名的全局污染,如:var MyApp = MyApp||{}; MyApp.method1=function(){}; MyApp.methond2=function(){};
- 如果不想引用多个JS文件,也可以将应用级和组件级的JS写到一个文件里。JS代码的组织方式,还有一种是模块化,也是为了防止变量名的全局污染,但易用性较差,不推荐。在Blazor中使用JS函数,遵守的原则还是:能不用,就不用。如果一个项目中,定义的JS函数超级多,除非是开发组件库,否则需要审视一下设计思路。
三、在C#中调用JS
1、使用步骤:C#调用JS,主要使用JSRuntime类,我们面向IJSRuntime接口,以依赖注入的方式来创建JSRuntime对象,并使用这个对象提供的两个主要方法:InvokeVoidAsync和InvokeAsync<T>,前者无返回值,后者有返回值,在泛型T中定义具体的返回值类型。使用非常简单,三步走:
(1)第一步:WEB根目录下,创建JS:
在www/js文件夹下,新建MyApp.js,增加一个simpleSum函数。本例创建一个应用级的JS函数。
var MyApp = MyApp || {};
MyApp.simpleSum = function (a, b) { return a + b; }
(2)第二步:HTML入口页面中,引用JS:
在_Layout.cshtml(Server模式),或index.html(WASM模式)中,<body>标签中引入JS文件
<body>......
<script>_framework/blazor.server.js</script>
<script>~/js/MyApp.js</script>
</body>
(3)第三步:Blazor组件中,调用JS:
在需要使用这个JS函数的Blazor组件中调用,
- 注入IJSRuntime对象:@inject IJSRuntime JS
- 在视图层,定义一件按钮事件来触发“调用JS”的C#方法:<button @click="InvokeJSMethod"></button>
- 在逻辑层定义“调用JS”的C#方法,在方法调用JS:int a = await JS.InvokeAsync<int>("MyApp.simpleSum",2,3)
//MyApp.js var MyApp = MyApp || {}; MyApp.simpleSum = function (a, b) { return a + b; }; //_Layout.cshtml <body> ...... <script src="_framework/blazor.server.js"></script> <script src="~/js/MyApp.js"></script> <script src="../Pages/Index.razor.js"></script> ...... </body> //Index.razor @page "/" @inject IJSRuntime JS <h1>JS返回结果1:@result1</h1> <button @onclick="invokeSimpleSum">调用JS函数simpleSum</button> @code{ private int result1; private async Task invokeSimpleSum() { result1 = await JS.InvokeAsync<int>("MyApp.simpleSum", 1, 2); } }
2、继续举些例子来熟悉InvokeVoidAsync和InvokeAsync<T>的使用(多个案例来自Blazor University)
1)直接在C#中调用JS的Alert,这个函数是JS本身就有的(属于window对象的API),所以我们不需要再定义JS,直接在Blazor中使用。
//Index.razor @page "/" @inject IJSRuntime JS <button @onclick="JSAlert">弹窗</button> @code{ private async Task JSAlert() { await JS.InvokeVoidAsync("alert", "这是弹窗警告信息"); } }
2)继续来调用一个有返回值的JS的confirm对话框,依然用的是JS内置的函数,不需要定义JS
//Index.razor @page "/" @inject IJSRuntime JS <h1>确定结果为:@result</h1> <button @onclick="JSConfirm">弹窗确认</button> @code{ private string? result; private async Task JSConfirm() { bool confirm = await JS.InvokeAsync<bool>("confirm", "您确定吗?"); result = confirm ? "是" : "否"; } }
3、InvokeVoidAsync和InvokeAsync<T>方法的参数
1)Invoke方法有几个重载,如下(无返回值和有返回值可以互换):
- InvokeAsync<TValue>(IJSRuntime, String, Object[])
- InvokeAsync<TValue>(IJSRuntime, String, TimeSpan, Object[])
- InvokeVoidAsync(IJSRuntime, String, CancellationToken, Object[])
2)参数说明:
- IJSRuntime:默认传入
- String:JS方法名
- Object[]:参数数组,可序列化的数据类型均可传入
- TimeSpan:设置调用JS的超时时间
- CancellationToken:传入CancellationToken对象,在外部停止调用过程
3)重点说明输入参数,Object[]
①参数:引用类型:
Object[]参数传入Invoke方法时,会自动序列化为Json,JS收到参数后,自动进行反序列化。参数不仅可以传入普通的值类型,也可以传入引用类型。以下案例,我们在C#的Invoke方法中,传入一个Student对象,首先自动序列化为Json,JS接收后自动反序列化为JS对象。JS如果返回JS对象,C#中可以使用相同结构的对象来直接接收。
//JS函数Index.razor.js var Index = Index || {}; Index.getStudent = function (student) { console.log(student); } //Blazor组件index.razor @page "/" @inject IJSRuntime JS <button @onclick="JSGetStudent">点击执行</button> @code{ private async Task JSGetStudent() { var stu1 = new Student() { Name = "张三", Age = 18 }; await JS.InvokeVoidAsync("Index.getStudent", stu1); } public class Student { public string? Name { get; set; } public int Age { get; set; } } }
//例子延伸,JS返回结果是一个JS对象,C#中有相同结构的对象来接收 //JS函数Index.razor.js var Index = Index || {}; Index.getStudent = function (student) { return student; } //Blazor组件index.razor @page "/" @inject IJSRuntime JS @if (stuResult != null) { <h1>确定结果为:@stuResult.Name</h1> } <button @onclick="JSGetStudent">点击执行</button> @code{ private string? result; private Student? stuResult; private async Task JSGetStudent() { var stu1 = new Student() { Name = "张三", Age = 18 }; stuResult = await JS.InvokeAsync<Student>("Index.getStudent", stu1); } public class Student { public string? Name { get; set; } public int Age { get; set; } } }
②参数:Blazor组件树元素引用
Blazor不建议我们直接操作DOM,有可能会破坏组件树的Diff更新,但有时候确实需要操作DOM,Blazor给我们提供了@ref属性,相当于可以为一个组件节点申明了一个变量,我们可以将这个变量作为参数传入JS。需要特别注意这里有一个坑,请回顾本章节的第一幅图,因为JS和Blazor的加载顺序问题,我们要在组件渲染后(OnAfterRender),DOM都加载完了,才能获得正确的ref引用。下面直接来两个非常好的案例:
//案例1:设置页面标题 //JS函数,MyApp.js var MyApp = MyApp || {}; MyApp.setTitle = function (title) { document.title = title; } //Blazor组件,Index.razor @page "/" @inject IJSRuntime JS @code{ //必须在OnAfterRender生命周期函数中调用 //根据firstRender判断,只在首次加载时调用JS函数 protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { await JS.InvokeVoidAsync("MyApp.setTitle", "这是Index页面"); } } }
//案例2:设置自动对焦 //JS函数:MyApp.js var MyApp = MyApp || {}; MyApp.setFocus = function (element) { element.focus(); } //Blazor组件,Index.razor @page "/" @inject IJSRuntime JS <label for="name" @ref="elementInputName">姓名:</label> <input type="text" id="name"/> <label for="age">年龄:</label> <input type="text" id="age"/> @code{ //ElementReference是组件节点的引用类型 private ElementReference elementInputName; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { await JS.InvokeVoidAsync("MyApp.setFocus", elementInputName); } } }
//案例3:紧接案例2,我们将设置自动对焦包装成一个组件 //JS函数没有变化,MyApp.js var MyApp = MyApp || {}; MyApp.setFocus = function (element) { element.focus(); } //新建一个Focus组件,Focus.razor //稍后解释,为什么父传子不是直接传ElementReference,而是一个Func委托? @inject IJSRuntime JS @code { [Parameter] public Func<ElementReference>? GetElement { get; set; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (GetElement is null) { throw new ArgumentException(); } if (firstRender) { await JS.InvokeVoidAsync("MyApp.setFocus", GetElement()); } } } //在Index.razor中,使用自动对焦组件<Focus> //与案例2的Index.razor相比较,不同之处在于,我们传入一个回调,且这个回调返回元素引用 //为什么要以回调的形式传入? //根据父子组件生命周期的顺序,在子组件渲染完成后,父组件才会渲染 //子组件执行AfterRender时,父组件可能还没有渲染完成,所以此时@ref不一定拿得到 //所以,我们需要在组件加载完成后,在回调里,把元素引用传入 @page "/" @inject IJSRuntime JS <label for="name" @ref="elementInputName">姓名:</label> <input type="text" id="name"/> <label for="age">年龄:</label> <input type="text" id="age"/> <Focus GetElement="@(()=>{return elementInputName;})"></Focus> @code{ private ElementReference elementInputName; }
四、在JS中调用C#
1、互操作的发起端
在JS中,使用 DotNet.invokeMethodAsync(异步,在Server和WASM模式下均可用,推荐),或者 DotNet.invokeMethod(同步,仅在WASM模式下可用),直接调用C#中使用JsInvokableAttribute特性标注的方法。调用静态方法和实例方法,稍有不同,后文详解。在JS中使用 DotNet.invokeMethodAsync 时,如果要获取异步结果,promise和async的语法均可以使用,推荐使用async。
由于加载JS时,Blazor可能还未加载,所以我们一般在C#端先发起JS的调用,然后才在JS端调用C#。多绕了一圈,肯定有性能损耗,但这样更安全。当然,也可以直接在视图层,直接发起原生的HTML事件上,直接调用JS函数,如下例所示:
//MyApp.js //使用 DotNet.invokeMethodAsync 调用C#的一个静态方法 //方法的第1个参数为项目名称;第2个参数为C#静态方法名称 //使用async语法执行异步函数,并获得返回结果 var MyApp = MyApp || {}; MyApp.getNetArray = async function() { const result = await DotNet.invokeMethodAsync("JSRuntimeServer", "ReturnArrayAsync"); console.log(result); } //Index.razor //注意button使用的是HTML原生的onclick事件,直接调用JS函数 //C#方法是公共的、静态的,且使用[JSInvokable]标注 @page "/" @inject IJSRuntime JS <button onclick="MyApp.getNetArray()">原生调用JS方法</button> @code{ [JSInvokable] public static Task<int[]> ReturnArrayAsync() { return Task.FromResult(new int[] { 1, 2, 3 }); } } //当然,C#中的ReturnArrayAsync也可以使用同步方法,如下: @code{ [JSInvokable] public static int[] ReturnArray() { return new int[] { 1, 2, 3 }; } }
2、调用C#的静态方法
调用C#静态方法的步骤比较简单,基本步骤如下所示,不再举例
1)第一步:定义JS函数
函数体中调用C#静态方法:DotNet.invokeMethodAsync( "{AssemblyName}", "{NETMethodID}", {Arguments} );
- AssemblyName:项目/程序集的名称
- NETMethodID:C#方法的标识符,即可以是方法名称,也可以是[JSInvokeable]特性的参数,如[JSInvokable("ReturnArray")]
- Arguments:多个参数,以逗号分隔,每个参数都必须是可序列化的
2)第二步:定义C#静态方法,并使用JSInvokeableAttribute标注
[JSInvokeable("NETMehtodID")]
public static ......
- 方法必须标注特性,[JSInvokeable],其中参数可用可不用,如果使用了参数,则NETMethodID就使用参数值,如果不使用,则为方法名
- 方法必须是public static,公开的、静态的
- 方法可以是同步方法,也可以是异步方法。异步方法的返回值,可以使用void、Task 或 Task<T>
3)第三步:在C#中调用JS函数
- 可以直接使用HTML原生事件直接调用:<button onclick="JSmethod1()"></button>
- 也可以如前面小节,注入JSRuntime来调用:<button @onclick="JSgetNetArray">C#中调用JS方法</button>......private void JSgetNetArray(){JS.InvokeVoidAsync("MyApp.getNetArray");}
3、调用C#的实例方法及使用实例引用的注意事项
调用C#的实例方法,会相对复杂一些。上节中,我们看到,调用静态方法时,我们使用框架提供的DotNet对象,就可以直接通过程序集,调用静态方法。而调用实例方法,需要将Blazor的组件实例传入到JS中,这是两者的主要区别。静态方法中我们通过DotNet对象的invokeMethodAsync来调用,而在实例方法中,我们需要通过使用实例的invokeMehtonAsync来调用。我们直接通过案例来学习使用方法。
1)第一步:定义JS函数,在函数中调用C#的实例方法
//MyApp.js //JS函数接受一个Blazor组件的实例对象 //通过这个实例对象的invokeMethod方法,调用组件的方法 var MyApp = MyApp || {}; MyApp.getNetArray = async function (blazorObject) { const result = await blazorObject.invokeMethodAsync("ReturnArrayID"); console.log(result); }
2)第二步:在Blaozr组件中,定义被JS调用的方法,并调用JS函数(将第2节的两步合并为这里的一步)
//Index.razor @page "/" @implements IDisposable @inject IJSRuntime JS <button @onclick="GetNetArrayJS">调用JS函数,该函数反过来调用C#方法</button> @code { //定义一个DotNetObjectReference<T>类型的字段 private DotNetObjectReference<Index>? blazorObject; private async Task GetNetArrayJS() { //使用DotNetObjectReference.Create()方法,创建当前组件的实例对象 blazorObject = DotNetObjectReference.Create(this); //调用JS函数时,传入当前组件的实例对象 await JS.InvokeVoidAsync("MyApp.getNetArray", blazorObject); } //使用JSInvokableAttribute特性标注对象,并使用参数设置方法标识符 [JSInvokable("ReturnArrayID")] public async Task<int[]> ReturnArray() { //异步返回一个数组给JS return await Task.FromResult(new int[] { 1, 2, 3 }); } //千万记得,在组件销毁生命周期函数中,销毁组件实例 //因为JS引用着组件实例,所以即使走了Dispose生命周期,组件不会被自动回收,会一直占有内存 public void Dispose() { blazorObject?.Dispose(); } }
五、其它知识点
1、调用JS的异常处理、超时处理、中止执行
1)调用JS的异常处理
//如果JS没有定义method1方法,将捕获一个JS执行异常 //使用JSException类型参数来接收异常信息 @code { private string? errorMessage; private string? result; private async Task CatchJSMethod1) { try { result = await JS.InvokeAsync<string>("method1"); } catch (JSException e) { errorMessage = $"Error Message: {e.Message}"; } } }
2)超时处理
//调用JS函数的默认超时时间为1分钟,可以在注册服务时配置默认时间 //设置超时的时间,主要在Server模式下应用,但WASM模式也可以使用 builder.Services.AddServerSideBlazor( options => options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(120)); //调用JS方法时,也可以设置超时的时间,如果设置,则覆盖默认时间 var result = await JS.InvokeAsync<string>("MyApp.method1", TimeSpan.FromSeconds(120), param1);
3)中止执行
//和很多异步方法一样,可以传入CancellationTokenSource对象 //实现在异步方法的外部,中止异步方法执行 //需要注意,在组件的Dispose生命周期函数中,记得回收CancellationTokenSource对象 @inject IJSRuntime JS <button @onclick="StartTask">开始执行</button> <button @onclick="CancelTask">中止执行</button> @code { private CancellationTokenSource? cts; private async Task StartTask() { cts = new CancellationTokenSource(); cts.Token.Register(() => JS.InvokeVoidAsync("MyApp.method1")); } private void CancelTask() { cts?.Cancel(); } public void Dispose() { cts?.Cancel(); cts?.Dispose(); } }
2、调用JS的模块化module方式,引用一个JS库
当需要引入一个JS库时,推荐使用module的方式,可以有效防止变量名的全局污染。基本的使用步骤如下(案例来自官方文档,做了简化):
1)我们假定自己做了一个简单的JS库,在 wwwroot/js 目录下,创建 MyLibrary.js ,里面只export一个函数,如下:
function alertMessage(message) {
alert(message);
}
export { alertMessage };
2)在C#中,以模块化的方式引入
@page "/" @implements IAsyncDisposable @inject IJSRuntime JS <button @onclick="AlertMessageJS">调用MyLibrary的JS函数</button> @code { //使用IJSObjectReference类型,来创建JS模块的对象 private IJSObjectReference? module; //组件完成渲染后,获取JS模块对象module protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { module = await JS.InvokeAsync<IJSObjectReference>("import", "../js/MyLibrary.js"); Console.WriteLine(module); } } //使用JS模块对象的InvokeVoidAsync方法,调用JS函数 private async Task AlertMessageJS() { if (module is not null) { await module.InvokeVoidAsync("alertMessage", "这是需要显示的信息"); } } //记得组件销毁时,要回收JS模块对象,否则GC不会主动回收,造成内存损耗 //当然,这里也可以使用同步的IDisposable public async ValueTask DisposeAsync() { if (module is not null) { await module.DisposeAsync(); } } }
3、WASM模式下同步调用JS函数
WASM模式下,在C#中可以同步调用JS,会带来一点点性能上的提升,案例如代码所示(官网案例)。Server模式下,仅可使用异步方法。推荐无论是WASM,还是Server,都永远使用异步方法。
//同步调用JS @inject IJSRuntime JS @code { protected override void HandleSomeEvent() { var JSIP = (IJSInProcessRuntime)JS; var value = JSIP.Invoke<string>("JSMethod1"); } }
4、Server模式下传输数据的大小限制
Server模式下,JS 到C #的SignalR有大小限制,默认情况下是32K,可以在服务容器中进行配置,如案例代码所示。但不建议调整太大,否则会占用更多服务器资源,引起性能损耗。如果数据很大,可以考虑式用流失数据。WASM模式下,没有大小限制。
//设置为64K builder.Services.AddServerSideBlazor() .AddHubOptions(options =>options.MaximumReceiveMessageSize = 64 * 1024);
5、不要循环调用
JS和C#可以互操作,复杂点,甚至能形成一个调用链,但最好不要循环引用