JavaScript
TypeScript
angular
webpack
パフォーマンス

Angular パフォーマンスアップ最短最速マニュアル

More than 1 year has passed since last update.

output_kMkTim.gif

動機

  • Angular(2 or 4)で開発中のプロダクト(SPA)が重い
    • JS の肥大化
    • 起動が遅い
  • パフォーマンスチューンしたい

で、みんないろいろ調べると思うけど…

  • AOT コンパイルとか module の最適化とか手段はいろいろあるのはわかった
  • が、ブログエントリを読んで今開発中のプロダクトに自分で解決策を注入するのが大変

サマリ

  • 対工数効果が高い部分に絞ってパフォーマンスチューンできる、「これさえやればOK!」みたいな進研ゼミ的マニュアルを作った(半ば覚え書きだけど)
  • 平均起動時間184%減
    • 4000msec超 -> 640ms2
  • JS ファイルサイズ323%減
    • 2770kb -> 2153kb (Parsed Size)
    • 最適化後の Gziped Size は 393.47kb

前提

  • この記事は、AngularClass/angular-starter を使ったプロダクトで実践したメモになっている
  • angular/angular-cli 採用プロダクト向けにも役には立つと思われるが、angular-starter 側でカバーしてくれている部分は自前でがんばる必要がある(後述)

webpack による bundle の状況把握

  • まずは現状把握から

webpack-bundle-analyzer を入れる

package.json

  "scripts": {

    (..略..)

    "server:dev:analyze": "cross-env ANALYZE=1 npm run server:dev",

    (..略..)

  },

server:devnpm run webpack-dev-server しているスクリプト。
開発時に analyze したい時だけ analyzer のサーバーを立ち上げるよう、必要に応じて書き換える。

webpack.dev.js (開発環境用設定)

const ANALYZE = process.env.ANALYZE;
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

(....)

plugins: [

  (....)

  new BundleAnalyzerPlugin({
    analyzerMode: ANALYZE ? 'server' : 'disabled',
  }),

]

webpack.prod.js (本番用設定)

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

(....)

plugins: [

  (....)

  new BundleAnalyzerPlugin({
    analyzerMode: 'static',
    reportFilename: 'report/bundle-analyzer.html',
    openAnalyzer: false,
  }),

]

出力

http://localhost:8888 で公開される

Pasted image at 2017_10_04 08_56 PM.png

lodash.js が無駄に複数 chunk 内に入り込んでますね。

上記スクリーンショットは rxjs, moment 及びアプリ内もそこそこ綺麗にした後なので既にそんなに目立たなくなってるけど、何も意識せずに開発してるともっと酷い。これはマニュアル後半の「bundle の最適化」でどうにかする。

最適化に着手する

では最短最速でチューンナップする。
まずは AOT コンパイル。

AOT コンパイル

参考

原理を知りたい人は以下で勉強する。

伏兵、循環参照

ngc に AOT コンパイルをやらせると、こんなので進まないことがある

main.bundle.js:22949 Uncaught TypeError: Cannot read property 'prototype' of undefined
    at __extends (main.bundle.js:22949)
    at main.bundle.js:22966
    at Object.<anonymous> (main.bundle.js:22980)
    at __webpack_require__ (inline.bundle.js:53)
    at Object.<anonymous> (main.bundle.js:14103)
    at __webpack_require__ (inline.bundle.js:53)
    at Object.<anonymous> (main.bundle.js:23073)
    at __webpack_require__ (inline.bundle.js:53)
    at Object.<anonymous> (main.bundle.js:14001)
    at __webpack_require__ (inline.bundle.js:53)
  • 原因
  • dump 方法

    • webpack-cyclic-dependency-checker
    • 共通設定

      webpack.common.js
      (....)
      
      plugins: [
      
        (....)
      
        new CircularDependencyPlugin({
          // exclude detection of files based on a RegExp
          exclude: /node_modules/,
          // add errors to webpack instead of warnings
          failOnError: true,
        }),
      
      ]
      
  • 対策

    • プラグインが出力したエラーを全てつぶす
      • 大体、 import { Something } from '../hoge';import { Something } from '../hoge/something.component'; のように書き換えれば減っていく
      • きれい好きな人はエラーを見てリファクタ(とかモジュール整理)を進めたくなると思うけど、この時点では AOT コンパイルを通すことを最優先して後回しにするのをおすすめする(bundle 最適化と合わせてやる方が効率いい)

モジュール設計がまずい場合は、リファクタしないといけないこともある。具体的には、Lazy Loading Module として設計し、かつ routing も行うようなモジュールの場合にハマりやすい。参考に挙げた Angular Style Guide を読んで理解して進むのが望ましいが、以下の Example のように、コンポーネントを export するモジュールファイルと RouterModule.forChild() を行うモジュールファイルを分けて実装すればなんとかなる。

app.routes.ts
(....)

export const ROUTES: Routes = [

  (....)

  { path: 'something', loadChildren: './something#SomethingRoutingModule' },

  (....)

something/something-router.module.ts
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';

import { routes } from './something.routes';
import { SomethingModule } from './something.module';

@NgModule({
  declarations: [],
  imports: [
    RouterModule.forChild(routes),
    SomethingModule 
  ]
})
export class SomethingRoutingModule {
  public static routes = routes;
}
something/something.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';

import { SharedPipeModule } from '../shared/pipe';

import { Something1Component } from './something1.component';
import { Something2Component } from './something2.component';
import { Something3Component } from './something3.component';

consts components:any[] = [
  Something1Component,
  Something2Component,
  Something3Component,
];

@NgModule({
  declarations: components,
  imports: [
    // import Angular's modules
    CommonModule,
    FormsModule,
    RouterModule,
    // import third-party modules
    (....)
    // import Application modules
    SharedPipeModule,
  ],
  providers: components
})
export class SomethingModule {
}

コンパイル成功までの道のり

  • 循環参照さえ解決すれば、基本的には参考に挙げた2つのエントリで紹介されている内容をこつこつやっていけばよい
    • コンパイラが出すエラーメッセージは重要なのでちゃんと読みましょう
  • 因みに AngularClass/angular-starter を採用したプロダクトでは、sass(scss) 周りで苦労しなくて済む

AOT コンパイル成功まで漕ぎ着ければ、この時点で起動時のコンポーネント初期化処理に関しては劇的に改善されているはず。

ただし各 chunk js ファイルには重複してライブラリが入っていたりするので、今度はそれを最適化する。

webpack による bundle の最適化

参考

Example:rxjs

shared/rxjs/index.ts
import 'rxjs/add/observable/forkJoin';
import 'rxjs/add/observable/timer';
import 'rxjs/...その他、アプリケーショングローバルに使いたいものだけ限定的に'
app.module.ts
(....)
import './shared/rxjs';
(....)

更に、他のチームメンバーが import { Observable } from 'rxjs' のようなコードを書かないように

tslint.json
(..略..)
"rules": {
  (..略..)
  "import-blacklist": [
    true,
    "rxjs",
    "rxjs/Rx",
  ],

Example:lodash

個々人の好みだが、以下はアプリケーショングローバルに使いたいものだけ限定的に import し、限定的に export する Example。

shared/lodash/index.ts
import cloneDeep from 'lodash/cloneDeep';
import includes from 'lodash/includes';
import pull from 'lodash/pull';
import find from 'lodash/find';
import remove from 'lodash/remove';

export {
  cloneDeep,
  includes,
  pull,
  find,
  remove,
};
app.module.ts
(....)
import './shared/lodash';
(....)

そして使う側。

something/something.component.ts
import * as _ from '../shared/lodash';

const hogeCloned: Hoge = _.cloneDeep(this.hoge);

更に、他のチームメンバーが import { Observable } from 'rxjs' のようなコードを書かないように

tslint.json
(..略..)
"rules": {
  (..略..)
  "import-blacklist": [
    true,
    "lodash",
  ],

プラクティス:moment の locale

webpack.common.js
(....)

plugins: [

  (....)

  // omit moment/locale/*.js
  new ContextReplacementPlugin(/moment[\/\\]locale$/, /ja/),

]

最適化後

Pasted image at 2017_10_04 10_59 PM.png

lodash、目立たなくなりましたね。
因みにこれは開発環境での出力なので、本番用(AOTコンパイル)だと @angular/compiler はいなくなる。

この記事で扱わなかった分野

shared modules

アプリケーショングローバルに利用する pipe なんかは shared module にしている。これは循環参照解決のセクションで書いた something/something.module.ts っぽく実装していけばいいと思う。チーム内からの「記事書け」圧力が強くなったら書くかも。

style sheet の最適化

今回は1つの styles.scss でいいか、というプロダクトだったので手を付けていない。

Server Side Rendering (SSR)

SSR は SEO という側面も強いが、クライアント(ブラウザ)上での表示時の User eXperience を「パフォーマンス」に含むとすれば、パフォーマンスチューンの側面もなくはない。そのうちやりたい。


  1. HTTPリクエスト開始からページ表示完了まで 

  2. AOT コンパイルを行う前後での成果なので、アプリ側モジュール整理を行えばもっと早くなるはず 

  3. AOT コンパイル後、bundle 最適化前後で比較、Lazy Loading Module を含み、Polyfill を除く JS 群の Parsed Size