使用 JavaScript 服務在 ASP.NET Core 中建立單頁應用程式

Fiyaz Hasan

警告

本文所述的功能從 ASP.NET Core 3.0 開始已過時。 Microsoft.AspNetCore.SpaServices.Extensions NuGet 套件中提供更簡單的 SPA 架構整合機制。 如需詳細資訊,請參閱 [公告] Obsoleting Microsoft.AspNetCore.SpaServices 和 Microsoft.AspNetCore.NodeServices

單頁應用程式 (SPA) 是 Web 應用程式的熱門類型,因為其固有豐富的使用者體驗。 將用戶端 SPA 架構或程式庫 (例如 AngularReact) 與 ASP.NET Core 等伺服器端架構整合可能很困難。 JavaScript 服務已開發,以減少整合流程中的摩擦。 它可讓不同用戶端與伺服器技術堆疊之間順暢地作業。

什麼是 JavaScript 服務

JavaScript Services 是適用於 ASP.NET Core 的用戶端技術集合。 其目標是將 ASP.NET Core 定位為開發人員組建 SPA 的慣用伺服器端平台。

JavaScript 服務包含兩個不同的 NuGet 套件:

這些套件在下列案例中很有用:

  • 在伺服器上執行 JavaScript
  • 使用 SPA 架構或程式庫
  • 使用 Webpack 組建用戶端資產

本文中的大部分焦點都放在使用 SpaServices 套件上。

什麼是 SpaServices

SpaServices 已建立,將 ASP.NET Core 定位為開發人員組建 SPA 的慣用伺服器端平台。 SpaServices 不需要使用 ASP.NET Core 開發 SPA,也不會將開發人員鎖定至特定的用戶端架構。

SpaServices 提供實用的基礎結構,例如:

這些基礎結構元件會共同增強開發工作流程和執行階段體驗。 您可以個別採用元件。

使用 SpaServices 的必要條件

若要使用 SpaServices,請安裝下列項目:

  • Node.js (版本 6 或更新版本) 與 npm

    • 若要確認這些元件已安裝且可以找到,請從命令列執行下列命令:

      node -v && npm -v
      
    • 如果部署至 Azure 網站,則不需要採取任何動作— Node.js 會在伺服器環境中安裝且可供使用。

  • .NET Core SDK 2.0 或更新版本

    • 在使用 Visual Studio 2017 的 Windows 上,會選取 .NET Core 跨平台開發 工作負載來安裝 SDK。
  • Microsoft.AspNetCore.SpaServices NuGet 套件

伺服器端預先轉譯

通用 (也稱為同構) 應用程式是一種能夠同時在伺服器和用戶端上執行的 JavaScript 應用程式。 Angular、React 和其他熱門架構提供此應用程式開發樣式的通用平台。 其概念是先透過 Node.js 在伺服器上轉譯架構元件,然後將進一步的執行委派給用戶端。

SpaServices 所提供的 ASP.NET Core 標記協助程式,藉由叫用伺服器上的 JavaScript 函式來簡化伺服器端預先轉譯的實作。

伺服器端預先轉譯必要條件

安裝 aspnet-prerendering npm 套件:

npm i -S aspnet-prerendering

伺服器端預先轉譯設定

標記協助程式可透過專案 _ViewImports.cshtml 檔案中的命名空間註冊來探索:

@using SpaServicesSampleApp
@addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers"
@addTagHelper "*, Microsoft.AspNetCore.SpaServices"

這些標記協助程式會利用 Razor 檢視內的類似 HTML 語法,將直接與低階 API 通訊的複雜度抽象化:

<app asp-prerender-module="ClientApp/dist/main-server">Loading...</app>

asp-prerender-module 標記協助程式

上述程式碼範例中使用的 asp-prerender-module 標記協助程式會透過 Node.js 在伺服器上執行 ClientApp/dist/main-server.js。 為了清楚起見,main-server.js 檔案是 Webpack 組建流程中 TypeScript 到 JavaScript 轉譯工作的成品。 Webpack 會定義 main-server 的進入點別名; 而此別名的相依性關係圖周遊會從 ClientApp/boot-server.ts 檔案開始:

entry: { 'main-server': './ClientApp/boot-server.ts' },

在下列 Angular 範例中,ClientApp/boot-server.ts 檔案會利用 aspnet-prerendering npm 套件的 createServerRenderer 函式和 RenderResult 型別,透過 Node.js 設定伺服器轉譯。 以伺服器端轉譯為目標的 HTML 標記會傳遞至解析函式呼叫,其包裝在強型別 JavaScript Promise 物件中。 Promise 物件的意義在於,它會以非同步方式將 HTML 標記提供給頁面,以在 DOM 的預留位置元素中插入。

import { createServerRenderer, RenderResult } from 'aspnet-prerendering';

export default createServerRenderer(params => {
    const providers = [
        { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
        { provide: 'ORIGIN_URL', useValue: params.origin }
    ];

    return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
        const appRef = moduleRef.injector.get(ApplicationRef);
        const state = moduleRef.injector.get(PlatformState);
        const zone = moduleRef.injector.get(NgZone);
        
        return new Promise<RenderResult>((resolve, reject) => {
            zone.onError.subscribe(errorInfo => reject(errorInfo));
            appRef.isStable.first(isStable => isStable).subscribe(() => {
                // Because 'onStable' fires before 'onError', we have to delay slightly before
                // completing the request in case there's an error to report
                setImmediate(() => {
                    resolve({
                        html: state.renderToString()
                    });
                    moduleRef.destroy();
                });
            });
        });
    });
});

asp-prerender-data 標記協助程式

asp-prerender-module 標記協助程式結合時,asp-prerender-data 標記協助程式可用來將內容資訊從 Razor 檢視傳遞至伺服器端 JavaScript。 例如,下列標記會將使用者資料傳遞至 main-server 模組:

<app asp-prerender-module="ClientApp/dist/main-server"
        asp-prerender-data='new {
            UserName = "John Doe"
        }'>Loading...</app>

接收的 UserName 引數會使用內建的 JSON 序列化程式序列化,並儲存在 params.data 物件中。 在下列 Angular 範例中,資料是用來建構 h1 元素內的個人化問候語:

import { createServerRenderer, RenderResult } from 'aspnet-prerendering';

export default createServerRenderer(params => {
    const providers = [
        { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
        { provide: 'ORIGIN_URL', useValue: params.origin }
    ];

    return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
        const appRef = moduleRef.injector.get(ApplicationRef);
        const state = moduleRef.injector.get(PlatformState);
        const zone = moduleRef.injector.get(NgZone);
        
        return new Promise<RenderResult>((resolve, reject) => {
            const result = `<h1>Hello, ${params.data.userName}</h1>`;

            zone.onError.subscribe(errorInfo => reject(errorInfo));
            appRef.isStable.first(isStable => isStable).subscribe(() => {
                // Because 'onStable' fires before 'onError', we have to delay slightly before
                // completing the request in case there's an error to report
                setImmediate(() => {
                    resolve({
                        html: result
                    });
                    moduleRef.destroy();
                });
            });
        });
    });
});

在標記協助程式中傳遞的屬性名稱會以 PascalCase 標記法表示。 與 JavaScript 形成對比,其中相同的屬性名稱會以 camelCase 表示。 預設 JSON 序列化設定會負責這項差異。

若要擴充上述程式碼範例,可以將提供給 resolve 函式的 globals 屬性, 將資料從伺服器傳遞至檢視:

import { createServerRenderer, RenderResult } from 'aspnet-prerendering';

export default createServerRenderer(params => {
    const providers = [
        { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
        { provide: 'ORIGIN_URL', useValue: params.origin }
    ];

    return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
        const appRef = moduleRef.injector.get(ApplicationRef);
        const state = moduleRef.injector.get(PlatformState);
        const zone = moduleRef.injector.get(NgZone);
        
        return new Promise<RenderResult>((resolve, reject) => {
            const result = `<h1>Hello, ${params.data.userName}</h1>`;

            zone.onError.subscribe(errorInfo => reject(errorInfo));
            appRef.isStable.first(isStable => isStable).subscribe(() => {
                // Because 'onStable' fires before 'onError', we have to delay slightly before
                // completing the request in case there's an error to report
                setImmediate(() => {
                    resolve({
                        html: result,
                        globals: {
                            postList: [
                                'Introduction to ASP.NET Core',
                                'Making apps with Angular and ASP.NET Core'
                            ]
                        }
                    });
                    moduleRef.destroy();
                });
            });
        });
    });
});

globals 物件內 定義的 postList 陣列會附加至瀏覽器的全域 window 物件。 這個變數會提升到全域範圍,可消除重複的工作,特別是因為與在伺服器上載入相同的資料並在客戶端上再次載入有關。

global postList variable attached to window object

Webpack Dev Middleware

Webpack Dev Middleware 引進簡化的開發工作流程,其中 Webpack 會視需要建置資源。 中介軟體會在瀏覽器中重新載入頁面時自動編譯並提供用戶端資源。 替代方法是當協力廠商相依性或自訂程式碼變更時,透過專案的 npm 組建指令碼手動叫用 Webpack。 package.json 檔案中的 npm 組建指令碼會顯示在下列範例中:

"build": "npm run build:vendor && npm run build:custom",

Webpack Dev Middleware 必要條件

安裝 aspnet-webpack npm 套件:

npm i -D aspnet-webpack

Webpack Dev Middleware 設定

Webpack Dev Middleware 會透過 Startup.cs 檔案 Configure 方法中的下列程式碼註冊到 HTTP 要求管線:

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseWebpackDevMiddleware();
}
else
{
    app.UseExceptionHandler("/Home/Error");
}

// Call UseWebpackDevMiddleware before UseStaticFiles
app.UseStaticFiles();

必須先呼叫 UseWebpackDevMiddleware 擴充方法,才能透過 UseStaticFiles 擴充方法註冊裝載的靜態檔案。 基於安全性考慮,只有在應用程式以開發模式執行時,才註冊中介軟體。

webpack.config.js 檔案的 output.publicPath 屬性會告知中介軟體監看 dist 資料夾是否有變更:

module.exports = (env) => {
        output: {
            filename: '[name].js',
            publicPath: '/dist/' // Webpack dev middleware, if enabled, handles requests for this URL prefix
        },

模組熱取代

將 Webpack 的模組熱取代 (HMR) 功能視為 Webpack Dev Middleware 的演進。 HMR 引進了所有相同的優點,但它會在編譯變更之後自動更新頁面內容,進一步簡化開發工作流程。 請勿將此與瀏覽器的重新整理混淆,這會干擾 SPA 的目前記憶體內部狀態和偵錯工作階段。 Webpack Dev Middleware 服務和瀏覽器之間有即時連結,這表示變更會推送至瀏覽器。

模組熱取代必要條件

安裝 webpack-hot-middleware npm 套件:

npm i -D webpack-hot-middleware

模組熱取代設定

HMR 元件必須在 Configure 方法中註冊至 MVC 的 HTTP 要求管線:

app.UseWebpackDevMiddleware(new WebpackDevMiddlewareOptions {
    HotModuleReplacement = true
});

如同 Webpack Dev MiddlewareUseWebpackDevMiddleware 擴充方法必須在 UseStaticFiles 擴充方法之前呼叫。 基於安全性考慮,只有在應用程式以開發模式執行時,才註冊中介軟體。

webpack.config.js 檔案必須定義 plugins 陣列,即使它保留空白:

module.exports = (env) => {
        plugins: [new CheckerPlugin()]

在瀏覽器中載入應用程式之後,開發人員工具的 [主控台] 索引標籤會提供 HMR 啟用的確認:

Hot Module Replacement connected message

路由協助程式

在大部分 ASP.NET Core 型 SPA 中,除了伺服器端路由之外,通常還想要用戶端路由。 SPA 和 MVC 路由系統可以在不干擾的情況下獨立運作。 不過,有一個邊緣案例提出了挑戰:識別 404 個 HTTP 回應。

請考慮使用 /some/page 的無擴充路由案例。 假設要求不符合伺服器端路由的模式,但其模式與用戶端路由相符。 現在,請考慮 /images/user-512.png 的連入要求,通常預期會在伺服器上尋找影像檔。 如果該要求的資源路徑不符合任何伺服器端路由或靜態檔案,用戶端應用程式不太可能處理它,通常傳回 404 HTTP 狀態碼。

路由協助程式必要條件

安裝用戶端路由 npm 套件。 使用 Angular 作為範例:

npm i -S @angular/router

路由協助程式設定

Configure 方法中會使用名為 MapSpaFallbackRoute 的擴充方法:

app.UseMvc(routes =>
{
    routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");

    routes.MapSpaFallbackRoute(
        name: "spa-fallback",
        defaults: new { controller = "Home", action = "Index" });
});

路由會依照其設定的順序進行評估。 因此,上述程式碼範例中的 default 路由會先用於模式比對。

建立新專案

JavaScript 服務提供預先設定的應用程式範本。 SpaServices 會與 Angular、React 和 Redux 等不同架構和程式庫搭配使用這些範本。

您可以執行下列命令,透過 .NET Core CLI 安裝這些範本:

dotnet new --install Microsoft.AspNetCore.SpaTemplates::*

會顯示可用的 SPA 範本清單:

範本 簡短名稱 語言 標籤
MVC ASP.NET Core 與 Angular angular [C#] Web/MVC/SPA
MVC ASP.NET Core 與 React.js react [C#] Web/MVC/SPA
MVC ASP.NET Core 與 React.js 和 Redux reactredux [C#] Web/MVC/SPA

若要使用其中一個 SPA 範本建立新專案,請在 dotnet new 命令中包含範本的簡短名稱。 下列命令會建立 Angular 應用程式,並針對伺服器端設定 ASP.NET Core MVC:

dotnet new angular

設定執行階段設定模式

存在兩種主要執行階段設定模式:

  • 開發
    • 包含來源對應以簡化偵錯。
    • 不會將用戶端程式代碼最佳化以達到效能。
  • 生產
    • 排除來源對應。
    • 透過統合和縮製來最佳化用戶端程式碼。

ASP.NET Core 會使用名為 ASPNETCORE_ENVIRONMENT 的環境變數來儲存設定模式。 如需詳細資訊,請參閱設定環境

使用 .NET Core CLI 執行

在專案根目錄執行下列命令,以還原必要的 NuGet 和 npm 套件:

dotnet restore && npm i

建置並執行應用程式:

dotnet run

應用程式會根據執行階段設定模式在 localhost 上啟動。 在瀏覽器中瀏覽至 http://localhost:5000 會顯示登陸頁面。

使用 Visual Studio 2017 執行

開啟 dotnet new命令所產生的 .csproj 檔案。 在專案開啟時,會自動還原必要的 NuGet 和 npm 套件。 此還原程序可能需要幾分鐘的時間,且應用程式已準備就緒在完成時執行。 按一下綠色執行按鈕或按 Ctrl + F5,瀏覽器會開啟至應用程式的登陸頁面。 應用程式會根據執行階段設定模式在 localhost 上執行。

測試應用程式

SpaServices 範本已預先設定為使用 KarmaJasmine 執行用戶端測試。 Jasmine 是 JavaScript 的熱門單元測試架構,而 Karma 則是這些測試的測試執行器。 Karma 已設定為使用 Webpack Dev Middleware,因此開發人員不需要在每次進行變更時停止並執行測試。 無論是針對測試案例或測試案例本身執行的程式碼,測試都會自動執行。

以 Angular 應用程式為例,counter.component.spec.ts 檔案中已提供兩個針對 CounterComponent 的 Jasmine 測試案例:

it('should display a title', async(() => {
    const titleText = fixture.nativeElement.querySelector('h1').textContent;
    expect(titleText).toEqual('Counter');
}));

it('should start with count 0, then increments by 1 when clicked', async(() => {
    const countElement = fixture.nativeElement.querySelector('strong');
    expect(countElement.textContent).toEqual('0');

    const incrementButton = fixture.nativeElement.querySelector('button');
    incrementButton.click();
    fixture.detectChanges();
    expect(countElement.textContent).toEqual('1');
}));

ClientApp 目錄中開啟命令提示字元。 執行以下命令:

npm test

指令碼會啟動 Karma 測試執行器,它會讀取 karma.conf.js 檔案中定義的設定。 在其他設定中,karma.conf.js 會識別要透過其 files 陣列執行的測試檔案:

module.exports = function (config) {
    config.set({
        files: [
            '../../wwwroot/dist/vendor.js',
            './boot-tests.ts'
        ],

發行應用程式

如需發佈至 Azure 的詳細資訊,請參閱此 GitHub 問題

將產生的用戶端資產和已發佈的 ASP.NET Core 成品合併成現成部署套件可能會很麻煩。 值得慶幸的是,SpaServices 會使用名為 RunWebpack 的自訂 MSBuild 目標協調整個發行流程:

<Target Name="RunWebpack" AfterTargets="ComputeFilesToPublish">
  <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
  <Exec Command="npm install" />
  <Exec Command="node node_modules/webpack/bin/webpack.js --config webpack.config.vendor.js --env.prod" />
  <Exec Command="node node_modules/webpack/bin/webpack.js --env.prod" />

  <!-- Include the newly-built files in the publish output -->
  <ItemGroup>
    <DistFiles Include="wwwroot\dist\**; ClientApp\dist\**" />
    <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
      <RelativePath>%(DistFiles.Identity)</RelativePath>
      <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
    </ResolvedFileToPublish>
  </ItemGroup>
</Target>

MSBuild 目標具有下列責任:

  1. 還原 npm 套件。
  2. 建立協力廠商用戶端資產的生產等級組建。
  3. 建立自訂用戶端資產的生產等級組建。
  4. 將 Webpack 產生的資產複製到 publish 資料夾。

執行時會叫用 MSBuild 目標:

dotnet publish -c Release

其他資源