Edited at

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


react-virtualized とは?


  • 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を散々読み漁ることになるわけですが、かなりつらいので、タイムライン型のコンポーネントを作るにあたって自分が得た知見を以下に記します。


本記事のexample


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

https://codesandbox.io/embed/github/1natsu172/react-virtualized_twitter_timeline-example/tree/master/

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

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


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


  • InfiniteLoader

  • WindowScroller

  • AutoSizer

  • List

  • CellMeasurer


    • CellMeasurerCache



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


*注意書き*

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

またコード片は長くなるので一部のみにしたり必須のpropを省略したりしています。

完全な動くコードは上記CodeSandboxのデモのコードを参照してください

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

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


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


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


Answer: ListautoHeightのPropsをTrueにする

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


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


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


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


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



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


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


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を使うとラクなれる気がする。

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

)1


InfiniteLoaderloadMoreRows が発火しない


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


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

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

  _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 が実行される

となる。

余談として、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表示は消したい…


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

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

つまり



  • 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インジケーターは消えて無限スクロールできないようになっている。


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


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


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

実はCSSが絡んでいる…

公式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のサイズになるためこの親の高さ確保は不要




その他知見


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 も視野にいれて検索すると解決したりする(かもしれない)


参考になるURLなど


以上です

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

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

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





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



  • WindowScroller + List してるのにwindowのスクロールにならない
  • Answer: ListのautoHeightのPropsをTrueにする
  • WindowScroller + Listでスクロール系のPropsが効かない
  • Answer: WindowScrollerからscrollTop, isScrollingなどのpropsをListコンポーネントにわたす
  • CellMeasurer の内側で画像が読み込まれたときにrowの高さが再度変わるがどうすればよい?
  • Answer: List の deferredMeasurementCache に CellMeasurerCache をわたしつつ、 function as childに measure が生えてるのでそれを <img onLoad={} /> 時に発火させる
  • InfiniteLoader の loadMoreRows が発火しない
  • Answer: リストアイテム数と rowCount の指定数が同じだとNG
  • なぜ +1 するのか?
  • loadMoreRows中はLoadingインジケーターを表示したい
  • Answer: isLoading フラグと hasMore フラグを立てて rowCount を調整する
  • AutoSizer 使ってるけどheightの計算が効かない
  • Answer: 親要素にheightを確保する必要がある
  • その他知見 :point_up_tone1:
  • 以上です