升级到 Angular 版本 19

有两种更新方式,直接使用 `ng update`,或者在更新 `global @angular/cli` 后创建新应用程序。它们产生的结果略有不同。主要是使用的 `builder`。新更改对 `angular.json` 的影响最大。一些新选项尚未记录。

更新至 Angular 19

最终的服务器代码可以在 StackBlitz 上找到

可选择以“npm install -g @angular/cli”开头,将全局构建器更新到新版本。

使用 `ng update @angular/core@19 @angular/cli@19`。或者使用 `ng new appname --ssr` 创建一个新应用程序。区别在于 `@angular/builder`,`ng update` 命令会提示您使用以下额外选项:

Select the migrations that you'd like to run 
❯◉ [use-application-builder] Migrate application projects to the new build system.
()

新的构建器没有单独的 `server` 构建器。相同的 `ng build` 将负责构建客户端和服务器端。

提示的另一个选项是将“APP_INITIALIZER”更新为新的“provideAppInitializer”。

当前项目变更

  • 这将删除所有 standalone: true 因为它是新版本中的默认设置。
  • APP_INITLAIZER 已弃用(见下文)。
  • 构建器 @angular-devkit/build-angular:server 已被弃用,我们不要使用它
  • @angular-devkit/build-angular 已更改为 @angular/build
  • 服务器端渲染实现发生了变化(我们将深入研究这一点)。
  • tsconfig.app.json 现在添加了服务器相关文件。
  • 小心已删除的文件,这可能不是一个好主意。
  • 有关新 CLI 选项的文档以及如何转移到新构建器的文档可以在 Angular 官方网站上找到。但并非所有内容都有很好的文档记录。

    更新 APP_INITIALIZER

    此提供程序现已弃用。新版本是一个函数,因此:

    // old
      {
        provide: APP_INITIALIZER,
        useFactory: configFactory,
        multi: true,
        deps: [ConfigService]
      },

    变成(provideAppInitializer 的 Angular 文档):

    // new
    provideAppInitializer(() => {
      const initializerFn = (configFactory)(inject(ConfigService));
      return initializerFn();
    }),

    新的提供程序需要一个 **EnvironmentProviders** 类型的函数。上面的 `configFactory` 是一个需要将 `ConfigService` 作为依赖项注入的函数。这是以​​下自动生成的代码:

    // the configFactory, and the ConfigService
    export const configFactory = (config: ConfigService) => () => {
      return config.loadAppConfig();
    };
    
    @Injectable({
      providedIn: 'root'
    })
    export class ConfigService {
        // ...
        loadAppConfig(): Observable {
            // return an http call to get some configuration json
            return this.http.get(this._getUrl).pipe(
                map((response) => {
                    return true;
                })
            );
        }
    }

    但等等。我们可以写得更好。由于我们有注入上下文,我们只需直接注入“ConfigService”即可。

    // a better way
    export const configFactory =  () => {
      // inject, and return the Observerable function
      const config = inject(ConfigService);
      return config.loadAppConfig();
    };
    
    // then just use directly
    provideAppInitializer(configFactory),

    一切按预期进行。

    更新 ENVIRONMENT_INITIALIZER

    另一个已弃用的令牌是“ENVIRONMENT_INITIALIZER”。请阅读替代方案(“provideEnvironmentInitializer”)的 Angular 文档。以下是最简单的提供程序的前后示例。

    // before
      {
        provide: ENVIRONMENT_INITIALIZER,
        multi: true,
        useValue() {
          console.log('environment');
        },
      }

    成为

    provideEnvironmentInitializer(() => {
      console.log('environment');
    })

    在更复杂的场景中,更改就像在 `APP_INITIALIZER` 中一样简单。下面是一个检测路由器滚动事件的提供程序的示例。

    // before, route provider:
    // factory
    const appFactory = (router: Router) => () => {
        // example
      router.events.pipe(
        filter(event => event instanceof Scroll)
      ).subscribe({
        next: (e: Scroll) => {
          // do something with scroll
          console.log(e.position);
        }
      });
    };
    
    // provided:
    {
      provide: ENVIRONMENT_INITIALIZER,
      multi: true,
      useFactory: appFactory,
      deps: [Router]
    }

    这将变成:

    // new, reduced appFactory with simple inject
    const appFactory = ()  => {
      const router: Router = inject(Router);
    
      router.events.pipe(
        filter(event => event instanceof Scroll)
      ).subscribe({
        next: (e: Scroll) => {
          // do something with scroll
          console.log(e.position);
        }
      });
    };
    
    // then simply use it:
    provideEnvironmentInitializer(appFactory)

    服务器端渲染、生成和融合

    该文档详细介绍了这三个选项:渲染(生成使用 **Express** 托管的 **NodeJs** 版本)、生成(生成由 **HTML 主机** 托管的 HTML 静态文件)和水化(生成两者并允许对选择性路由进行预渲染)。

    我们在这里要做的是按原样移动我们当前的应用程序,这里不是添加新选项的地方。因此,以下是我们为 SSR 自定义解决方案制作的内容。

    当前服务器构建器“@angular-devkit/build-angular:server”不再使用,因此创建单一配置的旧方法将不再起作用。

    注意:当前的 Angular 文档涵盖了这一点,但不是全部

    以下配置已更改:

    // angular.json, was
    "builder": "@angular-devkit/build-angular:browser",
    "outputPath": "../dist/public/",
    "resourcesOutputPath": "assets/",
    "main": "src/main.ts",

    成为

    // angular.json, is
    "builder": "@angular/build:application", // changed builder
    "outputPath": {
      "base": "../dist/public/", // example
      "browser": "browser", // sub folder for browser
      "media": "assets", // rename to assets to keep everything the same
      "server": "server" // sub folder to server, can be empty
    },
    "browser": "src/main.ts", // instead of "main"
    "server": "src/main.server.ts", // new... to explain
    "ssr": {
      "entry": "server.ts" // new
    },
    "prerender": false, // not needed

    `tsconfig.app.json` 现在包含新的服务器文件

    // tsconfig.app.json
    "files": [
      "src/main.ts",
      "src/main.server.ts",
      "src/server.ts"
    ],

    输出路径

    首先是“outputPath”。现在具体到“ng build”时会生成以下文件夹结构

    以下是 outputPath 官方文档的链接。

    |- dist
    |----public
    |-------browser
    |---------assets
    |-------server

    **单次构建** 会同时创建 NodeJs 和客户端。考虑到我一直将它们分开,这有点令人沮丧。让我们尝试尽可能接近一个可行的示例。

    仅限完整客户端

    创建与之前类似的输出的配置如下

    // angular.json
    "architect": {
        "build": {
            //...
            "configurations": {
                "production": {
                    "outputPath": {
                        "base": "dist",
                        "browser": "", // reduce to keep everything on root
                        "media": "assets"
                    },
                    "index": "src/index.html",
                    "browser": "src/main.ts",
                }
            }
      }
    }

    这将创建一个输出,其根目录下有 `index.html`,资产位于其资产文件夹中。非常简单。

    服务器端渲染

    要创建一个包含浏览器和服务器子文件夹的文件夹,无需预渲染,只需使用开箱即用的示例,我们需要添加“服务器”条目,然后添加另一个“ssr”条目。

    Angular 文档

    "outputPath": {
      "base": "ssr",
      "browser": "browser", // cannot be empty here
      "media": "assets",
      "server": "server" // can be empty
    },
    "index": "src/index.html",
    "browser": "src/main.ts",
    // The full path for the server entry point to the application, relative to the current workspace.
    "server": "src/main.server.ts", 
    // if "entry" is used, it should point to the Express server that imports the bootstrapped application from the main.server.ts
    "ssr": true,

    `main.server.ts` 必须具有导出的引导应用程序。`ssr` 必须为 `true`。

    生成的输出包含以下内容

    |-browser/
    |--main-xxxxx.js
    |--index.csr.html
    |-server/
    |--main.server.mjs
    |--index.server.html
    |--assets-chunks/

    这**不会产生服务器**,您需要编写自己的服务器,然后将这些文件夹映射到预期的路由。但我们至少需要`CommonEngine`。

    关于 CommonEngine 的说明

    `CommonEngine` 是当前正在运行的 NodeJs 引擎,但另一个 `AngularNodeAppEngine` 仍处于开发者预览阶段。

    SSR入口服务器

    配置略有不同,它包括 NodeJs `CommonEngine` 服务器。

    "outputPath": {
      "base": "ssr",
      "browser": "browser",
      "media": "assets",
      "server": "server"
    },
    "index": "src/index.html",
    "browser": "src/main.ts",
    "server": "src/main.server.ts", // has the bootstrapped app
    "ssr": {
        // The server entry-point that when executed will spawn the web server.
        // this has the CommonEngine
        "entry": "src/server.ts"
    },

    输出如下

    |-browser/
    |--main-xxxxx.js
    |--index.csr.html
    |-server/
    |--main.server.mjs
    |--index.server.html
    |--assets-chunks/
    |--server.mjs

    `server.mjs` 包含 **Express** 监听器,因此我们可以使用 `node server.mjs` 来启动服务器。(参见上面的 Angular 文档链接。)

    在禁用 JavaScript 的情况下运行服务器是可行的。浏览器文件夹仅在浏览器中运行 Angular 时才需要,但即使没有它,网站也可以正常工作(我还没有测试过多个路由)。

    删除“browser/index.csr.html”实际上没有任何作用!嗯。也许该文件是生成预渲染文件所必需的。

    隔离服务器

    我们首先在 `server.ts` 中导出没有监听器的 `CommonEngine`,然后创建我们自己的 **Express** 监听器。使用我们在上一篇文章中生成的相同代码,并且由于应用程序引导程序位于同一个文件中,因此下面是开始的配置:

    // angular.json
    "ssr": {
      "outputPath": {
        "base": "../garage.host/ssr", // a new folder in host
        "server": "", // simpler
        "browser": "browser",
        "media": "assets"
      },
      // this has the application bootstrapper as well
      "server": "server.ts",
      "ssr": true
    }

    我们需要做出的改变是:

  • 将 server.ts 添加到 tsconfig.app.json 中的文件列表中。
  • 从我们的服务器文件中删除导入 zone.js
  • 将 CommonEngine 源从 @angular/ssr 更改为 @angular/ssr/node
  • // server.ts in our new server
    // chagen to "node" sub folder
    import { CommonEngine, CommonEngineRenderOptions } from '@angular/ssr/node';
    // remove: import 'zone.js';

    然后 `ng build --configuration=ssr`

    我收到的第一个错误是

    [错误] 在“server.ts”中没有与导入“default”匹配的导出

    显然,Angular 构建器需要一些特定的东西。因此,让我们从服务器导出一个**默认引导应用程序**。

    // in server.ts, lets have a default export, this may be good enough
    const _app = () => bootstrapApplication(AppComponent, {
      providers: [
        provideServerRendering(),
        ...appProviders
      ]}
    );
    // make it the default
    export default _app;

    输出包含两个文件夹,根文件夹中有 `main.server.mjs`。它包含我们创建的 `crExpressEgine`。(您发现 `crExpressEgine` 中的拼写错误了吗?是的,现在修复它已经太晚了。)

    我们的 **Express** 仍然可以导入它并将其用作引擎。它看起来像这样:

    // our server.js in another folder
    
    // the ssr engine comes from the outout sever/main.server.mjs
    const ssr = require('./ssr/main.server.mjs');
    const app = express();
    
    // the dist folder is the browser
    const distFolder = join(process.cwd(), './ssr/browser');
    // use the engine we exported
    app.engine('html', ssr.crExpressEgine);
    app.set('view engine', 'html');
    app.set('views', distFolder);
    
    // ...
    app.get('*'), (req, res) => {
      const { protocol, originalUrl, headers } = req;
    
      // serve the main index file generated in browser
      res.render(`index.html`, {
        // set the URL here
        url: `${protocol}://${headers.host}${originalUrl}`,
        // pass providers here, if any, for example "serverUrl"
        providers: [
          {
            provide: 'serverUrl',
            useValue: res.locals.serverUrl // something already saved
          }
        ],
        // we can also pass other options
        // document: use this to generate different DOM content
        // turn off inlinecriticalcss
        // inlineCriticalCss: false 
      });
    });

    因此,唯一的变化是我们如何使用根目录和“浏览器”文件夹。当然,EJS 不能是“必需的”。我们可以用 typescript 构建服务器。或者将其转换为 **es-script**。我们从“package.json”开始

    // package.json on root folder of the express server
    {
        "type" :"module"
    }

    然后我们将所有“require”语句改为 imports

    // new esscript server
    import express from 'express';
    import { join } from 'path';
    
    import { crExpressEgine } from './ssr/server/main.server.mjs';
    const app = express();
    
    const distFolder = join(process.cwd(), './ssr/browser');
    app.engine('html', crExpressEgine);
    app.set('view engine', 'html');
    app.set('views', distFolder);
    
    app.use( express.static(distFolder));
    
    app.get('*'), (req, res) => {
        // This is the browser index, so if you have index.xxx.html use it
        res.render('index.html', {
        // ...
        });
    
    });

    在 Node(“node server”)中运行服务器,并在禁用 JavaScript 的情况下进行浏览,它看起来正在运行。

    请求和响应令牌

    以前,我们需要重新创建请求和响应令牌才能继续使用它们。在 Angular 19 中,令牌又回来了。好吧。没那么快。如果您在 `server.ts` 中看到 `CommonEngine` 的实现,它将不会实现请求令牌。但在 `AngularNodeAppEngine` 中我可以看到提供的令牌:

    // Angular source code for the new Enginer: AngularNodeAppEngine
    if (renderMode === RenderMode.Server) {
      // Configure platform providers for request and response only for SSR.
      platformProviders.push(
        {
          provide: REQUEST,
          useValue: request,
        },
        {
          provide: REQUEST_CONTEXT,
          useValue: requestContext,
        },
        {
          provide: RESPONSE_INIT,
          useValue: responseInit,
        },
      );
    }

    所以我们需要将“RenderMode”改为“Server”。

    // in the app.routes.server.ts, the out of the box file
    export const serverRoutes: ServerRoute[] = [
      {
        path: '**',
        // this needs to be Server to get access to tokens
        renderMode: RenderMode.Server
      }
    ];

    `AngularNodeAppEngine` 处于**开发者预览**模式。所以我们不必使用它。我们将继续像以前一样提供令牌。就我个人而言,我不喜欢必须配置服务器路由才能访问服务器`REQUEST`。

    该引擎的官方文档。

    我之前创建的令牌与 Angular 19 中官方的新令牌相比:

    // our REQUEST token, very simple
    export const REQUEST: InjectionToken = new InjectionToken('REQUEST Token');
    
    // official Angular 19 token
    export const REQUEST = new InjectionToken('REQUEST', {
      providedIn: 'platform',
      factory: () => null,
    });

    这只是装饰。另外两个标记是:“REQUEST_CONTEXT”和“RESPONSE_INIT”。咦!

    StackBlitz 项目包括自定义令牌。

    奖金

    所有这些中的小问题是,如果您像我一样进行自己的预渲染,您会希望单独提供浏览器文件夹。但由于新的“outputPath”不允许这样做,因此您只需将路由映射到此内部文件夹即可。例如,在**firebase**配置中,它看起来像这样

    // firebase.json
    {
        "hosting": [
            {
                "target": "web",
                // the inner most browser folder
                "public": "ssr/browser",
                "rewrites": [
                    {
                        "source": "/",
                        "destination": "/index.html"
                    }
                ]
            },
        ]
    }

    预渲染脚本(如果您有一个像我的脚本)应该写在“浏览器”文件夹中。

    结论

    随着 Angular 19 SSR 构建器的最新更新,当前项目只需进行少量更改,但需满足以下规格:

  • 使用独立的 Express 服务器为网站提供服务。因此,需要调整生成的包含侦听器的 server.ts 以删除该侦听器
  • 使用 Express Node 服务器,这可能需要更新到 ES 模块。
  • 本地化是本地完成的,单一构建可服务于多种语言,因此服务器和 index.html 是在单独的构建中创建的(本文未涉及)。
  • 我意识到,除非要生成动态数据,否则预渲染实际上毫无意义。AppShell 也是我从未使用过的东西。因此,部分水化对我来说是一个流行词。
  • 永远不要接受占领。