main_visual

Webのパフォーマンスを改善するテクニックとしてよく使われるlazyloadですが、一口にlazyloadといっても、その仕組みを解剖すると種類や実装方法は様々でした。今回はlazyloadを広義の『遅延読込』と捉えいくつかの視点から分類してまとめ、仕様に応じた実装方法について紹介します。

と言っても一般論ではなくあくまで持論なので、そこはご容赦ください🙆
(タイトルも盛りましたがご容赦ください🙅)

lazyloadの対象

よくあるのはFirstViewに含まれない画像の読込を遅延させる手法ですが、lazyloadを適用する対象は他にもあります。大きく分けて以下の3つかなと思います。

  1. 画像の読込とそのレンダリングを遅らせる
  2. jsonなどのデータの取得とそれを用いた要素のレンダリングを遅らせる
  3. レンダリングのみ遅らせる

②はTwitterなどのタイムライン系アプリ(要素を半永久的に読み込むもの)によく見られます。一定量スクロールすると後続要素を取得し描画する、アレです。

対して、読み込む総量が決まっているページでは、最初に全データを一括で取得するもののレンダリングは見えている範囲のみ行う、という③の手法が使われるケースがあります。VirtualRenderingをこの手法で実装するケースもあります(たぶん)。

lazyloadのトリガー

  1. 一定量のスクロールをトリガーとするScroll方式
  2. scriptの読込をトリガーとするasync/defer方式
  3. FirstViewの描画完了時をトリガーとするMount方式
  4. 一定時間が経過したことをトリガーとするTimer方式

※適当に名前つけました。

①の『スクロール量をトリガーにする』ことが一般的に多いと思います。
が、広義の遅延読込の例として他のものも挙げました。async/deferを使ったスクリプトの遅延読込であったり(非同期なので結果的に後追いになることがあるって程度なので遅延というには微妙ですが)、ReactのcomponentDidMountなどを使いFirstViewのマウント後に追加の読込をさせる手法(Mount方式)であったり、requestIdleCallbackなどTimer系functionを使って遅延させる手法(Timer方式)などが考えられます。

loadイベントのObserver

たとえばスクロールをトリガーにlazyloadを行う場合、「どの要素のスクロールを」「だれが監視するのか」を考える必要があります。SVO構文ですね(主語/動詞/目的語のアレ)。

今回は下図のように、スクロールする要素をScrollView、遅延読込される要素たちをLazyloadComponentと呼び、イベントの関係をまとめます。

scrollview_lazyloadcomponent

1. LazyloadComponent各々がScrollViewのスクロールイベントを監視する

この場合、LazyloadComponentはあらかじめ高さを確保したPlaceholderを描画しておき、ScrollViewのスクロールが一定のところまできたら自身の読込をし、Placeholderと実データを入れ替えます。

2. ScrollViewが自身のスクロールイベントを監視する

この場合は、ScrollViewは自身のスクロールが一定量になったら一定数のLazyloadComponentを読込みレンダリングします。そのため、ScrollViewはLazyloadComponentのリストを保持しておいて、「どれが読込済みか」「次はどの○件を読込むか」を把握しておく必要があります。

3. 第三者(LazyLoadHandler等)がScrollViewのスクロールイベントを監視する

②の方法から、スクロールするDOMと、それをハンドリングするfunctionを分離したパターンです。画面内に複数のlazyload要素がある場合、handlerを分離してモジュール化しておくと取り回しやすくなります。

LazyloadComponentの形式

遅延読込される要素(=LazyloadComponent)を予め空Divなどで読み込んでおくかどうか、等のパターンです。

  1. 遅延読み込みされる部分と同じHeight・WidthのPlaceholderをDivなどで予め描画しておく
  2. 遅延読み込みされる部分を空のDivなどで予め描画しておく
  3. 遅延読み込みされる部分は読み込まれるまで何も描画しない

タイムライン系のアプリでは③の手法が多いですが、AmazonやZOZOTOWNのような商品リスト系のアプリでは①の手法も見受けられます。

ここまでのまとめ

lazyloadを①対象②トリガー③Observer④load対象の形式、の4つの視点から分類しました。これをいかに組み合わせるかは仕様や状況によって異なると思います。

以降は、スクロールをトリガーにデータ取得とレンダリングを遅延させる仕組みについて3つの仕様例を挙げ、どのような実装ができるかををまとめてみます。サンプルのソースはReactを使って書きます。

事例1 lazyloadする要素のサイズが決まっている場合

LazyloadComponentのHeight・Widthが一定の場合はPlaceholderを事前にレンダリングしておき、各々のPlaceholderが自身のloadタイミングを管理するのがいいと思います。

LazyloadComponent.js

class LazyloadComponent extends React.Component {
  
  /** いろいろ省略、大事な部分だけ書きます */
 
  componentDidMount() {
    this.triggerHeight = 300;
    window.addEventListener('scroll', () => { this.onScroll(); }, { passive: true });
  }
  componentWillUnmount() {
    window.removeEventListener('scroll', () => { this.onScroll(); }, { passive: true });
  }
  onScroll() {
    if (window.pageYOffset > this.triggerHeight) {
      this.lazyload().then(() => {
        this.setState({ isLoaded: true });
      });
    }
  }
  render() {
    return this.state.isLoaded ? this.props.children :
                 <div style={{ height: this.props.height }}></div>
  }
}

Container.js

class Container extends React.Component {
  render() {
    return (
      this.state.items.map((each) => {
        return (
          <LazyloadComponent height="200" >
            <Content item={each} />
          </LazyloadComponent>
        );
      });
    );
  }
}

雰囲気はこんな感じです。load処理だったり、読み込んだデータをStoreする処理だったりは省略です。注目してほしいのは、LazyloadComponent#renderが、未ロード時は高さを確保したPlaceholderをレンダリングし、ロード後にはthis.props.childrenをレンダリングしている点です。

こうすることで遅延読込の処理をLazyloadComponent内で完結させることができ、外部に依存・影響が出ない実装をすることができます。removeEventListenerまできちんとComponent内で行っているので、使い回ししやすいと思います。
実際に親要素であるContainerでは特別なことをせずにLazyloadComponentを必要な分だけ描画しています。

事例2 lazyloadする要素のサイズが決まっていて、スクロールするのがwindow以外の場合

ケース1ではLazyloadComponentをPlaceholderとして予めレンダリングしておき、windowのスクロールイベントをハンドリングさせました。しかしスクロールする要素がwindow以外である場合、LazyloadComponentはどうやって親要素のスクロールイベントをハンドリングすれば良いでしょうか。

Reactは入れ子になっている子要素から順にマウントされるため、LazyloadComponent#componentDidMountが呼ばれるタイミングでは、スクロールする親要素を取得できずイベントハンドリングができません。

この場合は、親要素からprops経由でスクロール量を渡す方法が使えます(手段のひとつであり、別の方法もあります。あらゆる場面での最適解ではありません)。スクロールする親要素をScrollViewとして実装してみます。

ScrollView.js

class ScrollView extends React.Component {
  onScroll(event) {
    this.setState( { scrollTop: event.currentTarget.scrollTop });
  }
  render() {
    return (
      <div onScroll={this.onScroll} >
      {this.state.items.map((each) => {
        return (
          <LazyloadComponent height="200" scroll={this.state.scrollTop} >
            <Content item={each} />
          </LazyloadComponent>
      );})}
      </div>
    );
  }
}

ScrollView内ではスクロール量をstateとして持っておくことで、これが更新される度にrenderが呼ばれLazyloadComponentへスクロール量を渡すことができます。

あとは、LazyloadComponent#componentWillReceivePropsでloadのトリガーとなるHeightと比較させればOKです。

事例3 lazyloadする要素のサイズが不確定な場合

サイズが不確定な場合、Placeholderを使ってしまうとPlaceholderと実Componentのサイズが合わずレンダリング時にガタつくことがあります。そのため空Divを使うか、レンダリングしないか、いずれかの方法をとる必要があります。結果的にスクロールイベントのハンドリングはScrollView自身(もしくは第三者)にさせることになります。

以下はScrollView自身にイベントハンドリングさせる例です。

ScrollView.js

class ScrollView extends React.Component {
  constructor() {
    this.loadedPages = 1;       // 初期読込で1ページ分は読み込んでいる
    this.triggerHeight = 1000;  // 1000pxごとにlazyloadを行う
  }
  onScroll(event) {
    if (event.currentTarget.scrollTop > triggerHeight * isLoadedPage) {
      this.lazyLoad().then(() => {
        this.loadedPages++;
      });
    }
  }
  render() {
    return(
      <div onScroll={this.onScroll} >
        {this.state.items.map((each) => {
          return <Content item={each} />;
        })}
      </div>
    );
  }
}

ScrollView#lazyloadが呼ばれると追加読込分がStoreされ、そこから取得されるthis.state.itemsの件数が増えることでlazyloadが実現される仕組みです。

ScrollView#lazyloadの関数内では、既にどれだけのデータが読み込まれているか、追加で何件読み込むのか、といったpager的な処理をすることが必要になってきます。今回はそれを簡易的にloadedPagesという値で表現しました。

おわりに

lazyloadについて分類分けしたり仕様に応じた使い方の紹介をしましたが、もちろんこれらが全てではありませんし、自前で実装するよりもライブラリを使う方が良いケースもあると思います。そこはご理解を。

また、スクロールイベントの取扱いは注意しないとカクつきなどの原因となります。ご利用の際はthrottle等の併用をお忘れなく、また用法用量を守ってご利用ください。