Angular5 服務端渲染實戰

本文基於上一篇 Angular5 的文章繼續進行開發,上文中講了搭建 Angular5 有道翻譯的過程,以及遇到問題的解決方案。html

隨後改了 UI,從 bootstrap4 改到 angular material,這裏不詳細講,服務端渲染也與修改 UI 無關。前端

看過以前文章的人會發現,文章內容都偏向於服務端渲染,vue 的 nuxt,react 的 next。vue

在本次改版前也嘗試去找相似 nuxt.js 與 next.js 的頂級封裝庫,能夠大大節省時間,可是未果。node

最後決定使用從 Angular2 開始就可用的先後端同構解決方案 Angular Universal(Universal (isomorphic) JavaScript support for Angular.)react

在這裏不詳細介紹文檔內容,本文也儘可能使用通俗易懂的語言帶入 Angular 的 SSRwebpack

前提

前面寫的 udao 這個項目是徹底聽從於 angular-cli 的,從搭建到打包,這也使得本文通用於全部 angular-cli 搭建的 angular5 項目。git

搭建過程

首先安裝服務端的依賴github

yarn add @angular/platform-server express
yarn add -D ts-loader webpack-node-externals npm-run-all
複製代碼

這裏須要注意的是 @angular/platform-server 的版本號最好根據當前 angular 版本進行安裝,如: @angular/platform-server@5.1.0,避免與其它依賴有版本衝突。web

建立文件: src/app/app.server.module.tsexpress

import { NgModule } from '@angular/core'
import { ServerModule } from '@angular/platform-server'

import { AppModule } from './app.module'
import { AppComponent } from './app.component'

@NgModule({
  imports: [
    AppModule,
    ServerModule
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule { }
複製代碼

更新文件: src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'
// ...

import { AppComponent } from './app.component'
// ...

@NgModule({
  declarations: [
    AppComponent
    // ...
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'udao' })
    // ...
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
複製代碼

咱們須要一個主文件來導出服務端模塊

建立文件: src/main.server.ts

export { AppServerModule } from './app/app.server.module'
複製代碼

如今來更新 @angular/cli 的配置文件 .angular-cli.json

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "project": {
    "name": "udao"
  },
  "apps": [
    {
      "root": "src",
      "outDir": "dist/browser",
      "assets": [
        "assets",
        "favicon.ico"
      ]
      // ...
    },
    {
      "platform": "server",
      "root": "src",
      "outDir": "dist/server",
      "assets": [],
      "index": "index.html",
      "main": "main.server.ts",
      "test": "test.ts",
      "tsconfig": "tsconfig.server.json",
      "testTsconfig": "tsconfig.spec.json",
      "prefix": "app",
      "scripts": [],
      "environmentSource": "environments/environment.ts",
      "environments": {
        "dev": "environments/environment.ts",
        "prod": "environments/environment.prod.ts"
      }
    }
  ]
  // ...
}
複製代碼

上面的 // ... 表明省略掉,可是 json 沒有註釋一說,看着怪怪的....

固然 .angular-cli.json 的配置不是固定的,根據需求自行修改

咱們須要爲服務端建立 tsconfig 配置文件: src/tsconfig.server.json

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "baseUrl": "./",
    "module": "commonjs",
    "types": []
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts",
    "server.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "app/app.server.module#AppServerModule"
  }
}
複製代碼

而後更新: src/tsconfig.app.json

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "baseUrl": "./",
    "module": "es2015",
    "types": []
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts",
    "server.ts"
  ]
}
複製代碼

如今能夠執行如下命令,看配置是否有效

ng build -prod --build-optimizer --app 0
ng build --aot --app 1
複製代碼

運行結果應該以下圖所示

而後就是建立 Express.js 服務, 建立文件: src/server.ts

import 'reflect-metadata'
import 'zone.js/dist/zone-node'
import { renderModuleFactory } from '@angular/platform-server'
import { enableProdMode } from '@angular/core'
import * as express from 'express'
import { join } from 'path'
import { readFileSync } from 'fs'

enableProdMode();

const PORT = process.env.PORT || 4200
const DIST_FOLDER = join(process.cwd(), 'dist')

const app = express()

const template = readFileSync(join(DIST_FOLDER, 'browser', 'index.html')).toString()
const { AppServerModuleNgFactory } = require('main.server')

app.engine('html', (_, options, callback) => {
  const opts = { document: template, url: options.req.url }

  renderModuleFactory(AppServerModuleNgFactory, opts)
    .then(html => callback(null, html))
});

app.set('view engine', 'html')
app.set('views', 'src')

app.get('*.*', express.static(join(DIST_FOLDER, 'browser')))

app.get('*', (req, res) => {
  res.render('index', { req })
})

app.listen(PORT, () => {
  console.log(`listening on http://localhost:${PORT}!`)
})
複製代碼

理所固然須要一個 webpack 配置文件來打包 server.ts 文件: webpack.config.js

const path = require('path');
var nodeExternals = require('webpack-node-externals');

module.exports = {
  entry: {
    server: './src/server.ts'
  },
  resolve: {
    extensions: ['.ts', '.js'],
    alias: {
      'main.server': path.join(__dirname, 'dist', 'server', 'main.bundle.js')
    }
  },
  target: 'node',
  externals: [nodeExternals()],
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' }
    ]
  }
}
複製代碼

爲了打包方便最好在 package.json 裏面加幾行腳本,以下:

"scripts": {
  "ng": "ng",
  "start": "ng serve",
  "build": "run-s build:client build:aot build:server",
  "build:client": "ng build -prod --build-optimizer --app 0",
  "build:aot": "ng build --aot --app 1",
  "build:server": "webpack -p",
  "test": "ng test",
  "lint": "ng lint",
  "e2e": "ng e2e"
}
複製代碼

如今嘗試運行 npm run build,將會看到以下輸出:

node 運行剛剛打包好的 node dist/server.js 文件

打開 http://localhost:4200/ 會正常顯示項目主頁面

從上面的開發者工具能夠看出 html 文檔是服務端渲染直出的,接下來嘗試請求數據試一下。

注意:本項目顯式(菜單可點擊)的幾個路由初始化都沒有請求數據,可是單詞解釋的詳情頁是會在 ngOnInit() 方法裏獲取數據,例如:http://localhost:4200/detail/add 直接打開時會發生奇怪的現象,請求在服務端和客戶端分別發送一次,正常的服務端渲染項目首屏初始化數據的請求在服務端執行,在客戶端不會二次請求!

發現問題後,就來踩平這個坑

試想若是採用一個標記來區分服務端是否已經拿到了數據,若是沒拿到數據就在客戶端請求,若是已經拿到數據就不發請求

固然 Angular 早有一手準備,那就是 Angular Modules for Transfer State

那麼如何真實運用呢?見下文

請求填坑

在服務端入口和客戶端入口分別引入 TransferStateModule

import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';
// ...

@NgModule({
  imports: [
    // ...
    ServerModule,
    ServerTransferStateModule
  ]
  // ...
})
export class AppServerModule { }
複製代碼
import { BrowserModule, BrowserTransferStateModule } from '@angular/platform-browser';
// ...

@NgModule({
  declarations: [
    AppComponent
    // ...
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'udao' }),
    BrowserTransferStateModule
    // ...
  ]
  // ...
})
export class AppModule { }
複製代碼

以本項目爲例在 detail.component.ts 裏面,修改以下

import { Component, OnInit } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Router,  ActivatedRoute, NavigationEnd } from '@angular/router'
import { TransferState, makeStateKey } from '@angular/platform-browser'

const DETAIL_KEY = makeStateKey('detail')

// ...

export class DetailComponent implements OnInit {
  details: any

  // some variable

  constructor(
    private http: HttpClient,
    private state: TransferState,
    private route: ActivatedRoute,
    private router: Router
  ) {}

  transData (res) {
    // translate res data
  }

  ngOnInit () {
    this.details = this.state.get(DETAIL_KEY, null as any)

    if (!this.details) {
      this.route.params.subscribe((params) => {
        this.loading = true

        const apiURL = `https://dict.youdao.com/jsonapi?q=${params['word']}`

        this.http.get(`/?url=${encodeURIComponent(apiURL)}`)
        .subscribe(res => {
          this.transData(res)
          this.state.set(DETAIL_KEY, res as any)
          this.loading = false
        })
      })
    } else {
      this.transData(this.details)
    }
  }
}
複製代碼

代碼夠簡單清晰,和上面描述的原理一致

如今咱們只須要對 main.ts 文件進行小小的調整,以便在 DOMContentLoaded 時運行咱們的代碼,以使 TransferState 正常工做:

import { enableProdMode } from '@angular/core'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'

import { AppModule } from './app/app.module'
import { environment } from './environments/environment'

if (environment.production) {
  enableProdMode()
}

document.addEventListener('DOMContentLoaded', () => {
  platformBrowserDynamic().bootstrapModule(AppModule)
    .catch(err => console.log(err))
})
複製代碼

到這裏運行 npm run build && node dist/server.js 而後刷新 http://localhost:4200/detail/add 到控制檯查看 network 以下:

發現 XHR 分類裏面沒有發起任何請求,只有 service-worker 的 cache 命中。

到這裏坑都踩完了,項目運行正常,沒發現其它 bug。

總結

2018 第一篇,目的就是探索全部流行框架服務端渲染的實現,開闢了 angular 這個最後沒嘗試的框架。

固然 Orange 仍是前端小學生一枚,只知道實現,原理說的不是很清楚,源碼看的不是很明白,若有紕漏還望指教。

最後 Github 地址和以前文章同樣:https://github.com/OrangeXC/udao

Github 附有在線連接,好的就說到這了

相關文章
相關標籤/搜索