使用Preact和HTM的ASP.NET Core SPA

目录

介绍

创建后端

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

将天气预报API添加到后端

创建前端SPA

主页

计数器页面

获取数据页面

主要组件

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

页面

路由器

模板文字

泛型类型检查

JSDoc强类型检查

共享模型

捆绑(Bundling)和缩小(Minification)

发布

总结


介绍

几年前,我需要一种适用于小型ASP.NETCore)项目的轻量级解决方案。我的要求是:

  • 在成熟的Visual Studio中完成开发
  • 类型检查
  • 可重复使用的组件
  • ECMAScript模块支持
  • 无需编译或编译速度非常快
  • 用于生产的捆绑和缩小

我尝试Visual Studiotypescript中包含React模板,虽然它满足了我的大部分标准,更不用说访问各种花哨的React库,但我无法忍受编译所需的编译时间。而且它一点也不轻巧。我也尝试了许多其他模板,例如当时还不那么成熟的Blazor。它也一点也不轻量级,似乎需要相当长的学习曲线,并且创建与我需要的JavaScript库的绑定需要我无法承受的时间。我也玩过Visual Studio Code,虽然与完整的Visual Studio相比,它会大大加快前端编码的速度,但我无法用完整的Visual Studio来换C#。我也不想在多个编辑器中破坏多个项目的解决方案。然后我找到了很棒的库 Preact,一个具有相同API3kb React 克隆。他们还有自己的JSX替代方案,HTMHyperscript标记标记),它直接在浏览器中运行,因此不需要预编译。在本教程中,类似于Visual StudioReact模板的项目模板是逐步创建的,其中包含ASP.NET CorePreactHTM和其他一些帮助库。Bootstrap将像原始React模板一样用于CSS

在这里,我还可以提到,在这个模板之前,我从未尝试过自己制作这样的东西,而是只使用Visual Studio中包含的现成烘焙模板。例如,我已经检查了里面的内容,例如Visual StudioReact模板,但不了解其中的一行,也不了解使用的工具。当我给自己几天时间深入研究现代Web开发基础知识时,情况发生了变化,所以这也是这些工具的某种教程。

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

创建后端

让我们首先创建一个新项目,然后选择 ASP.NET Core Web应用并使用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库文本框并按回车键。此时将显示最新的引导程序版本。选择选择特定文件并仅选择“css/bootstrap.min.css  js/bootstrap.bundle.min.js然后单击安装。右键单击 wwwroot/lib 文件夹,然后再次选择添加客户端库。在打开的窗口中,在库文本框中键入htm并按回车键。此时将显示最新的HTM库版本。选择选择特定文件“preact/standalone.module.js然后单击安装。独立模块在一个包中包含PreactHTM。右键单击 wwwroot 并选择添加/新建文件夹并将其命名src。右键单击 wwroot/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);

第一行为PreactHTM导入必要的JavaScript。然后是仅呈现文本Hello worldPreact组件。最后一行然后告诉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 worldPreact的虚拟DOM引擎已经连接起来了!

将天气预报API添加到后端

Razor页面应用程序中,http API由服务定义。接下来,我们将创建API,它将像默认React模板一样返回天气预报。右键单击解决方案资源管理器中的AspNetCorePreactHtm应用程序,然后选择添加/新建文件夹以根目录并将其命名“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
    } 
}

将另一个代码文件添加到共享文件夹并将其命名为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 APIjson字符串的形式返回预测,而不是返回数组。原因是服务首先将发送数据序列化为JSON。默认序列化将属性名称转换为驼峰大小写(第一个字母始终很小)。直接调用System.Text.Json.JsonSerializer.Serialize会保留Pascal大小写,属性名称将保留。

创建前端SPA

现在是时候创建我们实际的前端SPASingle Page Application)。每个页面将在单独的文件中定义。

主页

让我们从主页开始。右键单击 wwroot/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://preactjs.com/' 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='http://getbootstrap.com/' 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将迫使我们编写更干净的代码,声明所有使用的变量等。

计数器页面

接下来,我们将创建计数器(counter)页面。右键单击 wwroot/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>
      `;
    }
}

计数器组件演示如何将DOM元素连接到模板文本中您自己的函数。请注意如何在渲染时获取状态的值currentCount,以及增量按钮的onClick事件如何连接到组件的incrementCounter()内部函数。

获取数据页面

右键单击 wwroot/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模板打开。使用导航栏链接在页面之间浏览。计数器页数按钮递增页面上的计数和获取数据页面显示我们之前创建的http api的随机天气预报。浏览器的后退和前进按钮遵循我们单页应用程序中早期导航的历史记录。

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

页面

ReactPreact看来,一切都是一个组件:App是我们的主要组件,并且HomeCounterFetchData是页面组件。由于App组件从其他JavaScript文件导入页面组件,因此必须在其源代码中导出这些组件,例如,在 Counter.js 中导出Counter类扩展Component。每个组件都有将其内容呈现到屏幕的渲染函数。一个组件可以包含其他组件,就像App组件包含所有页面组件一样。

路由器

通常ReactPreact使用一些外部路由器库,但这里的路由器通过超级简单的哈希路由器实现嵌入到App组件中,它使用浏览器窗口历史记录API来存储导航。它只是由包含页面名称作为键和页面组件作为值的字典组成。每个页面名称上的#字符告诉浏览器使用JavaScript API而不是从服务器获取数据。创建App组件时,首先调用构造函数,并定义窗口后导航处理程序函数。此外,html导航栏链接不包含任何href链接,而是通过将悬停时的光标更改为我们自己的导航函数来模仿一个链接,并将onClick事件连接到我们自己的导航函数,页面名称作为参数。 然后,Navigate函数调用使用Preact组件的SetState函数,该函数通过合并SetState调用组件内部状态中的任何值来改变组件的内部状态,并触发呈现以更新视图,在本例中为导航页面组件。导航到页面时,其名称和虚拟路径将被推送到浏览器的导航历史记录,以便稍后弹出。然后Render函数决定要路由的页面:如果navPage状态为null页面名称,则从浏览器历史记录中获取。然后,页面组件从字典中变为红色,并呈现到应用程序组件的内容部分。这不是我的发明,而是我多年前读过的原版JavaScript网络文章。我只是将其移植到Preact组件。我真的很想把功劳归功于原作者,但不记得是谁了。

模板文字

模板文字看起来很像React使用的JSX模板语言。JSX必须预编译到JavaScript React节点,以便浏览器理解它们。另一方面,HTM库使用模板文字将它们转译为浏览器中的Preact虚拟DOM节点,因此可以跳过预编译步骤。虽然JSX不能使用一些保留字html,如类,而是使用className替代模板文字没有此限制。转译也可以在服务器端进行,但我们将在缩小时回到这一点。

泛型类型检查

现在模板已经完全正常运行。如果使用它的项目保持较小,这可能就足够了。JavaScript本质上是非类型语言,这意味着任何变量在任何时候都可以有任何值,这对于JavaScript来说很好,并且无法发现拼写错误等。在编辑。因此,任何增加的复杂性都将受益于写入时类型检查,因此这就是我们接下来要做的事情。为此,我们将使用...typescript。这听起来可能很有趣,因为我们不是在编译我们的前端代码,而是按原样运行它。无论是否喜欢TypeScript,它都有一个很棒的功能——无需编译即可进行类型检查。事实上,它非常聪明,它可以自动获取客户端库的类型脚本类型,你使用它,它们肯定是类型的。此外,它在识别您在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规范用于检查。我们之前安装的插件在插件中声明。在您现在尝试运行项目时,您会注意到它不会再编译,但会给出有关Preact + HTM独立包的错误消息数量,因为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将函数toPage参数定义为带有注释注释的string类型。在编写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.csWeatherForecast.cs文件被放在单独的共享文件夹中并分成两个单独的文件的原因。如果你编译或运行项目,ECMAScript转换将完成,并在 wwwroot/src/shared 文件夹中创建等效的WeatherForecastSummary.jsWeatherForecast.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;
    }
}

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

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

如果你将鼠标悬停在 FetchData.jsf.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.Summaryf.Summary,编译器会检测到该WeatherForecast类没有Summary属性,并且项目将无法编译。生成的数组解串器也用于WeatherForecast.fromJSONArray(json)调用。如果要定义组件的内部状态类型,FetchDataState对象类型定义可能是最简单的,就像这里用对象定义一样。需要注意的一点是,不建议使用SetState突变来更改状态和触发渲染,因为智能感知不理解setState突变是指内部状态对象。这就是为什么在populateWeatherData函数中,状态被直接更改,然后通过forceUpdate调用,Preact被通知状态已更改并且需要渲染。

捆绑(Bundling)和缩小(Minification)

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

  1. JavaScript不是捆绑的。浏览器必须通过解析导入调用来逐个获取所有文件,从而导致性能下降,尤其是在有大量源文件的情况下
  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 first。在解决方案资源管理器中右键单击项目,然后选择在终端中打开”。在终端中,键入或粘贴:
npm run build-all 

并按回车键。带有 bundle.min.js  bundle.min.css文件bundle.jswwwroot中显示。脚本运行第一个下标build-rollup,该下标创建bundle.js文件打开bundle.js您会注意到rollup将所有代码捆绑在一个文件中,并在可能的情况下缩短了变量名称。所有注释、制表符和换行符仍然存在。下一个脚本运行下标build-terser,以缩小刚刚创建的bundle.js。打开 bundle.min.js,您会注意到所有注释、制表符、换行符和空格都从JavaScript代码部分中删除了。但是,组件的模板文本与以前一样。这是因为它们不是代码而是stringTerser无法知道我们使用它做什么。build-purgecss告诉PurgeCSSbootstrap.min.css中提取所有CSS,这是在创建的bundle.j中实际使用的并且将结果写入bundle.min.css文件。源代码现在已捆绑和缩小,但可以更进一步。使用babel-plugin-htmBabel可以将组件模板文字编译为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独立模块的位置。在终端中,键入或粘贴:

npm run build-all-babel 

并按回车键。这在前一个链上增加了一个步骤:在为Rollup提供捆绑代码之前,它运行build-babel触发babel将我们的每个JavaScript文件编译到 wwwroot/babel 文件夹的步骤。打开 wwwroot/babel/App.js你会注意到Babel连同babel-plugin-htmrender函数中编译了模板文字到Preact h节点调用,其中h对应于ReactcreateElement。脚本的其余部分与build-all相同,只是Rollup被指示使用这些Babel编译的源文件而不是原始文件。如果你打开 bundle.min.js,你会注意到模板文字已经消失,所有应用程序JavaScript都在缩小的行上。仅在发布之前需要捆绑和缩小,调试不需要。您可以在发布时从终端运行它,但如果您有像我这样的内存,则可以自动执行,例如通过右键单击项目并选择属性将其添加到项目的构建事件中。然后在生成事件部分,添加对生成后事件的npm run调用。在这种情况下,脚本将在每次编译项目时运行。

发布

现在我们已经捆绑并缩小了JavaScriptCSS以供发布。如果我们现在发布项目,它们将不会在 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中也是可能的,但我不知道如何。接下来打开页面\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是本教程中实际编写的缩小代码,包括嵌入式PreactHTM

总结

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

  • 在轻量级模板中使用Preact和HTM模板文字响应组件,而无需预编译为JavaScript。

以及我们没有得到的:

  • React应库。有大量的React库几乎可以用于您可能想到的任何目的。但是它们都使用JSX,因此需要重新编译

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

https://www.codeproject.com/Articles/5353140/ASP-NET-Core-SPA-with-Preact-and-HTM

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值