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

1. 介绍

尽管 Angular Universal 项目的目标是能够在服务器上无缝渲染 Angular 应用程序,但您应该考虑一些不一致之处。首先,服务器和浏览器环境之间存在明显的差异。在服务器上渲染时,应用程序处于短暂或“快照”状态。应用程序被完全渲染一次,返回完整的 HTML,而整个过程中的产生的状态被销毁,直到下一次渲染开始, 再重新计算这些状态。接下来,服务器环境本质上不具有与浏览器相同的功能(也有可能服务器拥有而浏览器没有的功能)。例如,服务器没有任何 cookie 的概念。您可以将此功能和其他功能 polyfill,但没有完美的解决方案来弥合这种差异。在后面的部分中,我们将介绍潜在的缓解措施,以减少在服务器上渲染时的错误机会。
还请注意 SSR 的目标:提高应用程序的初始渲染时间。这意味着,应该避免或充分防范任何可能在初始渲染中降低应用程序速度的情况。同样,我们将在后面的部分中回顾如何实现这一点。

2. “window is not defined”

使用 Angular Universal 时最常见的问题之一是服务器环境中缺少浏览器全局变量。这是因为 Angular Universal 项目使用 domino 作为服务器 DOM 渲染引擎。因此,服务器上不存在或不支持某些功能。这包括 window 和 document 全局对象、cookie、某些 HTML 元素(如 canvas)以及其他一些元素。没有详尽的列表,所以请注意,如果您看到这样的错误,其中没有定义以前可访问的全局对象,很可能是因为该全局无法通过 domino 获得。

Fun fact: Domino stands for “DOM in Node”

3. 如何解决上述问题

3.1. 策略 1:Injection

通常,所需的全局可以通过依赖注入(DI)通过 Angular 平台获得。
例如,我们可以通过@Inject(DOCUMENT)获得 document 对象。此外,还可以通过 DOCUMENT 对象获取 window 和 location 对象。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// example.service.ts
import { Injectable, Inject } from "@angular/core";
import { DOCUMENT } from "@angular/common";

@Injectable()
export class ExampleService {
constructor(@Inject(DOCUMENT) private _doc: Document) {}

getWindow(): Window | null {
return this._doc.defaultView;
}

getLocation(): Location {
return this._doc.location;
}

createElement(tag: string): HTMLElement {
return this._doc.createElement(tag);
}
}

但是我们不要期望这种方法能解决所有问题. localStorage 就是一个例外, localStorage 是一个经常被请求的 API,它无法在浏览器以外良好地工作。如果您需要编写自己的库组件,而且使用到 localstorage, 请考虑使用某种方法让其在服务器上和浏览器上提供相似的功能(这就是 Angular CDK 和 Material 所做的, 可以作为一种参考)。针对 localStorage 可以使用第三方组件例如 localstorage-polyfill 来替代.

3.2. 策略 2:Guard

如果我们不能从 Angular platform 注入所需的适当全局值,那么我们可以浏览器代码的调用的地方包装一层 Guard,只要您不需要在服务器上访问该代码。例如,全局窗口元素的调用通常是为了获取窗口大小或其他一些视觉元素。然而,在服务器上,没有“Screen”的概念,因此很少需要此功能。
您可以在网上或其他地方阅读到,建议使用 isPlatformBrowser 或 isPlatformServer。这种指南不太合适的方法。这是因为您最终会在应用程序代码中创建特定于平台的 if-else 分支代码。这不仅不必要地增加了应用程序的复杂度,而且还增加了必须维护的复杂性。通过依赖注入(DI)将代码分离为单独的特定于平台的模块和实现,您的业务代码可以保留关于业务逻辑的内容,而特定与平台的差异部分可以留给特定与平台的模块来处理, 并逐个案例逐个案例的完善抽象出来的 Guard 层。下面是一个例子:

1
2
3
4
5
6
7
8
9
// window-service.ts
import { Injectable } from "@angular/core";

@Injectable()
export class WindowService {
getWidth(): number {
return window.innerWidth;
}
}
1
2
3
4
5
6
7
8
9
10
// server-window.service.ts
import { Injectable } from "@angular/core";
import { WindowService } from "./window.service";

@Injectable()
export class ServerWindowService extends WindowService {
getWidth(): number {
return 0;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
// app-server.module.ts
import {NgModule} from '@angular/core';
import {WindowService} from './window.service';
import {ServerWindowService} from './server-window.service';

@NgModule({
providers: [{
provide: WindowService,
useClass: ServerWindowService,
}]
})

如果您有一个由第三方提供的组件,该组件与在各 Angular platform 上的行为不兼容,那么除了基本应用程序模块外,您还可以为浏览器和服务器创建两个单独的模块。基本应用程序模块将包含您所有的平台无关代码,浏览器模块将包含所有特定于浏览器的代码, 而服务器模块包含所有特定于服务器的代码. 而如果该组件对浏览器友好, 那么浏览器模块不必写太大代码,反之亦然。为了避免编辑过多的模板代码,可以创建一个无操作组件来放置库组件。下面是一个例子:

1
2
3
4
5
6
7
8
9
// example.component.ts
import { Component } from "@angular/core";

@Component({
selector: "example-component",
template: `<library-component></library-component>`, // this is provided by a third-party lib
// that causes issues rendering on Universal
})
export class ExampleComponent {}
1
2
3
4
5
6
7
8
9

// app.module.ts
import {NgModule} from '@angular/core';
import {ExampleComponent} from './example.component';

@NgModule({
declarations: [ExampleComponent],
})

1
2
3
4
5
6
7
8
9
10

// browser-app.module.ts
import {NgModule} from '@angular/core';
import {LibraryModule} from 'some-lib';
import {AppModule} from './app.module';

@NgModule({
imports: [AppModule, LibraryModule],
})

1
2
3
4
5
6
7
8
// library-shim.component.ts
import { Component } from "@angular/core";

@Component({
selector: "library-component",
template: "",
})
export class LibraryShimComponent {}
1
2
3
4
5
6
7
8
9
10
// server.app.module.ts
import { NgModule } from "@angular/core";
import { LibraryShimComponent } from "./library-shim.component";
import { AppModule } from "./app.module";

@NgModule({
imports: [AppModule],
declarations: [LibraryShimComponent],
})
export class ServerAppModule {}

3.3. 策略 3:Shims

如果以上所有策略都不能符合要求,并且您只需要访问某种浏览器功能,那么您可以修补服务器环境的 Global 变量,以包括所需的全局。例如:

1
2
3
4
// server.ts
global['window'] = {
// properties you need implemented here...
};

这种策略可以应用于任何浏览器环境有而服务环境未定义的元素。当你这样做的时候请小心,因为玩全局变量通常被认为是一种反模式。

fun fact:同样是功能补丁,shim 在以及存在的各 Angular platform 上永远不受支持。而 polyfill 是计划被支持的功能补丁,或者在较新版本的 platform 上以及被支持的功能

4. 应用程序速度慢,甚至无法渲染

Angular Universal 渲染过程很简单,但也可以被善意或无意识的代码阻止或减慢。
首先,渲染过程的一些异步进程。当对 platform-server (Angular Universal 平台)发出渲染请求时,将执行单一路线导航。当导航完成时,也就是说所有 Zone.js 宏任务都完成了,无论当时处于什么状态(完整或不完整)的 DOM 都会返回给用户。

A Zone.js macrotask is just a JavaScript macrotask that executes in/is patched by Zone.js

这意味着,如果有一个进程(如 microtask)需要占用一定数量的 CPU 时间片来才能完成,或者存在一个非常耗时的 HTTP 连接请求,则渲染过程将无法完成或者需要相当长的时间。宏任务包括对全局变量(如 setTimeout 和 setInterval)以及 Observables 的调用。调用它们而不取消它们,或者让它们在服务器上运行的时间超过所需的时间可能会导致渲染效果欠佳。

如果您还不知道微任务和宏任务的差别,可能值得复习 JavaScript 事件循环并学习微任务和宏任务之间的区别。这里有一个很好的参考。

5. HTTP,Firebase,WebSocket 等在渲染之前不会完成

与上面关于等待宏任务完成的部分类似,另一方面是平台不会等待微任务完成才完成渲染。在 Angular Universal 中,我们修补了 Angular HTTP 客户端,将其转换为宏任务,以确保给定渲染的任何所需的 HTTP 请求都已完成。但是,这种类型的补丁可能并不适合所有微任务,因此建议您对如何进行进行最佳判断。您可以查看代码参考,了解 Universal 如何包装任务以将其转换为宏任务,或者您可以简单地选择更改给定任务的服务器行为。

6. Angular 系列文章

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

7. 参考文档

Important Considerations when Using Angular Universal