虽然这是一个用C#写前端的系列教程,但对于房间里的大象我们还是不能假装看不见:Web前端的世界JavaScript依然是主流。很多前端的成熟类库其暴露的都是JS的编程接口,一个非常典型的例子就是诸如高德开放平台这样的地图平台,如果你想在用户的网页浏览器上展示地图,那么JavaScript API几乎就是你唯一的选择。这时,我们就需要有一种方式,能让我们在用C#写出来的前端代码中,以某种方式去调用JS类库中的函数。
除了能让我们在C#写的前端代码里调用到JS函数外,Blazor框架还提供了让我们能在JS代码中调用C#函数的能力。这就叫“互操作”,即interop。今天这篇文章,就是向大家介绍C#和JS是如何交叉感染的。
本文的结构是这样的:
第一部分:
给大家补充一些基础的JS知识,这部分不涉及JS作为一门程序设计语言的语法什么的,只是介绍一下浏览器如何加载JS文件,以及JS如何在浏览器环境中模块化
第二部分:
从最简单的例子入手,给大家先说明白如何在C#中调用JS,以及如何在JS中调用C#。
这一部分我们将手动分别写一些JS函数和C#函数,然后让它们互相交叉感染水乳交融,示例代码都没有什么实际意义,纯粹就是为了展示知识点。
第三部分:
我们会以高德开放平台和ECharts为示例,告诉大家如何在Blazor中使用第三方JS类库。示例代码依然是玩票性质的,但多少有了一些实际意义。
我相信这个系列教程的很多读者并不是以JavaScript为生的专业前端领域人员,但至少读者是有着相当的编程基础的,我无法假定你们目前对JavaScript,特别是浏览器环境下运行的JavaScript有多少了解,我只是按照我的感觉,选取一些知识点进行讲解,对于一些人来说,本节内容可能过于基础,你可以直接跳过,对于另外一些人来说,本节内容又可能过于陌生,你可能需要去花几个小时熟悉一下Javascript。但无论如何,我都建议你先阅读一遍本章节的内容,再做决定。
本章节的宗旨是
以下是一行JS代码,功能是在控制台输出Hello World
console.log("Hello World");
要让上面的代码在浏览器环境中执行,就需要把上面这行代码与一个HTML文档关联起来,通过让浏览器渲染HTML文档的方式,触发执行。所以“让浏览器执行JS代码的几种方式”这句话,基本等价于“将JS代码嵌入到HTML文档的几种写法”。
总共有三种方式:
<script></script>
标签里面如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
+ <script>
+ console.log("Hello World");
+ </script>
</head>
<body>
</body>
</html>
或者如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
</head>
<body>
+ <script>
+ console.log("Hello World");
+ </script>
</body>
</html>
<script>
标签既可以写在<head>
里面,也可以写在<body>
里面,你可能会问这两种写法有什么区别,或者如果一个HTML文档同时在<head>
和<body>
里都有<script>
,执行顺序如何,或者应该把<script>
放在<body>
的开头还是中间还是结尾,这个问题我们稍后会解答。
如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
</head>
<body>
+ <button onclick='console.log("Hello World")'>Click to say Hello</button>
</body>
</html>
这种写法下,浏览器并不会无缘无故的去自动执行这行代码,而必须要让用户去触发那个事件,才会执行。
我们将上面那行代码搬运到一个独立文件中去,如下所示:
然后在HTML中如下写:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
+ <script src="./hello.js"></script>
</head>
<body>
</body>
</html>
或者如下写:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
</head>
<body>
+ <script src="./hello.js"></script>
</body>
</html>
如果只是如上写的话,实际效果其实和第一种方式是没有什么本质区别的,你可以将这种写法理解为HTML版的#include
指令:浏览器在看到这玩意后,找到对应的JS文件,将内容贴在原处。
但<script src="xxx"></script>
还有两个可使用的属性,让事情变得稍微有一点复杂,这两个属性分别是defer
和async
,如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
</head>
<body>
+ <script defer src="./hello.js"></script>
</body>
</html>
或
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
</head>
<body>
+ <script async src="./hello.js"></script>
</body>
</html>
而要讲清楚这两个属性到底是什么效果,就需要了解一下HTML文档在渲染过程中的执行顺序了,我们放在知识点二中进行详细介绍,目前呢,你只需要知道:
async
和defer
同时出现的时候,效果等同于只有async
即以下两种写法是等价的
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
</head>
<body>
- <script async src="./hello.js"></script>
+ <script defer async src="./hello.js"></script>
</body>
</html>
把代码直接写在HTML元素的事件回调属中,即第二种方式,是非常古老、过时的写法,忘掉它吧,这是古早时代Web开发者才会用到的一种技巧。
如果我们抛开第三种方式的defer
和async
不谈的话,其实第一种方式和第三种方式是完全等价的。
那么我们换个角度来对引入JS的方式进行分类的话,可以重新分为三类
直接加载
包括以下两种方式
<script>
console.log("Hello World");
</script>
和
<script src="./hello.js"></script>
异步加载
<script async src="./hello.js"></script>
延迟加载
<script defer src="./hello.js"></script>
如果一个HTML文档中包含了对很多JS文件的加载,那么搞清楚这些JS文件的加载与执行顺序就尤为重要。或者说搞清楚这些JS文件的执行顺序更为重要一点。我们这里通过一个简单的实验来搞明白,各种加载方式、<script>
的书写位置,对执行顺序/优先级的影响。
首先,新建一个目录,并在新建目录中执行如下powershell脚本:
> mkdir HelloHtmlAndJs
> cd HelloHtmlAndJs
HelloHtmlAndJs> $loadMethods=("direct", "async", "defer");$positions=("head", "body");$names=("a", "b", "c", "d");foreach($m in $loadMethods){ foreach($p in $positions) { foreach($n in $names) { $fileName=$m+"_"+$p+"_"+$n+".js"; $fileContent="console.log(`""+$m+"_"+$p+"_"+$n+"`");"; New-Item $fileName -ItemType File -Value $fileContent; } } }
这段鬼画符的powershell脚本会创建24个JS文件,文件名的格式是[加载方式]_[script标签的书写位置]_[a|b|c|d].js
,各文件的内容格式如下:
console.log("[加载方式]_[script标签的书写位置]_[a|b|c|d]");
然后我们在这个目录下,新建如下的HTML文档,当然文件名叫index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
<script src="./direct_head_a.js"></script>
<script async src="./async_head_a.js"></script>
<script defer src="./defer_head_a.js"></script>
<script src="./direct_head_b.js"></script>
<script async src="./async_head_b.js"></script>
<script defer src="./defer_head_b.js"></script>
<script src="./direct_head_c.js"></script>
<script async src="./async_head_c.js"></script>
<script defer src="./defer_head_c.js"></script>
<script src="./direct_head_d.js"></script>
<script async src="./async_head_d.js"></script>
<script defer src="./defer_head_d.js"></script>
</head>
<body>
<script src="./direct_body_a.js"></script>
<script async src="./async_body_a.js"></script>
<script defer src="./defer_body_a.js"></script>
<script src="./direct_body_b.js"></script>
<script async src="./async_body_b.js"></script>
<script defer src="./defer_body_b.js"></script>
<script src="./direct_body_c.js"></script>
<script async src="./async_body_c.js"></script>
<script defer src="./defer_body_c.js"></script>
<script src="./direct_body_d.js"></script>
<script async src="./async_body_d.js"></script>
<script defer src="./defer_body_d.js"></script>
</body>
</html>
然后呢,为了更好的模拟实际开发过程中加载JS文件的情形,我们要做以下两件事:
对于#1,你可以将它托管在任何你熟悉的web server上,然后在#2中用http://localhost:xxxx/index.html
去访问。我这里推荐一个比较方便的工具,即visual studio code的一个插件,叫做Live Server
当然你需要首先安装visual studio code,也就是俗称的vscode,再打开软件后,如下图的指引,来安装好这个插件,安装完成后可能要重启vscode:
之后用vscode打开我们刚才整活的HelloHtmlAndJs
目录,打开index.html
,右键选择open with live server
即可开户一个本地web server并将当前index.html
托管到它体内。或者如下图所示,点击底部状态栏的图标开启本地web server,开启成功后询问状态栏会显示端口号:
默认的开启端口号是5500,这时就可以通过http://localhost:5500/index.html
访问到这个页面了。接下来,我们需要打开浏览器调试窗口,在网络选项卡中,如下图所示,让浏览器去模拟一个不太好的网络环境:
这样,浏览器就会假装要加载的JS文件需要几百毫秒乃至上千毫秒去加载,比较贴近现实生产环境。如果你觉得加载速度还是太快了,可以把fast 3G
改成slow 3G
,这样每个请求会走将近两秒的时间。
好了,现在买定离手,请大家猜一猜,浏览器控制台上的24行输出的先后顺序是什么。
公布答案:答案并不唯一,但有一定规律。在我本机上的其中一次运行结果如下所示:
以上图的实验结果,能总结出的规律有:
<script>
标签出现在文档中的位置越靠前,执行顺序越靠前<head>
中时,直接加载永远是最先执行的。异步加载和延迟加载的脚本,执行顺序有先有后。上面是从实验中能总结出来的规律,坦白讲这个实验做的相当粗糙,实验本身可以设计得更好,去展示脚本加载顺序与执行顺序的规律。。
不过至少这个实验能生动的向你展示“脚本的加载和执行是有先后顺序和规律的”,接下来的问题是:这个规律到底是什么?
要说明白这个问题,其实还挺不简单的,我尽量用自然语言来正确描述吧。
首先,对于直接加载的脚本,即没有使用defer
和async
的脚本,无论脚本是直接写在<script>
内部的,还是通过src
属性引用的,它们的执行顺序是严格等同于在文档中出现的先后顺序。你可以将浏览器的引擎想象成一个看报纸的老头,在web 1.0时代,脚本语言刚刚兴起的时候,浏览器渲染一份HTML文档就像老头在看报纸:严格的一行一行,从上到下看。
在这个看报纸的过程中,如果老头遇见了<script>
标签包裹的JS脚本,老头就会一行一行的去执行这段脚本。这样严格一行一行看报纸的执行模式其实还有个言外之意:即如果我们把<script>
标签写在<head>
中,或者写在<body>
中部的话,那么在脚本被执行的那段时间里,老头不光压根没看到后续的其它<script>
标签,甚至老头都没看到后续的HTML文档内容!!
这样的行为模式当然看起来不是非常合理,但历史发展就是这样,出于向前兼容,这种行为被保留了下来。。这个行为模式里的另外一个知识点则是:浏览器建立DOM树的过程也是缓慢建立,逐行渲染的,老头看到一个<button>
,就立即将它添加到dom树中去。而不是等到读完整个报纸再去建立DOM树。
理解这个知识点后你就会意识到为什么在实际生产过程中,大多数的直接脚本引用,要么写在<head>
里,要么写在<body>
的末尾,几乎从来没有人会把<script>
标签写在HTML文档的中部:
写在<head>
中的脚本会在DOM树空无一物的情况下去执行
写在<body>
中的脚本会在DOM树构建完成后执行,这种脚本大多是要操纵DOM元素的。
一个典型例子就是Blazor框架的index.html
文件,其中对_framework/blazor.webassembly.js
的引用就放在<body>
的末尾:因为blazor.webassembly.js
会触发WASM程序运行,从而取代掉index.html
文件中写的那个<div id="app">Loading...</div>
元素。
但严格意义上来讲,即便你将脚本的加载放置于<body>
的末尾,脚本执行的时候,浏览器对文档的渲染也没有完全结束:毕竟老头还没看到</body></html>
呢!我猜这个闹心的现状一度让很多工程师浑身难受,所以就会有DOMContentLoaded
事件, readystatechange
事件和load
事件。
其中DOMContentLoaded
事件代表着浏览器老头已经读完了当前HTML文档,并100%的建立了DOM树 -- 但是,加载图片、加载样式表、加载异步脚本等等异步操作在此刻并不一定已经完成。这也是一个一次性事件,不刷新页面的话,这个事件只会被激发一次。
而load
事件则是在DOMContentLoaded
的基础上,进一步保证所有异步加载任务都已经完成。这也是一个一次性事件。
readystatechange
事件则有些特殊:它不是一个一次性事件。它与document.readState
属性是关联的,表面上来讲,document.readState
属性每次发生变更,都会激发readystatechange
事件。
这三个事件是浏览器暴露给程序员的勾子事件,就像我们前面讲的组件的生命周期函数一样。虽然浏览器加载、渲染HTML文档的过程颇为复杂,但我们只需要简单的理解为,整个过程中有一个非常重要的checkpoint:即DOMContentLoaded
事件被激发的时刻,这个时刻代表着浏览器完全建立了DOM树。
把话题拉回来,对于异步加载的脚本,即<script async src="xxx"></script>
,它的加载和执行都是完全不可预料的,浏览器只承诺两件事:
由于<script async>
太不可预料了,所以就又有了defer
,它是对async
的加强,它保证了:
脚本将在浏览器激发DOMContentLoaded
的前一刻执行完毕。
DOMContentLoaded
事件DOMContentLoaded
事件多个延迟加载的脚本的执行顺序,按它们在文档中出现的顺序来排
需要注意的是:浏览器渲染网页,整个是个单线程异步模型!!这意味着老头并没有能力在脚本加载完成后,一边继续看报纸,一边去执行脚本中的代码!老头一次只能干一件事,老头执行代码的时候,看报纸就暂停了。
而正是由于老头在加载执行direct_xxx_x
的时候是完全同步加载和同步执行的,所以才导致我们上面的执行结果中,貌似所有异步脚本之间都是有严格顺序的。我们来截取一段代码来说明一下这个现象:
<!-- ... -->
<script src="./direct_head_a.js"></script>
<script async src="./async_head_a.js"></script>
<script defer src="./defer_head_a.js"></script>
<script src="./direct_head_b.js"></script>
<script async src="./async_head_b.js"></script>
<script defer src="./defer_head_b.js"></script>
<script src="./direct_head_c.js"></script>
<!-- ... -->
我们假设所有脚本的加载时间都是2秒左右,那么以下就是模拟的,按时间轴排序的事件:
00.000
~ 02.010
同步加载执行direct_head_a.js
02.010
~ 02.020
创建异步任务去加载async_head_a.js
02.020
~ 02.030
创建异步任务去加载defer_head_a.js
02.030
~ 04.040
同步加载并执行direct_head_b.js
04.040
~ 04.050
async_head_a.js
和defer_head_a.js
都已经加载完成,但仅有async_head_a.js
开始执行
04.050
~ 04.060
创建异步任务去加载async_head_b.js
04.060
~ 04.070
创建异步任务去加载defer_head_b.js
...后面的过程就可以略过了
其实写到这里你就应该能看明白了,在我们的实验中,之所以所有的异步脚本之间是严格按顺序执行的,只是因为在两个异步脚本的加载之间,插入了一个同步脚本的直接加载执行而已。理解这玩意的关键点就是,把浏览器想象成一个一次只能做一件事的老大爷。
如果你把整个index.html
改写成下面这样,就会看到,脚本的执行顺序就会变得随机那么一点点
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello HTML & JS</title>
<script async src="./async_head_a.js"></script>
<script async src="./async_head_b.js"></script>
<script async src="./async_head_c.js"></script>
<script async src="./async_head_d.js"></script>
</head>
<body>
<script async src="./async_body_a.js"></script>
<script async src="./async_body_b.js"></script>
<script async src="./async_body_c.js"></script>
<script async src="./async_body_d.js"></script>
</body>
</html>
比如你会看到如下的加载顺序:
不过在实际实践中你会看到,即便理论上,异步加载执行脚本的顺序只取决于网络加载延迟,但实际实验时,上面的代码的执行结果还是看起来有那么一点点的规律性,比如,几乎每次执行,async_head_a.js
都是最先执行的脚本,这又如何解释呢?
答:解释不了,只能用终极理论:未定义行为,浏览器怎么执行都是有理的。
<script async ...>
后,发个网络请求,不等脚本下载结束,就立即回头接着看报纸。等脚本下载结束后,老头又暂停看报纸转去执行脚本。最后,免责声明:
浏览器本身并不是一个单线程程序,甚至浏览器在渲染单一页面的时候,也不是单线程执行的。只是浏览器在渲染单一页面的时候,那个JS Runtime是单线程的。之所以在这里要把它描述成单线程式的老头看报纸,只是对于我们这种级别的程序员来说,这样理解成本最低而已。
对于其它程序设计语言来说,模块化是一个天然存在的东西:库、包、头文件等。当然如果咱要从理论层面辩经的话,函数、类也算是某种程度上的模块化。JS的历史比较混乱,JS最初的设计实现目标比较单纯,就是一个浏览器的DLC,所以早期的JS压根就不像是个正经的程序设计语言,语言设计者压根就没正经考虑过模块化这个东西,后来这玩意慢慢的越来越流行,各种标准和实现又在修修补补打补丁,导致不同于其它程序设计语言,JS在不同的时期,大家“模块化”的流行途径也不太一样。
JS上的模块化大致有五种途径:
其中对象式模块化
和类定义式模块化
严格来讲,是一种“设计模式”,是程序员在JS语言本身没有提供一个“体面的复用、分享、分发代码”的机制的情况下,通过实践经验总结出来的两个workaround方式,我来贴两个例子你就知道这玩意有多抽象了:
// 对象式模块
var myModule = {
myProperty: "someValue",
myConfig: {
useCaching: true,
language: "en"
},
myMethod: function() {
console.log("method");
}
}
// 类式模块
var myNamespace = (function () {
var myPrivateVar = 0;
var myPrivateMethod = function( foo ) {
console.log( foo );
};
return {
myPublicVar: "foo",
myPublicFunction: function( bar ) {
myPrivateVar++;
myPrivateMethod( bar );
}
};
})();
对象式模块非常简单粗暴:库的作者把库的所有内容扔进一个超级大的对象中去,对于调用者来说,通过myModule.myProperty
或myModule.myMethod()
这种方式去使用。类式模块则是利用语言特性,强行实现了“封装”,比如上面的模块中,使用者是无法访问到myNamespace.myPrivateVar
和myNamespace.myPrivateMethod
的。
分发这些模块的时候,库的作者就把这些代码打包进一个独立的js
文件中,比如myModule.js
或myNamespace.js
里,用户则在HTML文档的<head>
标签把这些库引入进来,然后在<body>
标签内的<script>
里使用这些定义好的“类”与“对象”。
这么搞了几年后大家受不了了,太恶心了,这玩意表面上看似解决了“模块化”的问题,但有个核心问题没解决:就是模块与模块之间的依赖。你设想一下,比如你写了一个库叫awesome.js
,不过它使用了上面写到的myModule.js
和myNamespace.js
,然后,你怎么把awesome.js
分发给其它人去使用?
现代程序设计语言一个非常重要的东西就是包管理系统,表面上包管理系统解决的是“模块化”的问题,其实解决的是“依赖”的问题。你看像C和C++这俩玩意,虽然有着非常体面的模块化设计:头文件+库文件,但没有一个标准的包管理系统,导致各种牛鬼蛇神满天飞。
Web发展太快了,JS虽然是一坨屎,但太流行了,行里人觉得这让库作者手动管理依赖不是长久之计,得想个办法,然后就又拉了一坨大的,这些人提出了一个绝妙的idea,就叫Asynchronous Module Definition
,简称AMD,这玩意的核心分三部分:
比如上面我们定义的myModule
,AMD的倡议就是让库作者改成下面这样:
define(
// 先写模块名
"aweSome",
// 再写所需要的依赖
["myModule", "myNamespace"],
// 再写模块的定义,依赖都通过参数传递进来
function(myModule, myNamespace) {
var aweSome = {
doStuff:function() {
myNamespace.myPublicFunction(myModule.myProperty);
}
}
return aweSome;
}
);
听起来蛮合理的,是吧?既照顾到了历史债务,也解决了实际问题。确实,只有一个小缺憾:没人鸟AMD,没几个人用,也从没流行过。确实有那么几个库在用这玩意,但这玩意从来没成为过主流,以至于现在教JS的书都不提这玩意了。
要我说这名多少起得有点晦气,你看看造芯片的那个AMD,支棱起来了吗?
开个小玩笑而已,AMD之所以没流行起来,主要是AMD想支棱的时候,有两个事阻止了它的流行:
谷歌把JS的运行时,从浏览器搬到了服务端,也就是nodeJS出现了
作为一个只运行在浏览器上的残废语言,缺乏包管理不是什么大问题,大家凑合凑合都能过日子。但作为一个服务端语言,就肯定不行了,谷歌在推nodeJS运行时的同时,也带来了自己的包管理和模块化方案,npm
包和commonJS模块。
和其它流行的服务端程序设计语言一样,nodeJS把代码复用抽象成了两个层级:模块和包。利用require
和module.exports
实现一个小粒度的commonJS模块概念,多个模块再攒一起形成npm package的包概念。而依赖的自动处理,是由npm package系统处理的,在模块这个层级上,无论是运行时,还是工具链,都不会提供“自动下载、加载间接依赖”这种事情。
但无论是commonJS的模块,还是npm package的包概念,都是服务端JS上的概念,它们在浏览器上是不被支持的,这也是为什么现代前端框架需要用到webpack
这种工具的原因:前端框架的开发是在node环境开发的,但打包、发布则需要用到工具,把写好的代码整合成一个或多个更紧凑的JS文件,以便让各式各样的浏览器,能以最智障的“直接加载”的方式去执行这些代码。
ECMA标准化组织在语言层面上,开始推动ES模块的概念
ECMA只在语言层面上推广自己的“模块”概念,即用import
和export
关键字来描述的ES模块,但在“包”这个层级上,ECMA并没有作为。我想这主要是考虑到npm package已经足够流行,没必要再搞山头了。
ES模块相较于commonJS的一大优势,是浏览器天然的就支持ES模块,手写的ES模块文件不需要什么转换、打包,就可以直接被浏览器正确处理。它的劣势是,ECMA并没有实现一个包管理系统来帮忙程序员管理依赖。
而如今的现状是,nodeJS也支持了ES模块。这其实也是个没办法的事,毕竟ECMA是以釜底抽薪的形式,将ES模块写进了语言标准里,commonJS再流行,也毕竟是谷歌的私有解决方案。
但蛋疼的事情是,模块这一层级上,看似是标准化组织最终获得了胜利,ES模块是最终的赢家,但在package这个层级上,nodeJS带来的npm package已经是无法撼动的业界事实标准了。并且在可预见的未来,ECMA也无法染指package的定义。并且由于在开发环境、工具链上,nodeJS也成了事实上的统一标准,所以浏览器厂商干脆摆烂,完全不想着如何去解决“依赖”这个问题,一股脑的将这个问题甩给了nodeJS下的各种工具链去解决。
反正开发过程中npm package系统会解析依赖,反正发布的时候你webpack
会把所有依赖都打包成bundle,我浏览器只需要按上古时期的样子,就地加载执行不就行了?
前端开发是这样的,npm只需要install就行了,程序员只需要在屎堆上叠叠乐就行了,而我浏览器考虑的东西可就多了。。算了,考虑不过来了,直接摆烂吧。
兜兜转转十几年,总结起来就是:模块化这个问题在nodeJS环境中得到了解决,在浏览器环境中,解决了一半。
接下来用两段代码给大家展示一下commonJS模块和ES模块:
以下是一个commonJS模块:
// util.js
module.exports.add = function(a, b) {
return a + b;
}
module.exports.subtract = function(a, b) {
return a - b;
}
在调用方,如下使用
const {add, subtract} = require('./util')
console.log(add(5, 5));
console.log(substract(10, 5));
以下是一个ES模块:
// util.mjs
// 是的,对于ES模块来说,标准“建议”使用.mjs后缀,以和非模块的JS文件做区分
// 这个“建议”也是成分复杂:由于浏览器厂商的支持力度不够,
// 所以实际情况是,大家在浏览器环境中依然在使用.js后缀
// 没有人鸟这个鬼建议
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
在调用方,如下使用:
import {add, subtract} from './util.mjs'
console.log(add(5, 5));
console.log(substract(10, 5));
模块的问题现在讲清楚了,回到我们小节的标题问题上:浏览器环境中,JS代码是如何模块化的?答案有两个:
你可能会想说:上古时期的玩意了,该扔了吧?没必要了解了吧?这话,既对,又不对。
接下来我们转变一下视角,把我们的视角拉回到一个Blazor开发者上来,作为Blazor框架的使用者,我们之所以要了解这些有关JS的知识,出于两个原因:
很多第三方库是用JS实现的,就比如文章开头提到的高德开放平台,或是ECharts。
这些JS库,对使用者暴露的API,都是以上古技巧包装在类模块或对象模块里面的。
其实这倒不是说这些库不求上进,这些库本身的开发都是紧跟时代潮流的,使用的技术、工具链都是最新的,只不过,考虑到浏览器兼容性或者其它什么原因,它们使用打包工具,将API打包成了最古老的风味而已。
比如下面是ECharts官方入门文档里的示例代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>ECharts</title>
<!-- 引入刚刚下载的 ECharts 文件 -->
<script src="echarts.js"></script>
</head>
<body>
<!-- 为 ECharts 准备一个定义了宽高的 DOM -->
<div id="main" style="width: 600px;height:400px;"></div>
<script type="text/javascript">
// 基于准备好的dom,初始化echarts实例
var myChart = echarts.init(document.getElementById('main'));
//...
</script>
</body>
下面是高德开放平台的入门文档示例代码:
<!doctype html>
<html style="height:100%;width:100%">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="initial-scale=1.0, user-scalable=no, width=device-width">
<title>HELLO,AMAP!</title>
<link rel="stylesheet" href="https://a.amap.com/jsapi_demos/static/demo-center/css/demo-center.css" />
</head>
<body style="height:100%;width:100%">
<div id="container" style="height:100%;width:100%"></div>
<script type="text/javascript" src="https://webapi.amap.com/maps?v=2.0&key=xxxxxxx"></script>
<script type="text/javascript">
// 创建地图实例
var map = new AMap.Map("container", {
zoom: 13,
center: [116.39, 39.92],
resizeEnable: true
});
// ...
</script>
</body>
以上例子中的echarts
和AMap
都是JS对象,库将它们的所有API都塞进一个对象中去。
在某些必要的场合,我们可能需要手动写一些JS代码
所以,站在一个Blazor开发者的角度来看这事的话,我们只需要做到:
逻辑知识已经讲完,下面我们通过一个实际的例子,来观察一下ES模块在浏览器环境是如何工作的:
目录结构如下所示:
HelloHTMLAndJS
|
|---modules
| \---utility.js
|
\---index.html
\---main.js
index.html
如下:
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title>Basic JavaScript module example</title>
<script type="module" src="main.js"></script>
</head>
<body>
</body>
</html>
main.js
如下
import { ChangeElementColor, ChangeElementHeight, ChangeElementWidth } from "./modules/utility.js"
function main() {
let d = document.createElement("div");
d.setAttribute("id", "rect");
document.body.appendChild(d);
ChangeElementColor("rect", "red");
ChangeElementWidth("rect", "100px");
ChangeElementHeight("rect", "100px");
}
addEventListener("DOMContentLoaded", main);
modules/utility.js
如下:
function ChangeElementColor(id, color) {
let element = document.getElementById(id);
element.style.backgroundColor = color;
}
function ChangeElementHeight(id, height) {
let element = document.getElementById(id);
element.style.height = height;
}
function ChangeElementWidth(id, width) {
let element = document.getElementById(id);
element.style.width = width;
}
export { ChangeElementColor, ChangeElementHeight, ChangeElementWidth };
运行效果非常直接,如下图所示:
注意点:
需要把文件托管在web server上才能看到实验效果,你可以用vscode的live server,当然如果不嫌麻烦的话也可以托管在本机的nginx上
如果直接在浏览器中打开index.html
的话,浏览器会因为跨域请求main.js
不受支持而禁止加载main.js
,如下:
modules/utility.js
显然是一个模块文件,这没什么可说的,但main.js
是一个模块文件吗?
答案是:是的,main.js
也是一个模块文件,所以需要在index.html
中使用<script type="module">
对其进行引用
如果你删掉type="module"
的话,浏览器会给你扔出下面一个报错:
报错信息说的很明白:import/export
关键字只有在浏览器把一个JS文件当module
加载的时候才会生效
main.js
是需要显式在HTML文档中通过<script>
进行引入的,虽然它加了type="module"
,但与其它<script>
标签一样:main.js
的加载、执行顺序和我们上面学习到的知识是一致的
modules/utility.js
不需要在HTML文档中进行显式引用,它的加载发生在main.js
被加载之后,由浏览器去解析依赖关系,然后加载必要的其它模块
import, export
的语法如下:
import
有选择性的引入一部分函数或对象:
import { someFunc, someVar } from './xxx.js';
// ...
someFunc();
console.log(someVar);
引入后改个名,以避免名称冲突:
import { someFunc as aliasFunc, someVar as aliasVar } from './xxx.js';
alias();
console.log(aliasVar);
引入目标模块里的所有东西,并把所有东西打包放在一个名称空间(其实就是对象)中,这样更能避免名称冲突
import * as someNamespace from './xxx.js'
someNamespace.someFunc();
console.log(someNamespace.someVar);
export
有选择性的导出一部分函数或对象:
const someVar = true;
function someFunc() { }
export { someVar, someFunc };
在导出的时候改名
const v = true;
function f() { }
export { v as someVar, f as someFunc };
在对象或函数声明的时候就导出
export const someVar = true;
export function someFunc() { }
默认导出与引入
默认导出,即default export
,只适用于模块只导出一个函数或对象,比如如下写,我们就只导出了一个对象
const someVar = true;
function someFunc() { }
export default someVar;
只有someVar
被导出了,而someFunc
则对外部完全不可见。而正是由于导出的东西只有一个,所以“名字”就显得有点多余了,即默认导出的东西,是没有名字的
在调用方,要使用上面导出的someVar
的话,可以这样写:
import someVar from './xxx.js';
console.log(someVar);
语法上的区别,就是没有大括号{}
,上面的写法与下面的写法完全等价:
import aliasVar from './xxx.js';
console.log(aliasVar);
所以你看,导入方想叫什么名字都可以。
终于说到正题了,我们创建一个空项目,来演示如何在Blazor WASM前端代码中调用JS代码,以下是项目创建流程:
> dotnet new blazorwasm-empty --hosted -f net7.0 -o HelloJSInterop
我们先直接在Client/wwwroot/index.html
中就地嵌入一段JS代码,用JS声明两个函数,一个函数没有返回值,一个函数有返回值,然后在Blazor组件中调用它
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>HelloJSInterop</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<!-- If you add any scoped CSS files, uncomment the following to load them
<link href="HelloJSInterop.Client.styles.css" rel="stylesheet" /> -->
+ <script>
+ function logSomething(str) {
+ console.log("LOG_BY_JS: " + str);
+ }
+ function convertStringToUpper(str) {
+ return str.toUpperCase();
+ }
+ </script>
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
然后把Client/Pages/Index.razor
改写成下面这样,以在生命周期勾子中对JS函数进行调用:
+@inject IJSRuntime JS
@page "/"
<h1>Hello, world!</h1>
+
+@code {
+ protected override async Task OnInitializedAsync()
+ {
+ string upperCaseStr = await JS.InvokeAsync<string>("convertStringToUpper", "OnInitializedAsync");
+ await JS.InvokeVoidAsync("logSomething", upperCaseStr);
+ }
+
+ public override async Task SetParametersAsync(ParameterView parameters)
+ {
+ await base.SetParametersAsync(parameters);
+ string upperCaseStr = await JS.InvokeAsync<string>("convertStringToUpper", "SetParametersAsync");
+ await JS.InvokeVoidAsync("logSomething", upperCaseStr);
+ }
+
+ protected override async Task OnParametersSetAsync()
+ {
+ await base.OnParametersSetAsync();
+ string upperCaseStr = await JS.InvokeAsync<string>("convertStringToUpper", "OnParametersSetAsync");
+ await JS.InvokeVoidAsync("logSomething", upperCaseStr);
+ }
+}
运行效果如下,非常直白:
其实说白了,在C#中调用JS是一个非常简单的事情,代码写起来巨简单,两步而已:
向当前上下文注入IJSRuntime
对象即可,Blazor框架在前端初始化时,已经把这个对象放进了DI池里,直接拿来用就行
按有没有返回值,来调用InvokeAsync
和InvokeVoidAsync
中的一个
InvokeAsync<T>(...)
,其中T
是你期望的返回值类型,参数里分别写上函数名和参数InvokeVoidAsync(...)
,参数里分别写上函数名和参数而整个事情的逻辑是这样的:
IJSRuntime
对象IJSRuntime
对象带着命令,跑到浏览器的JS runtime里,按照命令去调用对应的JS函数。如果有返回值的话,再把返回值带回来而这,未尝不是一种RPC调用呢?对于dotnet runtime上跑着的blazor前端代码来说,浏览器的JS Runtime完全就是一个外部势力,它完全没法预测这个调用啥时候能执行结束,所以传令兵仅有异步API的设计,即仅有InvokeVoidAsync
和InvokeAsync
供你用,没有Invoke
这种东西,也就非常合理了。
至于入参的数据类型转换、返回值的数据类型转换,在基础数据类型和简单的库容器类型范畴里,框架都会帮你搞定,而且数据类型转换的行为都是符合直觉的。
比如我们在index.html
中再写一个下面这样的JS函数,它接受一个数组作为参数,返回去重后的数组
function dedupNumericArray(arr) {
return [...new Set(arr)];
}
而我们在Index.razor
中如下调用它
protected override async Task OnInitializedAsync()
{
List<int> dedupedNumbers = await JS.InvokeAsync<List<int>>("dedupNumericArray", new List<int> { 1,1,2,2,3,3,3});
Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
}
也是一点毛病没有的,程序是会正常运行的,浏览器控制台上会输出1, 2, 3
。
下面的调用也是没毛病的:
protected override async Task OnInitializedAsync()
{
List<string> dedupedNumbers = await JS.InvokeAsync<List<string>>("dedupNumericArray", new List<string> { "1", "1", "2", "2", "3", "3", "3" });
Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
}
但下面的调用就会失败:
protected override async Task OnInitializedAsync()
{
List<int> dedupedNumbers = await JS.InvokeAsync<List<int>>("dedupNumericArray", new List<string> { "1", "1", "2", "2", "3", "3", "3" });
Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
}
报错如下:
这时候有些同学就会想了,凭什么啊?JS不是无类型语言吗?怎么把字符串转到数值还转不过去呢?
不是这样的,JS并不是无类型语言,而是动态类型语言,同时又是弱类型语言,JS在语言层面上也是有类型系统的,而之所以不少人有着“JS里没类型”这个刻板印象,是因为
JS中的基础数据类型(准确的来说叫原始值类型)共有七种:Null, Undefined, Boolean, Number, BigInt, String, Symbol
。复杂类型就是在Object
里一锅乱炖,像什么Array
、Map
,Set
之类的集合类型,以及Date
等,都算是Object
在对接传令兵的时候,JS清楚的知道自己的返回值是字符串数组,即返回值本身是个Array
,里面的元素都是Number
类型,而传令兵的期望是一个List<int>
,对不上,就异常了。
脑中理解了JS的基础类型系统,大致也就明白什么样的C#类型能和JS Runtime上的JS类型做互相转换了,按这样的理解,如下的代码应当也应该能正确运行并在浏览器控制台上输出 1, 2, 3
protected override async Task OnInitializedAsync()
{
List<string> dedupedNumbers = await JS.InvokeAsync<List<string>>("dedupNumericArray", new string[] { "1", "1", "2", "2", "3", "3", "3" });
Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
}
但实际情况是,当传令兵把入参带到JS Runtime时,转换过后的入参,变成了单个字符串"1"
自然,控制台上的输出就只剩下个1
为什么?凭什么?
而把上面的代码做一点小的修改,如下:
protected override async Task OnInitializedAsync()
{
List<int> dedupedNumbers = await JS.InvokeAsync<List<int>>("dedupNumericArray", new object[] { 1, 1, 2, 2, 3, 3, 3 });
Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
}
调试结果如下
再执行下去,它竟然报错了!如下:
这怎么回事?小小的脑袋大大的困惑!
这里其实有两个问题
传令兵做入参转换的时候出了差错
将new string[] {"1", "1", "2", "2", "3", "3", "3"}
传递到JS Runtime上的时候,竟然变成了单个字符串"1"
将new object[] {1, 1, 2, 2, 3, 3, 3}
传递到JS Runtime上的时候,竟然变成了单个数值1
在JS Runtime中,[...new Set("1")]
是一个合法操作,而[...new Set(1)]
却是非法操作
这纯粹就是JavaScript诸多屎点中的一个而已,或者更准确一点来说,不是JS语言层面上的屎,而是JS标准库里的Set
身上的一个屎点,比如我们可以在Node环境中完美复刻这个抽象现象:
两个问题里,#2已经就地解释了,那么我们现在来看#1,凭什么?
答案其实在于InvokeAsync
的定义身上,在IJSRuntime
接口里,这玩意的定义有两个重载:
ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(string identifier, object?[]? args);
ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(string identifier, CancellationToken cancellationToken, object?[]? args);
无论哪个重载,对args
的定义都是object?[]?
,它不是可变参数,它是固定参数。
但在扩展类JSRuntimeExtensions
里,扩展了如下重载
public static ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, params object?[]? args);
public static ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args);
public static async ValueTask<TValue> InvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
有点乱啊,我们捋一下,将关注点放在args
参数的定义上,并且忽略掉CancellationToken
之类的其它东西
IJSRuntime
接口里的两个签名,args
的类型都是object?[]?
接口语义也非常清晰:请将入参,无论有几个,都打包成一个数组然后传递给我
但这对于使用者而言很不方便,比如我在JS里的函数接受两个参数,那么在C#中调用它的话,我就得写出诸如下面的代码:
JS.InvokeAsync<...>(functionName, new object?[]{param1, param2})
JSRuntimeExtensions
里扩展出来的几个方法,args
的类型都是params object?[]? args
,其内部实现也非常简单,几乎可以认为这几个扩展方法的定义都长下面这样:
public static ValueTask<TValue> InvokeAsync(this IJSRuntime jsRuntime, string identifier, params object?[]? args)
{
return jsRuntime.InvokeAsync<TValue>(identifier, args);
}
这样定义几个扩展方法有什么好处呢?诶,还是上面那个两参函数的例子,此时,我就可以如下方便的调用了
JS.InvokeAsync<...>(functionName, param1, param2)
在扩展方法的实现中,param1
和param2
会被语言特性整合成一个参数,即params object?[]? args
,然后再转交给IJSRuntime
中定义的实现
所以现在答案就很明了了:
用new string[]{...}
去调用InvokeAsync
,匹配的是IJSRuntime.InvokeAsync
框架认为整个new string[]{...}
是多个参数打包后的object?[]? args
,即调用JS方法我们提供了多个参数
用new int[]{...}
去调用InvokeAsync
,匹配的是JSRuntimeExtensions.InvokeAsync
扩展方法会将new int[]{...}
匹配到可变参数列表args
中的第一个元素身上,即args
的值其实等于new object?[]{new int[]{...}}
然后这个args
会再被转发到IJSRuntime.InvokeAsync
身上,框架就会认为new int[]{...}
是一个参数
即new string[]{...}
去调用InvokeAsync
,其实与如下写法是等价的:
List<string> dedupedNumbers = await JS.InvokeAsync<List<string>>("dedupNumericArray", "1", "1", "2", "2", "3", "3", "3");
如何避免这种错误呢?
List
等容器而非数组int[]{...}
就不会错误匹配,而引用类型数组,像string[]{}
和object[]{}
都会错误匹配。我们上面说了,可以把对JS代码的调用,看作是一种RPC调用。所以框架只给我们提供了异步接口InvokeAsync
和InvokeVoidAsync
就显得非常合理。
不过再仔细想一想,这个说法好像也有问题:
这俩玩意虽然逻辑上不一样,但实际运行在同一个宿主环境:浏览器上。说这个调用过程是RPC,多少有点牵强,那为什么框架没有给我们提供同步调用接口呢?
答案是:Blazor框架是一个有多种运行模式的框架,我们上面的例子、解释等,其实都是以Blazor WASM为主的。而如果切换到Blazor Server模式的话:
而如果在实际开发过程中,我们非常笃定:我们书写的组件,只会被用在Blazor WASM模式下的话。我们其实可以做到以同步的方式去调用JS代码,方法也很简单:将DI池里的IJSRuntime
对象,强制类型转换为IJSInProcessRuntime
类型即可,如下我们重新写一下Index.razor
:
@inject IJSRuntime JS
@page "/"
<h1>Hello, world!</h1>
@code {
private IJSInProcessRuntime JSInProcess => (IJSInProcessRuntime)JS;
protected override void OnInitialized()
{
List<int> dedupedNumbers = JSInProcess.Invoke<List<int>>("dedupNumericArray", new List<int> { 1, 1, 2, 2, 3, 3, 3 });
Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
}
}
也就是说,在Blazor前端的DI容器里,当部署模式为WASM时,容器里那个登记类型为IJSRuntime
的对象,其实是IJSInProcessRuntime
对象。
但请注意,直接试图使用@inject IJSInProcessRuntime JSInProcess
去从DI容器里获取IJSInProcessRuntime
对象是会失败的,框架并没有向DI容器登记IJSInProcessRuntime
对象,唯一正确的方式是先获取IJSRuntime
再进行强制类型转换。
上面的例子,都是我们把JS函数写在一个全局作用域里(在浏览器环境中,其实就是window
对象里面),然后使用IJSRuntime.InvokeAsync
去调用。而我们在文章前半部分花了超长的篇幅介绍了JS的模块方面的基础知识,那么现在问题来了:
如何在C#中调用ES模块里的JS代码?
比如我们在Client
项目的wwwroot
目录下新建一个子目录叫js
,在里面创建一个ES模块文件叫utility.js
,内容如下:
/*
Client/wwwroot/js/utility.js
*/
export function Dedup(arr) {
return [...new Set(arr)];
}
怎么调用它?如果你天资聪颖善于举一反三,那么很容易的会想到,我们可以如下,在index.html
中将这个ES模块的函数导出到window
对象里面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>HelloJSInterop</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<!-- If you add any scoped CSS files, uncomment the following to load them
<link href="HelloJSInterop.Client.styles.css" rel="stylesheet" /> -->
<script type="module">
import { Dedup } from "./js/utility.js";
window.Dedup = Dedup;
</script>
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
然后正常在Index.razor
中如下调用就可以了:
@inject IJSRuntime JS
@page "/"
<h1>Hello, world!</h1>
@code {
protected override async Task OnInitializedAsync()
{
List<int> dedupedNumbers = await JS.InvokeAsync<List<int>>("Dedup", new List<int> { 1, 1, 2, 2, 3, 3, 3 });
Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
}
}
这样怎么说呢,不是不行,但偏题了。上面这种做法其实分了两步:
index.html
中把utility.js
模块中的函数导出,并且复制粘贴到了window
这个全局作用域下window.Dedup
这样做的坏处是
window
全局作用域下,函数多了的话,要手动处理名称冲突所以即使我们从Index.razor
中删掉对Dedup
的调用,用户浏览器依然会去加载utility.js
,这样显然是不合理的,也背离了ES模块的初衷。
正确的做法并不是把import
语句写在index.html
或其它JS文件中,而是写在C#组件代码里,听着有点不对劲是吧?你这样理解:
可以简单的把import
指令也理解为一个JS的库函数,import
语句就是一个函数调用,调用的结果是返回一个或多个对象
比如import * as someNamespace from './xxx.js'
这句,就可以理解为是一个函数调用,返回的结果是一个名为someNamespace
的对象
顺着这个思路,其实我们就可以用IJSRuntime.InvokeAsync
去调用import
指令了
如下:
首先是把index.html
里的<script>
标签彻底删掉,现在index.html
中不需要一行JS代码了,然后就是把Index.razor
改写成下面的模样:
@inject IJSRuntime JS
@page "/"
<h1>Hello, world!</h1>
@code {
protected override async Task OnInitializedAsync()
{
- List<int> dedupedNumbers = await JS.InvokeAsync<List<int>>("Dedup", new List<int> { 1, 1, 2, 2, 3, 3, 3 });
-
- Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
+ IJSObjectReference utilityModule = await JS.InvokeAsync<IJSObjectReference>("import", "/js/utility.js");
+ if(utilityModule is not null)
+ {
+ List<int> dedupedNumbers = await utilityModule.InvokeAsync<List<int>>("Dedup", new List<int> { 1, 1, 2, 2, 3, 3, 3 });
+ Console.WriteLine(string.Join(", ", dedupedNumbers.Select(n => n.ToString()).ToArray()));
+ }
}
}
import
“函数”的返回值其实就是个JS对象,那么自然在C#中的类型,名字叫IJSObjectReference
import
函数返回的对象更是如此,所以IJSObjectReference
也有接口InvokeVoidAsync
和InvokeAsync
让我们去调用对象内部定义的函数。和IJSRuntime
一样IJSRuntime
就是一个特殊的IJSObjectReference
,只不过它指的那个JS对象是浏览器的window
对象而已我们在之前的文章中已经讲过了组件类库中的静态资源路径的问题,这里再一笔带过复习一下:
在独立类库中,静态资源依然请放在类库中的wwwroot
目录中
在引用类库中的静态资源时,请使用_content/{LibraryProjectName}/{Path}
这种格式的路径,其中:
{LibraryProjectName}
是类库的名字{Path}
是静态资源在类库中以wwwroot
为起点的路径IJSObjectReference
是一个外部资源,在页面析构的时候是需要释放的上例中的utilityModule
其实是需要析构的,对于动态加载ES模块来说,官方给出的最佳实践要点包括以下三条:
OnAfterRender{Async}
回调中对其进行初始化null
判断IAsyncDisposable
接口,并在接口实现中调用ES模块字段的DisposeAsync
方法一个良好的例子如下:
@inject IJSRuntime JS
@implements IAsyncDisposable
...
@code {
...
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
this.module = await JS.InvokeAsync<IJSObjectReference>("import", "/js/utility.js");
}
}
...
async ValueTask IAsyncDisposable.DisposeAsync()
{
if (this.module is not null)
{
await this.module.DisposeAsync();
}
}
}
不过我说实话,不释放ES模块对象的后果也就是一点点的内存泄漏而已:随着页面跳转次数增多,当前Tab会一遍又一遍的在内存中重复创建这个对象,从而导致内存泄漏。
不过,泄漏的内存会随着页面被关闭而彻底释放。所以坦白讲,在实践中,这不算一个什么严重的问题。
但还是建议你释放,代码的世界,有时候很难讲的,比如我觉得很难想象有Blazor组件的应用场景中,用户会保持一个浏览器Tab十几个小时不关闭,并且来回跳转。但在实际生产中,真的会有人用Blazor组件来写混合App,或者就以纯Web来写文字版网游,在这些奇怪的用法中,组件中任何小小的内存泄漏都会随着时间积累起来,在一个随机的时间将整个应用Crash掉。
IJSInProcessObjectReference
通过JS.InvokeAsync<IJSObjectReference>("import", "xxx.js")
方式拿到的ES模块对象,和IJSRuntime
一样,只支持异步调用,即InvokeAsync
和InvokeVoidAsync
。
而如果想要同步调用模块中的函数,可以使用JS.InvokeAsync<IJSInProcessObjectReference>("import", "xxx.js")
来获取模块对象,
需要注意的是:
无论是同步版的ES模块对象,还是异步版的ES模块对象,都算组件资源,都需要在组件析构的时候对其进行释放。
异步版的对象只实现了IAsyncDisposable
接口,所以在析构时只能调用await this.module.DisposeAsync()
。
而同步版本的接口还在此基础上实现IDisposable
,除了可以按上面异步对象的方式析构,还可以直接调用this.module.Dispose()
进行析构
显然,和IJSInProcessRuntime
一样,同步版本的ES模块对象只适用于WASM模式,不适用于Server模式。
截至目前为止,我们举出的示例代码,都是没有意义的教学代码,因为我们调用的JS函数,其实完全可以翻译成C#版本。
而在浏览器环境中,JS最擅长做的事情,并不是工具函数,而是操纵DOM元素,比如下面这个HTML文件中的JS代码:
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title>Manipulate DOM element</title>
</head>
<body>
<script>
function setElementClass(element, className) {
element.classList.add(className);
}
function setElementStyle(element, k, v) {
let existingStyles = element.getAttribute("style");
if(existingStyles) {
element.setAttribute("style", existingStyles + k + ":" + v + ";");
} else {
element.setAttribute("style", k + ":" + v + ";");
}
}
function createRedRect(parentElement) {
let container = document.createElement('div');
setElementClass(container, "red-rect");
setElementStyle(container, "width", "100px");
setElementStyle(container, "height", "100px");
setElementStyle(container, "border", "1px solid red");
parentElement.appendChild(container);
}
createRedRect(document.body);
</script>
</body>
</html>
代码非常简单易懂,运行结果就是在屏幕上画了一个100*100的红矩形。运行效果如下所示:
现在的问题是:以我们现在如今的知识储备,我们是没法在Blazor组件中使用C#调用上面写的三个函数的:这三个函数都需要一个DOM元素作为参数,而我们并不知道如何在C#中抓一个DOM元素。
比如我们想在Index组件的OnAfterRender
回调里,创建一个红色的矩形并且放在当前组件中,那么我们怎么做?
答案是:
@ref
属性(还记得吗?这种以@
开头的特殊属性,叫directive attribute)来将当前组件中的DOM元素绑定到一个变量上去ElementReference
IJSRuntime.Invoke(Void)Async
,被IJSRuntime
翻译后,它就是一个DOM元素现在,我们把上面三个函数写在wwwroot/js/utility.js
文件中,如下:
/*
Client/wwwroot/js/utility.js
*/
export function Dedup(arr) {
return [...new Set(arr)];
}
export function SetElementClass(element, className) {
element.classList.add(className);
}
export function SetElementStyle(element, k, v) {
let existingStyles = element.getAttribute("style");
if(existingStyles) {
element.setAttribute("style", existingStyles + k + ":" + v + ";");
} else {
element.setAttribute("style", k + ":" + v + ";");
}
}
export function CreateRedRect(parentElement) {
let container = document.createElement('div');
SetElementClass(container, "red-rect");
SetElementStyle(container, "width", "100px");
SetElementStyle(container, "height", "100px");
SetElementStyle(container, "border", "1px solid red");
parentElement.appendChild(container);
}
然后再把Index.razor
改写成下面的样子:
@inject IJSRuntime JS
@page "/"
<div @ref=@this.div>
<h1>Hello, world!</h1>
</div>
@code {
private ElementReference div;
private IJSObjectReference utilityModule = default!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
this.utilityModule = await JS.InvokeAsync<IJSObjectReference>("import", "/js/utility.js");
if(this.utilityModule is null)
{
throw new Exception("load '/js/utility.js' failed");
}
if(firstRender)
{
await utilityModule.InvokeVoidAsync("CreateRedRect", this.div);
}
}
}
运行,效果如下:
上面那个画红色矩形的例子中,我们核心的代码文件是如下组织的:
HelloJSInterop
|
\--- Client
|
|--- wwwroot
| |
| |--- js
| | |
| | \---> utility.js
| |
| \---> index.html
|
\--- Pages
|
\---> Index.razor
这样做没有任何问题,随着项目规模膨胀,你所要做的只有两件事:
Client/wwwroot/js/
目录下添加越来越多的ES模块文件JS.InvokeAsync<IJSObjectReference>("import", "/js/xxx.js")
去动态加载ES模块,然后调用其中的JS函数不过,如果我们换个角度来吹毛求疵的话,会发现有这样一个问题:整个应用是由Blazor组件构成的,但Blazor组件的实现,又依赖于wwwroot
中JS文件的路径。
换句话说,wwwroot
目录中的JS文件路径每发生一次变动,所有牵扯到的Blazor组件的代码都要改写。
这当然不是个大问题,并且实际项目中,需要调用JS代码的组件毕竟是少数,手动管理这些路径是完全没问题的,并且我们还可以把JS文件路径变量化,比如写出这样一个类:
public static class JSPathes
{
public static readonly string Utility = "/js/utility.js";
// ...
}
然后在组件代码中如下使用它:
@code {
// ...
this.utilityModule = await JSInvokeAsync<IJSObjectReference>("import", JSPathes.Utility);
// ...
}
但是,更让人感到清爽的,应当是尽量做到每个组件的实现都相对独立,这个相对独立有两个要点:
第一点是普适性的低耦合编程指导思想,而第二点,举个例子,就应当把目录结构改成下面这样:
HelloJSInterop
|
\--- Client
|
|--- wwwroot
| |
| \---> index.html
|
\--- Pages
|
|---> Index.razor
\---> Index.razor.js
将Index.razor
组件要使用到的JS代码,放在隔壁一个叫Index.razor.js
的文件中,既不污染wwwroot
目录,也不污染其它组件的目录结构。
现在问题来了,我们把原js/utility.js
这个文件,改个名叫Index.razor.js
,放在Pages
目录下,那么这个JS文件在部署后的路径到底是什么呢?
答案是:wwwroot/Pages/Index.razor.js
这个规律是:
如果一个组件的名字叫狗.razor
组件文件的路径是Client/目录A/目录B/目录C/狗.razor
组件旁边还有一个JS文件,叫Client/目录A/目录B/目录C/狗.razor.js
那么,JS文件会在发布打包的时候,被拷贝至 wwwroot/目录A/目录B/目录C/狗.razor.js
所以,在如上改动之后,我们的Index.razor
代码就需要改成下面这样:
@inject IJSRuntime JS
@page "/"
<div @ref=@this.div>
<h1>Hello, world!</h1>
</div>
@code {
private ElementReference div;
private IJSObjectReference utilityModule = default!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
- this.utilityModule = await JS.InvokeAsync<IJSObjectReference>("import", "/js/utility.js");
+ this.utilityModule = await JS.InvokeAsync<IJSObjectReference>("import", "/Pages/Index.razor.js");
if(this.utilityModule is null)
{
- throw new Exception("load '/js/utility.js' failed");
+ throw new Exception("load '/Pages/Index.razor.js' failed");
}
if(firstRender)
{
await utilityModule.InvokeVoidAsync("CreateRedRect", this.div);
}
}
}
而如果组件并不是在Client项目中,而是在一个组件类库项目中,如下:
solution
|
|--- Client
| |
| \---> Some.Namespace.Client.csproj
| ...
|
|--- Server
| |
| |---> Some.Namespace.Server.csproj
| ...
|
|--- Shared
| |
| |---> Some.Namespace.Shared.csproj
| ...
|
\--- FancyComponents
|
|---> Some.Namespace.FancyComponents.csproj
|
|--- A
| |
| \--- B
| |
| \---> Dog.razor
| \---> Dog.razor.js
...
还记得之前我们讲静态资源路径的时候,组件类库中的静态资源的路径吗?这个也差不多,上例中的Dog.razor.js
最终被打包的路径是:
_content/Some.Namespace.FancyComponents/A/B/Dog.razor.js
有关如何从C#调用JS,其实还有不少内容可以展开详细说,但我觉得以上五个知识点基本足以涵盖绝大部分使用需求了。
其实说了那么多,简单来说就三板斧:
使用ES模块配合await JS.InvokeAsync<IJSObjectReference>("import", "xxxx.js");
去动态加载JS
组件析构时记得要释放JS模块对象
挑选一个你喜欢的目录组织形式:
wwwroot/js
目录中狗.razor.js
Blazor框架不光允许在C#代码上下文中调用JS代码,其实也允许在JS代码上下文调用C#代码。不过呢,相较于C#调用JS,JS调用C#的应用场景比较少,所以这个章节我们就简单介绍一下,写个简单的例子囫囵吞枣式的了解一下就可以了。
我们首先分别新建一个Blazor Server项目和Hosted Blazor WASM项目,命令如下:
> dotnet new blazorwasm-empty -o HelloBlazorWASM --hosted
> dotnet new blazorserver-empty -o HelloBlazorServer
然后分别将两个项目的Index.razor
中的内容从Hello world!
改写为Hello Blazor Server
和Hello Blazor WASM
。
无论是Blazor Server项目还是Blazor WASM项目,运行起来后,打开浏览器调试窗口,你都会看到,Blazor框架为window
对象里面塞了一个叫DotNet
的对象,并且这个对象有两个方法名为invokeMethod
和invokeMethodAsync
,如下两图所示:
这个DotNet
对象以及invokeMethod/invokeMethodAsync
方法就是Blazor框架向JS提供的,用来调用C#代码的入口。从名字上来看,显然一个是同步调用,一个是异步调用,而如果你理解了上面我们讲在C#中调用JS时的同步/异步的原理后,你也能举一反三出来:
DotNet.invokeMethodAsync
既能工作在Blazor Server模式下,也能工作在Blazor WASM模式下
它的返回值也是一个典型的JS Promise:即你无法在调用结束后立即得到调用结果,而需要以promise.then(onGood, onBad)
的方式来消费未来的调用结果。
其中onGood
和onBad
是两个回调函数,分别在调用成功和调用失败的情况下被执行
DotNet.invokeMethod
则只能工作在Blazor WASM模式下
它的返回值则直接就是调用结果
而invokeMethodAsync
和invokeMethod
的参数,则有三部分:
这些设计都很符合直觉,我们在后面的例子中会生动说明。
现在,在调用方要做的事情就解说完了,其实就一句话:调用window.invokeMethod
或window.invokeMethodAsync
方法。
而在被调方,还需要做一些额外的操作,这里,我们分两种情况讨论:
被调用的函数必须满足以下要求:
public
函数static
函数[JSInvokable]
修饰我们以Blazor WASM项目为例,在Client
项目中写两个方法
首先是在Index.razor
组件内部,添加一个静态方法,如下:
@page "/"
<h1>Hello Blazor WASM</h1>
+
+@code {
+ [JSInvokable]
+ public static int[] GetNumberSequence(int low, int high)
+ {
+ return Enumerable.Range(low, high).ToArray();
+ }
+}
然后在Client项目中添加一个普通的C#类,叫Utility.cs
吧,如下
using Microsoft.JSInterop;
namespace HelloBlazorWASM.Client;
public static class Utility
{
[JSInvokable]
public static int[] DedupNumbers(IEnumerable<int> numbers)
{
return new HashSet<int>(numbers).ToArray();
}
}
接下来,运行项目,我们就可以直接在浏览器调试窗口,以交互方式如下调用这两个方法:
首先是以同步方式直接调用,如下图所示:
然后是以异步方式调用,如下图所示:
上面的例子很直观,也很符合直觉,但如果我们把调用的代码直接写进index.html
,比如将wwwroot/index.html
改写成下面这样,就不行了:
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
+
+ <script>
+ let syncSeq = window.DotNet.invokeMethod("HelloBlazorWASM.Client", "GetNumberSequence", 12, 16);
+ console.log(syncSeq);
+ let syncDedup = window.DotNet.invokeMethod("HelloBlazorWASM.Client", "DedupNumbers", [1, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 4]);
+ console.log(syncDedup);
+ </script>
</body>
运行项目中,浏览器调试窗口会报如下的错:
原因很简单:当浏览器执行到index.html
中的JS代码时,我们的Blazor程序其实还没初始化完毕。那么问题就来了:我们如何能在JS代码中得知Blazor程序什么时候初始化完毕呢?有没有类似于addEventListener("DOMContentLoaded", (event) => {})
之类的事件可以拿来监听呢?
答案是:没有,我们无法在JS代码中判断Blazor框架是否初始化完成,即没有办法判断何时window.DotNet.invokeMethod(Async)
可用。
是不是感觉有点不可思议?感觉有点不可理喻?
其实,问题不是框架设计有问题,而是我们的思路有问题:
举个例子,我们把这个“内部调用了C#函数的JS方法”设成DOM元素的事件回调,由用户在看到页面渲染结果后触发执行,才勉强有那么一丝合理性,如下我们在Index.razor
中写的这样(记得在实验之前把index.html
恢复原样):
@page "/"
<h1>Hello Blazor WASM</h1>
+
+<button onclick="doSomething()">Click to see JS call C#</button>
+
+<script>
+ function doSomething() {
+ let numberSequence = window.DotNet.invokeMethod("HelloBlazorWASM.Client", "GetNumberSequence", 13, 17);
+ console.log(`sync call GetNumberSequence: ${numberSequence}`);
+ let dedupNumbers = window.DotNet.invokeMethod("HelloBlazorWASM.Client", "DedupNumbers", [1,1,1,1,2,2,2,3,3,4,4]);
+ console.log(`sync call DedupNumbers: ${dedupNumbers}`);
+
+ let onGood = res => console.log(res);
+ let onBad = err => console.log(err);
+ window.DotNet.invokeMethodAsync("HelloBlazorWASM.Client", "GetNumberSequence", 13, 17)
+ .then(
+ (res) => {
+ console.log(`async call GetNumberSequence: ${res}`);
+ },
+ (err) => {
+ console.log(err);
+ }
+ );
+ window.DotNet.invokeMethodAsync("HelloBlazorWASM.Client", "DedupNumbers", [1,1,1,1,2,2,2,3,3,4,4])
+ .then(
+ (res) => {
+ console.log(`async call DedupNumbers: ${res}`);
+ },
+ (err) => {
+ console.log(err);
+ }
+ );
+ }
+</script>
+
@code {
[JSInvokable]
public static int[] GetNumberSequence(int low, int high)
{
return Enumerable.Range(low, high).ToArray();
}
}
它的执行结果如下所示:
首先是基本示例,可以看到在Blazor Server模式下,invokeMethod
是不可用的:
其次是直接把JS代码写进Pages/_Host.cshtml
试图让浏览器直接执行也是行不通的:
最后是按钮事件回调的例子,在剔除了invokeMethod
的调用代码后,也是能正确执行的:
本来,到这个小节,我们应当讲一下如何从JS中调用C#的实例方法了。但现在我们应当暂停一下,然后补充一个额外的知识点:如何在JS Runtime和dotnet runtime两个运行时之间,传递“对象”了。
即如果我在JS runtime上构造了一个复杂无比的对象,我如何把它传递给dotnet runtime呢?反过来一个dotnet runtime上的复杂对象,如何传递给js runtime呢?
其实,说“传递”,也并不准确,对象本身在内存中并没有移动:JS runtime中的对象依然在JS runtime的内存模型中,dotnet runtime中的对象也依然存在在dotnet runtime的运行模型中。传递的东西其实只是“引用”或“指针”。
上面我们有说过,在C#代码中动态加载ES模块,其实我们得到的就是一个IJSObjectReference
或IJSInProcessObjectReference
,这个东西,其实就是一个JS Runtime上的JS对象,我们只不过通过拿到的C#变量远程引用着它而已。
这个东西其实说起来非常简单:两个runtime互相调用的时候,调用方可以以传递入参的方式将自己runtime上的对象传递给对方,另外在函数返回时被调用方可以以返回值的方式将自己runtime上的对象返回给调用方。
也就是说,无论是JS调用C#,还是C#调用JS,其实都可以跨runtime传递对象引用到对方那边。但我们上面讲了那么多,为什么没有涉及这个知识点呢?
因为在之前我们举的所有例子中,无论是JS调C#还是C#调用JS,我们传递的参数,和函数执行结束后返回的值,都是基本类型数据,都是通过中间商进行符合直觉的数据类型转换后,把“值”传递到了对方。
但有一个例外,就是动态加载ES模块的例子:
动态加载ES模块的例子中,第一步我们是先拿到了一个IJSObjectReference
或IJSInProcessObjectReference
,这其实是一个JS Runtime上的对象,被我们用C#中的一个变量引用着
现在在这个独立章节,我们捋一下,如何在两个runtime之间传递对象,以及拿到这些跨runtime的间接引用,能干什么。为了清晰起见,我们这个章节所有的例子,都是在一个hosted Blazor WASM的Index.razor
中书写的。
以下每个小节,都在回答两个问题:
@page "/"
@inject IJSRuntime JS
<h1>Hello Blazor WASM</h1>
<p>the dotnet object</p>
<p>@this.d</p>
<button @onclick=@this.Refresh>Refresh</button>
<script>
function ReceiveDotnetObjectThenDoSomething(d) {
window.d = d;
}
</script>
@code {
public class DICT
{
private Dictionary<int, int> dict = new Dictionary<int, int> { { 1, 1 }, { 2, 2 }, { 3, 3 } };
[JSInvokable]
public void Add(int key, int value) => this.dict.Add(key, value);
[JSInvokable]
public void Clear() => this.dict.Clear();
[JSInvokable]
public override string ToString()
{
return string.Join(", ", this.dict.Select(kvp => $"<{kvp.Key}, {kvp.Value}>"));
}
}
private DICT d = new DICT();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("ReceiveDotnetObjectThenDoSomething", DotNetObjectReference.Create(this.d));
}
void Refresh()
{
this.StateHasChanged();
}
}
上面这个例子做的事有:
首先是创建了一个名为DICT
的内部类,它内部包裹着一个标准库的字典,然后公开了三个方法:Add
, Clear
和ToString
其次是让Index
组件持有了这个类的一个实例对象this.d
,并把它渲染在页面上
在Index
的生命周期回调OnAfterRenderAsync
中,将this.d
通过调用JS函数的方式传递给了JS runtime,而被调用的JS函数其实是把this.d
的引用保存在了浏览器的window
对象中
这里是关键,注意之前我们在C#中调用JS函数时,传递参数都是直接把要传递的C#变量直接写进InvokeVoidAsync
末尾,这样的话IJSRuntime
其实是会把变量先JSON化, 然后把JSON化后的数据传递到JS函数中
而在这里,我们使用的是DotNetObjectReference.Create(this.d)
这个写法,通过这个工具方法,其实是告诉IJSRuntime
:我们试图传递的其实是对象的引用,是this.d
的跨runtime指针给JS runtime
这样,在JS runtime那边,ReceiveDotnetObjectThenDoSomething
函数接收到的就是一个跨runtime的引用,间接遥控的就是dotnet runtime上的this.d
对象
我们还在页面上添加了一个按钮,用来手动触发StateHasChanged()
来让页面重新渲染
那么拿到一个dotnet对象引用的JS runtime又能做些什么呢?答案很简单:可以通过invokeMethod
或invokeMethodAsync
来调用this.d
的实例方法:但仅限于有[JSInvokable]
标记的那些实例方法
所以我们能做出下面的实验来:
这个实验其实也间接的说明了我们上面暂停的内容:即如何在JS中调用C#的实例方法
@page "/"
@inject IJSRuntime JS
<h1>Hello Blazor WASM</h1>
<button onclick="passJSObjectToDotNet()">pass js object to dotnet</button>
<button @onclick=@this.ModifyJSObject>Modify JS Object</button>
<script>
window.jsObject = {
propertyA: "string property",
propertyB: 114514,
modifyProperties: function(a, b) {
this.propertyA = a;
this.propertyB = b;
},
toString: function() {
return `propertyA == ${JSON.stringify(this.propertyA)}, propertyB == ${JSON.stringify(this.propertyB)}`;
}
};
function passJSObjectToDotNet() {
window.DotNet.invokeMethod(
"HelloBlazorWASM.Client",
"ReceiveJSObject",
window.DotNet.createJSObjectReference(window.jsObject)
)
console.log("JS runtime: passed window.jsObject to dotnet runtime");
}
</script>
@code {
private static IJSObjectReference jsObj;
[JSInvokable]
public static void ReceiveJSObject(IJSObjectReference jsObj)
{
if(Index.jsObj is null)
{
Index.jsObj = jsObj;
Console.WriteLine("Dotnet runtime: just received jsObj");
}
}
private async Task ModifyJSObject()
{
await Index.jsObj.InvokeVoidAsync(
"modifyProperties",
new List<string> { "hello", "property", "A" },
new Dictionary<int, int> { { 233, 244 }, { 123, 111 } }
);
}
}
上面这个例子做的事有:
在Index
组件类中声明了一个static
成员jsObj
,以及一个静态函数ReceiveJSObject
,其中静态函数用[JSInvokable]
修饰着
这个函数就是给JS调用的,JS调用时应当传一个JS runtime上的对象的引用过来,我们把这个引用会保存在静态字段Index.jsObj
上
与此同时,我们用JS写了一段代码:
首先我们就地创建了一个对象,并把它塞进浏览器的window
对象里。这个对象有两个字段,两个方法
其次我们写了一个函数passJSObjectToDotNet
,在函数内部,调用window.DotNet.invokeMethod
调用了Index
组件里写的静态方法ReceiveJSObject
同时,在入参部分,我们调用window.DotNet.createJSObjectReference
,将上一步创建好的window.jsObject
对象传递给了dotnet runtime
然后我们纯前端的写法,将JS函数passJSObjectToDotNet
设定为了第一个按钮的回调函数
最后我们用Blazor的写法,为第二个按钮写了一个C#回调函数ModifyJSObject
在这个回调函数中,我们将在C#代码中,去调用那个JS对象的方法,从而改变那个对象内部的数据
这个例子的运行结果是如下:
这其中就是我们在dotnet中调用JS的实例方法。
跨runtime引用对方的对象虽然看起来非常好玩,但实际上还是要慎用,这主要是因为对象被外部势力引用之后,runtime就没法通过GC回收这个对象了。
试想,在C#上下文中创建一个对象,那个dotnet的GC就会在检测到该对象无人引用后,将该对象从内存中删掉。而如果我们把这个C#对象用DotNetObjectReference.Create(obj)
裹起来扔给JS runtime的话,那么对不起,GC永远都不会删除这个对象了。
因为dotnet的GC不可能知道JS那边到底还需要不需要这个对象,只能选择保留着,也就是:内存泄漏了。
换句话说:程序员需要自己手动去在合适的时机,释放这些对象。这不光包括从C#传给JS的那些dotnet对象,也包括从JS传递给C#的JS对象。
对于dotnet对象来说,释放它们有两个办法:
dispose
方法DotNetObjectReference.Create(obj)
的返回值保存起来,然后在这个返回值上调用Dispose
方法而对于JS对象来说,释放它们看起来只有一个办法,就是我们之前讲到的IJSObjectReference
接口的DisposeAsync
方法
把dotnet对象传递给JS runtime:
DotnetObjectReference.Create(obj)
把对象包起来扔给JS runtimedispose
方法释放对象,要么在C#这边调用Dispose
释放对象把JS对象传递给dotnet
window.DotNet.createJSObjectReference
把对象包起来扔给dotnet runtimeIJSObjectReference.DisposeAsync
释放对象IJSInProcessObjectReference
,转换后就可以以同步的方式去调用对象方法,以及释放时调用Dispose
了现在基本上有关JS的重要知识点就差不多说完了,当然并不是所有知识点都说完了,比如两个runtime之间还可以以序列化的方式传递数据呀之类的知识点,是没有涉及的。
但基本上,我们已经把实际工作中可能遇到、用到的知识点都讲到了,这个章节,我们来写两个例子:
使用任何开源的库,第一步都是进官网看文档。进入高德开放平台的官网,在“文档与支持”栏,我们可以看到有如下分类:
点进去就是高德地图为JS开放的API文档,查看文档的“准备”页,按照文档指引注册开发者账号,并创建应用及key,然后直接转向“快速上手”,按照文档指引,我们先写一个纯HTML+JS的demo,来展示一片区域的地图
整体逻辑分为三步:
key
,一个叫安全密钥
<script>
标签直接在HTML文档中引入。new AMap.Map
拿到AMap
对象,然后在里面各种操作即可这里有个敏感问题,就是key
和安全密钥
在哪一步提交给高德,而又如何防止泄露,这个我们后面再说。我们先以明文的方式去使用key
和安全密钥
,来先把功能写出来。
高德的API,在示例代码中有两种风格,第一种是直接通过AMap.Map
来创建地图对象,如下所示
<script src="https://webapi.amap.com/maps?v=2.0&key=您申请的key值"></script>
<script>
var map = new AMap.Map('container', {
viewMode: '2D', // 默认使用 2D 模式,如果希望使用带有俯仰角的 3D 模式,请设置 viewMode: '3D'
zoom:11, // 初始化地图层级
center: [116.397428, 39.90923] // 初始化地图中心点
});
</script>
这是2022年之前的推荐写法,这种写法中,调用者只需要提供一个key即可调用API。在这种写法中,是直接将key
作为URL参数,附加在引入库文件的请求里。
直接使用这种写法会导致key
以明文方式泄露,这样任何人都可以拿着你泄露的key
去调用高德的API。所以显然在生产环境你是不应当这样直接搞的,而应当用代理服务器把库文件请求中转一下,把key
保存在服务端,比如在前端代码里写:
<script src="/AMapLibrary/amap.js"></script>
然后在服务端,将/AMapLibrary/amap.js
的请求代理至https://webapi.amap.com/maps?v=2.0&key=xxxxx
上。这样的代理功能一般情况下都不写进应用的逻辑代码里,而是直接在部署时使用nginx这样的反向代理服务器来实现。
大概、或许是有太多的人不在乎key
的泄露,从而故意或者不小心的把他们的key
泄露了出去,于是高德在2022年之后,对于每个JS应用,除了key
之外,还新增了一个叫安全密钥
的东西,于是最新版的推荐写法变成了下面这样:
<script src="https://webapi.amap.com/loader.js"></script>
<script type="text/javascript">
window._AMapSecurityConfig = {
// ...
};
AMapLoader.load({
key: "您申请的key值",
version:"2.0",
})
.then((AMap) => {
var map = new AMap.Map('container', {
viewMode: '2D', // 默认使用 2D 模式,如果希望使用带有俯仰角的 3D 模式,请设置 viewMode: '3D'
zoom:11, // 初始化地图层级
center: [116.397428, 39.90923] // 初始化地图中心点
});
})
</script>
这一版的改动有以下关键点
加载库文件的方式变成了loader.js
配合AMapLoader.load()
方法执行,原AMap
不再是全局对象了
已经不在乎key
的泄露了,而是再造出了一个安全密钥
,填充在window._AMapSecurityConfig
中
安全密钥
写在window._AMapSecurityConfig
中window._AMapSecurityConfig = {
securityJsCode: "您申请的安全密钥"
};
_AMapSecurityConfig.serviceHost
,window._AMapSecurityConfig = {
serviceHost: "https://xxx.xx.com/_AMapService"
}
同时在部署环境中,设置如下三个请求转发代理:
/_AMapService/v4/map/styles
的请求代理至https://webapi.amap.com/v4/map/styles&jscode=您的安全密钥
/_AMapService/v3/vectormap
的请求代理至https://fmap01.amap.com/v3/vectormap&jscode=您的安全密钥
/_AMapService/xxx
的请求代理至https://restapi.amap.com/xxx&jscode=您的安全密钥
我们先不管如何在服务端设置转发以及保护安全密钥
,我们先直接以明文的方式写个html版本的demo,即如下:
<!DOCTYPE html>
<html>
<head>
<title>HELLO,AMAP!</title>
</head>
<body>
<div style="width:300px; height:300px" id="container"></div>
<script type="text/javascript">
const aMapSecurityJSCode = "???";
const aMapKey = "???";
window._AMapSecurityConfig = {
securityJsCode: aMapSecurityJSCode
};
</script>
<script src="https://webapi.amap.com/loader.js"></script>
<script type="text/javascript">
AMapLoader.load({
key: aMapKey,
version: "2.0",
})
.then((AMap) => {
// 第一步:创建AMap对象,替换掉文档中的 div#container
// 缩放等级为10级,地图中心点为北京市故宫,允许用户自定义缩放
const map = new AMap.Map(
"container",
{
zoom:10,
center:[116.397, 39.918],
resizeEnable: true
}
);
// 第二步:向地图中添加一个标记
const marker = new AMap.Marker({
position: [116.397, 39.918]
});
map.add(marker);
// 第三步:向地图中添加一个折线
const lineArr = [
[116.28, 39.83],
[116.275, 39.983],
[116.486, 39.99],
[116.485, 39.836]
];
const polyline = new AMap.Polyline({
path: lineArr,
strokeColor: "red"
});
map.add(polyline);
})
.catch((e) => {
console.error(e);
});
</script>
</body>
</html>
运行结果如下(你自己试验的时候记得向上面代码中的aMapSecurityJSCode
和aMapKey
赋值):
我们上面的示例做了三件事:
AMap
对象,就是地图对象,并且设置了地图的缩放等级以及中心点。在外围,我们还通过div#container
的CSS样式设定了地图的大小是一个300像素的正方形那么我们抛开具体如何实现不谈,如果我们将上面的功能包装成一个组件,那么这个Blazor组件应当有以下参数
那么我们可以把这个组件的外形写出来,我们先创建一个空的Blazor WASM项目,然后在其Client/Components
目录中新建一个AMap.razor
组件
@using HelloBlazorAMap.Shared
<p>@this.ParametersString()</p>
@code {
[Parameter]
public int WidthInPx { get; set; }
[Parameter]
public int HeightInPx { get; set; }
[Parameter]
public AMapCoordinates Center { get; set; }
[Parameter]
public int ZoomLevel{ get; set; }
[Parameter]
public AMapCoordinates Marker{ get; set; }
[Parameter]
public IList<AMapCoordinates> PolyPoints{ get; set; }
public string ParametersString()
{
return $"size: {WidthInPx}px * {HeightInPx}px, "
+ $"center: {Center}, "
+ $"zoomLevel: {ZoomLevel}, "
+ $"markers: {Marker}, "
+ $"poly points: {{ {string.Join(", ", PolyPoints.Select(m => m.ToString()))} }}";
}
}
其中的AMapCoordinates
是我们新建在Shared
项目中用来表示经纬度点的工具类,代码如下:
namespace HelloBlazorAMap.Shared;
public class AMapCoordinates
{
// 经度
public double Longitude { get; set; }
// 纬度
public double Latitude { get; set; }
public AMapCoordinates(double longitude, double latitude)
{
this.Longitude = longitude;
this.Latitude = latitude;
}
public override string ToString()
{
return $"[{Longitude}, {Latitude}]";
}
}
然后在调用方Pages/Index.razor
中,我们就可以如下调用它:
@using HelloBlazorAMap.Client.Components
@using HelloBlazorAMap.Shared
@page "/"
<h1>Hello AMap</h1>
<AMap
ZoomLevel=10
WidthInPx=300
HeightInPx=300
Center=@(new AMapCoordinates(116.397, 39.918))
Marker=@(new AMapCoordinates(116.397, 39.918))
PolyPoints=@(new List<AMapCoordinates>{
new AMapCoordinates(116.28, 39.83),
new AMapCoordinates(116.275, 39.983),
new AMapCoordinates(116.486, 39.99),
new AMapCoordinates(116.485, 39.836)
})
>
</AMap>
现在我们来考虑具体实现,首先是加载loader.js
的过程,需要我们先在window
对象中设定好_AMapSecurityConfig
对象,所以这部分其实没什么好办法,我们需要把这部分写在index.html
中,即如下:
<body>
+
+ <script>
+ window._AMapSecurityConfig = {
+ securityJsCode: "您的安全密钥"
+ }
+ </script>
+ <script src="https://webapi.amap.com/loader.js"></script>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
而之所以把这部分代码写的比较靠前,是为了保证在Blazor前端代码开始运行的时候,loader.js
已经被加载且执行完毕了。
我们的应用在Blazor初始化之前就会全局性的去加载并执行loader.js
,这其实并不是真正的高德API库文件,这只是一个载入器,它的体积是非常小的,所以即便是全局加载,对性能的影响也是微乎其微的。
再下一步就是在浏览器执行了loader.js
后,window
对象中就会存在一个叫AMapLoader
的对象,我们需要使用window.AMapLoader.load().then()
去实际的加载、执行并绘制地图了。
而这些动作,完全可以包装成ES模块函数,仅在AMap.razor
组件被渲染时才加载,所以我们可以新增一个AMap.razor.js
模块文件,内容如下:
export function LoadAndDrawAMap(zoomLevel, center, marker, polyPoints) {
window.AMapLoader.load({
key: "????", // 在此写上你自己的key
version: "2.0"
})
.then((AMap) => {
const map = new AMap.Map(
"amap_container",
{
zoom: zoomLevel,
center: [center.longitude, center.latitude],
resizeEnable: true
}
);
AddMarkerToAMap(AMap, map, marker);
AddPolyToAMap(AMap, map, polyPoints);
})
.catch((e) => { console.error(e); })
}
function AddMarkerToAMap(AMap, map, marker) {
map.add(new AMap.Marker({
position: [marker.longitude, marker.latitude]
}));
}
function AddPolyToAMap(AMap, map, polyPoints) {
const lineArr = [];
polyPoints.forEach(p => { lineArr.push([p.longitude, p.latitude]) });
const polyline = new AMap.Polyline({
path: lineArr,
strokeColor: "red"
});
map.add(polyline);
}
再然后,我们在渲染AMap
组件的时候,动态的去载这个ES模块,并调用其中的LoadAndDrawAMap
函数即可,对AMap.razor
的代码改动如下:
@using HelloBlazorAMap.Shared
+@implements IAsyncDisposable
+@inject IJSRuntime JS
-<p>@this.ParametersString()</p>
+<div id="amap_container" style=@($"height: {this.HeightInPx}px; width: {this.WidthInPx}px")></div>
@code {
[Parameter]
public int WidthInPx { get; set; }
[Parameter]
public int HeightInPx { get; set; }
[Parameter]
public AMapCoordinates Center { get; set; }
[Parameter]
public int ZoomLevel{ get; set; }
[Parameter]
public AMapCoordinates Marker{ get; set; }
[Parameter]
public IList<AMapCoordinates> PolyPoints{ get; set; }
+
+ private IJSObjectReference amapWrapperModule;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if(!firstRender)
+ {
+ return;
+ }
+
+ this.amapWrapperModule = await JS.InvokeAsync<IJSObjectReference>("import", "/Components/AMap.razor.js");
+ if(this.amapWrapperModule is not null)
+ {
+ await this.amapWrapperModule.InvokeVoidAsync("LoadAndDrawAMap", this.ZoomLevel, this.Center, this.Marker, this.PolyPoints);
+ }
+ }
public string ParametersString()
{
return $"size: {WidthInPx}px * {HeightInPx}px, "
+ $"center: {Center}, "
+ $"zoomLevel: {ZoomLevel}, "
+ $"markers: {Marker}, "
+ $"poly points: {{ {string.Join(", ", PolyPoints.Select(m => m.ToString()))} }}";
}
+
+ async ValueTask IAsyncDisposable.DisposeAsync()
+ {
+ if (this.amapWrapperModule is not null)
+ {
+ await this.amapWrapperModule.DisposeAsync();
+ }
+ }
}
运行程序,效果如下:
完美还原
安全密钥
上面的实现的一个大问题,就是我们把key
和安全密钥
都以明文的形式写在代码中,比如直接在浏览器调试窗口网络连接中查看对AMap.razor.js
请求,就能拿到key
:
在源代码界面或控制台,就能获得安全密钥
key
我们就不提了,因为高德官方文档已经不太强调让大家保护key了,估计是麻了,大家直接明文写吧。但有一个稍微能隐藏一下的办法,就是我们可以把key也通过参数的方式传递给js runtime的LoadAndDrawAMap
函数,这样至少key的值不会明晃晃的放在那里,太扎眼。不过这样做其实也是真的掩耳盗铃,有心人只需要抓一下调用断点就能看到key的值了。
不过不重要,就像高德官方文档说的那样,key
现在已经麻了,关键是要保护好安全密钥
。
按高德官方的建议做法,是将安全密钥的值配置在部署服务器的nginx代理配置里去,我们这里方便一点,学Blazor就是为了一把梭,我部署连nginx都不想用,我们直接在Server
项目中设定请求转发
Server
项目中转发请求转发请求做代理有两步要做,第一步,我们需要借助一个开源的第三方Nuget包来方便的实现请求转发代理,这个包叫AspNetCore.Proxy
,即要把Server.csproj
项目改成下面这样:
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="7.0.20" />
+ <PackageReference Include="AspNetCore.Proxy" Version="4.5.0" />
</ItemGroup>
第二步,我们在Server
项目中新增文件Controllers/AMapServiceController.cs
,来转发代理必要的请求,代码如下:
using AspNetCore.Proxy;
using Microsoft.AspNetCore.Mvc;
using RouteAttribute = Microsoft.AspNetCore.Mvc.RouteAttribute;
namespace HelloBlazorAMap.Server.Controllers
{
public class AMapServiceController : Controller
{
private const string amapSecurityCode = "安全密钥的值";
[Route("/_AMapService/{**rest}")]
public Task ProxyAMapRequests(string rest)
{
var queryString = this.Request.QueryString.Value;
if(rest.ToLower() == "v4/map/styles")
{
return this.HttpProxyAsync($"https://webapi.amap.com/v4/map/styles?{queryString}&jscode={amapSecurityCode}");
}
if(rest.ToLower() == "v3/vectormap")
{
return this.HttpProxyAsync($"https://fmap01.amap.com/v3/vectormap?{queryString}&jscode={amapSecurityCode}");
}
return this.HttpProxyAsync($"https://restapi.amap.com/{rest}{queryString}&jscode={amapSecurityCode}");
}
}
}
之后还要在Program.cs
中为DI容器注入转发代理必要的服务对象,如下:
// ...
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
+builder.Services.AddProxies();
var app = builder.Build();
// ...
如此操作之后,我们写的应用在收到/_AMapService/***
的请求后,就会将其转发代理,并在转发代理的过程中,会将安全密钥
发送给高德的服务器。整个过程中,前端完全接触不到安全密钥
Client
项目中重新配置_AMapSecurityConfig
对象我们需要对index.html
进行一点小修改,改动如下:
<body>
<script>
window._AMapSecurityConfig = {
- securityJsCode: "您的安全密钥"
+ serviceHost: `${window.location.origin}/_AMapService`
}
</script>
<script src="https://webapi.amap.com/loader.js"></script>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
这样,高德在做认证的时候,就会向/_AMapService
发一系列请求,而这些请求会被我们的Server
在服务器端进行拦截、代理。这样就保证了安全密钥
不泄漏
以上的举例,重点在于向大家展示在Blazor框架中使用JS类库的能力,包括接下来要展示的ECharts也是如此。
以上的代码虽然可以运行成功,但并不代表就是最佳实践,大家可以作为参考去使用,千万不要把思维局限于此。
ECharts与高德地图的最大的一个区别是:ECharts真的只是一个JS类库,开发者可以自行将某个版本的ECharts的JS文件托管在自己的服务器上,从而完全不需要与ECharts的任何API进行交互。
而高德地图其实并不仅仅是一个类库那么简单,它实际是一个独立的程序,你把地图嵌入到你的应用中后,地图自己还会不停的与高德的服务器进行交互。所以高德官方也是强烈建议所有开发者都以loader
的方式去动态加载AMap
对象的,强烈不建议开发者自行托管相关的JS文件的。
比如打开ECharts官网文档的第一页,ECharts就明确的告诉你:我这整个库,就是一个echarts.js
文件:
那我们也听劝,直接去这个官方推荐的CDN网站去把整个库下载下来,在我写这篇文章的时候,我使用到的下载链接是:https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js
即下载下来的文件叫echarts.min.js
我们先写一个纯HTML的demo,来画个图。在这个demo中,你可以选择以引用本地文件的方式,使用<script src=./echarts.min.js></script>
去引用ECharts库,也可以直接使用CDN链接去获取整个库,我这里使用本地文件吧。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello ECharts</title>
</head>
<body>
<div id="echarts_container" style="height:300px;width:300px"></div>
<script src="./echarts.min.js"></script>
<script>
const chart = window.echarts.init(document.getElementById("echarts_container"));
const option = {
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [
{
data: [150, 230, 224, 218, 135, 147, 260],
type: 'line'
}
]
};
chart.setOption(option);
</script>
</body>
</html>
上面这个例子足够简单,它的运行效果如下:
整个程序的运行逻辑就是,在浏览器加载运行echarts.min.js
后,就会把ECharts库里的所有东西都塞进window.echarts
对象中去,然后我们按着ECharts官方网站中的示例抄这样一个图表就可以了。
而如果你不爽这样的传统方式,ECharts还提供了ES模块式的库文件,在官方“下载”栏点进它们的GitHub编译产物页面:
esm
的意思就是ES Module,其中带min
指的是压缩后的JS文件,以.mjs
为后缀的是符合ES标准的新型后缀文件,这个我们在文章开头讲过。那么,现在我们把echarts.esm.min.js
下载到本地,重新复刻一下上面的例子吧:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello ECharts</title>
</head>
<body>
<div id="echarts_container" style="height:300px;width:300px"></div>
<script type="module">
import * as ECharts from "./echarts.esm.min.js"
function DrawLineChart(divId, xdata, ydata) {
const chart = ECharts.init(document.getElementById(divId));
const option = {
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [
{
data: [150, 230, 224, 218, 135, 147, 260],
type: 'line'
}
]
};
chart.setOption(option);
}
DrawLineChart("echarts_container", ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], [150, 230, 224, 218, 135, 147, 260]);
</script>
</body>
</html>
这个比较好分析,简单来说,如果我们要把“在页面上画一个折线图”这个功能包装成一个Blazor组件的话,这个组件应当有以下参数:
或者进一步的,不要把数据按轴分成横轴纵轴两部分,而应当把数据设定为IList<Tuple<横轴名,纵轴值>>
这样的结构
所以我们可以直接创建出一个Client/Components/LineChart.razor
组件,并如下写出它的框架:
@code {
[Parameter]
public int WidthInPx{ get; set; }
[Parameter]
public int HeightInPx{ get; set; }
[Parameter]
public IList<LineChartData> Data{ get; set; }
}
其中LineChartData
是我们写在Shared
项目中的一个数据结构,用来表示折线图中的一个点
namespace HelloBlazorECharts.Shared;
public class LineChartData
{
public string X { get; set; }
public double Y { get; set; }
public LineChartData(string x, double y)
{
this.X = x;
this.Y = y;
}
}
然后在Pages/Index.razor
中如下调用它
@using HelloBlazorECharts.Client.Components
@using HelloBlazorECharts.Shared
@page "/"
<h1>Hello ECharts</h1>
<LineChart
WidthInPx=300
HeightInPx=300
Data=@(new List<LineChartData>
{
new LineChartData("Mon", 150),
new LineChartData("Tue", 230),
new LineChartData("Wed", 224),
new LineChartData("Thu", 218),
new LineChartData("Fri", 135),
new LineChartData("Sat", 147),
new LineChartData("Sun", 260),
})
></LineChart>
接下来就是具体实现了,这个就非常简单了,首先我们把echarts.esm.min.js
放在wwwroot/js
目录下,作为一个整个项目都可能用到的ES模块文件放在那里。
然后我们创建Components/LineChart.razor.js
,在这里把我们上面的DrawLineChart
函数放进去,不同的点在于现在DrawLineChart
中的数据都是由参数传递进来的,如下:
import * as ECharts from "/js/echarts.esm.min.js"
export function DrawLineChart(divId, xdata, ydata) {
const chart = ECharts.init(document.getElementById(divId));
const option = {
xAxis: {
type: 'category',
data: xdata // 参数传递的数据,原先是['Mon', 'Tue', ...]
},
yAxis: {
type: 'value'
},
series: [
{
data: ydata, // 参数传递的数据,原先是[150, 230, ...]
type: 'line'
}
]
};
chart.setOption(option);
}
注意看import
语句的路径,要变更成/js/echarts/esm.min.js
,还要把函数DrawLineChart
导出。
现在,对于LineChart
组件来说,它唯一要做的事情就是要调用JS函数DrawLineChart
而已。它甚至都不需要去考虑echarts.esm.min.js
如何被加载:因为浏览器会搞定这些依赖加载。
所以我们只需要把LineChart.razor
改写成下面这样:
@using HelloBlazorECharts.Shared
+@implements IAsyncDisposable
+@inject IJSRuntime JS
+
+<div id="echart_container" style=@($"height: {this.HeightInPx}px; width: {this.WidthInPx}px")></div>
+
@code {
[Parameter]
public int WidthInPx { get; set; }
[Parameter]
public int HeightInPx { get; set; }
[Parameter]
public IList<LineChartData> Data { get; set; }
+
+ private IJSObjectReference jsModule;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if(!firstRender)
+ {
+ return;
+ }
+
+ this.jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "/Components/LineChart.razor.js");
+ if(this.jsModule is not null)
+ {
+ await this.jsModule.InvokeVoidAsync(
+ "DrawLineChart",
+ "echart_container",
+ this.Data.Select(d => d.X).ToList(),
+ this.Data.Select(d => d.Y).ToList()
+ );
+ }
+
+ }
+
+ async ValueTask IAsyncDisposable.DisposeAsync()
+ {
+ if(this.jsModule is not null)
+ {
+ await this.jsModule.DisposeAsync();
+ }
+ }
}
运行效果如下,完美复刻:
与JS互操作这块知识,细碎的小知识点非常多,纵然我这篇文章已经写得非常长了,但依然也只涵盖了其中的一小部分。
而在实际应用过程中,能被常用到的知识点又非常少。
这篇文章我们举了两个例子,一个高德地图,一个ECharts。我个人的感受是,要想搞定与JS库之间的交叉感染,你必须做到以下几点:
深刻理解组件的生命周期
多数图表与地图库,都是在js runtime上动态的替换掉当前DOM上的一个div
。如果你对组件的生命周期的理解还比较混乱的话,你就很难写好调用JS库的代码。并且很难在代码不能成功按预期运行的时候去排查问题。
要理解JS文件被浏览器加载的几种途径,以及JS文件被浏览器加载执行的时序
这个世界上的前端库其实就分两种:一种是提供了ES模块的库,比如echarts.esm.xxx
,另一种是不提供ES模块的库:比如高德,把东西全给你塞进window
里去
在面对传统的一些JS库的时候,一股脑将库文件在index.html
的<head>
区域引用进来当然省心省力,但会对整个应用的所有页面都加载这个JS文件
如果这个库文件的加载已经影响到性能的时候,你就得想个办法让它仅在被需要的时候加载了:即将<script>
放在组件内部。这时,你就必须清晰的明白组件的生命周期,以及脚本的加载执行时机,否则你甚至无法成功调用JS库中的函数。
在跨runtime引用对象的时候,要做好对象的生命周期管理,要记得释放对象,谨防内存泄漏
最后,再强调一个最重要的点:当你想把一个JS库集成在你的项目里的时候,你要做的第一件事,并不是去查这个库的官方文档:
而是去google或github上去搜索,是否已经有大善人把这个JS库封装成了Blazor组件类库,可以直接拿来用。
比如ECharts其实已经有现成的菩萨把这件事做好了,地图方面,虽然高德、百度之类的国内地图目前还没见有人干这个活,但Google Map是已经有菩萨封装成Blazor组件类库了。
只有在这样的菩萨不存在,或者菩萨封装的Blazor组件类库不能满足你的要求的时候,再去考虑直接使用JS库:
在时间的长河中,调用JS虽然几乎不可避免,但终归能直接使用别人的劳动成果是最好的