Angular8: パフォーマンス改善のためUniversalを導入しServer Side Rendering化する

#Angular8: パフォーマンス改善のためUniversalを導入しServer Side Rendering化する

パフォーマンスがひどいので改善するためにuniversalの導入を決めた（Lighthouseスコア）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307441/7db79654-d0d3-84ad-2833-a104dcd8cd0e.png)


###環境
```bash
$ 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コマンドは以下の通り。

```bash
$ git log //戻す対象のハッシュ値を調べる
commit ************************

$ git reset --hard ハッシュ値
``` 

##Universal導入

###インストール
コマンドは以下の通り。

```cmd
$ 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で動かせる。

```bash
$ 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を無効にする。

```typescript
//tsconfig.app.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "types": []
  },

  "angularCompilerOptions": {
    "enableIvy": false      //<-- falseに
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts"
  ]
}
```
```typescript
//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ファイルだった。

```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)`としておく。

```typescript
//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の

```json
"build:client-and-server-bundles": "ng build --prod && ng run benzoinfojapan:server:production --bundleDependencies all"
```

を

```json
"build:client-and-server-bundles": "ng build --prod && ng run benzoinfojapan:server:production --bundleDependencies none"
```

(--bundleDependencies all を　--bundleDependencies none に）

そして`npm run build:ssr`からやりなおす。


```bash
$ 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`にアクセスする
![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307441/6aeae0ca-1339-ae84-4f7c-1a8a5a00a33e.png)

FirestoreとFireauthが問題なく使えているので良し。ブラウザのJavaScriptをオフにしても閲覧できる。

##Firebase に デプロイする準備

[Deploy Angular 8 Universal (SSR) application on Firebase](https://stackoverflow.com/questions/58447458/deploy-angular-8-universal-ssr-application-on-firebase)にある手順どおりで大丈夫。

###Firebase Tools をインストール
```bash
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)

```bash

$ 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行をコメントアウトする

```typescript
…省略…
// 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を変更

以下のように。

```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 を追加。

```javascript
  output: {
    // Puts the output at the root of the dist folder
    path: path.join(__dirname, 'dist'),
    library: 'app',
    libraryTarget: 'umd',
    filename: '[name].js'
  },
```

externals: のところは以下のように変更。

```javascript

  externals: [
/*
　{
    './dist/server/main': 'require("./server/main")' // コメントアウトする
  },
*/
  /^firebase/
  ],
```

####functions/src/index.tsを編集

以下のコードにする。

```typescript
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フォルダ内でインストールして、

```bash

$ cd functions
$ npm i fs-extra
```

名前はなんでもいいのだけど、functionsフォルダ配下にcopy-angular-app.jsとか適当にファイルを作って以下のコードをペーストして保存。

```javascript
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が走るようにする。

```json
{
  "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本体をビルド

```bash
$ npm run build:ssr
```

次にfunctionsをビルド

```bash

$ cd functions
$ npm run build
```

Firebaseをローカルで作動

```bash

$ firebase serve
```
firebase serve コマンドはローカル環境で Firebase を動かすコマンド。

```csharp

$ 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 にデプロイ。プロジェクトルートに戻って以下のコマンド。

```bash
$ firebase deploy
```

```bash
$ 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導入後
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307441/73c2b36a-c578-d33f-88eb-ed1562573a0f.png)


######Twitterシェアボタンの削除とコードのスリム化後
Twitterシェアボタンを全ページに設けていたがすべて削除。
劇的に改善。
コードのスリム化はどれくらい寄与したかわからない。
こういう事だったらLazy Loadで良かったのか…？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307441/d423db54-bbfb-0b83-e89e-de46dc42bf5f.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307441/f4a21283-676f-1747-5fe7-62fc43016c90.png)

######ServiceWorkerModuleをオフに
そういえばSSR化してなぜPWAが動いているのだろう？と思って、よく見たらapp.module.tsでServiceWorkerModule が入ったままだった。

```typescript
import { ServiceWorkerModule } from '@angular/service-worker';
@NgModule({
  imports: [
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
　 ],
```
それを削除して（コメントアウトしただけ）再ビルド。
結果はこんな感じに。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307441/a0290ba6-c5ec-bcc3-4132-ec8120a917db.png)


<br>


参考：
[Deploy Angular 8 Universal (SSR) application on Firebase](https://stackoverflow.com/questions/58447458/deploy-angular-8-universal-ssr-application-on-firebase)
[Deploying an Angular 8 Universal App to Firebase with CircleCI](https://medium.com/javascript-in-plain-english/deploying-angular-8-universal-app-to-firebase-with-circleci-56b6d83749d1)
[Angular サーバーサイドレンダリング を Firebase Cloud Functions で動かす](https://webbibouroku.com/Blog/Article/angular-ssr-firebase)
[Angular Firebase Function Deploy Error: Cannot find module 'firebase/app'](https://stackoverflow.com/questions/57760710/angular-firebase-function-deploy-error-cannot-find-module-firebase-app)

<br>