Angular SSR踩坑记录

项目版本:

"@angular/core": "8.2.6"
"@angular/cli": "^8.3.3"

首先根据官方介绍配置相关文件:Server-side Rendering (SSR): An intro to Angular Universal

需要新增(用“*”标出)或修改的文件如下:

src/
  // app web page
  index.html
  // bootstrapper for client app
  main.ts
  // * bootstrapper for server app
  main.server.ts
  // styles for the app
  style.css
  // application code
  app/ ...         
    // * server-side application module
    app.server.module.ts  
// * express web server
server.ts      
// TypeScript client configuration
tsconfig.json      
//  TypeScript client configuration
tsconfig.app.json         
// * TypeScript server configuration
tsconfig.server.json    
// TypeScript spec configuration
tsconfig.spec.json           
// npm configuration
package.json              
// * webpack server configuration
webpack.server.config.js     
问题1、window, document, navigator

服务端没有浏览器API,因此如果直接使用,会在浏览器端报错。针对不同情况有以下几种解决方案:

  1. 通过PLATFORM_ID区分执行环境,进而执行特定代码:
import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
 
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
 
 ngOnInit() {
   if (isPlatformBrowser(this.platformId)) {
      console.log('仅在客户端运行')
   }
   if (isPlatformServer(this.platformId)) {
      console.log('仅在服务端运行')
   }
 }
  1. 很多时候使用的是navigator.userAgent,window.location.hostname,这些可以在node服务中拿到并注入到业务代码中:
// server.ts
app.get('*', (req, res) => {
  res.render(
    'index',
    {
      req,
      res,
      providers: [
        {
          provide: 'APP_HOSTNAME',
          useFactory: () => req.hostname,
          deps: []
        },
        // 注入cookies
        {
          provide: 'APP_COOKIES',
          useFactory: () => req.get('Cookie'),
          deps: []
        },
        // 注入ua
        {
          provide: 'APP_UA',
          useFactory: () => req.get('User-Agent'),
          deps: []
        }
      ]
    },
    (err, html) => {
      if (err) {
        throw err
      }
      res.send(html)
    }
  )
})

// test.component.ts
import {
  Inject,
  Optional,
  PLATFORM_ID
} from '@angular/core'

constructor(
@Inject(PLATFORM_ID) private platformId: Object,
@Inject('APP_HOSTNAME') @Optional() private readonly hostName: string
) {}
 
 ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      this.host = window.location.hostname
    } else {
      this.host = this.hostName
    }
 }
问题2、使用的第三方库不支持
  1. 有些第三方库并没有考虑到服务端执行,会报一些类似于document is not defined的错误,首先查看报错的库是否有更新修复,如果他们在后期支持了服务端的判断,则可以升级解决。
  2. 如果第三方库并没有解决,例如我们使用了countup.js,则可以将库移到本地修改以支持,缺点是后期不方便升级
  3. 动态引入。如果第三方库本来也不必在服务端执行,但是引入即报错,我们可以使用懒加载使得其在服务度不会被引入。 例如我们用到的库jsencrypt:
import { from, Observable, of } from 'rxjs'

@Injectable()
export class RsaService {
  encrypt (data): Observable<string> {
    if (isPlatformBrowser(this.platformId)) {
      return from(import('jsencrypt')).pipe(
        map(({ JSEncrypt }) => {
          const encrypt = new JSEncrypt()
          return encrypt.encrypt(data)
        })
      )
    } else {
      return of(data) as any
    }
  }
}
问题3、路由

如果项目原本有使用window.open或window.location.href等方式进行路由,统一都改为angular官方路由系统Router

import { Router } from '@angular/router'
constructor(
 private router: Router
){}

goToTest (type, symbol) {
  // 删除:window.location.href = `${this.url}/trade`
  // 改为:
  this.router.navigateByUrl(`${this.url}/trade`)
}
问题4、动画

如果你的项目有使用BrowserAnimationsModule添加动画,则需要在app.server.module.ts文件中引入NoopAnimationsModule模块,它模拟了真实的动画模块,但是实际上确什么都没有做。

import { NoopAnimationsModule } from '@angular/platform-browser/animations'

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    NgbModule,
    NoopAnimationsModule
  ],
  bootstrap: [AppComponent]
})
export class AppServerModule {}
问题5、避免重复的HTTP请求
  1. 如果你的http请求使用了angular的HttpClient来实现,则可以通过引入两个模块来实现数据的同步。在app.module.ts中添加TransferHttpCacheModule,在app.server.module.ts中添加ServerTransferStateModule模块。这样可以轻松实现服务的请求结果的缓存,客户端会从缓存中拿到结果进而避免重复的请求。
  2. 如果你使用了其他http库,则需要在上面的基础上,再在app.module.ts引入BrowserTransferStateModule。

ps: 具体用法请参考Avoiding duplicate HTTP calls in Angular Universal

  1. 我们遇到了一个情况是5个请求中,只有2个请求被缓存了。后来通过查看TransferHttpCacheModule的逻辑发现,如果你的请求中有post请求,则会被停止进行缓存,剩下的请求也都不会被缓存,导致到了客户端依旧会触发二次请求。最终我们自己修改了TransferHttpCacheModule的逻辑来支持(被逼无奈),但是其实在初始化页面中通常是不会含有post请求的,可能他们认为如果有post请求,则可能会导致之前的get请求内容过期。如果你遇到同样的问题,查看一下你们的请求类型。
问题6、服务端使用模块映射而不是懒加载
  1. 服务端使用模块映射替代懒加载,在server module添加如下:
// app.server.module.ts
import {ModuleMapLoaderModule} from '@nguniversal/module-map-ngfactory-loader';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ModuleMapLoaderModule
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

// server.ts
const DIST_FOLDER = join(process.cwd(), 'dist')
const { AppServerModuleNgFactory, LAZY_MODULE_MAP } = require(join(
  DIST_FOLDER,
  'server',
  'main'
))

app.engine('html', ngExpressEngine({
    bootstrap: AppServerModuleNgFactory,
    providers: [provideModuleMap(LAZY_MODULE_MAP)]
  }))
问题7、setTimeout造成的问题
  1. setTimeout或类似逻辑会拖慢服务器的渲染过程,如果你的页面迟迟不返回,检查一下你的代码逻辑中是否有setTimeout,或者rxjs的timer。
 private componentDestroyed$ = new Subject()
 constructor (
    @Inject(PLATFORM_ID) private platformId: object
  ) {}
 ngOnInit () {
    let timerTrigger = of(1)
    if (isPlatformBrowser(this.platformId)) {
      // 只在客户端启用timer,且在ngOnDestroy中删除他们。
      timerTrigger = timer(0, 20000).pipe(takeUntil(this.componentDestroyed$))
    }
 }
 
 ngOnDestroy () {
    this.componentDestroyed$.next()
    this.componentDestroyed$.complete()
 }
问题8、使用Renderer2操作DOM

Renderer2可以帮助你在服务端操作DOM的属性内容等,甚至可以完成创建插入等操作,因为我没有遇到,所以请参考官方文档:Renderer2

问题9、require 引用静态资源

我们项目中有require引用静态资源的情况,但是生成的html中的引用并没有动态改变路径,且文件名没有哈希值。需要在angular.json中添加配置解决,添加outputHashing解决哈希问题,添加deployUrl解决路径匹配问题(我们的静态资源都放在assets目录中,所以我们设置了:"deployUrl": "/assets/")

// angular.json
  "server": {
    "builder": "@angular-devkit/build-angular:server",
    "configurations": {
      "production": {
        // 可能会报Property deployUrl is not allowed.我这个版本的@angular/cli/lib/config/schema.json中server没有deployUrl的属性,所以会提示不合法,但是实际上装载的“@angular-devkit/build-angular:server”是包含这个属性的,所以可以正常执行。
        "deployUrl": "/assets/",
        "outputHashing": "media",
      }
    }
  }
问题10、页面闪烁

Cannot find a way to achieve a smooth transition to client app 闪烁是很多人提到的一个问题, 我们的闪烁主要是路由动画造成的,当我们删除路由动画时,闪烁问题不再存在,且切换更快速(完全没必要弄个路由动画)

问题11、websocket处理

因为页面有websocket导致请求无返回,因为我们的websocket服务是自己写的,所以加了判断在服务端返回rxjs的empty来解决这个问题。

未解决问题

开发过程中每次改动都需要重新build整个项目,非常影响效率,有如下issues提到该问题,现在还未解决:

  1. Rebuild angular universal on save
  2. https://github.com/angular/universal/issues/1202
参考
  1. https://github.com/laixiangran/angular-universal-starter
# fe  angular 

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×