Angular5 服務端渲染實戰

orangexc發表於2018-01-03
Angular5 服務端渲染實戰

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

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

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

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

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

在這裡不詳細介紹文件內容,本文也儘量使用通俗易懂的語言帶入 Angular 的 SSR

前提

前面寫的 udao 這個專案是完全遵從於 angular-cli 的,從搭建到打包,這也使得本文通用於所有 angular-cli 搭建的 angular5 專案。

搭建過程

首先安裝服務端的依賴

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

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

建立檔案: src/app/app.server.module.ts

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 0ng build --aot --app 1複製程式碼

執行結果應該如下圖所示

Angular5 服務端渲染實戰

然後就是建立 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 || 4200const 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,將會看到如下輸出:

Angular5 服務端渲染實戰

node 執行剛剛打包好的 node dist/server.js 檔案

開啟 http://localhost:4200/ 會正常顯示專案主頁面

Angular5 服務端渲染實戰

從上面的開發者工具可以看出 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 如下:

Angular5 服務端渲染實戰

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

到這裡坑都踩完了,專案執行正常,沒發現其它 bug。

總結

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

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

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

Github 附有線上連結,好的就說到這了

來源:https://juejin.im/post/5a4ca77b6fb9a0451e40330c?utm_medium=fe&utm_source=weixinqun

相關文章