宿泊事業本部の宇都宮です。
一休.com スマホサイトのホテルページパフォーマンス改善プロジェクトでは、フロントエンドには以下のような要件がありました。
- デザイン面は既存を踏襲する
- 機能はほぼ従来通り
- 日付等を変更した際の再検索は、画面遷移を挟まず、画面内で行えるようにする
- パフォーマンスをできるだけ改善する
要するに、従来と同様の機能+αを実現し、かつ、従来と同等以上のパフォーマンスを実現する、というミッションです。
このために、どのような取り組みを行ったか、紹介します。
パフォーマンス目標値の設定
まず、パフォーマンスの目標値を設定する必要があります。モバイルでは、ユーザの帯域幅は回線や時間帯によって大きな変動があります。多少回線状況が悪くても、閲覧を妨げない程度のパフォーマンスを実現する必要があります。
一休へアクセスするユーザのモニタリングを見ると、極端に遅い回線を使っているユーザは少ないことから、回線速度のベースラインは1.4Mbpsとしました(この数字はChrome DevToolsのFast 3Gの回線速度なので、シミュレートしやすく、開発上都合が良いという理由もあります)。また、ロードが完了し、ユーザーが操作できるようになるタイミング(TTI, Time To Interactive)までの所要時間は5秒以内を目標とします。
計測とボトルネックの把握
次に、現行サイトのパフォーマンスの計測を行いました。詳細な計測結果については、概要編をご覧ください。
計測の結果、現在のパフォーマンスは目標値(3G FastでTTI 5秒以内)に届いていないことがわかりました。TTIはおよそ15秒で、快適に使えるとはいいがたい状況でした。
また、フロントエンドでは、js/cssによるレンダリングブロックが大きなボトルネックになっていることがわかりました。
レンダリングブロックの解消
レンダリングブロックとは、ブラウザが画面の描画を開始するまでに読み込みの必要なリソースがあるせいで、レンダリングを始められない現象です。具体的には、headタグ内でjsやcssを読み込んでいる際に発生します。レンダリングブロックが発生しているページは、たとえサーバのレスポンスが十分に速くても、画面が表示されるのは遅くなってしまいます。
JavaScriptのレンダリングブロック解消のための手段は簡単で、全てのJavaScriptをbodyタグの末尾で読み込むことです。しかし、既存コードでは、headタグでライブラリを読み込んでいたり、bodyの途中にscriptタグを書いていたりして、レンダリングブロックが発生していました。そこで、全てのJSコードを見直し、bodyタグの末尾で読み込む単一のjsファイルが全ての処理の起点になるよう改めました。バグを作り込まないよう慎重に行う必要はありますが、それほど難しい作業ではありません。
これによってレンダリングブロックの解消ができました。めでたしめでたし…といいたいところですが、これだけでは十分なパフォーマンス改善は実現できませんでした。
リソースの削減
前述したように、帯域幅のベースラインは1.4Mbpsとしました。1.4Mbpsの回線で、5秒以内に操作可能にするためには、Webページの初期ロード時に読み込む全リソースの合計を、875KB(1.4Mbps(=175KB/s) * 5s)に収める必要があります。この数字はJavaScriptの実行時間等を考慮しない、理論上の上限値であり、実際にはこれより減らす必要があります。
では、一休の現行ホテルページはどうかというと、約1MBのリソースを読み込んでいました。これでは、どう頑張ってもパフォーマンスの目標値を達成することはできません。そこで、全リソースを合計したサイズの上限を、ひとまず700KBに設定します。
次に、Webページにおいて最も「重い」リソースであるJavaScriptについて。JavaScriptは、ダウンロードだけでなく、実行の時間もかかるため、極力サイズを減らす必要があります。一休の現行ホテルページでは400KBものJavaScriptを読み込んでいましたが、これは300KBに抑えることを目標にしました。 まとめると、
- 全体で700KB
- JSは300KB
を上限として、読み込むリソースの削減を目指しました。
JavaScriptの最適化
初期ロード時のJavaScriptの最適化には、2つのポイントがあります。1つはjsファイルの削減、もう1つは実行時間の削減です。
ここでは、主にビルド周り(webpack, Babel等)の最適化と、Vue.jsアプリケーションの最適化を行いました。
JSバンドルサイズの最適化
JavaScriptコードを削減するには、不要なコードの読み込みを減らす必要があります。一休.comでは、JavaScriptはwebpackでバンドルしています。従来のwebpackビルド設定は、バンドルの粒度が大きめで、不要なコードを読み込みがちという問題がありました。そこで、ページ毎にバンドルを作成するようにしました。これによって、不要コードを大幅に削減することができました。
また、後述しますが、dynamic importを使ってVueコンポーネントを動的に読み込むことで、初期ロード時のバンドルサイズを削減しています。
Babel設定の最適化
先日、Babel 7の正式版がリリースされました。Babel 7を導入することで、若干ですが、ビルドサイズの削減が見込めます。ホテルページの場合、productionビルド後のjsファイルのサイズが584KB => 549KBに減りました。
また、一休.comでは、IE 11でもPromise等のES2015以上で利用可能な機能を使えるようにするため、 @babel/polyfill を使っています。最新ブラウザをターゲットにするなら、polyfillを減らせます。そこで、モバイルOSのみをターゲットにビルドして、ビルド後のサイズがどの程度になるか確認しました。結果、ターゲットをiOS >= 9にすると544KB、iOS >= 10にすると518KBといった結果になりました(ちなみに、最新のChromeだけをターゲットにすると507KBまで減らせます)。
iOS 9系のユーザ数はかなり少なくなっていて、近い将来、ターゲットはiOS >= 10になることが予想されます。そこで、PCとモバイルでbabelのpresetを分け、別々にビルドするようにしました。
Vue.jsのランタイム限定ビルドを使う
Vue.jsには完全ビルドとランタイム限定ビルドがあります。ランタイム限定ビルドはVueコンポーネントのコンパイラを含まないため、30%ほどサイズが小さくなります。vue-cliを使えばデフォルトでランタイム限定ビルドが使われますが、webpackのconfigを手作りしている場合には完全ビルドを参照していることがあるので、設定を見直してみると良いと思います。 https://jp.vuejs.org/v2/guide/installation.html#%E3%81%95%E3%81%BE%E3%81%96%E3%81%BE%E3%81%AA%E3%83%93%E3%83%AB%E3%83%89%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6
遅延レンダリングによるJavaScript実行時間の削減
Vue.jsでは、条件付きレンダリングを行うためのディレクティブとして、 v-show
と v-if
が用意されています。この2つの使い分けは、ドキュメントには以下のように記載されています。
一般的に、v-if はより高い切り替えコストを持っているのに対して、 v-show はより高い初期描画コストを持っています。 そのため、とても頻繁に何かを切り替える必要があれば v-show を選び、条件が実行時に変更することがほとんどない場合は、v-if を選びます。 https://jp.vuejs.org/v2/guide/conditional.html#v-if-vs-v-show
どちらもそれほど変わりがないように思えますが、大量のコンポーネントを条件付きレンダリングする際には、どちらを使うかはとても重要です。私は、条件付きレンダリングでは原則として v-if
を使うべきだと考えています。なぜなら、v-ifを使うと、その要素は遅延レンダリングされるからです。
// SomeComponent のコードは実行されない <some-component v-if="false" /> // SomeComponent のコードは実行され、描画も行われるが、非表示になる <some-component v-show="false" />
- デフォルトで非表示の要素 => v-ifで遅延レンダリングする
- デフォルトで表示する要素 => 原則v-ifを使うが、v-showでもよい
といった具合で使い分けています。また、v-ifは次節で解説する非同期コンポーネントと組み合わせできる点でも、パフォーマンス上有利です。
非同期コンポーネント
ECMAScriptのモジュールシステムには、静的なimport(import 'module'
)と、動的なimport(import('module')
)があります。後者の動的なimport(dynamic import)を使うと、必要になったタイミングでjsコードを取得できます。
特に、複雑なVueコンポーネントはサイズが大きくなりがちなため、dynamic importで動的にコードを読み込むようにすると、初期ロード時に必要なjsの量を大きく減らすことができます。
Vue.jsでは、以下のように、コンポーネント登録時にimport()を呼び出す関数を使用することで、コンポーネントをdynamic importできます。
// グローバル登録の場合 Vue.component('RoomDetail', () => import('./RoomDetail.vue')); // ローカル登録の場合 export default { name: 'RoomList', components: { RoomDetail: () => import('./RoomDetail.vue'), } };
このように、dynamic importされるVueコンポーネントのことを、Vue.jsのドキュメントでは非同期コンポーネント(async component)と呼んでいます。
なお、dynamic importは通信のオーバーヘッドが発生する分、静的なimportよりも遅いです。dynamic importの対象は、初期描画には不要なコンポーネントのみにすべきです。
また、v-ifでレンダリングしない状態になっているコンポーネントは、フラグがtrueになった段階でdynamic importされます。以下のように、初期状態では非表示で、フラグによってレンダリングされるコンポーネントがある場合、dynamic importを使用した遅延読み込みを検討すべきです。
<button type="button" @click="flag = true">Click me</button> <!-- SomeComponentは非同期コンポーネントにできる --> <some-component v-if="flag" /> <!-- このdivはコンポーネント化すれば非同期コンポーネントにできる --> <div v-if="flag"> ... </div>
Vueコンポーネント設計のアンチパターン
Vueコンポーネントは、ビルド後のサイズはそれほど小さくありません。シンプルなコンポーネントでもminify後で1KBほどになります。たとえば、ボタンやアイコン等、見た目が異なるだけのコンポーネントを細かくコンポーネント化すると、ビルド後のjsファイルのサイズが膨らんでしまいます。パフォーマンスの観点からすると、細かいVueコンポーネントを大量に作るのは避けるべきです。
ライブラリの削減
不要なライブラリを削除するのはもちろんですが、それだけでなく、ライブラリの使用量自体を抑えることを考えるべきです。今回のプロジェクトでは、jquery-migrateを削除することはできましたが、jQueryを依存関係から取り除くには至りませんでした。 jQueryは軽量なライブラリではないため、パフォーマンスの観点からは削除したいライブラリの1つです。一方で、その便利さから至るところで使われているため、簡単に依存を取り除くことはできませんでした。
改善結果
概要編にもいくつか結果が載っていますが、ここでは転送量に着目するためWebPageTestの結果を掲載します。
Before
After
転送量(Fully Loaded > Bytes in)が300KB近く減り、Speed Indexは1000以上改善しました。
パフォーマンス監視の導入
パフォーマンスは放っておくと劣化していくので、Webサイトの速度を維持し続ける仕組みが必要です。ここでは、パフォーマンス予算(Performance Budget)の設定と、監視を行っています。具体的には、パフォーマンス監視SaaSのCalibreを使用し、予算を超過した際にアラートがSlackの開発者向けチャンネルに流れるようにしました。
今後の展望
ホテルページについては一定のパフォーマンス改善を実現することができましたが、サイト全体としてはまだまだ伸びしろがあると考えています。スムーズに宿泊施設を探すには、ページ単体ではなく、検索導線(トップ・リスト・ホテル)の全体的な回遊性が重要です。パフォーマンス改善も含め、引き続きUI/UXの改善を行っていきたいと考えています。
一休では、UI/UXの改善に熱意のあるエンジニアを募集しています!