#Angular8: パフォーマンス改善のためUniversalを導入しServer Side Rendering化する
パフォーマンスがひどいので改善するためにuniversalの導入を決めた(Lighthouseスコア)
###環境
$ ng --version
_ _ ____ _ ___
/ \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _|
/ △ \ | '_ \ / _` | | | | |/ _` | '__| | | | | | |
/ ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | |
/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___|
|___/
Angular CLI: 8.3.18
Node: 10.15.3
OS: win32 x64
Angular: 8.2.13
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router, service-worker
Package Version
--------------------------------------------------------------------
@angular-devkit/architect 0.803.18
@angular-devkit/build-angular 0.803.18
@angular-devkit/build-optimizer 0.803.18
@angular-devkit/build-webpack 0.803.18
@angular-devkit/core 8.3.18
@angular-devkit/schematics 8.3.18
@angular/cdk 8.2.3
@angular/cli 8.3.18
@angular/fire 5.2.3
@angular/flex-layout 8.0.0-beta.27
@angular/material 8.2.3
@angular/platform-server 8.2.14
@angular/pwa 0.803.18
@ngtools/webpack 8.3.18
@nguniversal/express-engine 8.1.1
@nguniversal/module-map-ngfactory-loader 8.1.1
@schematics/angular 8.3.18
@schematics/update 0.803.18
rxjs 6.5.3
typescript 3.5.3
webpack 4.39.2
###調査と前準備
- さまざまなテストと調査の結果、firebaseuiとalgoliaはUniversalとのコンパチがヤバそうなのでそれらの機能を使用しているコンポーネントは別プロジェクト移した。
- ロールバックできるようにGitでの管理は必須。ロールバックGitコマンドは以下の通り。
$ git log //戻す対象のハッシュ値を調べる
commit ************************
$ git reset --hard ハッシュ値
##Universal導入
###インストール
コマンドは以下の通り。
$ ng add @nguniversal/express-engine --clientProject <プロジェクト名>
実行すると以下のファイルが追加される。
src/main.server.ts
src/app/app.server.module.ts
tsconfig.server.json
webpack.server.config.js
server.ts
server.ts が Cloud Functions で実行される Node の Express のサーバーのプログラム。
既存のファイルもいくつか更新される。
package.json
angular.json
src/main.ts
src/app/app.module.ts
以上でbuild:ssr でビルドして、serve:ssr でサーバーを起動すればもうSSRで動かせる。
$ npm run build:ssr
$ npm run serve:ssr
> angular-universal-functions@0.0.0 serve:ssr ./projectName
> node dist/server
Node Express server listening on http://localhost:4000
だが実際はいくつかファイルを修正しないといけなかった。
##修正箇所
###UniversalとIvyは併用できない。
npm run build:ssr
でエラー:
ERROR in Node does not exist: C:/ProjectName/node_modules/@nguniversal/express-engine`
UniversalとIvyは併用できないようなのでIvyを無効にする。
//tsconfig.app.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"types": []
},
"angularCompilerOptions": {
"enableIvy": false //<-- falseに
},
"exclude": [
"test.ts",
"**/*.spec.ts"
]
}
//tsconfig.spec.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/spec",
"types": [
"jasmine",
"node"
]
},
"angularCompilerOptions": {
"enableIvy": false //<--falseに
},
"files": [
"test.ts",
"polyfills.ts"
],
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}
###main.server.tsへのパスが間違ってる
npm run build:ssr
でエラー:
ERROR in error TS6053: File 'C:/ProjectName/src/src/main.server.ts' not found.
src/src/..なんてフォルダはないのでビルドスクリプトが参照するmain.server.tsへのパスの記述がどこかのコンフィグファイルで間違っていると思われ。探したらtsconfig.server.jsonファイルだった。
//tsconfig.server.json
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"outDir": "../out-tsc/app-server",
"module": "commonjs"
},
"files": [
"src/main.server.ts" //<-- src/を削除、"main.server.ts"とする。
],
"angularCompilerOptions": {
"entryModule": "./app/app.server.module#AppServerModule"
}
}
###SafePipeクラスに属性を追加
npm run build:ssr
でエラー:
ERROR in C:\ProjectName\src\app\service\safe.pipe.spec.ts
[tsl] ERROR in C:\ProjectName\src\app\service\safe.pipe.spec.ts(5,18)
TS2554: Expected 1 arguments, but got 0.
SafePipeを使ってる場合のみ。
const pipe = new SafePipe();
の構文で「Attributeがないよー」とエラー。new SafePipe(null)
としておく。
//safe.pipe.spec.ts
import { SafePipe } from './safe.pipe';
describe('SafePipe', () => {
it('create an instance', () => {
const pipe = new SafePipe(); //<--属性にnullを入れてSafePipe(null)としておく
expect(pipe).toBeTruthy();
});
});
###/dist/serverにpackage.jsonがないとのエラー
npm run serve:ssr
でエラー
main.js:165011
throw new Error("package.json does not exist at " + package_json_path);
確かめると確かにビルド先のdist/serverの中にpackage.jsonがない。
よくわからないが、package.jsonの
"build:client-and-server-bundles": "ng build --prod && ng run benzoinfojapan:server:production --bundleDependencies all"
を
"build:client-and-server-bundles": "ng build --prod && ng run benzoinfojapan:server:production --bundleDependencies none"
(--bundleDependencies all を --bundleDependencies none に)
そしてnpm run build:ssr
からやりなおす。
$ npm run build:ssr
Generating ES5 bundles for differential loading...
ES5 bundle generation complete.
$ Angular_Projects@0.0.0 compile:server Angular_Projects
$ webpack --config webpack.server.config.js --progress --colors
Hash: adsfadfadsfsdafadsf
Version: webpack 4.39.2
Time: 29389ms
Built at: 2019-12-22 16:02:13
Asset Size Chunks Chunk Names
server.js 958 KiB 0 [emitted] server
Entrypoint server = server.js
[0] ./server.ts 1.99 KiB {0} [built]
[2] external "events" 42 bytes {0} [built]
[3] external "fs" 42 bytes {0} [built]
[4] external "timers" 42 bytes {0} [optional] [built]
[5] external "crypto" 42 bytes {0} [built]
[13] external "path" 42 bytes {0} [built]
[22] external "util" 42 bytes {0} [built]
[30] external "net" 42 bytes {0} [built]
[35] external "buffer" 42 bytes {0} [built]
[56] external "stream" 42 bytes {0} [built]
[75] external "querystring" 42 bytes {0} [built]
[82] external "url" 42 bytes {0} [built]
[89] external "http" 42 bytes {0} [built]
[94] ./src sync 160 bytes {0} [built]
[121] external "require(\"./server/main\")" 42 bytes {0} [built]
+ 107 hidden modules
$ npm run serve:ssr
$ Angular_Projects@0.0.0 serve:ssr
./Angular_Projects
> node dist/server
Node Express server listening on http://localhost:4000
http://localhost:4000
にアクセスする
FirestoreとFireauthが問題なく使えているので良し。ブラウザのJavaScriptをオフにしても閲覧できる。
##Firebase に デプロイする準備
Deploy Angular 8 Universal (SSR) application on Firebaseにある手順どおりで大丈夫。
###Firebase Tools をインストール
npm install -g firebase-tools
###Firebase プロジェクトを初期化
すでにプロジェクトにフックしてあってもいちおうやり直した方がいいかも。(HostingとCloud Functionsを選ぶ)(TypeScriptを選ぶ)("What do you want to use as your public directory?"はdist/browserに)("Configure as a single page app?"はYes)(File dist/browser/index.html already exists. Overwrite?はNo)
$ firebase init
######## #### ######## ######## ######## ### ###### ########
## ## ## ## ## ## ## ## ## ## ##
###### ## ######## ###### ######## ######### ###### ######
## ## ## ## ## ## ## ## ## ## ##
## #### ## ## ######## ######## ## ## ###### ########
You're about to initialize a Firebase project in this directory:
./projectName
Before we get started, keep in mind:
* You are currently outside your home directory
? Are you ready to proceed? Yes
? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices.
( ) Database: Deploy Firebase Realtime Database Rules
( ) Firestore: Deploy rules and create indexes for Firestore
(*) Functions: Configure and deploy Cloud Functions
>(*) Hosting: Configure and deploy Firebase Hosting sites
( ) Storage: Deploy Cloud Storage security rules
? What language would you like to use to write Cloud Functions?
JavaScript
> TypeScript
? What do you want to use as your public directory? dist/browser
? Configure as a single-page app (rewrite all urls to /index.html)? Yes
? File dist/browser/index.html already exists. Overwrite? No
####server.tsを変更
24行目の
const app = express();
を
export const app = express();に変更。
それから、
最後の3行をコメントアウトする
…省略…
// Start up the Node server
/*
app.listen(PORT, () => {
console.log(`Node Express server listening on http://localhost:${PORT}`);
});
*/
この3行はfirebase deployするときはコメントアウトするのだけど、npm run serve:ssr
するときは必要。めんどいけど…。
####firebase.jsonを変更
以下のように。
{
"functions": {
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
],
"source": "functions"
},
"hosting": {
"public": "dist/browser",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"function": "ssr" //変更
}
]
}
}
####webpack.server.config.jsを変更
Output: {} にlibrary と libraryTarget を追加。
output: {
// Puts the output at the root of the dist folder
path: path.join(__dirname, 'dist'),
library: 'app',
libraryTarget: 'umd',
filename: '[name].js'
},
externals: のところは以下のように変更。
externals: [
/*
{
'./dist/server/main': 'require("./server/main")' // コメントアウトする
},
*/
/^firebase/
],
####functions/src/index.tsを編集
以下のコードにする。
import * as functions from 'firebase-functions';
const universal = require(`${process.cwd()}/dist/server`).app;
export const ssr = functions.https.onRequest(universal);
####distフォルダをコピーするステップを追加
Angular本体をビルドしてできるdistフォルダをfunctionsフォルダ配下にコピーする必要があって、手作業でやってもいいのだけどスクリプトで行うようにする
いちどnpm run build:ssr
したら以下のコマンドでfs-extraをfunctionsフォルダ内でインストールして、
$ cd functions
$ npm i fs-extra
名前はなんでもいいのだけど、functionsフォルダ配下にcopy-angular-app.jsとか適当にファイルを作って以下のコードをペーストして保存。
const fs = require('fs-extra');
fs.copy('../dist', './dist').then(() => {
// distフォルダをfunctions配下にコピーしてかつ/dist/browser/index.htmlは削除するというわけ
fs.remove('../dist/browser/index.html').catch(e => console.error('REMOVE ERROR: ', e));
}).catch(err => {
console.error('COPY ERROR: ', err)
});
firebase deployするときはbrowser/index.htmlは削除しないといけないが、npm run serve:ssr
するときは必要。npm run build:ssr
で再作成される。めんどいけど…。
####functions/package.jsonを変更
プロジェクトルートではなくfunctions配下のpackage.jsonね。そのbuild:セクションを以下のように変更して上記で作成したcopy-angular-app.jsが走るようにする。
{
"name": "functions",
"scripts": {
"lint": "tslint --project tsconfig.json",
"build": "node copy-angular-app && tsc", //変更
"serve": "npm run build && firebase serve --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "8"
},
…省略…
##Firebase ローカルテスト & デプロイ
ではテストする。
まずAngular本体をビルド
$ npm run build:ssr
次にfunctionsをビルド
$ cd functions
$ npm run build
Firebaseをローカルで作動
$ firebase serve
firebase serve コマンドはローカル環境で Firebase を動かすコマンド。
$ firebase serve
=== Serving from './projectName'...
! Your requested "node" version "8" doesn't match your global version "10"
+ functions: Emulator started at http://localhost:5001
+
i functions: Watching "./projectName/functions" for Cloud Functions...
i hosting: Serving hosting files from: dist/browser
+ hosting: Local server: http://localhost:5000
+
+ functions[ssr]: http function initialized (http://localhost:5001/project/us-central1/ssr).
[hosting] Rewriting / to http://localhost:5001/project/us-central1/ssr for local Function ssr
http://localhost:5000
にアクセスすると、Cloud Functions が呼び出され、サーバーサイドでレンダリングされた結果が返る。
正しく起動することが確認できたら Firebase にデプロイ。プロジェクトルートに戻って以下のコマンド。
$ firebase deploy
$ firebase deploy
=== Deploying to 'project'...
i deploying functions, hosting
Running command: npm --prefix "$RESOURCE_DIR" run lint
> functions@ lint .projectName/functions
> tslint --project tsconfig.json
Running command: npm --prefix "$RESOURCE_DIR" run build
> functions@ build
> node copy-angular-app && tsc
+ functions: Finished running predeploy script.
i functions: ensuring necessary APIs are enabled...
+ functions: all necessary APIs are enabled
i functions: preparing functions directory for uploading...
i functions: packaged functions (21.99 MB) for uploading
+ functions: functions folder uploaded successfully
i hosting[project]: beginning deploy...
i hosting[project]: found 122 files in dist/browser
+ hosting[project]: file upload complete
i functions: updating Node.js 8 function ssr(us-central1)...
+ functions[ssr(us-central1)]: Successful update operation.
i hosting[project]: finalizing version...
+ hosting[project]: version finalized
i hosting[project]: releasing new version...
+ hosting[project]: release complete
+ Deploy complete!
Project Console: https://console.firebase.google.com/project/project/overview
Hosting URL: https://project.firebaseapp.com
##Appendix
パフォーマンスを上げるためにやったのだが、SSR化はたいして貢献しなかった。
######Universal導入後
######Twitterシェアボタンの削除とコードのスリム化後
Twitterシェアボタンを全ページに設けていたがすべて削除。
劇的に改善。
コードのスリム化はどれくらい寄与したかわからない。
こういう事だったらLazy Loadで良かったのか…?
######ServiceWorkerModuleをオフに
そういえばSSR化してなぜPWAが動いているのだろう?と思って、よく見たらapp.module.tsでServiceWorkerModule が入ったままだった。
import { ServiceWorkerModule } from '@angular/service-worker';
@NgModule({
imports: [
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
],
それを削除して(コメントアウトしただけ)再ビルド。
結果はこんな感じに。
参考:
Deploy Angular 8 Universal (SSR) application on Firebase
Deploying an Angular 8 Universal App to Firebase with CircleCI
Angular サーバーサイドレンダリング を Firebase Cloud Functions で動かす
Angular Firebase Function Deploy Error: Cannot find module 'firebase/app'