Angular universal服务器端渲染与预渲染

1. 前言

当初选择将应用做成 SPA(单页应用)的时候主要是觉得用户体验非常丝滑, 当时也知道 SPA 很难做 SEO, 还是毅然决然的选择做成 SPA 应用. 当时还是 Angularjs 1.X 的时候, 就觉得 Angular 的理念跟自己对前端的看法特别契合, 后来将框架升级到 Angular 11 继而 13, 虽然费了很多时间和精力, 但是收获非常多, 由于本文的重点是 SSR 与 prerendering,所以这里不赘述原因了. 之前也了解到 Angular Universal 是做服务器端渲染的套件(SSR), 乘最近有空刚好将其引入到项目. 实现地过程中虽然遇到问题, 但是还是有些小兴奋的感觉, 一来解决了首次访问应用时白屏的问题, 二来将当初打算舍弃的 SEO 能力也找了回来, 而且整个对引入 SSR 实现 SEO 的过程还是相当轻松的, 特写此文, 以防遗忘, 也希望给后来者有所帮助.

2. 什么是 Angular universal

Angular universal 是一个用于服务器端渲染 Angular 应用程序的框架. 它允许在服务器上生成 HTML, 以便在浏览器中更快地呈现应用程序, 这对于提高应用程序的性能和搜索引擎优化(SEO)非常有用. 在使用 Angular Universal 时, 应用程序的初始加载时间可能会增加, 但是在浏览器中呈现应用程序的速度会更快, 因为大部分工作已经在服务器上完成了.

3. 为什么需要 SSR(服务器端渲染)

在深入了解 Angular universal 之前, 我们需要了解一下 SSR(服务器端渲染), 一项技术的出现并流行, 一定是它解决了一类问题或者是解决了一些痛点. 那么 SSR(Server Side Rendering)的出现解决了哪些痛点呢? 相对于 MPA(Multiple Page Application)风格来说,SPA 这种架构风格有很多的优点,但是也存在非常明显两个的缺点, 而 SSR 技术的出现就是为了解决这两个痛点的. 根据本文的主题 SPA 的优缺点列举如下, 比如:

  • 更快的用户体验
  • 更好的交互性
  • 更好的可维护性

SPA 架构风格也存在两个非常大的痛点:

  • 初始加载时间较长

未经优化的 SPA 应用在进行首次内容绘制(FCP - First Contentful Paint)时会存在非常严重的首页白屏问题, 由于用户首次访问 SPA 时需要加载大量的 JS 代码, 而且要经过解析后才能开始渲染页面, 整个过程需要耗费大量的时间, 往往会大大超过用户原意等待时间 3 秒, 而且随着应用的功能增加问题越来越严重.

  • SEO 不友好

SPA 不利于搜索引擎的抓取, 由于搜索引擎使用网络爬虫来索引网页, 这些爬虫依赖 HTML 内容来理解网页的结构和内容. 然而, 在 SPA 中, 内容是由 JavaScript 动态生成的, 这意味着发送到浏览器的初始 HTML 文件通常为空或包含非常少的内容. 这使得搜索引擎难以正确索引页面, 因为它们可能无法看到由 Javascript 生成的内容. 强如 google 这样的索引擎虽然可以索引 SPA 网页内容, 但是这并不总是可靠的. 如果你想要确保 SPA 网页内容能被搜索引擎正确索引, 最好使用服务器端渲染来生成 HTML 内容并将其发送到浏览器.

而 SSR 就是为解决以上两个痛点而是的.

4. Angular Universal 如何解决 FCP 和 SEO 问题

Angular Universal 允许我们为 Angular 应用程序进行服务器端渲染. 这意味着我们可以在服务器上生成 HTML 内容并将其发送到浏览器, 而不是在浏览器中使用 Javascript 动态生成内容. 这样, 搜索引擎可以看到页面的全部内容并正确索引它, 从而解决 SPA 应用程序的 SEO 问题.

此外, Angular Universal 还可以解决 FCP(首次内容绘制)性能问题. 由于 SPA 应用程序的初始 HTML 文件通常为空或者包含非常少的内容, 因此它们需要大量的 JavaScript 代码来生成内容. 这会导致长时间的初始加载时间, 从而影响 FCP 性能. 使用 Angular Universal 进行服务器端渲染可以解决这个问题, 因为它可以在服务器上生成完整的 HTML 内容并将其发送到浏览器, 从而在让浏览器快速展现页面轮廓, 减少白屏时间, 于此同时浏览器会加载整个应用所需的 Javascript 代码, 从而提高整体的用户体验. 读到这里读者可能会有一个疑惑, 那就是 SPA 不又变成 MPA 了吗? 其实不然, HTML 内容只是为了减少用户等待时间, 在 Javascript 完全加载完成之前,给用户展示页面内容, 一旦 Javascript 加载完成, 前端页面会被重新绘制, 然后完全接管用户的交互任务. 所以在 javascript 加载完成后页面有一个非常短的闪烁, 那就是 Javascript 重新绘制 First Content 的过程. 当然这样的闪烁动作对要求苛刻的系统来说也是不可接受的, Angular 也有相应的解决办法, 后面文章会讲到.

那么具体来讲, Angular Universal 是如何进行服务器端渲染的呢?

我们知道 Javascript 是可以通过 Node.js 在在服务器端执行的, 每错 Angular Universal 正式通过 Express 这个 Node.js 应用程序框架提供的强大功能和工具在后端处理请求, 再将生成的 HTML 页面发送到前端或者喂给爬虫, 这样前端就能快速渲染页面, 爬虫也能得到某个页面完整的 HTML 内容, 从而正确地为页面建立索引.

当然这并不是完美地解决 FCP 和 SEO 问题, 因为 Node.js 执行 javascript 仍然需要时间和占用大量的服务器资源, 一旦请求增多, 频率加快, express 将会称为瓶颈, 是一个非常影响 TTFB(Time to first byte)指标的问题.

那么怎样进一步解决该问题呢, 这里有两种思路:

  • 一种是采用数据库缓存例如 Redis 或 Memcached, 将页面缓存起来, 当下次访问相同页面时从缓存调取, 从而避免 express 重新执行 javascript 从而提高性能. 而此方案不是本文讲解的重点, 如果要采用此方案, 请自行百度或 google 了解相关详情.

  • 另外一种方案叫 Prerender 技术. 它在构建时就为应用程序的每个页面生成静态 HTML 文件, 而不是依赖 JavaScript 在运行时动态生成内容. 当用户请求页面时, 这些静态 HTML 文件可以直接发送到用户浏览器, 从而提高首次内容呈现(FCP)的时间, 并使搜索引擎更容易爬取和索引内容.

5. 开启 SSR

您可以使用@nguniversal/express-engine 依赖包在 Angular 应用程序中启用服务器端渲染,如下:

1
2
3
4
5

# 进入项目根目录
# 添加express-engine
ng add @nguniversal/express-engine

说明: Angular Universal 需要 Node.js 的 Active LTS 版本或维护 LTS 版本。有关信息,请参阅版本兼容性指南以了解当前支持的版本。

该命令更新应用程序代码以启用 SSR,并向项目结构中添加额外的文件。

该命令会新增以下三个文件, 并修改以下一些文件

1
2
3
src / main.server.ts; // <-- * server-side application configuration (standalone app only)
src / app / app.server.module.ts; // <-- * server-side application module (NgModule app only)
server.ts; // <-- * express web server

angular-universal-demo/angular.json

注意: 此处的 angular.json 有一些手动更改, 主要在”serve-ssr”这个节点, 主要是为了解决 Angular 的一个 bug.
bug 详情请参考这个issue report - Congiguration ‘development’ is not set in the workspace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84

--- a/angular-universal-demo/angular.json
+++ b/angular-universal-demo/angular.json
@@ -36,7 +36,7 @@
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
- "outputPath": "dist/angular-universal-demo",
+ "outputPath": "dist/angular-universal-demo/browser",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
@@ -152,6 +152,73 @@
"devServerTarget": "angular-universal-demo:serve:dev"
}
}
+ },
+ "server": {
+ "builder": "@angular-devkit/build-angular:server",
+ "options": {
+ "outputPath": "dist/angular-universal-demo/server",
+ "main": "server.ts",
+ "tsConfig": "tsconfig.server.json",
+ "optimization": false,
+ "sourceMap": true,
+ "extractLicenses": false
+ },
+ "configurations": {
+ "production": {
+ "outputHashing": "media",
+ "fileReplacements": [
+ {
+ "replace": "src/environments/environment.ts",
+ "with": "src/environments/environment.prod.ts"
+ }
+ ],
+ "optimization": true,
+ "sourceMap": false,
+ "extractLicenses": true
+ },
+ "dev": {
+ "fileReplacements": [
+ {
+ "replace": "src/environments/environment.ts",
+ "with": "src/environments/environment.dev.ts"
+ }
+ ]
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve-ssr": {
+ "builder": "@nguniversal/builders:ssr-dev-server",
+ "options": {
+ "browserTarget": "angular-universal-demo:build",
+ "serverTarget": "angular-universal-demo:server"
+ },
+ "configurations": {
+ "production": {
+ "browserTarget": "angular-universal-demo:build:production",
+ "serverTarget": "angular-universal-demo:server:production"
+ }
+ }
+ },
+ "prerender": {
+ "builder": "@nguniversal/builders:prerender",
+ "options": {
+ "routes": [
+ "/"
+ ]
+ },
+ "configurations": {
+ "production": {
+ "browserTarget": "angular-universal-demo:build:production",
+ "serverTarget": "angular-universal-demo:server:production"
+ },
+ "development": {
+ "browserTarget": "angular-universal-demo:build:development",
+ "serverTarget": "angular-universal-demo:server:development"
+ }
+ },
+ "defaultConfiguration": "production"
}

angular-universal-demo/package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

--- a/angular-universal-demo/package.json
+++ b/angular-universal-demo/package.json
@@ -9,7 +9,11 @@
"buildProd": "ng build --configuration production",
"test": "ng test",
"lint": "ng lint",
- "e2e": "ng e2e"
+ "e2e": "ng e2e",
+ "dev:ssr": "ng run angular-universal-demo:serve-ssr",
+ "serve:ssr": "node dist/angular-universal-demo/server/main.js",
+ "build:ssr": "ng build && ng run angular-universal-demo:server",
+ "prerender": "ng run angular-universal-demo:prerender"
},
"private": true,
"dependencies": {
@@ -18,24 +22,27 @@
"@angular/cdk": "13.3.1",
"@angular/platform-browser-dynamic": "13.3.1",
+ "@angular/platform-server": "13.3.1",
"@angular/router": "13.3.1",
"@swimlane/ngx-charts": "19.1.0",
"@swimlane/ngx-datatable": "20.0.0",
+ "@nguniversal/express-engine": "^13.1.0",
"@angular/cli": "13.3.1",
"@angular/compiler-cli": "13.3.1",
"@angular/language-service": "13.3.1",
+ "@nguniversal/builders": "^13.1.0",
+ "@types/express": "^4.17.0",

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
--- a/angular-universal-demo/src/app/app.module.ts
+++ b/angular-universal-demo/src/app/app.module.ts
@@ -33,7 +33,7 @@ const DEFAULT_PERFECT_SCROLLBAR_CONFIG: PerfectScrollbarConfigInterface = {

@NgModule({
imports: [
- BrowserModule,
+ BrowserModule.withServerTransition({ appId: 'serverApp' }),
BrowserAnimationsModule,
FormsModule,
HttpClientModule,

Angular 会把 appId 值(它可以是任何字符串)添加到服务端渲染页面的样式名中,以便在客户端应用启动时可以找到并移除它们。

app.routing.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
--- a/angular-universal-demo/src/app/app.routing.ts
+++ b/angular-universal-demo/src/app/app.routing.ts
@@ -19,10 +19,10 @@ export const routes: Routes = [
@NgModule({
imports: [
RouterModule.forRoot(routes, {
- preloadingStrategy: PreloadAllModules,
- relativeLinkResolution: 'legacy',
- // useHash: true
- })
+ preloadingStrategy: PreloadAllModules,
+ relativeLinkResolution: 'legacy',
+ initialNavigation: 'enabledBlocking'
+})
],
exports: [
RouterModule

“enabledBlocking”-在创建根组件之前开始初始化导航。引导程序将被 blocked,直到初始导航完成。此值是服务器端渲染工作所必需的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

--- a/angular-universal-demo/src/main.ts
+++ b/angular-universal-demo/src/main.ts
@@ -9,5 +9,15 @@ if (environment.production) {
enableProdMode();
}

-platformBrowserDynamic().bootstrapModule(AppModule)
+function bootstrap() {
+ platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));
+ };
+
+
+if (document.readyState === 'complete') {
+ bootstrap();
+} else {
+ document.addEventListener('DOMContentLoaded', bootstrap);
+}

6. 开启客户端水合(Client Hydration)

客户端水合是在客户端上恢复服务器端呈现的应用程序的过程。这包括重用服务器呈现的 DOM 结构、持久化应用程序状态、传输服务器已经检索到的应用程序数据以及其他进程。
您可以通过修改 app.module.ts 文件来启用水合

如果是 Angular 11 以上 16 以下的版本, 要达到 Hydration 的效果可参考如下配置, 需要引入 BrowserTransferStateModule.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { AppComponent } from "./app.component";
import { BrowserTransferStateModule } from "@angular/platform-browser";

@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule.withServerTransition({ appId: "serverApp" }),
BrowserTransferStateModule,
],
bootstrap: [AppComponent],
})
export class AppModule {}

如果 Angular 16 为了达到 Hydration 的效果, 可以参考如下配置:
。从@angular/platform-browser 导入 provideClientHydration 函数,并将函数调用添加到 AppModule 的 providers 部分,如下所示。

1
2
3
4
5
6
7
8
9
10
11
import { provideClientHydration } from "@angular/platform-browser";
// ...

@NgModule({
// ...
providers: [provideClientHydration()], // add this line
bootstrap: [AppComponent],
})
export class AppModule {
// ...
}

更新: 2023/11/10

即使升级到 Angular 15, 16 以后, 如果使用的是 NgModule 的方式引导启动应用程序, 本教程还是完全适用的.

另外 Angular 15 对 以 standalone component 启动应用并且开启 ssr 的支持没有 16 完善, 在条件许可的情况下建议升级完 15 后接着升级到 16, 如果坚持使用 15 的话, 建议不要使用 standalone component 来引导应用程序, 到 16 以后就可以放心大胆使用 standalone component 来启动应用了.

在 Angular 15, 16 中, 如果使用的是 Angular standalone component 引导启动应用程序, 以下三个文件 server.ts, src/main.server.ts, app.config.server.ts 的写法是跟本教程稍有不同, 为了不破坏文档的结构, 也避免制造一些混乱, 这里不将这种配置上差异列举出来了. 具体差异可以结合 github 上的一个示例angular-v16-universal-standalone, 参考本文档, 可以解决大部分的问题.

7. 使用 Universal 构建和运行

构建 SSR

1
npm run build:ssr

构建完应用之后,启动服务器

1
npm run serve:ssr

或者构建同时启动服务器

1
npm run dev:ssr

8. Prerender 预渲染静态 HTML

经过上面的步骤后,如果我们通过npm run build:ssr 构建项目,你会发现在 dist/<your project>/browser 下面只有 index.html 文件,打开文件查看,发现其中还有 <app-root></app-root> 这样的元素,也就是说你的网页内容并没有在 html 中生成。这是因为 Angular 使用了动态路由,比如 /product/:id 这种路由,而页面的渲染结果要经过 JS 的执行才能知道,因此,Angular 使用了 Express 作为 Web 服务器,能在服务端运行时根据用户请求(爬虫请求)使用模板引擎生成静态 HTML 界面。

而 prerender(npm run prerender)会在构建时生成静态 HTML 文件。比如我们做企业官网,只有几个页面,那么我们可以使用预渲染技术生成这几个页面的静态 HTML 文件,避免在运行时动态生成,从而进一步提升网页的访问速度和用户体验。

8.1. 预渲染路径配置

需要进行预渲染(预编译 HTML)的网页路径,可以有几种方式进行提供:

  1. 通过命令行的附加参数:
1
2
3

ng run <app-name>:prerender --routes /product/1 /product/2

这里有个需要注意的地方, 即使我只指定了两个路径, Angular universal 还是会使用 guess-parser 解析 routes 猜测可能的路径帮助渲染一堆路径, 实际上这种猜测有些鸡肋, 根本不会准确, 没有太大帮助. 此时可以使用 –no-guess-routes 选项将其关闭.

1
2
3

ng run <app-name>:prerender --no-guess-routes --routes /product/1 /product/2

或者修改 angular.json, 将 guessRoutes 设置为 false

1
2
3
4
5
6
7
8
9
"prerender": {
"builder": "@nguniversal/builders:prerender",
"options": {
"routes": [
"/product/1",
"/product/2"
],
"guessRoutes": false
}
  1. 如果路径比较多,比如针对 product/:id 这种动态路径,则可以使用一个路径文件:

routes.txt

1
2
3
4
/products/1
/products/23
/products/145
/products/555

然后在命令行参数指定该文件:

1
ng run <app-name>:prerender --routes-file routes.txt

或者在 angular.json 中指定 routes-file

1
2
3
4
5
6
7

"prerender": {
"builder": "@nguniversal/builders:prerender",
"options": {
"guessRoutes": false,
"routesFile": "routes-to-prerender.txt" // 在这里指定routes-file
},
  1. 在项目的 angular.json 文件配置需要的路径
1
2
3
4
5
6
7
8
9
10
11
"prerender": {
"builder": "@nguniversal/builders:prerender",
"options": {
"routes": [ // 这里配置
"/",
"/main/home",
"/main/service",
"/main/team",
"/main/contact"
]
},

配置完成后,重新执行预渲染命令(npm run prerender 或者使用命令行参数则按照上面<1><2>中的命令执行),编译完成后,再打开 dist/<your project>/browser 下的 index.html 会发现里面没有 <app-root></app-root> 了,取而代之的是主页的实际内容。同时也生成了相应的路径目录以及各个目录下的 index.html 子页面文件。

9. SEO 优化

9.1. 关键词与描述的优化

SEO 的关键在于对网页 title,keywords 和 description 的收录,因此对于我们想要让搜索引擎收录的网页,可以修改代码提供这些内容。

在 Angular 14 中,如果路由界面通过 Routes 配置,可以将网页的静态 title 直接写在路由的配置中:

1
{ path: 'home', component: AbmHomeComponent, title: '<你想显示在浏览器 tab 上的标题>' },

另外,Angular 也提供了可注入的 Title 和 Meta 用于修改网页的标题和 meta 信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Meta, Title } from "@angular/platform-browser";

export class ExampleComponent implements OnInit {
constructor(private _title: Title, private _meta: Meta) {}

ngOnInit() {
this._title.setTitle("<此页的标题>");
this._meta.addTags([
{ name: "keywords", content: "<此页的 keywords,以英文逗号隔开>" },
{ name: "description", content: "<此页的描述>" },
]);
}
}

9.2. 内部跳转优化

这个是指应用内部页面跳转尽量使用 a 标签,而不是使用别的标签加(click)事件进行跳转。

9.3. 样式文件打包

另外一个需要注意的地方是,如果网站的样式很多很复杂,那么网站发布的时候 angular.json 中 extractCss 需要设置为 true,即单独打包一个独立的样式文件,而不是将样式全部包含在发布后的 index.html 中。

全部包含在 index.html 中会造成抓取保存的静态 html 文件过大,百度不能正确保存快照(百度限制了缓存文件大小,好像是 100k)。

9.4. 添加 robots.txt

在网站优化过程中,有些时候,网站中有重要及私密的内容,站长并不希望某些页面被蜘蛛抓取,比如后台的数据,测试阶段的网站,还有一种很常见的情况,搜索引擎抓取的大量没有意义的页面。

robots.txt 是一个纯文本文件,用于声明该网站中不想被蜘蛛访问的部分,或指定蜘蛛抓取的部分,当蜘蛛访问一个站点时,它会首先检查该站点是否存在,robots.txt,如果找到,蜘蛛就会按照该文件中的内容来确定抓取的范围,如果该文件不存在,那么蜘蛛就会沿着链接直接抓取。即,只有在需要禁止抓取某些内容是,写 robots.txt 才有意义.

robots 配置方法如下:

  • 在 project_root/src 路径下创建 robots.txt 文件,里面输入你的 robots 配置,如果不懂,可以百度 robots 的语法,修改后保存即可提交。

下面是一个简单的 robots.txt 的例子

1
2
3
User-agent: *
Disallow:
Sitemap: http://your_domain/sitemap.xml

还需要修改 angular.json 文件, 这样 robots.txt 文件才能被访问到

1
2
3
4
5
6
7
8
"build": {
......
"assets": [
"src/favicon.ico",
"src/robots.txt",
"src/sitemap.xml",
"src/assets"
],

9.5. 自动生成 sitemap

安装工具 ngx-sitemap

1
npm install ngx-sitemap --save-dev

在 prerendering 后运行以下命令, 就可以生成 sitemap.xml

1
2
3
4

$./node_modules/.bin/ngx-sitemap ./dist/angular-universal-demo/browser htts://your_domain
sitemap.xml successfully created in './dist/angular-universal-demo/browser/'

10. 使用 Nginx 部署

整个 topo 结构是这样的, 首先要使用 pm2 将服务器端渲染程序运行起来, 然后通过 Nginx 将请求反向代理到 pm2.

这样 PM2 就会实际处理所有请求, 对于已经 prerender 过的页面 PM2 会直接去 browser 文件夹中去取, 对于没有 prerender 的页面, 首先会在服务器端渲染然后传送到浏览器端.
页面到达流量器端后, 首先页面有一个基本的静态呈现, 与此同时会继续向后端请求 javascript, 直到 javascript 下载完成后, 将会进行 CSR(客户端渲染), 如果没有使用到 hydration 技术, 此时页面会有一个比较明显的闪烁, 如果使用了 hydration 技术此时会 CSR 渲染会比较平滑地替代 SSR 渲染的页面, 除了页面被重新渲染以外, 前端路由也会被 javascript 接管. 此时如果用户不刷新页面, 整个应用就运行在 SPA 模式下了.

对于搜索引擎爬虫, 由于它只读取页面文本内容, 不会去执行 javascript 代码, 所以相对于浏览器访问, 爬虫只是爬取那一页的内容, 不会有 javascript 下载过程, 更不会有 CSR, 以及 hydration 的过程.

整个部署过程是这样的, 首先安装 Nginx, node 以及 PM2. 然后启动 PM2, Nginx 反向代理到 PM2.

10.1. 安装 Nginx

参考我的博客 鹏叔的技术博客-nginx 安装教程

10.2. 安装 Node

安装 Nodejs 可以参考博客 安装并配置 nodejs | 鹏叔的技术博客

10.3. 安装 PM2

安装 pm2 来支持 SSR

1
2
3
4
5
# 安装pm2
npm install -g pm2
# 查看pm2版本
pm2 --v
# 5.3.0

10.4. 配置并启动 pm2

为了能在任意位置都能执行 pm2, 我们将 pm2 添加到 PATH 下, 添加的方法是在/usr/bin 下创建一个软连接

1
2
3

ln -s /usr/local/node-v14.17.5-linux-x64/lib/node_modules/pm2/bin/pm2 /usr/bin/pm2

我们顺便查看一下 pm2 的版本并确保软连接创建有效

1
2
3
4
pm2 --version
[PM2] Spawning PM2 daemon with pm2_home=/root/.pm2
[PM2] PM2 Successfully daemonized
5.3.0

接下来, 我们就可以使用 PM2 启动服务器端渲染了,
这里我们假设已经将程序部署到了服务器的/var/your_app/webapp 目录, 结构如下

1
2
3
4
tree -L 1 /var/your_app/webapp
/var/your_app/webapp
├── browser
└── server

启动 SSR

1
2
3

pm2 start --name app_ssr /var/your_app/webapp/server/main.js

注意: 使用 angular cli 默认生成的 distFolder 部署到服务器, 会出现 index 文件找不到.
这里可以修改以下让其相对于 main.js 查找 index 而不是 process.cwd()

vi server.ts

1
2
const parentFolder: string = join(__dirname, "../");
const distFolder: string = join(parentFolder, "browser");

设置开机自启动

1
2
3
4
5
6
7
8
9
# 保存要在机器重新启动时重新生成的列表
pm2 save

# 生成开机自启动服务
pm2 startup

# enable pm2开机自启
systemctl enable pm2-root

另外一些有用的 pm2 命令

1
2
3
4
5
6
7
8
9
# 查看进程
pm2 list
# 关闭prcess
pm2 stop process_name
# 删除进程
pm2 delete process_name

# 查看process 详情
pm2 show process_name

至此 PM2 配置完成, ssr 默认会监听在 4000 端口, 可以通过如下命令查找端口号

1
grep "process.env\[\"PORT\"\]" /var/your_app/webapp/server/main.js

其他一些有用的 pm2 命令

查看日志某个任务的日志

1
2
3

pm2 log the_process_name

10.5. 配置 Nginx 反向代理

修改/etc/nginx/conf.d/defaut.conf 将之前的配置由如下

1
2
3
4
5
6
7

location / {
root /var/your_app/webapp/;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}

改成

1
2
3
4
5
6
7

location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:4000/;
}

11. troubleshooting

11.1. 问题 1: Configuration ‘development’ is not set in the workspace

描述: 当执行npm run dev:ssr 系统抛出如下错误 Configuration ‘development’ is not set in the workspace.

原因: 原因实际上是一个 bug, Angular 开发人员未考虑到, 我们的配置于他们期望的有差异.
详细原因和解决方案请参考this issue
也请注意, 我贴出来的ng add @nguniversal/express-engine自动配置的 angular.json 的 serve-ssr 部分是有修改过的, 目的就是为了解决这个问题.

11.2. 问题 2: ReferenceError: window is not defined

描述: 当执行’npm run dev:ssr’ 系统抛出如下错误 ReferenceError: window is not defined

原因: 问题的原因是在 Nodejs 运行时, 没有 window, document, navigator 等等浏览器端对象.

可以参考我的博客使用 Angular Universal 时的重要注意事项,
里面提到了三种解决该问题的策略, 这里我选择了 策略 3:Shims, 相应的修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

import 'zone.js/dist/zone-node';
import { join } from 'path';
-
+import "localstorage-polyfill";
import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
-import { existsSync } from 'fs';
-
+import { existsSync,readFileSync } from 'fs';
+import { createWindow } from "domino";
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const distFolder = join(process.cwd(), 'dist/angular-universal-demo/browser');
const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';

+ applyDomino(indexHtml)
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine('html', ngExpressEngine({
bootstrap: AppServerModule,
}));

@@ -35,26 +36,41 @@ export function app(): express.Express {
});

return server;
}

+function applyDomino(indexHtml: string): void {
+
+ const win = createWindow(indexHtml)
+
+ console.log("applying mock window ")
+ global["localStorage"] = localStorage;
+
+ // Polyfills
+ (global as any).window = win;
+ (global as any).document = win.document;
+ (global as any).navigator = win.navigator;
+ (global as any).location = win.location;
+
+}
+

11.3. 问题 3: Flex Layout loaded on the server without FlexLayoutServerModule

描述: 当执行npm run dev:ssr 或者 npm run prerender时, 频繁的输出警告 Warning: Flex Layout loaded on the server without FlexLayoutServerModule

原因分析: FlexLayout 需要在 js 运行时确定屏幕大小等等信息, 在 server side 由于没有 screen 的概念所以需要模拟浏览器端的行为, FlexLayout 专门为 ssr 提供了一套 Module 来模拟浏览器端的行为, 所以最好在服务器端程序引入 FlexLayoutServerModule, 也即在 app.server.module.ts 中引入 FlexLayoutServerModule

1
2
3
4
5
6
7
8
9
10
11
//app.server.module.ts
import {NgModule} from '@angular/core';
import {FlexLayoutServerModule} from '@angular/flex-layout/server';

@NgModule({
imports: [
... other imports here
FlexLayoutServerModule,
]
})

以及定义在 SSR 配置渲染时模拟的屏幕大小, 修改 app.module.ts

1
2
3
4
5
6
7
8
//app.module.ts to simulate breakpoints
@NgModule({
imports: [
... other imports here
FlexLayoutModule.withConfig({ssrObserveBreakpoints: ['xs', 'lt-md']})
]
})

11.4. 问题 4: XMLHttpRequest is not defined

描述: 当执行npm run dev:ssr 或者 npm run prerender时, 系统抛出如下错误 ReferenceError: XMLHttpRequest is not defined

原因分析: 原因是我的代码中调用了 ajax 这个 rxjs operator import { ajax, AjaxResponse } from 'rxjs/ajax';其底层实现需要 XMLHttpRequest,
而在 Sever side javascript 环境中没有引入 xmlhttprequest 这个包.

解决办法: 解决办法可以有多种, 我选择了将 ajax 这个 rxjs operator 全部替换成了 HttpClient call, 例如

1
2
3
4
5
6
concatMap((remoteServerUri: string) => {
const headers = new HttpHeaders({
Authorization: "Basic " + idToken,
});
return this.httpClient.get<any>(remoteServerUri + url, { headers: headers });
}),

参考文章 How to resolve window is not defined on npm run serve:ssr

11.5. 问题 5: \dist\demo-web\browser...ReferenceError: Image is not defined

问题分析:

当页面使用 const bookImage = new Image(); 的时候, 在 server side rendering 时报如题错误。问题的原因是由于代码运行在 nodejs 运行时环境,而不是浏览器环境。正如我的博客使用 Angular Universal 时的重要注意事项中所提到的,服务器上不存在或不支持某些功能 Canvas,image 等对象.

三次尝试:第三次成功

  1. 即使我尝试使用const bookImage = this.document.createElement('img'); 尝试使用 domino 的 document 对象的实现方式, 仍然遇到dist\demo-web\browser...Error: NotYetImplemented的错误。虽然这是由于 domino 仍然没有实现创建 image 对象的方法。
1
2
3
4
5
6
7
8
9
10
11
12

constructor(@Inject(DOCUMENT) private document: Document) {}

ngAfterViewInit() {
const bookImage = this.document.createElement('img');
bookImage.src = 'assets/img/wb/cover_template.png'
this.ctx = this.wbCoverCanvas?.nativeElement.getContext('2d')
bookImage.onload = () => {
this.drawCover(bookImage)
}
}

  1. 后来尝试使用 canvas nodejs 包npm install canvas, 依然出现错误。参考问题ReferenceError: Image is not definednode-canvas,仍然报错node_modules/canvas/types/index.d.ts:3:26 - error TS2307: Cannot find module 'stream' or its corresponding type declarations.

尝试解决以上错误,还是遇到很多问题。

  1. 最终还是回到 使用 Angular Universal 时的重要注意事项中所提到的办法 - 策略 2:Guard

    将 drawCover 抽取到一个 service 中, 当运行 server 端代码时, 专门针对 server side 注入不同的 canvas service, 跳过 canvas 创建,这样并不会影响用户体验, 当客户端水合过程中毕竟会重绘 canvas.

12. 相关阅读

本技术博客原创文章位于Angular universal 服务器端渲染与预渲染 | 鹏叔的技术博客, 要获取最近更新请访问原文.

更多 Angular 相关文章请访问Angular 合集 | 鹏叔的技术博客

更多技术博客请访问: 鹏叔的技术博客

13. 参考文档

Server-side rendering (SSR) with Angular Universal

Important Considerations when Using Angular Universal

2022 前端性能优化最佳实践

使用 Angular Universal 时的重要注意事项

Angular 服务器渲染常遇的坑

Angular SSR 探究

Angular 7 SSR 之后使用 node + nginx 部署在 linux

Better Approach for Styles for SSR (Angular Universal)

Feature Request: ability to produce and load CSS via link tag in index.html

angular-v16-universal-standalone