コード分割(Code Splitting)を簡単に実装できるgrow-loaderを作った話

はじめに

こんにちは、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マンガでの解決方法

上記の問題の対策を検討した結果、以下の結論に至りました。

  1. 各ページのプレイホルダーコンポーネントを切り出さなければ、スムーズさは維持できる。
  2. プレイスホルダー以外のパーツを切り出せば、長いページでも2段階表示のおかげでDOMの初回反映は早くなる。
  3. 手動で分割するのが無理ならwebpackローダーを作れば自動的に分割できそう。

こうして、grow-loaderが生まれました。

grow-loaderとは

grow-loaderを使えば、デコレータの@growを付けるだけでメソッドをまとめて動的にインポートできます。

以下のクラスを例に説明します。

class SampleClass {

    @grow
    methodToGrow() {
        // ...
    }

    @grow
    methodToGrowAndBind = () => {
        // ...
    }

    methodToBeBundled(){

    }
}

grow-loaderを組み込むと、このクラスは以下のように処理されます。

  1. @growが付いている2つのメソッド、methodToGrowmethodToGrowAndBindが切り出されます。
  2. 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、今日の天気は?』を理解させることの面白さ」です。お楽しみに。

Related Post