您好,登錄后才能下訂單哦!
本文基于上一篇 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 express yarn 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 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
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持億速云。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。