Help us understand the problem. What is going on with this article?

react-virtualizedでタイムラインコンポーネントを作ろうとしてハマったときの知見

More than 1 year has passed since last update.

react-virtualized とは? :eyeglasses:

  • Reactで大量のリストアイテムを描画するとパフォーマンスが悪くなるという問題があります
  • react-virtualizedはそういうときにパフォーマンス維持するためのライブラリです
  • React公式ドキュメントでも紹介されています

公式デモページ
https://bvaughn.github.io/react-virtualized/

リポジトリ
https://github.com/bvaughn/react-virtualized

作者のプレゼンスライド
https://bvaughn.github.io/forward-js-2017/#/17/0

経緯とか理論とかがわかる


本記事の趣旨

いざreact-virtualizedを使ってみると結構APIが複雑でハマりがたくさんあります。

端的にいうと各コンポーネントの組み合わせパターンが多くドキュメントからは察せない、あるいはIssueやExampleにしか情報がないなどがあり撃沈します。

そこでライブラリのドキュメントとIssueを散々読み漁ることになるわけですが、かなりつらいので、タイムライン型のコンポーネントを作るにあたって自分が得た知見を以下に記します。 :pencil:

本記事のexample :film_frames:

TwitterとかFBのタイムラインみたいなコンポーネント

https://codesandbox.io/embed/github/1natsu172/react-virtualized_twitter_timeline-example/tree/master/
Edit react-virtualized-demo-likes-twitter-timeline

いわゆる無限スクロールのリスト型コンポーネントです。

またDynamicHeight(リストのアイテムとなるRowの高さが固定ではない)というめんどくさい構成です。

使用しているreact-virtualizedコンポーネント

  • InfiniteLoader
  • WindowScroller
  • AutoSizer
  • List
  • CellMeasurer
    • CellMeasurerCache

この構成なのでGridとかTableとかCollectionなど上記構成以外のコンポーネントを組み合わせるときはまた違うハマりがあるだろうなと思います :anger_right:

*注意書き*

執筆時点のreact-virtualizedのバージョンは9.20.1です。

またコード片は長くなるので一部のみにしたり必須のpropを省略したりしています。
完全な動くコードは上記CodeSandboxのデモのコードを参照してください

一部WindowsScrollerを用いる場合は不必要な知見がありますがハマる可能性もあるので一応記載しています。

また文中のRVは"react-virtualized"の略称です。 :information_desk_person:

WindowScroller + List してるのにwindowのスクロールにならない

Question: なぜかリストのコンテナ内がスクロールしてしまう… :anguished:

Answer: ListautoHeightのPropsをTrueにする

<List autoHeight={true} ...{listProps} />

WindowScroller + Listでスクロール系のPropsが効かない

Question: Listのスクロールのpropsが発火しなかったり… :anguished:

Answer: WindowScrollerからscrollTop, isScrollingなどのpropsをListコンポーネントにわたす

...
<WindowScroller>
  {({ height, isScrolling, scrollTop, onChildScroll }) => (
    <AutoSizer disableHeight={true}>
      {({ width }) => (
        <List
          isScrolling={isScrolling} // ← これ
          onScroll={onChildScroll} // ← これ
          scrollTop={scrollTop} // ← これ
          {...ListProps}
        />
      )}
    </AutoSizer>
  )}
</WindowScroller>
...

参考になった WindowScroller + InfiniteLoader + AutoSizer な Plunker :pray:

https://next.plnkr.co/edit/C7UYFmBhYqRbAxC5?preview

CellMeasurer の内側で画像が読み込まれたときにrowの高さが再度変わるがどうすればよい?

Question: 画像読み込み時にrowの高さを再計算する必要がありそうだがどうすれば? :anguished:

Answer: ListdeferredMeasurementCacheCellMeasurerCache をわたしつつ、 function as childに measure が生えてるのでそれを <img onLoad={} /> 時に発火させる

長いし意味わからん。

ようするに

  • ListCellMeasurerCache を渡して
  • 画像のonLoad時に CellMeasurer から生えてる measure メソッドを発火させる
...
<List
  deferredMeasurementCache={this._renderCache} // ← わたす
  rowRenderer={this._renderRow}
  {...ListProps}
/>
...

...
_renderRow = ({ index, key, parent, style }) => (
  <CellMeasurer {...CellMeasurerProps} >
    // ↓ measureがあるので
    {({ measure }) => (
      <div style={style} className="row">
        <Tweet
          measure={measure} // ← わたす
        />
      </div>
    )}
  </CellMeasurer>
)
...

// SFC
const Tweet = ({measure,...rest}) => (
  <div className="tweetImage">
    <img
      src={...}
      onLoad={measure} // ← ここでね
      alt=""
    />
  </div>
)

例ではTweetコンポーネントにバケツリレーしているけれど、measureメソッドを使いたい場所は深いところにあるかもしれないので、React16以降ならContextAPIを使うとラクなれる気がする。 :bulb:

あと deferredMeasurementCacheList のドキュメントに載っていないが、実はこれはRVの Grid コンポーネントのものなのでそっちのドキュメントに載っている…(後述 :speech_balloon:)1

InfiniteLoaderloadMoreRows が発火しない

Question: リストの最後までスクロールしたのに追加読み込みが実行されない… :anguished:

Answer: リストアイテム数と rowCount の指定数が同じだとNG

これはかなりややこしくて、Twitterのタイムラインのようなリストのアイテムの数がいくつになるか不透明なものの場合は、InfiniteLoaderrowCount を『現段階のリストアイテム数+1』としておく。 :up:

  _rowCount = this.state.tweets.length

  render() {
    return (
      <InfiniteLoader
        isRowLoaded={this._isRowLoaded}
        loadMoreRows={this._loadMoreRows}
        rowCount={this._rowCount + 1} // リストアイテム数 + 1
        {...InfiniteProps}
        >
        {({ onRowsRendered, registerChild }) => (
          <List
            rowCount={this._rowCount + 1} // リストアイテム数 + 1
            {...ListProps}
          />
        )}
      </InfiniteLoader>
    )
  }

なぜ +1 するのか?

InfiniteLoaderisRowLoadedfalse が返ってくるとそのrowのindex値を元に loadMoreRows を実行するようになっている。

そこで +1 しておくことで、たとえば現時点のリストアイテムが20件の場合、21件目を読み込もうと試行するようになる。

けれどリストアイテムは20件しかなく当然21件目はないので isRowLoadedfalse が返る

つまり

falseが返る → InfiniteLoader 的には 『追加読み込み分がある』と解釈される → loadMoreRows が実行される

となる。 :ok_hand:

余談として、Twitterタイムラインのようなリストアイテム数が不明な場合はこの +1 戦法を使うけれど、アイテム数が事前にわかっていたりクエリパラメータに次のindexを渡したりする必要があるときは以下の公式ドキュメントにあるように読み込み済みのrowのindexをメモ化したりトラッキングする必要がある。

https://github.com/bvaughn/react-virtualized/blob/master/docs/InfiniteLoader.md#edge-cases-and-considerations

本稿では前提が変わるのでこれ以上は解説しないですが……そういうパターンもあるということです。

loadMoreRows中はLoadingインジケーターを表示したい

Question: 無限スクロールで次のコンテンツのロード中にはLoadingインジケーターを表示したい。読み込むコンテンツがなくなったらLoading表示は消したい… :anguished:

Answer: isLoading フラグと hasMore フラグを立てて rowCount を調整する

前項でいうところの +1 した結果 false が返ってくるrowがある = "追加読み込み分ありと解釈できる" という挙動を利用する。 :point_up_tone1:

つまり

  • isRowLoadedtrue が返ってくるならコンテンツが読み込み済みであると考えられる
    • false が返ってくるならloadingインジケーターを出せばOK
class extends React.Component {
  state = {
   tweets: [],
   isLoading: false,
   hasMore: true
  }

  _isRowLoaded = ({ index }) => !!this.state.tweets[index]

  _infiniteRowCount = () =>
    !this.state.isLoading && this.state.hasMore
      ? this.state.tweets.length + 1
      : this.state.tweets.length

  _listRowCount = () =>
    this.state.hasMore ? this.state.tweets.length + 1 : this.state.tweets.length

  render() {
    return (
      <InfiniteLoader
        isRowLoaded={this._isRowLoaded}
        loadMoreRows={this._loadMoreRows}
        rowCount={this._infiniteRowCount()} // ← これ
        {...InfiniteProps}
        >
        {({ onRowsRendered, registerChild }) => (
          <List
            rowRenderer={this._renderRow}
            rowCount={this._listRowCount()} // ← これ
            {...ListProps}
          />
        )}
      </InfiniteLoader>
    )
  }

  _renderRow = ({ index, key, parent, style }) => (
    <CellMeasurer {...CellMeasurerProps}>
      <div style={style} className="row">
        {
        // falseが返ってくる => +1した余分なrow
        this._isRowLoaded({ index }) ? (
          <Tweet {...tweet} />
        ) : (
          <div>loading...</div> // ← インジケーター
        )}
      </div>
    </CellMeasurer>
  )
}

hasMore フラグによって +1 するかしないかを調整している。+1 しなければInfiniteLoaderloadMoreRows は発火しないし、Listに余分なRowも表示されないので目的通りの挙動になる。

CodeSandboxのデモでは80件まで読み込むと hasMorefalse になるようにしてあるので、Loadingインジケーターは消えて無限スクロールできないようになっている。 :shield:

AutoSizer 使ってるけどheightの計算が効かない

Question: コンテナのheightが0になってなにも描画されない… :anguished:

Answer: 親要素にheightを確保する必要がある

実はCSSが絡んでいる… :scream_cat:

公式DocsにFAQがあり言及されているが見つけづらい。

https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#why-is-my-autosizer-setting-a-height-of-0

RVの親要素にheightの指定をする、あるいはflex:1の指定をするとかして高さを確保する。

<div style={{ display: 'flex' }}>
  <div style={{ 
    flex: '1 1 auto',
    // or
    height: 600px;
   }}>
    <AutoSizer>
      {({ height, width }) => (
        <List {...ListProps} />
      )}
    </AutoSizer>
  </div>
</div>

なお WindowScroller を利用する場合は高さはwindowのサイズになるためこの親の高さ確保は不要


その他知見 :point_up_tone1:

List のアレコレは Grid コンポーネントのアレコレだったりする

RVのドキュメントやexampleコードとかを読んでいると一見 List のpropsに関係ないものが出てきたりする。

これはどういうことなのかというと List コンポーネントは Grid コンポーネントを内部で利用してrowの描画を行っているからで、propsはすべて Grid コンポーネントへ渡されている。

List のドキュメントの最初の行で実は言及がされている

https://github.com/bvaughn/react-virtualized/blob/master/docs/List.md

This component renders a windowed list (rows) of elements. It uses a Grid internally to render the rows and all props are relayed to that inner Grid. That means that List also accepts Grid props in addition to the props shown below.

なのでもし List を使っていてなにかあれば Grid の問題だったりする。List についてissueやStackOverFlowで検索するときも Grid も視野にいれて検索すると解決したりする(かもしれない) :cat2:

参考になるURLなど :ideograph_advantage:

以上です

とにかく頑張ってハマりから抜け出す必要があります… :anguished:

本稿はRVを使ってTwitter/FBライクな無限スクロールのリストを作るという目的に沿ったハマり集ですが、他の形式のRVを用いたコンポーネント作成の際にもなにか参考になればと思います。

DynamicHeightなInfiniteLoader + WindowScroller + ……etc という本構成のような動くデモは自分が探した限り見つからなかったので同様のものを考えている方の参考にもなれば幸いです :green_heart:


  1. List のアレコレは Grid コンポーネントのアレコレだったりする 

cureapp
「治療アプリ」という診療現場における新しい疾患治療ツールを開発するプログラム医療機器ベンチャーです。
https://cureapp.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした