(この内容は社内LTで使ったものを文章化して引き伸ばしたものです。)
(ゆっくり書いていたらAngular 4 RCが出てしまいました。。。)
Angular Universalを使って、隙間時間にちまちまと社内向けのサービスを開発しました。
その間に試していて、詰まったり引っかかったりしたことを連々と書いていきます。
はじめに
そもそもAngular Universalとは何かに関してはQuramyさんのこちらのAngular2のServer Side Renderingに触れてみるにお任せしたいと思います。
タイトルの通りAngularでServer Side Rendering(SSR)を行うためのモジュール群です。
Angular Universalのモジュール自体はまだRC1の段階で、
npmに公開されているものはAngular 2自体のバージョンアップ(2.4)に追従できていない状態です。
正式版のリリースはAngular 4と同じく3月の予定らしいのでもう少しだけ待ちましょう。
それでも私のようにとりあえず試してみたい方はAngular 2 Universal Starterをcloneして動かしてみてください。
どんなものか分かると思います。
今回作ったサービスもAngular 2 Universal Starterをもとにして作っていきました。
次から現状のAngular Universalでのポイントを書いていきます。
ポイントに関して間違いがあればコメントをお願いします。
ポイント1: SSR失敗はエラーにならない
「SSRしている割に遅いな、Angularが重たいからだろうか」と思ったらSSRできていなかった、ということがありました。
原因はNode側のMainModuleとなるnode.module.ts
内でtypoしていたことでした。
SSRは途中で失敗したものの、express自体はクラッシュせずにサーバとして生きていたため、クライアント側でレンダリングしていた、ということです。
Note
今回は関数の返り値の型を書かずにいたためにコンパイルエラーをすり抜けた、
しかもtslint
も常にwatchで動かしていれば気づけた問題でした。
ただ逆を言えばLintだけでなく、TypeScriptのように型があればエラーも減らせる、
ということなので皆さんTypeScriptを書きましょう。
ポイント2.1: ブラウザとNodeの差異
Universalなコードを書く際に気をつけたいのがブラウザとNodeの差異です。
特にそれぞれにしか存在しないオブジェクトを触りたい、ということが往々にしてあると思います。
そしてできれば可能な限り(Component
は)綺麗に書きたい、と思うはずです。
今回開発していた際には、Notification
やSessionStorage
を使いたいという状況でした。
Note
おそらくこういう場合Angularとしてはある程度Component
を綺麗にしつつService
を作るのが正しいだろう、と思い実装しました。
こうすることでComponent
はブラウザとNodeの差異を気にする必要がなくなりました。
ただ結局Service
でブラウザとNodeの差異を気にする必要があるということです。
ここで差異をどうするかに関しては結果は一緒ですが2通りあると思っています。
Angular Universalに依存しない形で判定
よくある条件式である if (typeof window === 'undefined') { }
などでブラウザかNodeを判定します。
この場合はService
を含めて、今の段階からNgModule
として外部公開でき、
別の個所でAngularのAPIが変わらない限り後方互換を維持できます。
コード例:
@Injectable
class NotificationService {
constructor() {}
requestPermission() {
if (this.isBrowser && this.isPermitted) {
window.Notification && Notification.requestPermission();
}
}
get isBrowser(): boolean {
return typeof window !== 'undefined';
}
get isPermitted(): boolean {
return window.Notification && Notification.permission === 'granted';
}
}
Angular Universalに依存する形で判定
Angular Universalには判定用の値が含まれているので以下のようにimportすれば良いです。
import {isBrowser, isNode} from 'angular2-universal/browser'
import {isBrowser, isNode} from 'angular2-universal/node'
どちらからimportするのかに関してはAngular 2 Universal Starterの
src/{browser.module.ts, node.module.ts}
を見てもらえばわかるように、ブラウザとNodeそれぞれのMainModule
を作るのでそこでDIすることになります。
そしてService
などではコンストラクタでDIして値を利用できます。
この場合はNgModule
として、一部を外部公開することなくアプリケーションに閉じる場合に有効です。
(今後AngularのCoreにUniversalが含まれるようになるのであれば、こちらで記述しても外部公開はできますが後方互換はできないと思われます。)
コード例:
@Injectable
class NotificationService {
constructor(@Inject('isBrowser') private isBrowser) {}
requestPermission() {
if (this.isBrowser && this.isPermitted) {
window.Notification && Notification.requestPermission();
}
}
get isPermitted(): boolean {
return window.Notification && Notification.permission === 'granted';
}
}
ポイント2.2: Angular 2モジュールの扱い
ポイント2.1に近いですが、こちらはnpm
で提供されているモジュールを使う話です。
npm
にはangular2-
やng2-
というプレフィックスで様々なモジュールが提供されています。
しかし殆どがUniversalを意識していないため、実際に使おうとすると以下のエラーに遭遇することが多々あります。
ReferenceError: window is not defined
Note
これに関して、ポイント2.1にもあったisBrowser, isNode
の値をDIし、テンプレート内やComponent
などで使い回避できる場合もあります。
しかし回避できない場合もあるので、自分で直してPRということもあるかと思います。
ただし判定し出し分けするということはSSRできないものが増えるため、初期表示が遅くなる欠点があります。
今回は気にしないことにしましたが、実際のアプリケーションでは何か対応する必要が出てくると思います。
ポイント3: CSSの扱い
Angular 2 Universal Starterではwebpack.config.ts
を見てもらえば分かるように、ブラウザ・NodeともにCSSをraw-loader
で出力しています。
そのため読み込み時に2重でCSSが出力されてしまい、CSSフレームワークを使う場合やサービスで大きなCSSになると余計に通信量がかかってしまいます。
またAngular 4では修正されていると思いますが、AppComponent
でencapsulation: ViewEncapsulation.None
として
グローバルなスタイルを当てようとした場合に開発時は問題なし、しかしAOTビルドをした場合にビルドは通るものの実行時にエラーになります。
Note
この問題に対しては、グローバルにしたいスタイルはWebpackのextract-text-webpack-pluginで結合したCSSファイルにするという手段を取りました。
そしてページに紐づくCSSは別途raw-loader
で出力するという形にしました。
実装例は以下のコミットで確認できます。
https://github.com/nana4gonta/universal-starter/commit/7fc3fdd3fdc5ee041e5535af2863c4442804a955
ポイント4: Express側の非同期処理
今回のコードではExpress側はAngularのSSRだけでなく、MySQLや別のAPIから取得したデータを返すAPIサーバの役割もあったため非同期処理が必要でした。
Angular 4が出る前でTypeScriptは2.0系なので、非同期処理の方法としては以下が考えられました。
- Promise
- co + Generator
- (Angularの依存関係にある)RxJS
Note
Express側でしか使わないため、依存を増やさないようにとRxJSで実装を進めましたが悪手でした。
MySQLからデータを引く・挿入するなどあまりRxの恩恵を得られず、逆に不慣れなため汚いコードになってしまいました。
この点に関しては、Promiseでも同様になると思われるので、co + Generator
または、Angular 4であればasync/await
でいい感じのコードになるのではと思います。
適材適所が大事でした。
まとめ
自分が試した際にはAngular 2.0系 + Angular Universal RCという環境であったため
- TypeScript 2.1で追加された機能の恩恵にあやかれない
- Angular 2.2, 2.3, 2.4の恩恵にあやかれない
という二重苦と今まで上げた4つのポイントで時間を食う羽目になりましたが、なかなか楽しい開発ができました。
Angular 4の対応を行う前にソースコードが公開されると思いますが、次のためにもAngular 4に対応するだけでなくUniversalな綺麗なコードを目指したいと思います。