参考资料:
目的:基于Angular的服务端渲染和预渲染功能来生成多页静态页面。
Angular Universal 会在服务端通过一个被称为服务端渲染(server-side rendering - SSR)的过程生成静态的应用页面。
它可以生成这些页面,并在浏览器请求时直接用它们给出响应。 它也可以把页面预先生成为 HTML 文件,然后把它们作为静态文件供服务器使用。
客户端在接收html文件之前,服务端将html标签占位做动态数据填充;服务端处理好一个html字符串文件生成一个静态的html页面文件返回给客户端,客户端即会解析html,渲染呈现出UI/UX;
注意:angular服务端渲染会先在服务器端渲染好的只是静态的页面内容,然后响应客户端,而一些交互在加载完成之前是不可用的。
服务端渲染特性:
1、访问加载速度比其它任意方式几乎都快;
2、利于搜索引擎抓取,方便网站SEO
3、单个静态页只分配特定的路由
4、不用执行Javascript
5、chrome中访问后,当前页右键【查看网页源代码】,对应页面内容都可在源代码中查看
6、资源只单次渲染,无任何异步加载
预渲染参照如下流程图:
注解:
1、 服务端存放了打包好的静态资源与文件(./dist/browser下的所有文件)。
2、 浏览器解析渲染返回的html内容。
3、 打包好的bundle.js / chunk.js
不需要预渲染的情况
1、生成针对特定路由的静态HTML 文件,可以是一个项目工程,也可以是一个CDN服务;所以预渲染不适合动态路由的页面项目;
2、如果页面中有动态的操作,以及频繁的数据变动,就不得不经常升级/发布页面,对运维以及开发维护不友好;不建议使用预渲染;
3、Pre-render几乎涵盖了SSR的所有优点,但如果是一个复杂操作功能的项目,且还有N多个页面,那么预渲染在开发维护阶段将会变得异常艰难,因为它生成的是一个个对应的html页面,即有N多个路由,所以在它开发构建之时编译将会变得异常缓慢;
按照Angular服务端渲染教程执行ng add @nguniversal/express-engine
,自动添加服务端渲染对应的文件和配置,然后执行npm run prerender
,生成静态html页面。
注意事项:
(1)路由策略配置:angular路由策略只能是默认的PathLocationStrategy,不能是HashLocationStrategy(IE9只能用HashLocationStrategy)。用HashLocationStrategy,会导致找不到对应路由而渲染默认路由,表现为生成的html都是默认的路由内容。
(2)angular.json中的prerender的routes配置
如果要只预渲染某个特定路由哦/XX/XXX,则可如下配置:
"prerender": {
"builder": "@nguniversal/builders:prerender",
"options": {
"browserTarget": "projectId:build:production",
"serverTarget": "projectId:server:production",
"guessRoutes": false, // 只渲染routes和routesFile中定义的路由
"routes": [
"/XX/XXX" // 指定的路由,可多个
]
},
"configurations": {
"production": {}
}
}
(3)预渲染不支持路由参数(?后的内容),故routes中配置/XX?XXX=XXX这样的路由,执行预渲染命令会直接报错。源码中取路由参数的地方,一定要记得做空判断。
(4)预渲染只能接收静态路由,如product/:id
这样的动态路由,要渲染,必须指定id值,如product/1
,这样才能渲染成html页面。
预渲染的http请求必须使用绝对路径,而非相对路径。如请求assets中的静态资源,使用相对路径的话,预渲染时运行的是server/main.js,而非browser/main.js,会因找不到资源而报错。
由于我项目的要求只是生成多个静态页面,而不是服务器渲染加快响应速度,所以我解决这个问题的方法是:静态资源host配置在环境变量universalServerUrl,默认值为http://localhost:4200
,在执行预渲染前,另开个终端,执行npm run start
,这样预渲染过程才能访问到本地的静态资源,确保http请求成功,生成完整内容的静态页面。
(1)解决翻译JSON文件无法获取的问题
新增个http拦截器,如果是json文件并且是服务端渲染的话,就使用静态资源的host拼接url,构成绝对路径。
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import {
HttpEvent, HttpInterceptor, HttpHandler, HttpRequest
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { isPlatformServer } from '@angular/common';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class UniversalInterceptor implements HttpInterceptor {
constructor(@Inject(PLATFORM_ID) private platformId: object) { }
intercept(req: HttpRequest, next: HttpHandler): Observable> {
let serverSeq = req;
if (req.url.endsWith('.json') && isPlatformServer(this.platformId)) {
// 服务器渲染,转换url
serverSeq = req.clone({ url: environment.universalServerUrl + req.url });
}
return next.handle(serverSeq);
}
}
在app.module.ts的providers中添加拦截器:
{ provide: HTTP_INTERCEPTORS, useClass: UniversalInterceptor, multi: true }
如果是要实现服务器渲染,那么请参考这篇文章解决这个问题:小谈Angular SSR项目的国际化
(2)解决antd icon找不到的问题
我项目的antd icon使用的是动态加载的方式,会发起http请求动态引入,需要修改预渲染时的请求路径。
在app.module.ts的构造器中,如果是服务端渲染的话,则通过 NzIconService 的 changeAssetsSource() 方法来修改图标资源的位置。
export class AppModule {
constructor(
@Inject(PLATFORM_ID) private platformId: object,
@Inject(APP_ID) private appId: string,
private iconService: NzIconService) {
const platform = isPlatformBrowser(platformId) ? 'in the browser' : 'on the server';
console.log(`Running ${platform} with appId=${appId}`);
if (isPlatformServer(platformId)) {
this.iconService.changeAssetsSource(environment.universalServerUrl);
}
}
}
(3)解决http请求需要token的问题
预渲染时相当于你本地启动服务,然后打开浏览器访问对应的路由,组件内部执行初始化逻辑(如http请求获取后端数据),渲染页面完毕,你将当前页面的html保存到本地。
由于后端接口请求都需要token,所以我们需要配置个token进行http请求,方便预渲染过程中http请求拿到后端数据后去进行渲染。如果没有配置token,则接口请求会报401,跳转到登录页面,渲染失败。
在环境变量中配置个universalToken变量,预渲染前,先登录获取到token,然后赋值给universalToken变量,再执行预渲染命令,就可以为接口请求带上token了。
修改http拦截器,判断平台是否为服务端,是则取universalToken环境变量作为token参数值。
export class AuthInterceptor implements HttpInterceptor {
constructor(
@Inject(PLATFORM_ID) private platformId: object
) { }
intercept(req: HttpRequest, next: HttpHandler): Observable> {
let authReq = req;
if (!req.url.endsWith('.json')) {
// 添加token参数
if (isPlatformBrowser(this.platformId)) {
authReq = req.clone({ setParams: { token: localStorage.getItem('token') } });
} else {
authReq = req.clone({ setParams: { token: environment.universalToken } });
}
}
return next.handle(authReq).pipe(
catchError((error: HttpErrorResponse) => this.handleError(error))
);
}
}
在app.module.ts的providers中添加拦截器:
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
PS:由于不同用户获取的后端数据是不一样的,所以推荐使用个没有数据的用户的token,这样预渲染的时候,只渲染UI,而后端数据为空。页面请求的时候,页面上先显示渲染的空数据,然后再显示请求到的后端数据。
(1)使用Angular抽象层
服务端应用不能引用浏览器独有的全局对象,比如 window、document、navigator 或 location。但Angular 提供了一些这些对象的可注入的抽象层,比如 Location 或 DOCUMENT,它可以作为你所调用的 API 的等效替身。
比如你在视图初始化时要通过document.querySelector定位某个dom对象,可以采用如下方式实现:
import { DOCUMENT } from '@angular/common'; // 引入DOCUMENT抽象层
export class AppComponent implements OnInit {
constructor(
@Inject(DOCUMENT) private document: Document // 注入DOCUMENT抽象层,当在客户端运行时,就是浏览器的document对象
) { }
ngInit() { // 在视图初始化时去使用document对象
this.element = this.document.querySelector('.class');
}
}
(2)判断平台类型,执行不同的代码
export class AppModule {
constructor(
@Inject(PLATFORM_ID) private platformId: object) { // 注入PLATFORM_ID,值为server | browser
const platform = isPlatformBrowser(platformId) ? 'in the browser' : 'on the server';
console.log(`Running ${platform} with appId=${appId}`);
if (isPlatformServer(platformId)) {
// 在服务端运行
}
}
}
比如在node中没有localStorage,所以预渲染会报错localStorage对象未定义。通过在源码中添加对平台类型的判断,是客户端才去localStorage中取数据,不是则取默认值,这样就能保证预渲染顺利进行,也不影响在客户端的运行。