はじめに
こんにちは、LINEマンガJavaScript担当の@sunderlsです。
これはLINE Advent Calendar 2017の22日目の記事です。今日は、webpackローダーのgrow-loaderを紹介します。
LINE Engineering Blogの記事「LINEマンガ:Page Stackを使ってサクサクなページ遷移を実現できました」でご紹介しましたが、LINEマンガはWeb技術で実装されています。
Webでネイティブアプリに近いユーザー体験を提供するため、いろいろ工夫しています。今回は、数ある工夫の中からコード分割の実装を紹介したいと思います。
なぜコードを分割するのか
LINEマンガでは、JavaScriptのソースコードをもともと1つのファイルにバンドルしていました。しかしサービスが成長するにつれて、ページ数が増え仕様も複雑になり、バンドル後のファイルサイズがどんどん大きくなってきました。将来を考えると1つにバンドルするのはやはり望ましくないと考え、コード分割の実装を始めました。
一般的な実装
最初はreact-loadableのようなHOC(Higher Order Component)の方法を試しましたが、問題がいくつかありました。
1. ページ遷移を即時に実行できなくなった
原因は明白です。ユーザーがインタラクトしてからコンポーネントがロードされ始めるので、遷移が始まる前に遅延を感じます。マンガの場合これは致命的な問題になります。
2. プリロードすればある程度改善できるが、長いページではどうしても遅いと感じる
ユーザーインタラクションが発生する前に遷移先のコンポーネントを事前にロードしておけば、おおよその遅延は改善できます。しかし、LINEマンガの場合ページが多く、各ページでプリロードのコードを書くのはとても大変で、実際には遷移しないページをロードするのも非効率だと感じていました。
また、長いページはプリロードしてもDOMの生成時間が変わらず、遅延をなくすことはできません。
3. 共通のローディングインジケーターを作れば即時性は保たれるが、サービスに合わない
LINEマンガではプレイスホルダーコンポーネントを各ページに適用しているため、共通のローディングインジケーターは使用していません。プレイスホルダーコンポーネントはHOC形式ではなく、各ページコンポーネントでダミーデータを使って実装しているため、簡単に分割したり流用したりできません。
4. 独自に作ったコンポーネントフックとの相性が悪い
即時のページ遷移を実現するため、LINEマンガではPage Stackを実装してcomponentDidHideなどの独自フックを追加しました。HOC形式でコードを分割しようとするなら、独自フックを維持するために、Routerの定義を含めいろいろなコードを変えないといけません。頑張ってやってみましたが、変更する箇所が多くて断念しました。
LINEマンガではスムーズさを一番大事にしています。これだけは妥協できません。
LINEマンガでの解決方法
上記の問題の対策を検討した結果、以下の結論に至りました。
- 各ページのプレイホルダーコンポーネントを切り出さなければ、スムーズさは維持できる。
- プレイスホルダー以外のパーツを切り出せば、長いページでも2段階表示のおかげでDOMの初回反映は早くなる。
- 手動で分割するのが無理ならwebpackローダーを作れば自動的に分割できそう。
こうして、grow-loaderが生まれました。
grow-loaderとは
grow-loaderを使えば、デコレータの@grow
を付けるだけでメソッドをまとめて動的にインポートできます。
以下のクラスを例に説明します。
class SampleClass {
@grow
methodToGrow() {
// ...
}
@grow
methodToGrowAndBind = () => {
// ...
}
methodToBeBundled(){
}
}
grow-loaderを組み込むと、このクラスは以下のように処理されます。
@grow
が付いている2つのメソッド、methodToGrow
とmethodToGrowAndBind
が切り出されます。grow()
が追加されます。このメソッドを実行すると、切り出されたメソッドが一度にロードされます。
以下のように使います。
const sample = new SampleClass();
console.assert(a.methodToGrow === undefined);
console.assert(a.methodToGrowAndBind === undefined);
sample.grow().then(() => {
sample.methodToGrow();
sample.methodToGrowAndBind();
});
Reactでの使い方
上記の例を参考にすると、Reactコンポーネントにも簡単に対応できます。
たとえば下記のコンポーネントを分割するとします。
export default class LongPage extends React.Component {
methodToGrow() {
// ..
}
methodToGrowAndBind = () => {
// ..
}
methodToBeBundled(){
// ...
}
render(){
return <div>
this is a very long page
</div>
}
}
まず、共通のベースコンポーネントを用意しましょう。
class GrowablePage extends React.Component {
// componentDidMountの時点でロードを始める。
componentDidMount() {
if (this.grow) {
this.grow().then(() => {
this.hasGrown = true;
this.forceUpdate();
});
}
}
}
後は適当にrender()
を分割して@grow
を付けるだけです。
export default class LongPage extends GrowablePage {
@grow
methodToGrow() {
// ...
}
@grow
methodToGrowAndBind = () => {
// ...
}
methodToBeBundled(){
// ...
}
@grow
renderMore() {
return <div>
this is below the first view
</div>
}
render(){
return <div>
this is basic part
{ this.hasGrown ? this.renderMore() : null}
</div>
}
}
grow-loaderを使った結果
grow-loaderはソース変換ツールに過ぎないので、ファイルサイズをどのぐらい小さくできるかは使い方次第です。上記の例からもわかりますが、render()
のコードをさらにrenderMore()
に移すとか@grow
をもっと付けるとかすると、ファイルサイズをさらに小さくできます。
LINEマンガの場合は、grow-loaderを利用した結果、エントリポイントのファイルサイズが15%減りました。一般的なHOCの方法には勝てませんが、その代わりにトップページのマウント時間(constructor()
からcomponentDidMount()
までの時間)は40%減ったので、なかなかに役立っていると思います。
終わりに
grow-loaderはGitHubで公開されています。ぜひ使ってみてください。バグやご意見について、気軽にissueを作成してもらえると嬉しいです。
明日は佐藤敏紀さんの「AIに『Clova、今日の天気は?』を理解させることの面白さ」です。お楽しみに。