Loading

第十章-JavaScript 互操作性

从 C# 调用 JavaScript

浏览器具有许多您可能希望在 Blazor 网站中使用的功能。 例如,您可能希望使用浏览器的本地存储来跟踪某些数据。由于 Blazor 的 JavaScript 互操作性,这很容易。

提供胶水功能

要调用 JavaScript 功能,首先要在 JavaScript 中构建粘合函数。我喜欢将这些函数称为粘合函数(我自己的命名约定),因为它们成为 .NET 和 JavaScript 之间的粘合剂。

Glue 函数是常规的 JavaScript 函数。 JavaScript 粘合函数可以采用任意数量的参数,条件是它们是 JSON 可序列化的(这意味着您只能使用可转换为 JSON 的类型,包括其属性是 JSON 可序列化的类)。 这是必需的,因为参数和返回类型在 .NET 和 JavaScript 运行时之间作为 JSON 发送。

然后将此函数添加到 JavaScript 全局范围对象,在浏览器中该对象是窗口对象。 稍后您将查看一个示例,因此请继续阅读。 然后,您可以从 Blazor 组件调用此 JavaScript 粘合函数。

使用 IJSRuntime 调用 Glue 函数

回到 .NET 领域。 要从 C# 调用 JavaScript 粘合函数,请使用通过依赖注入提供的 .NET IJSRuntime 实例。 此实例具有 InvokeAsync 泛型方法,该方法采用胶水函数的名称及其参数并返回 T 类型的值,这是胶水函数的 .NET 返回类型。 如果您的 JavaScript 方法不返回任何内容,那么还有 InvokeVoidAsync 方法。 如果这听起来令人困惑,您将立即查看一个示例。

InvokeAsync 方法是异步的,支持所有异步场景,这是调用 JavaScript 的推荐方式。 如果需要同步调用胶水函数,可以将 IJSRuntime 实例向下转换为 IJSInProcessRuntime 并调用其同步的 Invoke 方法。 此方法采用与具有相同约束的 InvokeAsync 相同的参数。

不推荐对 JavaScript 互操作使用同步调用! 服务器端 Blazor 需要使用异步调用,因为调用将通过 SignalR 序列化到客户端。

使用 Interop 在浏览器中存储数据

是时候看一个例子了,你将从 JavaScript 粘合函数开始。 打开提供的 JSInterop 解决方案(或者您可以从头开始创建一个新的 Blazor WebAssembly 项目)。 从 JSInterop 项目中打开 wwwroot 文件夹并添加一个名为 scripts 的新子文件夹。 将一个新的 JavaScript 文件添加到名为 interop 的脚本文件夹中。 js 并添加清单 10-1 中的粘合函数。 这会将 blazorLocalStorage 对象添加到全局窗口对象,其中包含三个粘合函数。 这些粘合函数允许您从浏览器访问 localStorage 对象,这允许您将数据存储在客户端的计算机上,以便以后访问它,即使在用户重新启动浏览器或计算机之后也是如此。

清单 10-1 blazorLocalStorage 粘合函数

window.blazorLocalStorage = {
    get: key => key in localStorage ? JSON.parse(localStorage[key]) : null,
    set: (key, value) => { localStorage[key] = JSON.stringify(value); },
    delete: key => { delete localStorage[key]; },
};

您的 Blazor 网站需要包含此脚本,因此打开 wwwroot 文件夹中的 index.html 文件并在 Blazor 脚本后添加脚本引用,如清单 10-2 所示。

清单 10-2 在 HTML 页面中包含脚本引用

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0,
                                       maximum-scale=1.0, user-scalable=no" />
        <title>JSInterop</title>
        <base href="/" />
        <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
        <link href="css/app.css" rel="stylesheet" />
        <link href="JSInterop.styles.css" rel="stylesheet" />
    </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>
        <script src="scripts/interop.js"></script>
    </body>
</html>

现在让我们看看如何调用这些 set/get/delete 粘合函数。 打开 Counter.razor Blazor 组件并将其修改为如清单 10-3 所示。 Counter 组件现在将使用本地存储来记住计数器的最后一个值。 即使重新启动浏览器也不会丢失计数器的值,因为本地存储是永久的。为此,您使用 CurrentCount 属性,该属性调用属性设置器中的粘合函数来存储最后一个值。 Counter 组件重写 OnInitializedAsync 方法以使用 window.blazorLocalStorage.get 粘合函数从本地存储中检索最后存储的值。 可能还没有值,这就是为什么我们需要捕获在这种情况下抛出的异常。 我尝试使用可为空的 int,但在将 JavaScript null 转换为值类型时,IJSRuntime 会引发错误。

清单 10-3 从 Blazor 组件调用 Glue 函数

@page "/counter"
@inject IJSRuntime js
<h1>Counter</h1>
<p>Current count: @CurrentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
    private int currentCount = 0;
    public int CurrentCount
    {
        get => currentCount;
        set
        {
            if (currentCount != value)
            {
                currentCount = value;
                js.InvokeVoidAsync("blazorLocalStorage.set",
                                   nameof(CurrentCount), currentCount);
            }
        }
    }
    private void IncrementCount()
    {
        CurrentCount++;
    }
    protected override async Task OnInitializedAsync()
    {
        try
        {
            int c = await js.InvokeAsync<int>(
                "blazorLocalStorage.get", nameof(CurrentCount));
            currentCount = c;
        }
        catch { }
    }
}

运行解决方案并修改计数器的值。 现在,当您刷新浏览器时,您将看到 Counter 的最后一个值。 计数器现在在会话之间保持不变! 您可以退出浏览器并再次打开它,您将再次看到带有最后一个值的计数器。

传递对 JavaScript 的引用

有时您的 JavaScript 需要访问您的 HTML 元素之一。 为此,您可以将元素存储在 ElementReference 中,然后将此 ElementReference 传递给粘合函数。

您应该将此 ElementReference 用作不透明句柄,这意味着您只能将其传递给 JavaScript 粘合函数,该函数将接收它作为对元素的 JavaScript 引用。 您甚至不能将 ElementReference 传递给另一个组件。 这是设计使然,因为每个组件都是独立渲染的,这可能会使 ElementReference 指向不再存在的 DOM 元素。

让我们看一个示例,通过使用 interop 将焦点设置在输入元素上。说实话,Blazor 中有一个内置方法可以做到这一点,但我想以此作为一个简单的示例。 继续阅读; 我将向您展示如何在没有互操作的情况下聚焦输入元素。

首先将 ElementReference 类型的属性添加到 Counter 组件的 @code 区域,如清单 10-4 所示。

清单 10-4 添加 ElementReference 属性

private ElementReference? inputElement;

然后添加一个带有 @ref 属性的 input 元素来设置 inputElement 字段,如清单 10-5 所示。 我们以前见过这种@ref 语法; 您可以使用它来获取对 Blazor 组件和 HTML 元素的引用。

清单 10-5 设置输入元素

<div>
	<input @ref="inputElement" @bind="@CurrentCount" />
</div>

现在使用示例 10-6 中的胶水函数添加另一个 JavaScript 文件 focus.js。不要忘记添加对 index.html 的脚本引用。

清单 10-6 添加 blazorFocus.set 粘合函数

window.blazorFocus = {
    set: (element) => { element.focus(); }
}

现在是“棘手”的部分。 Blazor 将创建您的组件,然后调用生命周期方法,例如 OnInitializedAsync。 如果在 OnInitializedAsync 中调用 blazorFocus.set 粘合函数,则 DOM 尚未使用输入元素更新,因此这将导致运行时错误,因为粘合函数将接收空引用。 您需要等待 DOM 更新,这意味着您应该只将 ElementReference 传递给 OnAfterRender/OnAfter RenderAsync 方法中的胶水函数!

重写 OnAfterRenderAsync 方法,如清单 10-7 所示。 由于渲染完成,我们可以预期 inputElement 被设置,我们调用 blazorFocus.set 粘合函数。 但为了安全起见,我检查 inputElement 是否不为空。

清单 10-7 在 OnAfterRenderAsync 中传递 ElementReference

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (inputElement is not null)
    {
        await js.InvokeVoidAsync("blazorFocus.set", inputElement);
    }
}

运行您的解决方案,您应该会看到输入元素自动获得焦点,如图 10-1 所示。

image

从 JavaScript 调用 .NET 方法

您还可以从 JavaScript 调用 .NET 方法。 例如,您的 JavaScript 可能想告诉您的组件发生了一些有趣的事情,例如用户在浏览器中单击了某些内容。 或者,您的 JavaScript 可能想向 Blazor 组件询问它需要的一些数据。 您可以调用 .NET 方法,但有几个条件。首先,您的 .NET 方法的参数和返回值必须是 JSON 可序列化的,方法必须是公共的,并且您需要在方法中添加 JSInvokable 属性。 方法可以是静态方法或实例方法。

要调用静态方法,请使用 JavaScript DotNet.invokeMethodAsync 或 DotNet.invokeMethod 函数,传递程序集名称、方法名称及其参数。 要调用实例方法,请将包装为 DotNetObjectRef 的实例传递给 JavaScript 粘合函数,然后该函数可以使用 DotNetObjectRef 的 invokeMethodAsync 或 invokeMethod 函数调用 .NET 方法,并传递 .NET 方法的名称及其参数。 如果希望组件在 Blazor Server 中工作,则需要使用异步函数。

添加采用 .NET 实例的 Glue 函数

让我们继续前面的例子。 当您对本地存储进行更改时,存储会触发 JavaScript 存储事件,传递旧值和新值(以及更多)。这允许您注册其他浏览器选项卡或窗口中的更改,并使用它来更新页面 localStorage 中的最新数据。

打开前面示例中的 interop.js 并添加一个监视函数,如清单 10-8 所示。 watch 函数获取对 DotNetObjectRef 实例的引用,并在存储更改时调用此实例的 UpdateCounter 方法。 您可以通过注册 JavaScript 存储事件来检测存储的变化。

清单 10-8 手表功能允许您注册本地存储更改

window.blazorLocalStorage = {
    get: key => key in localStorage ? JSON.parse(localStorage[key]) : null,
    set: (key, value) => { localStorage[key] = JSON.stringify(value); },
    delete: key => { delete localStorage[key]; },
    watch: async (instance) => {
        window.addEventListener('storage', (e) => {
            instance.invokeMethodAsync('UpdateCounter');
        });
    }
};

当任何人或任何事物更改此网页的本地存储时,浏览器将触发存储事件,并且我们的 JavaScript 互操作将调用 C# Blazor 组件中的 UpdateCounter 方法(我们将在接下来实现)。

是时候添加 UpdateCounter 方法了。 打开 Counter.razor 并将 UpdateCounter 方法添加到 @code 区域,如清单 10-9 所示。

清单 10-9 更新计数器方法

[JSInvokable]
public async Task UpdateCounter()
{
    int c = await js.InvokeAsync<int>("blazorLocalStorage.get",nameof(CurrentCount));
    currentCount = c;
    this.StateHasChanged();
}

此方法触发 UI 使用 CurrentCounter 的最新值进行更新。 请注意,此方法遵循返回 Task 实例的 .NET 异步模式,因为 JavaScript 互操作将使用清单 10-8 中的 invokeMethodAsync 函数异步调用此方法。 要完成该示例,请添加示例 10-10 所示的 OnAfterRenderAsync 生命周期方法。 OnAfterRenderAsync 方法将 Counter 组件的 this 引用包装在 DotNetObjectRef 中,并将其传递给 blazorLocalStorage.watch 粘合函数。

清单 10-10 OnAfterRenderAsync 方法

protected override async Task OnAfterRenderAsync(
    bool firstRender)
{
    if (inputElement is not null)
    {
        await js.InvokeVoidAsync("blazorFocus.set", inputElement);
    }
    var objRef = DotNetObjectReference.Create(this);
    await js.InvokeVoidAsync("blazorLocalStorage.watch", objRef);
}

要查看此操作,请在您的网站上并排打开两个浏览器选项卡。 当您更改一个选项卡中的值时,您应该会看到另一个选项卡自动更新为相同的值! 您可以使用它在同一浏览器中的两个选项卡之间进行通信,就像我们在此处所做的那样。

使用服务进行互操作

前面的示例不是我推荐的与 JavaScript 进行互操作的方式,因为我们的组件与 IJSRuntime 紧密耦合。 有一种更好的方法,那就是将 IJSRuntime 代码封装在服务中。 这将隐藏与 JavaScript 交互的所有肮脏细节,并允许更轻松的维护。 在未来几代的 Blazor 中,可能只包含其中的一些功能,然后我们只需要更新服务实现即可。 在单元测试期间,服务也可以很容易地被替换。

构建 LocalStorage 服务

将新的 Services 文件夹添加到客户端项目。 在该文件夹中添加一个新接口,将其命名为 ILocalStorage,并将清单 10-11 中的三个方法添加到其中。

清单 10-11 构建 ILocalStorage 服务接口

using System.Threading.Tasks;
namespace JSInterop.Services
{
    public interface ILocalStorage
    {
        ValueTask<T> GetProperty<T>(string propName);
        ValueTask SetProperty<T>(string propName, T value);
        ValueTask WatchAsync<T>(T instance) where T : class;
    }
}

这些方法与 interop.js 中的粘合函数相对应。

现在向同一个 Services 文件夹添加一个新类并将其命名为 LocalStorage。 这个类应该实现 ILocalStorage 接口,如清单 10-12 所示。 看看这个类如何隐藏执行 JavaScript 互操作的所有细节? 这是一个简单的案例!

清单 10-12 实现 LocalStorage 服务类

using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using System.Threading.Tasks;
namespace JSInterop.Services
{
    public class LocalStorage : ILocalStorage
    {
        private readonly IJSRuntime js;
        public LocalStorage(IJSRuntime js)
        {
            this.js = js;
        }
        public ValueTask<T> GetProperty<T>(string propName)
            => js.InvokeAsync<T>("blazorLocalStorage.get", propName);
        public ValueTask SetProperty<T>(string propName, T value)
            => js.InvokeVoidAsync("blazorLocalStorage.set", propName, value);
        public ValueTask WatchAsync<T>(T instance) where T : class
            => js.InvokeVoidAsync("blazorLocalStorage.watch",
                                  DotNetObjectReference.Create(instance));
    }
}

组件将通过依赖注入接收此服务,因此将其添加为单例,如清单 10-13 所示。

清单 10-13 在依赖注入中注册 LocalStorage 服务

using JSInterop.Services;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace JSInterop
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");
            builder.Services
                .AddScoped(sp => new HttpClient
                           {
                               BaseAddress =
                                   new Uri(builder.HostEnvironment.BaseAddress)
                           });
            builder.Services
                .AddSingleton<ILocalStorage, LocalStorage>();
            await builder.Build().RunAsync();
        }
    }
}

返回到 Counter 组件,将使用 blazorLocalStorage 对 IJSRuntime 的每个调用替换为 LocalStorage 服务。 首先为 ILocalStorage 服务添加注入指令,如清单 10-14 所示。

清单 10-14 将 ILocalStorage 服务注入 Counter 组件

@page "/counter"
@inject JSInterop.Services.ILocalStorage localStorage

现在进入 OnInitializedAsync 方法,我们从本地存储中检索值。 将 IJSRuntime 调用替换为 LocalStorage 调用,如清单 10-15 所示。

清单 10-15 实现 OnInitializedAsync

protected override async Task OnInitializedAsync()
{
    try
    {
        await localStorage.WatchAsync(this);
        int c = await localStorage
            .GetProperty<int>(nameof(CurrentCount));
        currentCount = c;
    }
    catch { }
}

对示例 10-16 中的 UpdateCounter 方法执行相同的操作。

清单 10-16 使用 LocalStorage 服务的 UpdateCounter 方法

[JSInvokable]
public async Task UpdateCounter()
{
    int c = await localStorage
        .GetProperty<int>(nameof(CurrentCount));
    currentCount = c;
    this.StateHasChanged();
}

更新 CurrentCount 属性的 setter,如清单 10-17 所示。

清单 10-17 记住计数器的值

private int currentCount = 0;
public int CurrentCount
{
    get => currentCount;
    set
    {
        if (currentCount != value)
        {
            currentCount = value;
            localStorage
                .SetProperty<int>(nameof(CurrentCount), currentCount);
        }
    }
}

最后,更新 OnAfterRenderAsync 方法,如清单 10-18 所示。 此方法现在还使用内置的 FocusAsync 方法将焦点设置在输入上。 不需要 JavaScript 互操作。 此方法确实需要您添加 @using 语句,因为 FocusAsync 是一种扩展方法:

@using Microsoft.AspNetCore.Components

清单 10-18 计数器的 OnAfterRenderAsync 方法

private ElementReference inputElement = default!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        await inputElement.FocusAsync();
    }
}

使用模块动态加载 JavaScript

我们的应用程序已向应用程序添加了一些 JavaScript,我们已将其添加到 index.html 页面。 这意味着我们的 JavaScript 会被下载,即使我们不使用它(因为没有人点击 Counter 链接)。 这不是很好。 此外,我们的 JavaScript 正在向 JavaScript 窗口对象添加另一个标识符。 同样不太好,因为另一个组件可能会意外选择相同的名称。 在这里,我们将研究如何使用模块动态下载 JavaScript,因此仅在需要时才下载。

使用 JavaScript 模块

早期使用 JavaScript 是为了实现小而直接的功能。 然后 JavaScript 的使用开始爆炸式增长,使程序变得复杂且难以维护。 从那时起,人们尝试在 JavaScript 中引入“库”,这些库可以包含在您的程序中。 今天,JavaScript 具有我们可以在 Blazor 中使用的模块机制。 您可以比较 JavaScript 模块,如 .NET 库,可以动态加载。 在我们正在构建的当前 JSInterop Blazor 应用程序中,复制 interop.js 文件,将其命名为 localstorage.js,并将其修改为如清单 10-19 所示。 我们没有向全局窗口对象添加 get、set 和 watch 函数,而是使用 JavaScript 模块导出这些函数(类似于用于使类在库外部可用的 C# public 关键字)。 模块也像命名空间一样,使 get、set 和 watch 函数与模块相关,并且不会污染全局 JavaScript 窗口对象。

清单 10-19 localStorage JavaScript 模块

let get = key => key in localStorage ? JSON.
parse(localStorage[key]) : null;
let set = (key, value) => { localStorage[key] = JSON.stringify(value); };
let watch = async (instance) => {
    window.addEventListener('storage', (e) => {
        instance.invokeMethodAsync('UpdateCounter');
    });
};
export { get, set, watch };

将模块加载到 Blazor 服务中

模块准备就绪后,我们可以使用 IJSRuntime 实例将其导入 Blazor 组件或服务。 它通过使用 InvokeAsync 方法与任何其他 JavaScript 互操作一样工作,但现在我们使用 T 的 IJSObjectReference 类型,调用 Blazor 提供的导入函数。

首先,在 ILocalStorage 接口中添加一个 Init 方法,如示例 10-20 所示。

清单 10-20 更新的 ILocalStorage 接口

using System.Threading.Tasks;
namespace JSInterop.Services
{
    public interface ILocalStorage
    {
        ValueTask Init();
        ValueTask<T> GetProperty<T>(string propName);
        ValueTask SetProperty<T>(string propName, T value);
        ValueTask WatchAsync<T>(T instance) where T : class;
    }
}

在 LocalStorage 类中实现这个方法,如清单 10-21 所示。 这个方法在这里完全没有做任何事情,但是我们将在另一个类中实现它。

清单 10-21 LocalStorage 的初始化方法

public ValueTask Init() => new ValueTask();

创建 LocalStorage.cs 服务的副本,并将其命名为 LocalStorageWithModule.cs。 将其修改为如示例 10-22 所示。 该类的大部分实现类似于LocalStorage 类,但要注意Init 方法。 在这里,我们调用“import”方法,将路径传递给 JavaScript 模块。 Blazor 动态加载它并返回一个 IJSObjectReference,我们使用它来调用 get、set 和 watch JavaScript 函数。 为什么不在构造函数中这样做呢? 因为 InvokeAsync 是一个异步方法,我们不应该在构造函数中调用它们。

清单 10-22 加载 JavaScript 模块

using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using System.Threading.Tasks;
namespace JSInterop.Services
{
    public class LocalStorageWithModule : ILocalStorage
    {
        private readonly IJSRuntime js;
        private IJSObjectReference? module;
        public LocalStorageWithModule(IJSRuntime js)
        {
            this.js = js;
        }
        public async ValueTask Init()
        {
            module = module ?? await js.InvokeAsync<IJSObjectReference>
                ("import", "./scripts/localstorage.js");
        }
        public ValueTask<T> GetProperty<T>(string propName)
            => module!.InvokeAsync<T>("get", propName);
        public ValueTask SetProperty<T>(string propName, T value)
            => module!.InvokeVoidAsync("set", propName, value);
        public ValueTask WatchAsync<T>(T instance) where T : class
            => module!.InvokeVoidAsync("watch",
                                       DotNetObjectReference.Create(instance));
    }
}

在 Counter 组件中使用这个新类,如清单 10-23 所示。 实际上,我们唯一需要改变的是调用 localStorage 服务的 Init 方法。

清单 10-23 使用 JavaScript 模块的计数器组件

protected override async Task OnInitializedAsync()
{
    try
    {
        await localStorage.Init();
        await localStorage.WatchAsync(this);
        int c = await localStorage.GetProperty<int>(nameof(CurrentCount));
        currentCount = c;
    }
    catch { }
}

构建并运行; 一切都应该仍然有效。 最大的优势是我们不需要将 JavaScript 添加到 index.html 页面。 这对于组件库来说变得更加有趣。

将地图添加到 PizzaPlace

许多实体企业使用地图向人们展示他们所在的位置。 用地图装饰 PizzaPlace 应用程序,显示您在哪里以及 PizzaPlace 餐厅在哪里,不是很好吗? 这就是我们接下来要做的。

选择 Map JavaScript 库

我们将使用哪个地图库? JavaScript 库有很多可供选择,例如谷歌地图、必应地图等。作者的特权是选择地图库,而我选择了 Leaflet 开源库,它是轻量级的,有很多自定义选项,并且是 GitHub、Flickr、Etsy 和 Facebook 等一些领先公司使用。 您可以在 https://leafletjs.com 找到图书馆的网站。

添加Leaflet库

打开 index.html 页面,添加 Leaflet 样式和 JavaScript 脚本,如清单 10-24 所示。 最简单的方法是从 https://leafletjs.com/examples/quick-start/ 的 Leaflet QuickStart 页面复制它。 这也将确保您使用最新版本(有破坏性更改的风险)。

清单 10-24 添加Leaflet库

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0,
                                       maximum-scale=1.0, user-scalable=no" />
        <title>PizzaPlace</title>
        <base href="/" />
        <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
        <link href="css/app.css" rel="stylesheet" />
        <link href="PizzaPlace.Client.styles.css" rel="stylesheet" />
        <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/
                                     leaflet.css"
              integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqC
                         mblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
              crossorigin="" />
    </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>
        <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
                integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUr
                           Bc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
                crossorigin=""></script>
    </body>
</html>

构建 Leaflet Map Razor 库

您可以在许多应用程序中使用地图,因此我认为将其构建为剃刀库非常有意义。 您可以找到为您提供 Map 组件的 Blazor 组件库(例如,https://github.com/fis-sst/BlazorMaps),但在这里我们将构建一个作为练习。 将新的 Razor 类库添加到您的解决方案并将其命名为 Leaflet.Map。

从此项目中删除除 _Imports.razor 文件和 wwwroot 文件夹之外的所有文件。 在 wwwroot 中添加一个新的 map.js JavaScript 文件,如清单 10-25 所示。 为了节省打字(和拼写错误),我建议您从提供的来源复制此内容。

清单 10-25 地图 JavaScript 模块

let showOrUpdate = (elementId, zoom, markers) => {
    let elem = document.getElementById(elementId);
    if (!elem) {
        throw new Error('No element with ID ' + elementId);
    }
    // Initialize map if needed
    if (!elem.map) {
        elem.map = L.map(elementId).setView([50.88022, 4.29660], zoom);
        L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/
                    {y}?access_token=***ACCESSTOKEN***', {
    attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/
    copyright">OpenStreetMap</a> contributors, Imagery © <a href="https://www.
    mapbox.com/">Mapbox</a>',
    maxZoom: 18,
        id: 'mapbox/streets-v11',
            tileSize: 512,
                zoomOffset: -1,
                    accessToken: '***ACCESSTOKEN***'
}).addTo(elem.map);
}
export { showOrUpdate };

要完成示例 10-25,我们还需要做一件事。 这是您需要用自己的令牌替换的 ***ACCESSTOKEN*** 占位符,我们接下来会这样做。

向地图提供者注册

Leaflet 将从地图提供商处下载其地图,在这里,我们将使用您可以免费用于开发的 MapBox。 您可以在 www.mapbox.com/maps 找到他们的网站。 您将需要在此站点上注册以获取您的访问令牌。 所以注册后,你应该去你的帐户并创建一个访问令牌。 复制此令牌并替换***ACCESSTOKEN*** 与代码清单 10-25 中的令牌(两次)。

创建地图组件

现在向 Leaflet.Map 库项目中添加一个新的 razor 组件并将其命名为 Map。实现该组件,如清单 10-26 所示。 该组件使用一个 div,Leaflet 将用地图替换它。 这个 div 需要一个唯一的 id,我们使用 .NET 的 Guid 类型生成它,我们设置它的样式来填充父元素。 清单 10-25 中的 JavaScript 模块使用 id 从 DOM 中检索 div:

let elem = document.getElementById(elementId);

然后 Map 组件使用来自 wwwroot 的静态 map.js 资源的路径加载 map.js 模块。 我们只需要执行一次,因此我们在 OnInitializedAsync 方法中执行此操作。

最后,当 Map 组件被渲染后,我们在 OnAfterRenderAsync 方法中使用我们的模块调用 Leaflet 库。 但是,由于 OnInitializedAsync 方法还没有完成,我们需要检查模块是否已经加载。 当 OnInitializedAsync 方法完成时,组件将再次呈现,然后将调用 showOrUpdate JavaScript 方法。

清单 10-26 地图组件

@using Microsoft.JSInterop
@inject IJSRuntime JSRuntime
<div id="@elementId" style="height: 100%; width: 100%;"></div>
@code {
    string elementId = $"map-{Guid.NewGuid().ToString("D")}";
    [Parameter] public double Zoom { get; set; } = 17.0;
    private IJSObjectReference leaflet;
    protected override async Task OnInitializedAsync()
    {
        leaflet = await JSRuntime.InvokeAsync<IJSObjectReference>
            ("import", "./_content/Leaflet.Map/map.js");
    }
    protected async override void OnAfterRender(bool firstRender)
    {
        if (leaflet is not null)
        {
            await leaflet.InvokeVoidAsync(
                411
                "showOrUpdate",
                elementId, Zoom/*, Markers*/);
        }
    }
}

使用地图组件

在 PizzaPlace.Client 项目中,添加对 Leaflet.Map 组件库的项目引用。

将 @using Leaflet.Map 添加到您的 PizzaPlace.Client 项目的 _Imports.razor 文件中,如清单 10-27 所示。 这将有助于使用该库。

清单 10-27 将@using 添加到 _Imports.razor

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using PizzaPlace.Client
@using PizzaPlace.Client.Shared
@using PizzaPlace.Shared
@using Leaflet.Map

打开 Index.razor,在 CustomerEntry 组件下方,添加 Map 组件,如清单 10-28 所示。 我们还需要设置 Zoom 参数,我发现 Zoom 17 会显示足够详细的位置以查看道路。 如果你愿意,你可以试验这个参数。

清单 10-28 添加地图组件

<!-- End customer entry -->
<!-- Map -->
<div class="map">
    <Map Zoom="17" />
</div>
<!-- End Map -->

在 Pages 文件夹中的客户端项目中添加一个名为 Index.razor.css 的新文件,并添加地图类,如清单 10-29 所示。

清单 10-29 样式化地图容器

.map {
    width: 550px;
    height: 550px;
}

运行 PizzaPlace 应用程序。 您应该会看到如图所示的地图。 如您所见,地图显示了我工作的位置。 如果您愿意,可以更改示例 10-25 中的坐标以适应您居住或工作的位置。

image

向地图添加标记

仅显示地图是不够的。 让我们添加一些标记来显示 PizzaPlace 的位置和您的位置。 首先,在 Leaflet.Map 项目中添加一个新的 Marker 类,如清单 10-30 所示。 该类将序列化为 Leaflet 库使用的 JavaScript 对象。 在 Leaflet 库网站上,您可以找到更多关于添加圆形、多边形和弹出窗口的信息。 我们不会这样做,因为这与标记非常相似。

清单 10-30 标记类

namespace Leaflet.Map
{
    public class Marker
    {
        public string Description { get; set; }
        public double X { get; set; }
        public double Y { get; set; }
        public bool ShowPopup { get; set; }
    }
}

向 Map 组件添加一个名为 Markers 的新参数,如清单 10-31 所示。

清单 10-31 地图的标记参数

[Parameter] public List<Marker> Markers { get; set; }
= new List<Marker>();

更新 showOrUpdate 方法以传递 Markers 参数,如清单 10-32 所示。

清单 10-32 将标记参数传递给 JavaScript

protected async override void OnAfterRender(bool firstRender)
{
    if (leaflet is not null)
    {
        await leaflet.InvokeVoidAsync(
            "showOrUpdate",
            elementId, Zoom, Markers);
    }
}

我们的 JavaScript 还没有对这些标记做任何事情,所以我们必须更新 JavaScript 模块。 更新 map.js,如示例 10-33 所示。 这需要输入很多内容,因此您可能需要从提供的来源中复制此内容。 不要忘记更新 ***ACCESSTOKEN*** 占位符。

清单 10-33 更新的 map.js 模块

let showOrUpdate = (elementId, zoom, markers) => {
    let elem = document.getElementById(elementId);
    if (!elem) {
        throw new Error('No element with ID ' + elementId);
    }
    // Initialize map if needed
    if (!elem.map) {
        elem.map = L.map(elementId).setView([50.88022, 4.29660], zoom);
        elem.map.addedMarkers = [];
        L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/
                    {y}?access_token=***ACCESSTOKEN***', {
    attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/
    copyright">OpenStreetMap</a> contributors, Imagery © <a href="https://www.
    mapbox.com/">Mapbox</a>',
    maxZoom: 18,
        id: 'mapbox/streets-v11',
            tileSize: 512,
                zoomOffset: -1,
                    accessToken: '***ACCESSTOKEN***'
}).addTo(elem.map);
}
// Add markers
let map = elem.map;
if (map.addedMarkers.length !== markers.length) {
    // Markers have changed, so reset
    map.addedMarkers.forEach(marker => marker.removeFrom(map));
    map.addedMarkers = markers.map(m => {
        return L.marker([m.y, m.x]).bindPopup(m.description).addTo(map);
    });
    // Auto-fit the view
    var markersGroup = new L.featureGroup(map.addedMarkers);
    map.fitBounds(markersGroup.getBounds().pad(0.3));
    // Show applicable popups. Can't do this until after the view was
    auto-fitted.
    markers.forEach((marker, index) => {
        if (marker.showPopup) {
            map.addedMarkers[index].openPopup();
        }
    });
} else {
    // Same number of markers, so update positions/text without changing
    view bounds
    markers.forEach((marker, index) => {
        animateMarkerMove(
            map.addedMarkers[index].setPopupContent(marker.description),
            marker,
            4000);
    });
}
};
let animateMarkerMove = (marker, coords, durationMs) => {
    if (marker.existingAnimation) {
        cancelAnimationFrame(marker.existingAnimation.callbackHandle);
    }
    marker.existingAnimation = {
        startTime: new Date(),
        durationMs: durationMs,
        startCoords: { x: marker.getLatLng().lng, y: marker.getLatLng().lat },
        endCoords: coords,
        callbackHandle: window.requestAnimationFrame(() => animateMarker
                                                     MoveFrame(marker))
    };
}
let animateMarkerMoveFrame = (marker) => {
    var anim = marker.existingAnimation;
    var proportionCompleted = (new Date().valueOf() - anim.startTime.
                               valueOf()) / anim.durationMs;
    var coordsNow = {
        x: anim.startCoords.x + (anim.endCoords.x - anim.startCoords.x) *
        proportionCompleted,
        y: anim.startCoords.y + (anim.endCoords.y - anim.startCoords.y) *
        proportionCompleted
    };
    marker.setLatLng([coordsNow.y, coordsNow.x]);
    if (proportionCompleted < 1) {
        marker.existingAnimation.callbackHandle = window.requestAnimationFrame(
            () => animateMarkerMoveFrame(marker));
    }
}
export { showOrUpdate };

现在让我们在 PizzaPlace 应用程序中添加一些标记。 向 Index.razor 组件添加一个新组件,如清单 10-34 所示。 随意将坐标更新到您附近的地方。

清单 10-34 添加一些标记

private List<Marker> Markers = new List<Marker> {
    new Marker {
        X = 4.29660,
        Y = 50.88022,
        Description = "Pizza Place" },
    new Marker {
        X = 4.27638,
        Y = 50.87136,
        Description = "You",
        ShowPopup = true },
};

数据将此绑定到 Map 组件的 Markers 参数,如清单 10-35 所示。

清单 10-35 将标记传递给地图组件

<!-- Map -->
<div class="map">
    <Map Zoom="17" Markers="@Markers"/>
</div>
<!-- End Map -->

构建并运行 PizzaPlace 应用程序。 您现在应该在地图上看到标记,如图 10-3 所示。 单击标记时,它将显示一个弹出窗口。
image

posted @ 2022-09-04 14:19  F(x)_King  阅读(225)  评论(0编辑  收藏  举报