65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET Core SPA 结合 Preact 和 HTM

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2023 年 1 月 27 日

MIT

21分钟阅读

viewsIcon

9469

创建轻量级的 VS 2022 ASP.NET Core SPA 模板,支持 React 风格组件

引言

几年前,我需要一个轻量级的解决方案来处理小型 ASP.NET (Core) 项目。我的要求是:

  • 在功能齐全的 Visual Studio 中进行开发
  • 类型检查
  • 可重用组件
  • ECMAScript 模块支持
  • 无编译或极快的编译速度
  • 生产环境的打包和压缩

我尝试了 Visual Studio 内置的 React 模板(带 TypeScript),它满足了我大部分标准,更不用说能够使用各种炫酷的 React 库了,但我无法忍受其编译所需的时间。而且它一点也不轻量。我也尝试了许多其他模板,例如 Blazor,当时它还不算成熟。它也一点都不轻量,学习曲线似乎相当陡峭,而且为我需要的 JavaScript 库创建绑定将需要我无法承受的时间。我还玩过 Visual Studio Code,虽然与完整的 Visual Studio 相比,它会大大加快前端编码速度,但我无法放弃 Visual Studio 中的 C# 开发。而且我不想将解决方案拆分成多个编辑器中的多个项目。然后我找到了很棒的库 Preact,一个 3kb 的 React 克隆,API 相同。他们还有自己的 JSX 替代品 HTM (Hyperscript Tagged Markup),可以直接在浏览器中运行,因此无需预编译。在本教程项目模板中,将逐步使用 ASP.NET Core、Preact、HTM 和一些其他辅助库来创建一个类似于 Visual Studio 的 React 模板。CSS 将像原始 React 模板一样使用 Bootstrap。

在这里,我还可以提到,在此模板之前,我从未尝试过自己制作类似的东西,而是只使用了 Visual Studio 中包含的现成模板。我曾检查过 Visual Studio 的 React 模板等内容,但对其代码和使用的工具都一无所知。当我给自己几天时间深入了解现代 Web 开发基础知识后,这种情况发生了改变,所以这某种程度上也是一个关于这些工具的教程。

如果您赶时间,本教程的结果也可以从 GitHub 下载。

创建后端

让我们开始创建一个新项目,选择 **ASP.NET Core Web App**,并将其命名为 `AspNetCorePreactHtm`,使用默认设置。由于我们将使用 JavaScript 创建 DOM 内容,请打开 _Pages\Shared_Layout.cshtml_ 并将内容替换为:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>AspNetCorePreactHtm</title>        
</head>
<body>
    @RenderBody()    
    @RenderSection("Scripts", required: false)
</body>
</html>

打开 _Pages\index.cshtml_ 并将内容替换为:

@page 

如果运行项目,应该会打开一个空白页,因为没有可见的 HTML。

使用在浏览器中编译的虚拟 DOM 创建 Preact 前端

首先,删除解决方案浏览器中 _wwwroot/lib_ 下的所有现有内容。然后右键单击空的 _wwwroot/lib_ 文件夹,选择 **添加 / 客户端库**。在打开的窗口中,在库文本框中键入 `bootstrap`,然后按 Enter。将出现最新的 Bootstrap 版本。选择 **选择特定文件**,仅选择 _css/bootstrap.min.css_ 和 _js/bootstrap.bundle.min.js_,然后单击 **安装**。右键单击 _wwwroot/lib_ 文件夹,再次选择 **添加客户端库**。在打开的窗口中,在库文本框中键入 `htm`,然后按 Enter。将出现最新的 HTM 库版本。选择 **选择特定文件**,仅选择 _preact/standalone.module.js_,然后单击 **安装**。Standalone 模块在一个包中包含 Preact 和 HTM。右键单击 _wwwroot_,选择 **添加 / 新建文件夹**,并将其命名为 **src**。右键单击 _wwwroot/src_,选择 **添加 / 新建项 / JavaScript 文件**,并将其命名为 _App.js_。打开创建的文件 _wwwroot/src/App.js_ 并粘贴以下代码:

import { html, Component, render } from '../lib/htm/preact/standalone.module.js';

class App extends Component {

    constructor() {
        super();
    }

    render() {
        return html`Hello World!`;
    }
}

render(html`<${App} />`, document.body);

第一行导入 Preact 和 HTM 所需的 JavaScript。然后是 Preact 组件,它只渲染文本“Hello world”。最后一行告诉 Preact 将组件渲染到 HTML 文档的 body 部分。

再次打开 _Layout.cshtml_,并将内容替换为以下代码片段:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>AspNetCorePreactHtm</title>    
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
    <script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
    <script defer type="module" src="~/src/App.js"></script>    
</head>
<body>
    @RenderBody()
    @RenderSection("Scripts", required: false)
</body>
</html>

添加了一行,即对我们刚刚创建的 _App.js_ 的引用。它具有重要的定义 `defer type="module"`,它告诉浏览器代码包含现代 ECMAScript 代码而不是普通 JavaScript。再次运行应用程序,您应该会看到打开的网页上的 Hello world。Preact 的虚拟 DOM 引擎已连接!

为后端添加 WeatherForecast API

在 Razor 页面应用程序中,HTTP API 由服务定义。接下来,我们将创建一个 API,该 API 将返回天气预报,就像默认的 React 模板一样。右键单击解决方案浏览器中的 AspNetCorePreactHtm 应用程序,选择 **添加** / **新建文件夹** 到根目录,并将其命名为 **Shared**。右键单击新的 _Shared_ 文件夹,选择 **添加** / **新建项** / **代码文件**,并将其命名为 _WeatherForecastSummary.cs_。粘贴以下代码:

namespace AspNetCorePreactHtm.Shared
{
    /// <summary>
    /// Forecast feel enum definition
    /// </summary>
    public enum WeatherForecastSummary
    {
        /// <summary>
        /// Feels freezing
        /// </summary>
        Freezing,
        
        /// <summary>
        /// Feels bracing
        /// </summary>
        Bracing, 
        
        /// <summary>
        /// Feels chilly
        /// </summary>
        Chilly, 
        
        /// <summary>
        /// Feels cool
        /// </summary>
        Cool, 
        
        /// <summary>
        /// Feels mild
        /// </summary>
        Mild, 
        
        /// <summary>
        /// Feels warm
        /// </summary>
        Warm, 
        
        /// <summary>
        /// Feels balmy
        /// </summary>
        Balmy, 
        
        /// <summary>
        /// Feels hot
        /// </summary>
        Hot, 
        
        /// <summary>
        /// Feels sweltering
        /// </summary>
        Sweltering, 
        
        /// <summary>
        /// Feels scorching
        /// </summary>
        Scorching
    } 
}

向 Shared 文件夹添加另一个代码文件,并将其命名为 _WeatherForecast.cs_,然后粘贴以下代码:

using System.ComponentModel.DataAnnotations;

namespace AspNetCorePreactHtm.Shared
{
    /// <summary>
    /// Weather forecast class definition
    /// </summary>
    public class WeatherForecast
    {
        /// <summary>
        /// Forecast date time
        /// </summary>
        public DateTime Date { get; set; } = DateTime.Now;

        [Range(-50, 100)]
        /// <summary>
        /// Forecast tempereture in celsius degrees
        /// </summary>
        public int TemperatureC { get; set; } = 0;

        [Range(-58, 212)]
        /// <summary>
        /// Forecast temperature in Fahrenheit degrees
        /// </summary>
        public int TemperatureF { get; set; } = 32;

        /// <summary>
        /// Forecast summary enum value
        /// </summary>
        public WeatherForecastSummary Summary 
               { get; set; } = WeatherForecastSummary.Cool;
    }
}

有很多注释我们现在不需要,但稍后会回来处理。右键单击解决方案浏览器中的项目,选择 **添加** / **新建项** / **代码文件**,并将其命名为 _WeatherForecastService.cs_。粘贴以下代码:

namespace AspNetCorePreactHtm
{
    using AspNetCorePreactHtm.Shared;

    public interface IWeatherForecastService
    {
        string Get();       
    }
    public class WeatherForecastService : IWeatherForecastService
    {       
        public string Get()
        {
            var WeatherForecasts = new List<WeatherForecast>();
            for (int i = 1; i <= 5; i++)
            {
                WeatherForecast wf = new WeatherForecast();
                wf.Date = DateTime.Now.AddDays(i);
                wf.TemperatureC = Random.Shared.Next(-20, 55);
                wf.TemperatureF = 32 + (int)(wf.TemperatureC / 0.5556);
                wf.Summary = (WeatherForecastSummary)Random.Shared.Next
                (0, Enum.GetNames(typeof(WeatherForecastSummary)).Length-1);

                WeatherForecasts.Add(wf);
            }
            return System.Text.Json.JsonSerializer.Serialize(WeatherForecasts);
        }        
    }
}

这定义了我们的天气预报服务。为了使其生效,我们需要将其注册到 Web 应用程序构建器。打开 _Program.cs_,在 `builder.Services.AddRazorPages();` 行之后粘贴以下代码片段:

// register weatherforecast service as singleton
builder.Services.AddSingleton<AspNetCorePreactHtm.IWeatherForecastService, 
                 AspNetCorePreactHtm.WeatherForecastService>();

我们仍然需要定义响应新服务的 HTTP GET 请求的相对路径。在文件 `app.Run();` 的最后一行之前粘贴以下代码片段:

// map weatherforecast api
app.MapGet("/api/weatherforecast", 
    (AspNetCorePreactHtm.IWeatherForecastService service) =>
{
    return Results.Ok(service.Get());
});

现在 WeatherForeCast API 已完成。您可能会注意到创建的 WeatherForecast API 返回的预测是 JSON 字符串而不是数组。原因是服务首先将发送的数据序列化为 JSON。默认序列化会将属性名转换为驼峰式(首字母小写)。直接调用 System.Text.Json.JsonSerializer.Serialize 会保留 Pascal 格式,属性名将保持不变。

创建前端 SPA

现在是时候创建我们真正的 SPA(**S**ingle **P**age **A**pplication)了。每个页面将在一个单独的文件中定义。

首页

让我们从主页开始。右键单击 _wwwroot/src_,选择 **添加** / **新建项** / **JavaScript 文件**,并将其命名为 _Home.js_。粘贴以下代码:

"use strict";

import { html, Component } from '../lib/htm/preact/standalone.module.js';

export class Home extends Component {

    constructor(props) {
        super(props);
    }

    render() {
        return html`
 <div>
    <h1>Hello, world!</h1>
    <p>Welcome to your new single-page application, built with:</p>
    <ul>
        <li><a href='https://get.asp.net/'>ASP.NET Core</a> and 
            <a href='https://msdn.microsoft.com/en-us/library/67ef8sbd.aspx' 
             target="_blank" rel="noopener noreferrer">C#</a> 
             for cross-platform server-side code</li>
        <li><a href='https://preact.reactjs.ac.cn/' target="_blank" 
             rel="noopener noreferrer">Preact</a> with 
            <a href='https://github.com/developit/htm'>HTM 
            (Hyperscript Tagged Markup)</a> rendering for client-side code</li>
        <li><a href='https://bootstrap.ac.cn/' target="_blank" 
             rel="noopener noreferrer">Bootstrap</a> for layout and styling</li>
    </ul>
    <p>To help you get started, we have also set up:</p>
    <ul>
        <li><strong>Client-side navigation</strong>. 
        For example, click <em>Counter</em> then <em>Back</em> to return here.</li>
    </ul>    
 </div>
      `;
    }
}

Home 组件是一个普通的组件,只渲染静态 HTML。文件开头的 `use strict` 选项将强制我们编写更干净的代码,声明所有使用的变量等。

计数器页面

接下来,我们将创建计数器页面。右键单击 _wwwroot/src_,选择 **添加** / **新建项** / **JavaScript 文件**,并将其命名为 _Counter.js_。粘贴以下代码:

"use strict";

import { html, Component } from '../lib/htm/preact/standalone.module.js'; 

export class Counter extends Component {

    constructor(props) {
        super(props);
        this.state = { currentCount: 0 };
    }

    incrementCounter() {
        this.setState({
            currentCount: this.state.currentCount + 1
        });
    }

    render() {
        return html`
<div>
    <h1>Counter</h1>
    <p>This is a simple example of a React component.</p>
    <p aria-live="polite">Current count: <strong>${this.state.currentCount}</strong></p>
    <button class="btn btn-primary" onClick=${() => 
            this.incrementCounter()}>Increment</button>
</div>
      `;
    }
}

Counter 组件演示了如何将 DOM 元素绑定到模板字面量内的您自己的函数。请注意,状态值 currentCount 如何在渲染时获取,以及 increment 按钮的 onClick 事件如何绑定到组件的内部 incrementCounter() 函数。

FetchData 页面

右键单击 _wwwroot/src_,选择 **添加** / **新建项** / **JavaScript 文件**,并将其命名为 _FetchData.js_。粘贴以下代码:

"use strict";

import { html, Component } from '../lib/htm/preact/standalone.module.js';

var feelsLike = ["Freezing", "Bracing", "Chilly", "Cool", "Mild", 
                 "Warm", "Balmy", "Hot", "Sweltering", "Scorching"];

export class FetchData extends Component {

    constructor(props) {
        super(props);
        this.state = { forecasts: [], loading: true };
    }

    componentDidMount() {
        this.populateWeatherData();
    }

    async populateWeatherData() {
        const response = await fetch('api/weatherforecast');
        const json = await response.json();
        this.state.forecasts = JSON.parse(json);
        this.state.loading = false;
        this.forceUpdate();
    }

    render() {
        if (this.state.loading) {
            return html`<p><em>Loading...</em></p>`;
        }
        else {
            return html`
<div>
    <h1>Weather forecast</h1>
    <p>This component demonstrates fetching data from the server.</p>
    <table class="table table-striped">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
    ${this.state.forecasts.map(f => html`
            <tr>
                <th scope="row">${f.Date.toLocaleString()}</th>
                <td>${f.TemperatureC}</td>            
                <td>${f.TemperatureF}</td>
                <td>${feelsLike[f.Summary]}</td>
            </tr>
    `)}
        </tbody>
    </table>    
</div>
`;
        }
    }
}

FetchData 组件演示了如何使用 JavaScript map 方法在模板字面量中渲染列表。通过 HTTP 调用从服务器获取数据可能是一个漫长的过程,因此使用异步函数,该函数在获取完成后触发渲染。

主组件

现在所有页面都已定义,是时候更新应用程序的主组件 App 组件,以便它可以路由到并渲染已创建的页面组件了。打开 _wwwroot/src/App.js_ 并将现有代码替换为:

"use strict";

import { html, Component, render } from '../lib/htm/preact/standalone.module.js';
import { Home } from './Home.js';
import { Counter } from './Counter.js'
import { FetchData } from './FetchData.js'

// router pages, first page is considered home page
var pages = { '#Home': Home, '#Counter': Counter, '#FetchData': FetchData };

class App extends Component {

    constructor() {
        super();

        // window back navigation handler
        window.onpopstate = () => { this.Navigate(null); };

        // initial page
        this.state = { navPage: '#Home' };
    }
    
    Navigate(toPage) {        
        this.setState({ navPage: toPage });        
    }

    render() {

        // get page to navigate to or browser back/forward navigation page or
        // first (home) page
        let page = this.state.navPage ? this.state.navPage : 
        window.location.hash ? window.location.hash : Object.entries(pages)[0][0]; 
        
        // push page to browser navigation history if not current one
        if (window.location.hash !== page) {
            window.history.pushState({}, page, window.location.origin + page);
        }                   
          
        let content = html`<${pages[page]} />`;

        return html`
<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <div class="container-fluid">
    <a class="navbar-brand" style="cursor: pointer" 
     onClick=${() => this.Navigate("#Home")}>PreactHtmStarter</a>
    <button class="navbar-toggler" type="button" 
     data-bs-toggle="collapse" data-bs-target="#navbarNav" 
     aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNav">
      <ul class="navbar-nav">
        <li class="nav-item">
          <a class="nav-link" style="cursor: pointer" 
           onClick=${() => this.Navigate("#Home")}>Home</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" style="cursor: pointer" 
           onClick=${() => this.Navigate("#Counter")}>Counter</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" style="cursor: pointer" 
           onClick=${() => this.Navigate("#FetchData")}>Fetch data</a>
        </li>        
      </ul>
    </div>
  </div>  
</nav>
<div class="container-fluid body-content">
    ${content}
</div>
      `;
    }
}

render(html`<${App} />`, document.body);

App.js 中发生了许多变化,但我们稍后会详细介绍。现在运行项目。网页类似于默认的 React 模板。使用导航栏链接在页面之间浏览。计数器页面的计数按钮会增加页面上的计数,而 Fetch Data 页面会显示我们之前创建的 HTTP API 中的随机天气预报。浏览器中的后退和前进按钮会遵循我们单页应用程序内早期导航的历史记录。

那么这一切是如何工作的呢?

页数

从 React 或 Preact 的角度来看,一切都是一个组件:App 是我们的主组件,HomeCounterFetchData 是页面组件。由于 App 组件从其他 JavaScript 文件导入页面组件,因此它们必须在它们的源代码中导出,例如,在 _Counter.js_ 中导出类 Counter 扩展 Component。每个组件都有一个渲染函数,该函数将其内容渲染到屏幕上。一个组件可以包含其他组件,例如 App 组件包含所有页面组件。

路由

通常 React 和 Preact 使用一些外部路由库,但在这里路由被嵌入到 App 组件中,通过一个超级简单的哈希路由实现,它使用浏览器窗口历史 API 来存储导航。它仅由一个字典组成,以页面名称作为键,以页面组件作为值。每个页面名称上的“#”字符会告诉浏览器使用 JavaScript API 而不是从服务器获取数据。当 App 组件创建时,首先调用构造函数,并在其中定义窗口后退导航处理函数。此外,HTML 导航栏链接不包含任何 href 链接,而是通过在悬停时更改光标并将 onClick 事件绑定到我们自己的导航函数(以页面名称为参数)来模仿它。Navigate 函数调用然后使用 Preact 组件的 SetState 函数,该函数通过将 SetState 调用中的任何值合并到组件的内部状态来修改组件的内部状态,并触发渲染以更新视图,在这种情况下是导航到的页面组件。导航到页面时,其名称和虚拟路径会被推送到浏览器的导航历史记录中,以便稍后弹出。Render 函数然后决定路由到哪个页面:如果状态 navPagenull,则从浏览器历史记录中获取页面名称。然后从字典中读取页面组件,并将其渲染到 App 组件的内容部分。这不是我的发明,而是我多年前读到的一篇原生 JavaScript Web 文章的灵感。我只是将其移植到了 Preact 组件。我很想将功劳归于原作者,但我记不清是谁了。

模板字面量

模板字面量看起来非常像 React 使用的 JSX 模板语言。JSX 必须预编译成 JavaScript React 节点,浏览器才能理解它们。另一方面,HTM 库使用模板字面量将它们转译为浏览器中的 Preact 虚拟 DOM 节点,因此可以跳过预编译步骤。虽然 JSX 不能使用某些 HTML 保留字,例如 class,而是使用 className,但模板字面量没有这个限制。转译也可以在服务器端完成,但我们将在压缩时再次讨论。

泛型类型检查

现在模板已经完全可用了。如果使用它的项目保持较小,这可能就足够了。JavaScript 本质上是一种非类型语言,这意味着任何变量都可以随时具有任何值,这对于 JavaScript 来说是可以接受的,并且无法在编辑器中发现拼写错误等。因此,任何增加的复杂性都将受益于编写时类型检查,所以这就是我们接下来要做的事情。为此,我们将使用……TypeScript。这听起来可能很奇怪,因为我们不编译我们的前端代码,而是按原样运行它。无论一个人是否喜欢 TypeScript,它都有一个很棒的功能——无需编译即可进行类型检查。事实上,它非常智能,可以自动获取您使用的客户端库的 TypeScript 类型,如果它们是 Definitely Typed 的话。此外,它在识别您在 JavaScript 中使用的变量类型方面非常有效。右键单击解决方案浏览器中的 AspNetCorePreactHtm 项目,然后选择 **管理 NuGet 包**。选择 **浏览**,然后在搜索文本框中键入 typescript。在匹配项中,选择 Microsoft.TypeScript.MSBuild 包并安装最新版本。右键单击解决方案浏览器中的 AspNetCorePreactHtm 项目,然后选择 **添加新项**,然后选择 **npm 配置文件**。保留默认名称 _package.json_。将 _package.json_ 文件内容替换为以下代码片段:

{
  "version": "1.0.0",
  "name": "asp.net",
  "private": true,
  "devDependencies": {
    "typescript-lit-html-plugin": "0.9.0"   
  }
}

要使用 npm 包,您应该安装 Node.js。右键单击 _package.json_,然后选择 **还原包**。这将安装 typescript-lit-html-plugin。它不会以任何方式影响解决方案的操作,但会突出显示模板字面量中的 HTML。因此,而不是仅仅用红色显示字符串内容,它还应该为在模板字面量字符串中编写 HTML 提供某种智能感知。我之所以说“应该”,是因为它在我使用 Visual Studio 2022 时对我来说并不完美,但在以前版本的 Visual Studio 中没有问题。在编写本教程时,我只知道这个任务没有更好的库了。右键单击解决方案浏览器中的 AspNetCorePreactHtm 项目,然后选择 **添加新项**,然后选择 **TypeScript JSON 配置文件**。保留默认名称 _tsconfig.json_。将 _tsconfig.json_ 文件内容替换为以下代码片段:

{
  "compilerOptions": {
    // Allow checking JavaScript files
    "allowJs": true,
    // I mean, check JavaScript files for real
    "checkJs": true,
    // Do not generate any output files
    "noEmit": true,
    // Target ES syntax version
    "target": "ES2022",
    // html template literals highlight
    "plugins": [
      {
        "name": "typescript-lit-html-plugin"
      }
    ]
  },
  "exclude": [ "wwwroot/lib" ],
   "include": [ "wwwroot/src" ] 
}

此配置告诉 TypeScript 检查 JavaScript 文件,而默认情况下它只检查 TypeScript 文件(我们没有)。此外,不允许发出任何转译代码,只允许检查。检查时使用最新的 ECMAScript 规范。前面安装的插件在 plugins 中声明。当您尝试运行项目时,您会发现它不再编译,而是会显示有关 Preact + HTM standalone 包的许多错误消息,因为 TypeScript 找不到该文件的类型。通过在编辑器中打开已安装的包 _wwwroot/lib/htm/preact.standalone.module.js_ 来解决此问题。在 JavaScript 之前添加新行,然后键入或粘贴:

//@ts-nocheck 

这将通知 TypeScript 不要检查其内容。现在项目应该像以前一样编译和运行。

JSDoc 强类型检查

现在客户端端具有某种程度的类型检查,但它不是非常强大,因为 TypeScript 只能尝试猜测我们 JavaScript 变量的类型。我们如何定义它们的类型而不编写 TypeScript 代码?答案是 JSDoc 定义,它们定义为 JavaScript 注释注解,并且不会以任何方式修改 JavaScript 本身。TypeScript 理解这些注解,并可以使用它们进行类型检查。让我们以 _App.js_ 作为简单示例。在 _App.js_ 文件中,将光标放在 `Navigate(toPage) {` 行的开头,然后键入“/**”。编辑器将自动为您创建 JSDoc 模板,如下所示:

/**
    * 
    * @param {any} toPage
    */

将其替换为以下代码片段:

/**
     * Navigates to page by name
     * @param {string} toPage Page name to navigate
     */

其中 Navigates to page by name 是函数的通用定义,@param {string} toPage Page name to navigate 定义函数参数 toPagestring 类型,并附带注解注释。在编写 JavaScript 代码时,智能感知将为您提供这些信息。尝试将鼠标悬停在组件模板字面量部分的 this.Navigate 调用上,您将立即看到智能感知如何提供有关该函数的注解信息。尝试将组件构造函数部分中的 this.Navigate(null); 调用更改为 this.Navigate(1);。您将收到一个警告,因为数字 1 不可分配给 string。基本上,任何类、结构或变量都可以通过 JSDoc 进行类型化,您可以自由地只类型化您认为合适的变量。

共享模型

有许多库可以将 C# 模型转换为 TypeScript。我曾以为也会有将 C# 模型转换为 JSDoc 注解的 ECMAScript 类的库,当我找不到任何时感到很惊讶。了解 Roslyn 代码分析一直是我待办事项列表中的一项,所以我用它制作了一个小型库,用于将 C# 模型转换为 JSDoc 注解的 ECMAScript 类。它不是很流畅,但如果您的模型不太复杂,应该可以完成工作。您可以从 GitHub 下载它。编译程序以生成 _CSharpToES.exe_。编译后,它可以在项目(或其他任何项目)中使用。右键单击解决方案浏览器中的项目根目录,选择 **属性**。导航到“生成”和“事件”。在“预生成事件”中,添加以下文本:

<path to CSharpToES.exe> $(ProjectDir)Shared $(ProjectDir)wwwroot\src\shared 

其中 <path csharptoes.exe="" to=""> 应该类似于:

C:\CSharpToES\CSharpToES\bin\Release\net6.0\CSharpToES.exe 

取决于您在计算机上的下载位置。当然,您也可以将编译后的内容复制到更方便的路径,例如 _C:\CSharpToES\CSharpToES.exe_ 并在此处引用它。$(ProjectDir)Shared 然后告诉 CSharpToES 共享 C# 模型的相对源文件夹,$(ProjectDir)wwwroot\src\shared 是编译后的 js 类文件的相对目标文件夹,因为它通过文件而不是反射读取 C# 模型。转换必须在构建前发生,否则编译器无法捕获使用编译后代码的 JavaScript 代码的编译错误。CSharpToES 会遵循源文件夹结构,以便每个 _*.cs_ 文件都编译成等效的 _*.js_ 文件。为了演示这一点,这就是 _WeatherForecastSummary.cs_ 和 _WeatherForecast.cs_ 文件被放在单独的 Shared 文件夹中并分成两个独立文件的原因。如果您编译或运行项目,将进行 ECMAScript 转换,并在 _wwwroot/src/shared_ 文件夹中创建等效的 _WeatherForecastSummary.js_ 和 _WeatherForecast.js_ 文件。_WeatherForecastSummary.js_ 将如下所示:

/**
* Forecast feel enum definition
* @readonly
* @enum {number}
* @property {number} Freezing Feels freezing
* @property {number} Bracing Feels bracing
* @property {number} Chilly Feels chilly
* @property {number} Cool Feels cool
* @property {number} Mild Feels mild
* @property {number} Warm Feels warm
* @property {number} Balmy Feels balmy
* @property {number} Hot Feels hot
* @property {number} Sweltering Feels sweltering
* @property {number} Scorching Feels scorching
*/
export const WeatherForecastSummary = {
    /** Feels freezing */
    Freezing: 0,
    /** Feels bracing */
    Bracing: 1,
    /** Feels chilly */
    Chilly: 2,
    /** Feels cool */
    Cool: 3,
    /** Feels mild */
    Mild: 4,
    /** Feels warm */
    Warm: 5,
    /** Feels balmy */
    Balmy: 6,
    /** Feels hot */
    Hot: 7,
    /** Feels sweltering */
    Sweltering: 8,
    /** Feels scorching */
    Scorching: 9
}

JavaScript 没有 enum 类型,但这种方法非常接近。C# 模型中的任何注释都会编译成 JSDoc 注解,以帮助前端编码。_WeatherForecast.js_ 将如下所示:

import { WeatherForecastSummary } from './WeatherForecastSummary.js';

/** Weather forecast class definition */
export class WeatherForecast {

    // private values
    /** @type {Date} */ #Date;
    /** @type {number} */ #TemperatureC;
    /** @type {number} */ #TemperatureF;
    /** @type {WeatherForecastSummary} */ #Summary;

    /** Weather forecast class definition */
    constructor() {
        this.#Date = new Date();
        this.#TemperatureC = 0;
        this.#TemperatureF = 32;
        this.#Summary = WeatherForecastSummary.Cool;
    }

    /**
    * Forecast date time
    * Server type 'DateTime'
    * @type {Date}
    */
    get Date() {
        return this.#Date;
    }
    set Date(val) {
        if (val instanceof Date) {
            this.#Date = val;
        }
    }

    /**
    * Server type 'int' custom range -50 ...  100
    * @type {number}
    */
    get TemperatureC() {
        return this.#TemperatureC;
    }
    set TemperatureC(val) {
        if (typeof val === 'number') {
            this.#TemperatureC = (val < -50 ? -50 : 
            (val >  100 ?  100 : Math.round(val)))
        }
    }

    /**
    * Server type 'int' custom range -58 ...  212
    * @type {number}
    */
    get TemperatureF() {
        return this.#TemperatureF;
    }
    set TemperatureF(val) {
        if (typeof val === 'number') {
            this.#TemperatureF = (val < -58 ? -58 : 
                                 (val >  212 ?  212 : Math.round(val)))
        }
    }

    /**
    * Forecast summary enum value
    * Server type enum 'WeatherForecastSummary' values [0,1,2,3,4,5,6,7,8,9]
    * @type {WeatherForecastSummary}
    */
    get Summary() {
        return this.#Summary;
    }
    set Summary(val) {
        if ([0,1,2,3,4,5,6,7,8,9].includes(val)) {
            this.#Summary = val;
        }
    }

    /** WeatherForecast JSON serializer. Called automatically by JSON.stringify(). */
    toJSON() {
        return {
            'Date': this.#Date,
            'TemperatureC': this.#TemperatureC,
            'TemperatureF': this.#TemperatureF,
            'Summary': this.#Summary
        }
    }

    /**
    * Deserializes json to instance of WeatherForecast.
    * @param {string} json json serialized WeatherForecast instance
    * @returns {WeatherForecast} deserialized WeatherForecast class instance
    */
    static fromJSON(json) {
        let o = JSON.parse(json);
        return WeatherForecast.fromObject(o);
    }

    /**
    * Maps object to instance of WeatherForecast.
    * @param {object} o object to map instance of WeatherForecast from
    * @returns {WeatherForecast} mapped WeatherForecast class instance
    */
    static fromObject(o) {
        if (o != null) {
            let val = new WeatherForecast();
            if (o.hasOwnProperty('Date')) { val.Date = new Date(o.Date); }
            if (o.hasOwnProperty('TemperatureC')) { val.TemperatureC = o.TemperatureC; }
            if (o.hasOwnProperty('TemperatureF')) { val.TemperatureF = o.TemperatureF; }
            if (o.hasOwnProperty('Summary')) { val.Summary = o.Summary; }
            return val;
        }
        return null;
    }

    /**
    * Deserializes json to array of WeatherForecast.
    * @param {string} json json serialized WeatherForecast array
    * @returns {WeatherForecast[]} deserialized WeatherForecast array
    */
    static fromJSONArray(json) {
        let arr = JSON.parse(json);
        return WeatherForecast.fromObjectArray(arr);
    }

    /**
    * Maps array of objects to array of WeatherForecast.
    * @param {object[]} arr object array to map WeatherForecast array from
    * @returns {WeatherForecast[]} mapped WeatherForecast array
    */
    static fromObjectArray(arr) {
        if (arr != null) {
            let /** @type {WeatherForecast[]} */ val = [];
            arr.forEach(function (f) { val.push(WeatherForecast.fromObject(f)); });
            return val;
        }
        return null;
    }
}

这对于模型采取了明确的方法。最简单的方法是在构造函数中声明属性。这样就不会保护属性,因为属性将简单地成为类型化字段,并且 JavaScript 中的错误就可以将任何值写入它们。我想保护属性,使它们例如在 C# 源属性未标记为可空时不能在客户端设置为 null。这是通过 private # 字段完成的,其中包含实际的属性值。然后,private 字段仅通过 getter 和 setter 访问,就像在 C# 中一样。由于 C# 有许多不同的数值类型,而 JavaScript 基本上只有一个,setter 代码会限制值在 C# 数据类型的范围内。这种方法的缺点是增加了复杂性和 JavaScript 代码的数量。但由于它们是自动生成的,所以问题不大。此外,ECMAScript 类的序列化和反序列化需要额外的工作,而普通 JavaScript 对象可以通过 JSON.Stringify 轻松序列化,并通过 JSON.parse 反序列化。JSON.parse 会检查对象是否定义了 toJSON 函数并使用它,因此序列化这些模型可以正常工作,使用 JSON.Stringify。另一方面,反序列化更棘手。为此,提供了自定义 static 函数:从 JSON 字符串反序列化,从普通 JavaScript 对象或其数组反序列化。该库支持以下功能:

  • 文件之间的自动导入导出生成
  • 日期作为日期对象而不是字符串处理
  • C# 字典转换为 JS Map 而不是普通对象,因为 Map 可以使用 JSDoc 进行强类型化
  • 支持属性和字段的简单初始化,以便在 JavaScript 中创建新对象与 C# 中的新对象具有相同的值
  • 简单的继承

如果您将鼠标悬停在 _FetchData.js_ 中的 f.Datef.TemperatureCf.TemperatureFf.Summary 变量上,您会注意到智能感知不知道它们是什么。任何拼写错误都可能导致应用程序在运行时崩溃。为了演示共享模型的优势,我们将修改 FetchData 组件。将 _FetchData.js_ 替换为以下代码:

"use strict";

import { html, Component } from '../lib/htm/preact/standalone.module.js';
import { WeatherForecast } from './Shared/WeatherForecast.js';

var feelsLike = ["Freezing", "Bracing", "Chilly", "Cool", "Mild", 
                 "Warm", "Balmy", "Hot", "Sweltering", "Scorching"];

/**
 * @typedef {Object} FetchDataState FetchData component state structure
 * @property {WeatherForecast[]} forecasts array of WeatherForecast class instances
 * @property {boolean} loading true = values still loading from server, 
 * false = values has been loaded from server
 */

export class FetchData extends Component {

    constructor(props) {
        super(props);
        /** @type{FetchDataState} */ this.state = { forecasts: [], loading: true };
    }

    componentDidMount() {
        this.populateWeatherData();
    }

    async populateWeatherData() {
        const response = await fetch('api/weatherforecast');
        const json = await response.json();         
        this.state.forecasts = WeatherForecast.fromJSONArray(json);        
        this.state.loading = false;
        this.forceUpdate();
    }

    render() {
        if (this.state.loading) {
            return html`<p><em>Loading...</em></p>`;
        }
        else {
            return html`
<div>
    <h1>Weather forecast</h1>
    <p>This component demonstrates fetching data from the server.</p>
    <table class="table table-striped">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
    ${this.state.forecasts.map(f => html`
            <tr>
                <th scope="row">${f.Date.toLocaleString()}</th>
                <td>${f.TemperatureC}</td>            
                <td>${f.TemperatureF}</td>
                <td>${feelsLike[f.Summary]}</td>
            </tr>
    `)}
        </tbody>
    </table>    
</div>
`;
        }
    }
}

现在页面为 WeatherForecastFetchData 组件的内部状态定义了类型。WeatherForecast 类型是从共享模型导入的。如果您将鼠标悬停在 f.Datef.TemperatureCf.TemperatureFf.Summary 变量上,智能感知会显示其数据类型以及在 C# 模型中编写的注释。如果您拼写错误,例如将 f.Summary 拼写为 f.Summary,编译器会检测到 WeatherForecast 类没有 Summary 属性,项目将无法编译。此外,生成的数组反序列化器用于 WeatherForecast.fromJSONArray(json) 调用。如果您想定义组件内部状态对象的类型,类型定义可能最简单,就像这里用 FetchDataState 对象定义所做的那样。需要注意的一点是,不建议使用 SetState 变异来更改状态并触发渲染,因为智能感知无法理解 setState 变异引用的是内部状态对象。这就是为什么在这里 populateWeatherData 函数中,直接修改状态,然后通过 forceUpdate 调用,Preact 会被通知状态已更改,需要渲染。

捆绑和小型化

现在模板已完全可用并支持类型定义。它可以按原样发布,并且支持模块的现代浏览器可以运行它。仍然存在一些问题:

  1. JavaScript 未打包。浏览器必须逐个文件获取所有文件,通过解析 import 调用,这会导致性能下降,尤其是在源文件很多的情况下。
  2. 文件未压缩发送到浏览器
  3. 您的 JavaScript 源代码完全暴露

让我们开始打包。打开 _package.json_ 并将内容替换为以下内容:

{
  "version": "1.0.0",
  "name": "asp.net",
  "private": true,
  "scripts": {
    "build-babel": "babel wwwroot/src -d wwwroot/babel",
    "build-rollup": "rollup wwwroot/src/App.js --file wwwroot/bundle.js --format esm",
    "build-rollup-babel": "rollup wwwroot/babel/App.js 
     --file wwwroot/bundle.js --format esm",
    "build-terser": "terser --compress --mangle -- wwwroot/bundle.js > 
     wwwroot/bundle.min.js",
    "trash-babel": "trash wwwroot/babel",
    "build-purgecss": "purgecss 
     --css wwwroot/lib/bootstrap/css/bootstrap.min.css --content wwwroot/bundle.js 
     --output wwwroot/bundle.min.css",
    "build-all": "npm run build-rollup && 
     npm run build-terser && npm run build-purgecss",
    "build-all-babel": "npm run build-babel && 
     npm run build-rollup-babel && npm run build-terser && 
     npm run build-purgecss && npm run trash-babel"
  },
  "devDependencies": {
    "@babel/cli": "7.17.6",
    "@babel/core": "7.17.5",
    "purgecss": "4.1.3",
    "rollup": "2.69.0",
    "terser": "5.12.0",
    "trash-cli": "5.0.0",
    "typescript-lit-html-plugin": "^0.9.0"
  },
  "dependencies": {
    "babel-plugin-htm": "3.0.0"
  }
}

然后右键单击 _package.json_,选择 **还原包**。包出现在项目根目录的新文件夹 _node_modules_ 中。添加了许多包,这里简要介绍它们的作用:

  • Rollup 将多个 ECMAScript 文件打包成一个
  • Terser 压缩打包后的 ECMAScript 文件
  • Babel 使用 babel-plugin-htm 将模板字面量转换为 Preact 节点,如 HTM 项目网站所述
  • trash-cli 用于清理 Babel 创建的临时文件
  • PurceCSS 通过解析 JavaScript 文件中实际使用的 CSS 样式来压缩 CSS,并删除所有其他样式。脚本是到库的实际调用。脚本也可以打包成一个调用,然后按顺序处理。定义了两个这样的调用:build-allbuild-all-babel。让我们先尝试 build-all。右键单击解决方案浏览器中的项目,选择 **在终端中打开**。在终端中,键入或粘贴:
npm run build-all 

然后按 Enter。文件 _bundle.js_、_bundle.min.js_ 和 _bundle.min.css_ 出现在 wwwroot 中。脚本首先运行子脚本 build-rollup,该脚本创建 _bundle.js_ 文件。打开 _bundle.js_,您会注意到 rollup 已将我们所有代码打包到一个文件中,并在可能的情况下缩短了变量名。所有注释、制表符和换行符仍然存在。接下来,脚本运行子脚本 build-terser,该脚本压缩刚刚创建的 _bundle.js_。打开 _bundle.min.js_,您会注意到所有注释、制表符、换行符和空格都已从 JavaScript 代码部分中删除。然而,组件的模板字面量与之前一样。这是因为它们不是代码,而是 strings,Terser 无法知道我们用它们做什么。build-purgecss 告诉 PurgeCSS 从 _bootstrap.min.css_ 中提取在创建的包 _bundle.js_ 中实际使用的所有 CSS,并将结果写入 _bundle.min.css_ 文件。源代码现在已打包和压缩,但可以再进一步。Babel 配合 babel-plugin-htm 可以将组件模板字面量编译为 Preact 节点。这会使包稍微变小,因此加载速度更快,浏览器 JavaScript 解析器不必编译它们,因为它们已经编译了,这加快了浏览器中的代码启动速度。在运行 babel 之前,需要定义一个配置文件。右键单击项目,**添加** / **新项** / **JSON 文件**,将其命名为 _babel.config.json_,并将以下内容粘贴到文件中:

{
  "presets": [],
  "plugins": [
    [
      "babel-plugin-htm",
      {
        "tag": "html",
        "import": "../lib/htm/preact/standalone.module.js"
      }
    ]
  ]
}

这告诉 babel 使用 babel-plugin-htm 以及 Preact standalone 模块的位置。在终端中,键入或粘贴:

npm run build-all-babel 

然后按 Enter。这在前一个链条中增加了一个步骤:在将代码提供给 Rollup 打包之前,它会运行 build-babel 步骤,该步骤会触发 babel 将我们的每个 JavaScript 文件编译到 _wwwroot/babel_ 文件夹。打开 _wwwroot/babel/App.js_,您会注意到 Babel 与 babel-plugin-htm 一起将 render 函数中的模板字面量编译为 Preact h 节点调用,其中 h 对应于 React 的 createElement。脚本的其余部分与 build-all 中的相同,只是 Rollup 被指示使用这些 Babel 编译后的源文件而不是原始文件。如果您打开 _bundle.min.js_,您会注意到模板字面量已消失,并且所有应用程序 JavaScript 都在压缩行上。打包和压缩仅在发布之前需要,调试时不需要。您可以在发布时从终端运行它,但如果您有我的记忆力,它可以自动化,例如通过将其添加到项目的生成事件中,方法是右键单击项目并选择 **属性**。然后在“生成事件”部分,添加 `npm run` 调用到“发布后事件”。在这种情况下,脚本将在每次编译项目时运行。

出版

现在我们有了用于发布的打包和压缩后的 JavaScript 和 CSS。如果我们现在发布项目,它们将不会被使用,因为 _Pages\Shared_Layout.cshtml_ 中的 HTML 仍然调用 _wwwroot/src_ 中的源代码,并且 _wwwroot_ 中的所有内容都会被发布。暂时关闭项目,导航到项目定义文件 _EsSpaTemplate.csproj_,并用记事本(我最喜欢的是 Notepad++)等打开它。在最后一个 PropertyGroup 定义之后,粘贴以下代码片段,保存并重新打开项目。

<ItemGroup>
    <Content Update="wwwroot\src\**" CopyToPublishDirectory="Never" />
    <Content Update="wwwroot\bundle.js" CopyToPublishDirectory="Never" />
    <Content Update="wwwroot\babel\**" CopyToPublishDirectory="Never" />
</ItemGroup>

这将防止发布任何源代码。也许这也可以在 Visual Studio 中完成,但我不知道如何。接下来打开 _Pages\Shared_Layout.cshtml_。将内容替换为以下内容:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PreactHtmStarter</title>    

    <environment include="Development">
        <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
    </environment>
    <environment exclude="Development">
        <link rel="stylesheet" href="~/bundle.min.css" />
    </environment>  

    <script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>        

    <environment include="Development">
        <script defer type="module" src="~/src/App.js" 
                asp-append-version="true"></script>
    </environment>
    <environment exclude="Development">
        <script type="module" src="~/bundle.min.js" 
                asp-append-version="true"></script>
    </environment>  

</head>
<body>
    @RenderBody()    
    @RenderSection("Scripts", required: false)
</body>
</html>

Razor 引擎有一个很棒的功能,它可以在调试模式下向浏览器注入与已发布版本不同的代码。<environment include="Development"><environment exclude="Development"> 指令就是为此目的而添加的。简单来说,这指示在调试时使用 _wwwroot/src_ 中的源代码,在发布时使用压缩后的代码。现在模板已为发布准备就绪,符合序言中的承诺。

我做了一个小测试,将默认的 React 模板发布到 IIS,并将此模板发布到 IIS,在 Chrome 浏览器中的结果如下:

默认 React 模板需要加载

192 kb + 8.2 kb ≈ 200 kb JavaScript 165 kb + 573 b ≈ 166 kb CSS

此模板需要加载

78.4 kb + 16.9 kb ≈ 96 kb JavaScript 10.3 kb CSS

其中 78.4 kb 是 _bootstrap.bundle.min.js_,16.9 kb 是在本教程中实际编写并包含嵌入式 Preact 和 HTM 的压缩代码。

摘要

基本上,我们通过此模板获得的是:

  • 使用 Preact 和 HTM 模板字面量的 React 组件,在一个轻量级模板中,无需预编译为 JavaScript。

而我们没有得到的是:

  • React 库。市面上有大量的 React 库,几乎可以满足您的任何需求。但它们都使用 JSX,因此需要编译过程。

到目前为止,对于我使用此模板的小型 Web 用户界面编写必要的组件对我来说不成问题。出于同样的原因,我可能不会将其用于大型和复杂的项目。

祝您编码愉快!如果您能坚持读到这里,非常感谢。本教程比我预期的要长得多。

© . All rights reserved.