Blazor教程 第十一课:与JavaScript的交叉感染

Blazor

虽然这是一个用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类库。示例代码依然是玩票性质的,但多少有了一些实际意义。

1. 让我们先补充或重申一些有关JS的基础知识

我相信这个系列教程的很多读者并不是以JavaScript为生的专业前端领域人员,但至少读者是有着相当的编程基础的,我无法假定你们目前对JavaScript,特别是浏览器环境下运行的JavaScript有多少了解,我只是按照我的感觉,选取一些知识点进行讲解,对于一些人来说,本节内容可能过于基础,你可以直接跳过,对于另外一些人来说,本节内容又可能过于陌生,你可能需要去花几个小时熟悉一下Javascript。但无论如何,我都建议你先阅读一遍本章节的内容,再做决定。

本章节的宗旨是

  1. 不补充JS语法层面上的知识
  2. 补充的都是浏览器运行环境的知识

知识点一: 让浏览器执行JS代码的几种方式

以下是一行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>的开头还是中间还是结尾,这个问题我们稍后会解答。

第二种方式:将代码写在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>
+    <button onclick='console.log("Hello World")'>Click to say Hello</button>
 </body>
 </html>

这种写法下,浏览器并不会无缘无故的去自动执行这行代码,而必须要让用户去触发那个事件,才会执行。

第三种方式:将代码写在一个独立文件中,然后在HTML文档中引用它

我们将上面那行代码搬运到一个独立文件中去,如下所示:

hello_js

然后在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>还有两个可使用的属性,让事情变得稍微有一点复杂,这两个属性分别是deferasync,如下

 <!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文档在渲染过程中的执行顺序了,我们放在知识点二中进行详细介绍,目前呢,你只需要知道:

  • asyncdefer同时出现的时候,效果等同于只有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开发者才会用到的一种技巧。

如果我们抛开第三种方式的deferasync不谈的话,其实第一种方式和第三种方式是完全等价的。

那么我们换个角度来对引入JS的方式进行分类的话,可以重新分为三类

  1. 直接加载

    包括以下两种方式

    <script>
        console.log("Hello World");
    </script>

    <script src="./hello.js"></script>
  2. 异步加载

    <script async src="./hello.js"></script>
  3. 延迟加载

    <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. 用浏览器访问这个页面,同时在浏览器调试窗口故意拉长加载时间

对于#1,你可以将它托管在任何你熟悉的web server上,然后在#2中用http://localhost:xxxx/index.html去访问。我这里推荐一个比较方便的工具,即visual studio code的一个插件,叫做Live Server

当然你需要首先安装visual studio code,也就是俗称的vscode,再打开软件后,如下图的指引,来安装好这个插件,安装完成后可能要重启vscode:

live_server

之后用vscode打开我们刚才整活的HelloHtmlAndJs目录,打开index.html,右键选择open with live server即可开户一个本地web server并将当前index.html托管到它体内。或者如下图所示,点击底部状态栏的图标开启本地web server,开启成功后询问状态栏会显示端口号:

start_live_server

默认的开启端口号是5500,这时就可以通过http://localhost:5500/index.html访问到这个页面了。接下来,我们需要打开浏览器调试窗口,在网络选项卡中,如下图所示,让浏览器去模拟一个不太好的网络环境:

slow_loading

这样,浏览器就会假装要加载的JS文件需要几百毫秒乃至上千毫秒去加载,比较贴近现实生产环境。如果你觉得加载速度还是太快了,可以把fast 3G改成slow 3G,这样每个请求会走将近两秒的时间。

好了,现在买定离手,请大家猜一猜,浏览器控制台上的24行输出的先后顺序是什么。

执行顺序到底是什么?

公布答案:答案并不唯一,但有一定规律。在我本机上的其中一次运行结果如下所示:

24_logs

以上图的实验结果,能总结出的规律有:

  1. 在加载方式一致的情况下,<script>标签出现在文档中的位置越靠前,执行顺序越靠前
  2. 在书写位置基本相邻的情况下,比如都写在<head>中时,直接加载永远是最先执行的。异步加载和延迟加载的脚本,执行顺序有先有后。

上面是从实验中能总结出来的规律,坦白讲这个实验做的相当粗糙,实验本身可以设计得更好,去展示脚本加载顺序与执行顺序的规律。。

不过至少这个实验能生动的向你展示“脚本的加载和执行是有先后顺序和规律的”,接下来的问题是:这个规律到底是什么?

脚本执行的顺序

要说明白这个问题,其实还挺不简单的,我尽量用自然语言来正确描述吧。

首先,对于直接加载的脚本,即没有使用deferasync的脚本,无论脚本是直接写在<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>,它的加载和执行都是完全不可预料的,浏览器只承诺两件事:

  1. 在老头看报纸看到异步加载的时候,会异步的去加载这个脚本,并立即开始阅读下一行
  2. 老头承诺一旦脚本加载完成,就会立即执行脚本。。至于脚本啥时候加载完成,那时DOM到底构建了多少,就跟老头没关系了

由于<script async>太不可预料了,所以就又有了defer,它是对async的加强,它保证了:

  1. 脚本将在浏览器激发DOMContentLoaded前一刻执行完毕。

    • 你可以简单的把前一刻理解为:老头已经看完了报纸,只是没有激发DOMContentLoaded事件
    • 如果脚本还没有加载和执行完成,浏览器不会激发DOMContentLoaded事件
  2. 多个延迟加载的脚本的执行顺序,按它们在文档中出现的顺序来排

需要注意的是:浏览器渲染网页,整个是个单线程异步模型!!这意味着老头并没有能力在脚本加载完成后,一边继续看报纸,一边去执行脚本中的代码!老头一次只能干一件事,老头执行代码的时候,看报纸就暂停了。

而正是由于老头在加载执行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.jsdefer_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_random1

不过在实际实践中你会看到,即便理论上,异步加载执行脚本的顺序只取决于网络加载延迟,但实际实验时,上面的代码的执行结果还是看起来有那么一点点的规律性,比如,几乎每次执行,async_head_a.js都是最先执行的脚本,这又如何解释呢?

答:解释不了,只能用终极理论:未定义行为,浏览器怎么执行都是有理的。

小总结

  • 浏览器是一个一次只能做一件事的老头,老头看报纸是一行一行看的。
  • 直接加载就是老头看到脚本后,立即开始下载并执行脚本,在脚本下载过程中,老头什么事也不做就搁那干等。
  • 异步加载就是老头看到<script async ...>后,发个网络请求,不等脚本下载结束,就立即回头接着看报纸。等脚本下载结束后,老头又暂停看报纸转去执行脚本。
  • 延迟加载和异步加载差不多,只不过老头要等到报纸看完后才执行脚本。
  • 老头看报纸模型只能用来解释“标准的规定”,但浏览器的具体实现里有很多具体行为,是没法用这个简单模型解释的。

最后,免责声明:

浏览器本身并不是一个单线程程序,甚至浏览器在渲染单一页面的时候,也不是单线程执行的。只是浏览器在渲染单一页面的时候,那个JS Runtime是单线程的。之所以在这里要把它描述成单线程式的老头看报纸,只是对于我们这种级别的程序员来说,这样理解成本最低而已。

知识点3:浏览器环境中,JS代码是如何模块化的

对于其它程序设计语言来说,模块化是一个天然存在的东西:库、包、头文件等。当然如果咱要从理论层面辩经的话,函数、类也算是某种程度上的模块化。JS的历史比较混乱,JS最初的设计实现目标比较单纯,就是一个浏览器的DLC,所以早期的JS压根就不像是个正经的程序设计语言,语言设计者压根就没正经考虑过模块化这个东西,后来这玩意慢慢的越来越流行,各种标准和实现又在修修补补打补丁,导致不同于其它程序设计语言,JS在不同的时期,大家“模块化”的流行途径也不太一样。

JS上的模块化大致有五种途径:

  • 对象式模块化
  • 类定义式模块化
  • AMD模块化
  • CommonJS标准下的模块化
  • ECMA标准下的模块化

其中对象式模块化类定义式模块化严格来讲,是一种“设计模式”,是程序员在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.myPropertymyModule.myMethod()这种方式去使用。类式模块则是利用语言特性,强行实现了“封装”,比如上面的模块中,使用者是无法访问到myNamespace.myPrivateVarmyNamespace.myPrivateMethod的。

分发这些模块的时候,库的作者就把这些代码打包进一个独立的js文件中,比如myModule.jsmyNamespace.js里,用户则在HTML文档的<head>标签把这些库引入进来,然后在<body>标签内的<script>里使用这些定义好的“类”与“对象”。

这么搞了几年后大家受不了了,太恶心了,这玩意表面上看似解决了“模块化”的问题,但有个核心问题没解决:就是模块与模块之间的依赖。你设想一下,比如你写了一个库叫awesome.js,不过它使用了上面写到的myModule.jsmyNamespace.js,然后,你怎么把awesome.js分发给其它人去使用?

现代程序设计语言一个非常重要的东西就是包管理系统,表面上包管理系统解决的是“模块化”的问题,其实解决的是“依赖”的问题。你看像C和C++这俩玩意,虽然有着非常体面的模块化设计:头文件+库文件,但没有一个标准的包管理系统,导致各种牛鬼蛇神满天飞。

Web发展太快了,JS虽然是一坨屎,但太流行了,行里人觉得这让库作者手动管理依赖不是长久之计,得想个办法,然后就又拉了一坨大的,这些人提出了一个绝妙的idea,就叫Asynchronous Module Definition,简称AMD,这玩意的核心分三部分:

  1. 祖宗之法不可变,屎山不要轻易动,大家现在用着的“对象模块”和“类模块”,接着用。
  2. 我们AMD的idea,是倡议所有的库作者,在为你们现在定义的“类模块”和“对象模块”上做一点微小的工作:以统一、标准的格式,写明白你的依赖信息
  3. 我们AMD同时倡议所有浏览器厂商,在加载JS脚本的时候,先别着急执行脚本内容,而是看一眼脚本中的依赖信息,然后自行分析依赖,以最合理的方式去自动加载依赖

比如上面我们定义的myModule,AMD的倡议就是让库作者改成下面这样:

define(
   // 先写模块名
   "aweSome",
   // 再写所需要的依赖
   ["myModule", "myNamespace"],
   // 再写模块的定义,依赖都通过参数传递进来
   function(myModule, myNamespace) {
      var aweSome = {
         doStuff:function() {
            myNamespace.myPublicFunction(myModule.myProperty);
         }
      }

      return aweSome;
   }
);

听起来蛮合理的,是吧?既照顾到了历史债务,也解决了实际问题。确实,只有一个小缺憾:没人鸟AMD,没几个人用,也从没流行过。确实有那么几个库在用这玩意,但这玩意从来没成为过主流,以至于现在教JS的书都不提这玩意了。

要我说这名多少起得有点晦气,你看看造芯片的那个AMD,支棱起来了吗?

开个小玩笑而已,AMD之所以没流行起来,主要是AMD想支棱的时候,有两个事阻止了它的流行:

  1. 谷歌把JS的运行时,从浏览器搬到了服务端,也就是nodeJS出现了

    作为一个只运行在浏览器上的残废语言,缺乏包管理不是什么大问题,大家凑合凑合都能过日子。但作为一个服务端语言,就肯定不行了,谷歌在推nodeJS运行时的同时,也带来了自己的包管理和模块化方案,npm包和commonJS模块。

    和其它流行的服务端程序设计语言一样,nodeJS把代码复用抽象成了两个层级:模块和包。利用requiremodule.exports实现一个小粒度的commonJS模块概念,多个模块再攒一起形成npm package的包概念。而依赖的自动处理,是由npm package系统处理的,在模块这个层级上,无论是运行时,还是工具链,都不会提供“自动下载、加载间接依赖”这种事情。

    但无论是commonJS的模块,还是npm package的包概念,都是服务端JS上的概念,它们在浏览器上是不被支持的,这也是为什么现代前端框架需要用到webpack这种工具的原因:前端框架的开发是在node环境开发的,但打包、发布则需要用到工具,把写好的代码整合成一个或多个更紧凑的JS文件,以便让各式各样的浏览器,能以最智障的“直接加载”的方式去执行这些代码。

  2. ECMA标准化组织在语言层面上,开始推动ES模块的概念

    ECMA只在语言层面上推广自己的“模块”概念,即用importexport关键字来描述的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代码是如何模块化的?答案有两个:

  1. 使用上古时期的技巧:类模块和对象模块
  2. 使用ES模块

你可能会想说:上古时期的玩意了,该扔了吧?没必要了解了吧?这话,既对,又不对。

接下来我们转变一下视角,把我们的视角拉回到一个Blazor开发者上来,作为Blazor框架的使用者,我们之所以要了解这些有关JS的知识,出于两个原因:

  1. 很多第三方库是用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>

    以上例子中的echartsAMap都是JS对象,库将它们的所有API都塞进一个对象中去。

  2. 在某些必要的场合,我们可能需要手动写一些JS代码

所以,站在一个Blazor开发者的角度来看这事的话,我们只需要做到:

  1. 在使用第三方库时,了解浏览器加载JS文件的规律,以便在正确的时机确保库暴露给我们的“对象模块”或者“类模块”是可用的
  2. 在自己需要写一些JS代码时,我们应当使用ES模块去组织我们的JS代码

逻辑知识已经讲完,下面我们通过一个实际的例子,来观察一下ES模块在浏览器环境是如何工作的:

ES模块demo

目录结构如下所示:

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 };

运行效果非常直接,如下图所示:

es_module

注意点:

  1. 需要把文件托管在web server上才能看到实验效果,你可以用vscode的live server,当然如果不嫌麻烦的话也可以托管在本机的nginx上

    如果直接在浏览器中打开index.html的话,浏览器会因为跨域请求main.js不受支持而禁止加载main.js,如下:

    local_file_cors

  2. modules/utility.js显然是一个模块文件,这没什么可说的,但main.js是一个模块文件吗?

    答案是:是的,main.js也是一个模块文件,所以需要在index.html中使用<script type="module">对其进行引用

    如果你删掉type="module"的话,浏览器会给你扔出下面一个报错:

    import_outside_module

    报错信息说的很明白:import/export关键字只有在浏览器把一个JS文件当module加载的时候才会生效

  3. main.js是需要显式在HTML文档中通过<script>进行引入的,虽然它加了type="module",但与其它<script>标签一样:main.js的加载、执行顺序和我们上面学习到的知识是一致的

    modules/utility.js不需要在HTML文档中进行显式引用,它的加载发生在main.js被加载之后,由浏览器去解析依赖关系,然后加载必要的其它模块

  4. import, export的语法如下:

    1. import

      1. 有选择性的引入一部分函数或对象:

        import { someFunc, someVar } from './xxx.js';
        
        // ...
        
        someFunc();
        console.log(someVar);
      2. 引入后改个名,以避免名称冲突:

        import { someFunc as aliasFunc, someVar as aliasVar } from './xxx.js';
        
        alias();
        console.log(aliasVar);
      3. 引入目标模块里的所有东西,并把所有东西打包放在一个名称空间(其实就是对象)中,这样更能避免名称冲突

        import * as someNamespace from './xxx.js'
        
        someNamespace.someFunc();
        console.log(someNamespace.someVar);
    2. export

      1. 有选择性的导出一部分函数或对象:

        const someVar = true;
        
        function someFunc() {  }
        
        export { someVar, someFunc };
      2. 在导出的时候改名

        const v = true;
        
        function f() {  }
        
        export { v as someVar, f as someFunc };
      3. 在对象或函数声明的时候就导出

        export const someVar = true;
        export function someFunc() {  }
    3. 默认导出与引入

      默认导出,即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);

      所以你看,导入方想叫什么名字都可以。

2. 如何在C#中调用JS代码

终于说到正题了,我们创建一个空项目,来演示如何在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);
+    }
+}

运行效果如下,非常直白:

call_js

其实说白了,在C#中调用JS是一个非常简单的事情,代码写起来巨简单,两步而已:

  1. 向当前上下文注入IJSRuntime对象即可,Blazor框架在前端初始化时,已经把这个对象放进了DI池里,直接拿来用就行

  2. 按有没有返回值,来调用InvokeAsyncInvokeVoidAsync中的一个

    • 有返回值的话,调用InvokeAsync<T>(...),其中T是你期望的返回值类型,参数里分别写上函数名和参数
    • 没返回值的话,调用InvokeVoidAsync(...),参数里分别写上函数名和参数

而整个事情的逻辑是这样的:

  • 运行在浏览器dotnet runtime上的Blazor前端代码,将“我要调用一个函数”这个命令发送给框架里的传令兵,即IJSRuntime对象
  • IJSRuntime对象带着命令,跑到浏览器的JS runtime里,按照命令去调用对应的JS函数。如果有返回值的话,再把返回值带回来

而这,未尝不是一种RPC调用呢?对于dotnet runtime上跑着的blazor前端代码来说,浏览器的JS Runtime完全就是一个外部势力,它完全没法预测这个调用啥时候能执行结束,所以传令兵仅有异步API的设计,即仅有InvokeVoidAsyncInvokeAsync供你用,没有Invoke这种东西,也就非常合理了。

2.1 调用过程中的数据类型转换

至于入参的数据类型转换、返回值的数据类型转换,在基础数据类型和简单的库容器类型范畴里,框架都会帮你搞定,而且数据类型转换的行为都是符合直觉的。

比如我们在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()));
    }

报错如下:

type_error_when_call_js

这时候有些同学就会想了,凭什么啊?JS不是无类型语言吗?怎么把字符串转到数值还转不过去呢?

不是这样的,JS并不是无类型语言,而是动态类型语言,同时又是弱类型语言,JS在语言层面上也是有类型系统的,而之所以不少人有着“JS里没类型”这个刻板印象,是因为

  1. 动态类型是指JS中的变量没有类型约束,任何变量都可以被赋予,以及重新赋予各种类型的值
  2. 弱类型是指JS会在操作涉及不匹配的类型时,JS内部会进行隐式类型转换

JS中的基础数据类型(准确的来说叫原始值类型)共有七种:Null, Undefined, Boolean, Number, BigInt, String, Symbol。复杂类型就是在Object里一锅乱炖,像什么ArrayMapSet之类的集合类型,以及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"

arr_to_string

自然,控制台上的输出就只剩下个1

only_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()));
    }

调试结果如下

arr_to_number

再执行下去,它竟然报错了!如下:

not_iterable

这怎么回事?小小的脑袋大大的困惑!

这里其实有两个问题

  1. 传令兵做入参转换的时候出了差错

    new string[] {"1", "1", "2", "2", "3", "3", "3"}传递到JS Runtime上的时候,竟然变成了单个字符串"1"

    new object[] {1, 1, 2, 2, 3, 3, 3}传递到JS Runtime上的时候,竟然变成了单个数值1

  2. 在JS Runtime中,[...new Set("1")]是一个合法操作,而[...new Set(1)]却是非法操作

    这纯粹就是JavaScript诸多屎点中的一个而已,或者更准确一点来说,不是JS语言层面上的屎,而是JS标准库里的Set身上的一个屎点,比如我们可以在Node环境中完美复刻这个抽象现象:

    shitty_js

两个问题里,#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)

    在扩展方法的实现中,param1param2会被语言特性整合成一个参数,即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[]{}都会错误匹配。

2.2 以同步方式调用JS代码

我们上面说了,可以把对JS代码的调用,看作是一种RPC调用。所以框架只给我们提供了异步接口InvokeAsyncInvokeVoidAsync就显得非常合理。

不过再仔细想一想,这个说法好像也有问题:

  1. 我们的Blazor前端代码是运行在浏览器上的dotnet runtime上的
  2. JS代码也是运行在浏览器的js runtime上的

这俩玩意虽然逻辑上不一样,但实际运行在同一个宿主环境:浏览器上。说这个调用过程是RPC,多少有点牵强,那为什么框架没有给我们提供同步调用接口呢?

答案是:Blazor框架是一个有多种运行模式的框架,我们上面的例子、解释等,其实都是以Blazor WASM为主的。而如果切换到Blazor Server模式的话:

  1. Blazor的“前端代码”就运行在了服务端
  2. JS代码则运行在用户浏览器的JS runtime上

而如果在实际开发过程中,我们非常笃定:我们书写的组件,只会被用在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再进行强制类型转换。

2.3 调用ES模块里的JS代码

上面的例子,都是我们把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()));
    }
}

这样怎么说呢,不是不行,但偏题了。上面这种做法其实分了两步:

  1. 第一步,在index.html中把utility.js模块中的函数导出,并且复制粘贴到了window这个全局作用域下
  2. 第二步,Blazor前端里的C#代码调用的其实是window.Dedup

这样做的坏处是

  1. 需要把所有用到的函数都先复制粘贴到window全局作用域下,函数多了的话,要手动处理名称冲突
  2. 所有ES模块,甭管实际用得上用不上,都会被加载

所以即使我们从Index.razor中删掉对Dedup的调用,用户浏览器依然会去加载utility.js,这样显然是不合理的,也背离了ES模块的初衷。

正确的做法并不是把import语句写在index.html或其它JS文件中,而是写在C#组件代码里,听着有点不对劲是吧?你这样理解:

  1. 可以简单的把import指令也理解为一个JS的库函数,import语句就是一个函数调用,调用的结果是返回一个或多个对象

    比如import * as someNamespace from './xxx.js'这句,就可以理解为是一个函数调用,返回的结果是一个名为someNamespace的对象

  2. 顺着这个思路,其实我们就可以用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
  • JS对象里什么都能装,自然也能装函数。而import函数返回的对象更是如此,所以IJSObjectReference也有接口InvokeVoidAsyncInvokeAsync让我们去调用对象内部定义的函数。和IJSRuntime一样
  • 我们可以简单而又错误的理解为,IJSRuntime就是一个特殊的IJSObjectReference,只不过它指的那个JS对象是浏览器的window对象而已

在独立组件类库中加载ES模块时,请记得静态资源的路径规则

我们在之前的文章中已经讲过了组件类库中的静态资源路径的问题,这里再一笔带过复习一下:

  1. 在独立类库中,静态资源依然请放在类库中的wwwroot目录中

  2. 在引用类库中的静态资源时,请使用_content/{LibraryProjectName}/{Path}这种格式的路径,其中:

    • {LibraryProjectName}是类库的名字
    • {Path}是静态资源在类库中以wwwroot为起点的路径

IJSObjectReference是一个外部资源,在页面析构的时候是需要释放的

上例中的utilityModule其实是需要析构的,对于动态加载ES模块来说,官方给出的最佳实践要点包括以下三条:

  1. 使用组件私有字段保存ES模块,并在OnAfterRender{Async}回调中对其进行初始化
  2. 每次使用ES模块字段时,都需要对其进行null判断
  3. 组件应当实现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掉。

要想以同步的方式调用ES模块中的函数,请使用IJSInProcessObjectReference

通过JS.InvokeAsync<IJSObjectReference>("import", "xxx.js")方式拿到的ES模块对象,和IJSRuntime一样,只支持异步调用,即InvokeAsyncInvokeVoidAsync

而如果想要同步调用模块中的函数,可以使用JS.InvokeAsync<IJSInProcessObjectReference>("import", "xxx.js")来获取模块对象,

需要注意的是:

  1. 无论是同步版的ES模块对象,还是异步版的ES模块对象,都算组件资源,都需要在组件析构的时候对其进行释放。

    异步版的对象只实现了IAsyncDisposable接口,所以在析构时只能调用await this.module.DisposeAsync()

    而同步版本的接口还在此基础上实现IDisposable,除了可以按上面异步对象的方式析构,还可以直接调用this.module.Dispose()进行析构

  2. 显然,和IJSInProcessRuntime一样,同步版本的ES模块对象只适用于WASM模式,不适用于Server模式。

2.4 引用DOM对象

截至目前为止,我们举出的示例代码,都是没有意义的教学代码,因为我们调用的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的红矩形。运行效果如下所示:

red_rect

现在的问题是:以我们现在如今的知识储备,我们是没法在Blazor组件中使用C#调用上面写的三个函数的:这三个函数都需要一个DOM元素作为参数,而我们并不知道如何在C#中抓一个DOM元素。

比如我们想在Index组件的OnAfterRender回调里,创建一个红色的矩形并且放在当前组件中,那么我们怎么做?

答案是:

  1. 在razor代码中,可以使用@ref属性(还记得吗?这种以@开头的特殊属性,叫directive attribute)来将当前组件中的DOM元素绑定到一个变量上去
  2. 这个变量的类型在C#中是ElementReference
  3. 这个变量可以传递给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);
        }
    }
}

运行,效果如下:

red_rect

2.5 把JS文件与组件文件放在一起

上面那个画红色矩形的例子中,我们核心的代码文件是如下组织的:

HelloJSInterop
   |
   \--- Client
          |
          |--- wwwroot
          |       |
          |       |--- js
          |       |    |
          |       |    \---> utility.js
          |       |
          |       \---> index.html
          |
          \--- Pages
                  |
                  \---> Index.razor

这样做没有任何问题,随着项目规模膨胀,你所要做的只有两件事:

  1. Client/wwwroot/js/目录下添加越来越多的ES模块文件
  2. 在各个需要调用JS的Blazor组件中,使用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);
   // ...
}

但是,更让人感到清爽的,应当是尽量做到每个组件的实现都相对独立,这个相对独立有两个要点:

  1. 每个组件的实现,都尽量不要直接依赖其它组件或类,将依赖接口化
  2. 组件实现过程中用到的,特殊化的各种东西,都应当在文件目录结构上,与组件本身放在一起,不要污染其它目录

第一点是普适性的低耦合编程指导思想,而第二点,举个例子,就应当把目录结构改成下面这样:

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

2.6 暂时性的小结

有关如何从C#调用JS,其实还有不少内容可以展开详细说,但我觉得以上五个知识点基本足以涵盖绝大部分使用需求了。

其实说了那么多,简单来说就三板斧:

  1. 使用ES模块配合await JS.InvokeAsync<IJSObjectReference>("import", "xxxx.js");去动态加载JS

  2. 组件析构时记得要释放JS模块对象

  3. 挑选一个你喜欢的目录组织形式:

    1. 要么把JS全放在wwwroot/js目录中
    2. 要么每个组件把自己需要的JS代码放在自己身边,比如狗.razor.js

3. 如何JS代码中调用C#代码

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 ServerHello Blazor WASM

无论是Blazor Server项目还是Blazor WASM项目,运行起来后,打开浏览器调试窗口,你都会看到,Blazor框架为window对象里面塞了一个叫DotNet的对象,并且这个对象有两个方法名为invokeMethodinvokeMethodAsync,如下两图所示:

blazor_server_dotnet

blazor_wasm_dotnet

这个DotNet对象以及invokeMethod/invokeMethodAsync方法就是Blazor框架向JS提供的,用来调用C#代码的入口。从名字上来看,显然一个是同步调用,一个是异步调用,而如果你理解了上面我们讲在C#中调用JS时的同步/异步的原理后,你也能举一反三出来:

  • DotNet.invokeMethodAsync既能工作在Blazor Server模式下,也能工作在Blazor WASM模式下

    它的返回值也是一个典型的JS Promise:即你无法在调用结束后立即得到调用结果,而需要以promise.then(onGood, onBad)的方式来消费未来的调用结果。

    其中onGoodonBad是两个回调函数,分别在调用成功和调用失败的情况下被执行

  • DotNet.invokeMethod则只能工作在Blazor WASM模式下

    它的返回值则直接就是调用结果

invokeMethodAsyncinvokeMethod的参数,则有三部分:

  1. 第一个参数,是被调用的C#函数所处的Assembly的名字,或者可以简单的理解其为dll文件的名字
  2. 第二个参数,是被调用的C#函数的名字,这里仅仅只需要提供函数名就可以了,不需要提供带namespace的全名
  3. 后续的参数,则是会被传入被调函数的参数

这些设计都很符合直觉,我们在后面的例子中会生动说明。

现在,在调用方要做的事情就解说完了,其实就一句话:调用window.invokeMethodwindow.invokeMethodAsync方法。

而在被调方,还需要做一些额外的操作,这里,我们分两种情况讨论:

  • 被调用的是一个静态函数,static函数
  • 被调用的是一个实例方法

3.1 调用静态函数

被调用的函数必须满足以下要求:

  1. public函数
  2. static函数
  3. [JSInvokable]修饰

3.1.1 在Blazor WASM项目中进行基本示例

我们以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();
    }
}

接下来,运行项目,我们就可以直接在浏览器调试窗口,以交互方式如下调用这两个方法:

首先是以同步方式直接调用,如下图所示:

blazor_wasm_interactive_call_dotnet_sync

然后是以异步方式调用,如下图所示:

blazor_wasm_interactive_call_dotnet_async

3.1.2 注意调用的时机

上面的例子很直观,也很符合直觉,但如果我们把调用的代码直接写进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>

运行项目中,浏览器调试窗口会报如下的错:

call_dispatcher_not_found

原因很简单:当浏览器执行到index.html中的JS代码时,我们的Blazor程序其实还没初始化完毕。那么问题就来了:我们如何能在JS代码中得知Blazor程序什么时候初始化完毕呢?有没有类似于addEventListener("DOMContentLoaded", (event) => {})之类的事件可以拿来监听呢?

答案是:没有,我们无法在JS代码中判断Blazor框架是否初始化完成,即没有办法判断何时window.DotNet.invokeMethod(Async)可用。

是不是感觉有点不可思议?感觉有点不可理喻?

其实,问题不是框架设计有问题,而是我们的思路有问题:

  • Blazor框架是一个C#的全栈解决方案,无论是C#调用JS还是JS调用C#,都是框架为了方便程序员去解决一些脏活的时候的工具
  • 用Blazor框架去开发,一定是以C#为主,JS为辅
  • 用Blazor开发的项目,不应当写出一坨JS代码让浏览器去自动执行。换个说法,只有C#有能力明晰自己的运行时机/框架和组件的生命周期,JS是不应该试图做这种倒反天罡的行为的

举个例子,我们把这个“内部调用了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();
     }
 }

它的执行结果如下所示:

js_call_dotnet_in_event

3.1.3 在Blazor Server项目中复刻上面的例子

首先是基本示例,可以看到在Blazor Server模式下,invokeMethod是不可用的:

blazor_server_invoke_not_work

其次是直接把JS代码写进Pages/_Host.cshtml试图让浏览器直接执行也是行不通的:

blazor_server_invoke_not_work2

最后是按钮事件回调的例子,在剔除了invokeMethod的调用代码后,也是能正确执行的:

js_call_dotnet_in_event_blazor_server

3.2 暂停一下

本来,到这个小节,我们应当讲一下如何从JS中调用C#的实例方法了。但现在我们应当暂停一下,然后补充一个额外的知识点:如何在JS Runtime和dotnet runtime两个运行时之间,传递“对象”了。

即如果我在JS runtime上构造了一个复杂无比的对象,我如何把它传递给dotnet runtime呢?反过来一个dotnet runtime上的复杂对象,如何传递给js runtime呢?

其实,说“传递”,也并不准确,对象本身在内存中并没有移动:JS runtime中的对象依然在JS runtime的内存模型中,dotnet runtime中的对象也依然存在在dotnet runtime的运行模型中。传递的东西其实只是“引用”或“指针”。

上面我们有说过,在C#代码中动态加载ES模块,其实我们得到的就是一个IJSObjectReferenceIJSInProcessObjectReference,这个东西,其实就是一个JS Runtime上的JS对象,我们只不过通过拿到的C#变量远程引用着它而已。

4 对象(引用)在两个runtime上的传递

这个东西其实说起来非常简单:两个runtime互相调用的时候,调用方可以以传递入参的方式将自己runtime上的对象传递给对方,另外在函数返回时被调用方可以以返回值的方式将自己runtime上的对象返回给调用方。

也就是说,无论是JS调用C#,还是C#调用JS,其实都可以跨runtime传递对象引用到对方那边。但我们上面讲了那么多,为什么没有涉及这个知识点呢?

因为在之前我们举的所有例子中,无论是JS调C#还是C#调用JS,我们传递的参数,和函数执行结束后返回的值,都是基本类型数据,都是通过中间商进行符合直觉的数据类型转换后,把“值”传递到了对方。

但有一个例外,就是动态加载ES模块的例子:

动态加载ES模块的例子中,第一步我们是先拿到了一个IJSObjectReferenceIJSInProcessObjectReference,这其实是一个JS Runtime上的对象,被我们用C#中的一个变量引用着

现在在这个独立章节,我们捋一下,如何在两个runtime之间传递对象,以及拿到这些跨runtime的间接引用,能干什么。为了清晰起见,我们这个章节所有的例子,都是在一个hosted Blazor WASM的Index.razor中书写的。

以下每个小节,都在回答两个问题:

  1. 跨runtime怎么传递引用
  2. 拿到这种特殊引用后,能做什么

4.1 C#调用JS,以入参的方式将dotnet对象传递给JS runtime

@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();
    }
}

上面这个例子做的事有:

  1. 首先是创建了一个名为DICT的内部类,它内部包裹着一个标准库的字典,然后公开了三个方法:Add, ClearToString

  2. 其次是让Index组件持有了这个类的一个实例对象this.d,并把它渲染在页面上

  3. 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对象

  4. 我们还在页面上添加了一个按钮,用来手动触发StateHasChanged()来让页面重新渲染

那么拿到一个dotnet对象引用的JS runtime又能做些什么呢?答案很简单:可以通过invokeMethodinvokeMethodAsync来调用this.d的实例方法:但仅限于有[JSInvokable]标记的那些实例方法

所以我们能做出下面的实验来:

js_call_dotnet_method

这个实验其实也间接的说明了我们上面暂停的内容:即如何在JS中调用C#的实例方法

4.2 JS调用C#,以入参的方式将JS对象传递给dotnet runtime

@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 } }
        );
    }
}

上面这个例子做的事有:

  1. Index组件类中声明了一个static成员jsObj,以及一个静态函数ReceiveJSObject,其中静态函数用[JSInvokable]修饰着

    这个函数就是给JS调用的,JS调用时应当传一个JS runtime上的对象的引用过来,我们把这个引用会保存在静态字段Index.jsObj

  2. 与此同时,我们用JS写了一段代码:

    1. 首先我们就地创建了一个对象,并把它塞进浏览器的window对象里。这个对象有两个字段,两个方法

    2. 其次我们写了一个函数passJSObjectToDotNet,在函数内部,调用window.DotNet.invokeMethod调用了Index组件里写的静态方法ReceiveJSObject

      同时,在入参部分,我们调用window.DotNet.createJSObjectReference,将上一步创建好的window.jsObject对象传递给了dotnet runtime

  3. 然后我们纯前端的写法,将JS函数passJSObjectToDotNet设定为了第一个按钮的回调函数

  4. 最后我们用Blazor的写法,为第二个按钮写了一个C#回调函数ModifyJSObject

    在这个回调函数中,我们将在C#代码中,去调用那个JS对象的方法,从而改变那个对象内部的数据

这个例子的运行结果是如下:

dotnet_call_js_method

这其中就是我们在dotnet中调用JS的实例方法。

4.3 跨runtime引用对象的生命周期

跨runtime引用对方的对象虽然看起来非常好玩,但实际上还是要慎用,这主要是因为对象被外部势力引用之后,runtime就没法通过GC回收这个对象了。

试想,在C#上下文中创建一个对象,那个dotnet的GC就会在检测到该对象无人引用后,将该对象从内存中删掉。而如果我们把这个C#对象用DotNetObjectReference.Create(obj)裹起来扔给JS runtime的话,那么对不起,GC永远都不会删除这个对象了。

因为dotnet的GC不可能知道JS那边到底还需要不需要这个对象,只能选择保留着,也就是:内存泄漏了。

换句话说:程序员需要自己手动去在合适的时机,释放这些对象。这不光包括从C#传给JS的那些dotnet对象,也包括从JS传递给C#的JS对象。

对于dotnet对象来说,释放它们有两个办法:

  1. 在JS那边,调用dispose方法
  2. 在dotnet这边,将DotNetObjectReference.Create(obj)的返回值保存起来,然后在这个返回值上调用Dispose方法

而对于JS对象来说,释放它们看起来只有一个办法,就是我们之前讲到的IJSObjectReference接口的DisposeAsync方法

4.4 总结

  1. 把dotnet对象传递给JS runtime:

    • DotnetObjectReference.Create(obj)把对象包起来扔给JS runtime
    • JS收到后就可以调用对象方法了
    • 在使用结束后,要么在JS那边调用dispose方法释放对象,要么在C#这边调用Dispose释放对象
  2. 把JS对象传递给dotnet

    • window.DotNet.createJSObjectReference把对象包起来扔给dotnet runtime
    • dotnet这边收到后就可以调用对象方法了
    • 使用结束后,要记得调用IJSObjectReference.DisposeAsync释放对象
    • 我们上面有一点没提,但你应该能想得到,就是在Blazor WASM模式下,C#在收到对象后可以强行通过强制类型转换将对象类型转换为IJSInProcessObjectReference,转换后就可以以同步的方式去调用对象方法,以及释放时调用Dispose

5. 两个略微贴近一点现实的例子

现在基本上有关JS的重要知识点就差不多说完了,当然并不是所有知识点都说完了,比如两个runtime之间还可以以序列化的方式传递数据呀之类的知识点,是没有涉及的。

但基本上,我们已经把实际工作中可能遇到、用到的知识点都讲到了,这个章节,我们来写两个例子:

  • 使用高德开放平台的JS API,在Blazor应用中嵌入地图
  • 在Blazor应用中使用一个纯JS实现的图表库 ECharts 来画一些数据图表

5.1 高德开放平台

5.1.1 预习文档并写出一个纯HTML+JS的demo

使用任何开源的库,第一步都是进官网看文档。进入高德开放平台的官网,在“文档与支持”栏,我们可以看到有如下分类:

amap_official_website

点进去就是高德地图为JS开放的API文档,查看文档的“准备”页,按照文档指引注册开发者账号,并创建应用及key,然后直接转向“快速上手”,按照文档指引,我们先写一个纯HTML+JS的demo,来展示一片区域的地图

amap_doc_list

整体逻辑分为三步:

  1. 在开放平台的控制台创建一个“应用”,这个应用有两个身份证号:一个叫key,一个叫安全密钥
  2. 引入高德的API库,这通常是一个JS文件,以<script>标签直接在HTML文档中引入。
  3. 通过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>

这一版的改动有以下关键点

  1. 加载库文件的方式变成了loader.js配合AMapLoader.load()方法执行,原AMap不再是全局对象了

  2. 已经不在乎key的泄露了,而是再造出了一个安全密钥,填充在window._AMapSecurityConfig

    • 对于测试环境,可以直接以如下方式将安全密钥写在window._AMapSecurityConfig
    window._AMapSecurityConfig = {
       securityJsCode: "您申请的安全密钥"
    };
    • 对于生产环境,则需要指定_AMapSecurityConfig.serviceHost
    window._AMapSecurityConfig = {
       serviceHost: "https://xxx.xx.com/_AMapService"
    }

    同时在部署环境中,设置如下三个请求转发代理:

    1. /_AMapService/v4/map/styles的请求代理至https://webapi.amap.com/v4/map/styles&jscode=您的安全密钥
    2. /_AMapService/v3/vectormap的请求代理至https://fmap01.amap.com/v3/vectormap&jscode=您的安全密钥
    3. /_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>

运行结果如下(你自己试验的时候记得向上面代码中的aMapSecurityJSCodeaMapKey赋值):

amap_html_sample

5.1.2 分析需求,包装组件

我们上面的示例做了三件事:

  1. 创建了一个AMap对象,就是地图对象,并且设置了地图的缩放等级以及中心点。在外围,我们还通过div#container的CSS样式设定了地图的大小是一个300像素的正方形
  2. 向地图对象中添加了一个标注点
  3. 向地图对象中添加了一段折线

那么我们抛开具体如何实现不谈,如果我们将上面的功能包装成一个组件,那么这个Blazor组件应当有以下参数

  1. 中心点经纬度
  2. 缩放等级
  3. 地图容器尺寸
  4. 标注点的经纬度
  5. 折线途径的点的经纬度

那么我们可以把这个组件的外形写出来,我们先创建一个空的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();
+        }
+    }
 }

运行程序,效果如下:

amap_blazor_render

完美还原

5.1.3 正确处理安全密钥

上面的实现的一个大问题,就是我们把key安全密钥都以明文的形式写在代码中,比如直接在浏览器调试窗口网络连接中查看对AMap.razor.js请求,就能拿到key:

amap_key_leak

在源代码界面或控制台,就能获得安全密钥

amap_code_leak

amap_code_leak2

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在服务器端进行拦截、代理。这样就保证了安全密钥不泄漏

5.1.4 一点点额外说明

以上的举例,重点在于向大家展示在Blazor框架中使用JS类库的能力,包括接下来要展示的ECharts也是如此。

以上的代码虽然可以运行成功,但并不代表就是最佳实践,大家可以作为参考去使用,千万不要把思维局限于此。

5.2 ECharts图表库

ECharts与高德地图的最大的一个区别是:ECharts真的只是一个JS类库,开发者可以自行将某个版本的ECharts的JS文件托管在自己的服务器上,从而完全不需要与ECharts的任何API进行交互。

而高德地图其实并不仅仅是一个类库那么简单,它实际是一个独立的程序,你把地图嵌入到你的应用中后,地图自己还会不停的与高德的服务器进行交互。所以高德官方也是强烈建议所有开发者都以loader的方式去动态加载AMap对象的,强烈不建议开发者自行托管相关的JS文件的。

比如打开ECharts官网文档的第一页,ECharts就明确的告诉你:我这整个库,就是一个echarts.js文件:

offline_echarts

那我们也听劝,直接去这个官方推荐的CDN网站去把整个库下载下来,在我写这篇文章的时候,我使用到的下载链接是:https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js

即下载下来的文件叫echarts.min.js

5.2.1 预习文档并写出一个纯HTML+JS的demo

我们先写一个纯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_html

整个程序的运行逻辑就是,在浏览器加载运行echarts.min.js后,就会把ECharts库里的所有东西都塞进window.echarts对象中去,然后我们按着ECharts官方网站中的示例抄这样一个图表就可以了。

而如果你不爽这样的传统方式,ECharts还提供了ES模块式的库文件,在官方“下载”栏点进它们的GitHub编译产物页面:

echart_esm_download_link

echart_esm_download_page

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>

5.2.2 分析需求,包装组件

这个比较好分析,简单来说,如果我们要把“在页面上画一个折线图”这个功能包装成一个Blazor组件的话,这个组件应当有以下参数:

  1. 图的尺寸
  2. 横轴数据
  3. 纵轴数据

或者进一步的,不要把数据按轴分成横轴纵轴两部分,而应当把数据设定为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();
+        }
+    }
 }

运行效果如下,完美复刻:

blazor_echart

6. 总结

与JS互操作这块知识,细碎的小知识点非常多,纵然我这篇文章已经写得非常长了,但依然也只涵盖了其中的一小部分。

而在实际应用过程中,能被常用到的知识点又非常少。

这篇文章我们举了两个例子,一个高德地图,一个ECharts。我个人的感受是,要想搞定与JS库之间的交叉感染,你必须做到以下几点:

  1. 深刻理解组件的生命周期

    多数图表与地图库,都是在js runtime上动态的替换掉当前DOM上的一个div。如果你对组件的生命周期的理解还比较混乱的话,你就很难写好调用JS库的代码。并且很难在代码不能成功按预期运行的时候去排查问题。

  2. 要理解JS文件被浏览器加载的几种途径,以及JS文件被浏览器加载执行的时序

    这个世界上的前端库其实就分两种:一种是提供了ES模块的库,比如echarts.esm.xxx,另一种是不提供ES模块的库:比如高德,把东西全给你塞进window里去

    在面对传统的一些JS库的时候,一股脑将库文件在index.html<head>区域引用进来当然省心省力,但会对整个应用的所有页面都加载这个JS文件

    如果这个库文件的加载已经影响到性能的时候,你就得想个办法让它仅在被需要的时候加载了:即将<script>放在组件内部。这时,你就必须清晰的明白组件的生命周期,以及脚本的加载执行时机,否则你甚至无法成功调用JS库中的函数。

  3. 在跨runtime引用对象的时候,要做好对象的生命周期管理,要记得释放对象,谨防内存泄漏

最后,再强调一个最重要的点:当你想把一个JS库集成在你的项目里的时候,你要做的第一件事,并不是去查这个库的官方文档:

而是去google或github上去搜索,是否已经有大善人把这个JS库封装成了Blazor组件类库,可以直接拿来用。

比如ECharts其实已经有现成的菩萨把这件事做好了,地图方面,虽然高德、百度之类的国内地图目前还没见有人干这个活,但Google Map是已经有菩萨封装成Blazor组件类库了。

只有在这样的菩萨不存在,或者菩萨封装的Blazor组件类库不能满足你的要求的时候,再去考虑直接使用JS库:

在时间的长河中,调用JS虽然几乎不可避免,但终归能直接使用别人的劳动成果是最好的