react-virtualizedで膨大なリストの表示と状態復元

React(web)で膨大な数が見込まれるリスト表示にはreact-virtualized使っていくのがベターっぽいのでメモ。
virtualScroll実装は普通にjQueryやスタンドアローンのライブラリでもありそうなので、DOMをめっちゃappendしていくような処理があれば導入を検討してもいいかも。

用途

  • 無限スクロール実装等の初期レンダリング負荷削減
  • SPA時のリストDOM復元/スクロール位置復元の処理高速化
  • virtualDOMの差分計算の簡略化

要するにブラウザに優しい。

コンテナとなる親divの高さを動的に拡張していく実装の備忘録。

import { List  , AutoSizer , WindowScroller} from 'react-virtualized';
class HugeList extends React.Component<{dataList : Array<{}>} , {}>{
    constructor(props) {
        super(props);
    }

    noRowsRenderer(){
        return <div>noRow</div>
    }

    renderRow({style , index}) {
        return (
            <div style={style} key={index}>
                リストの一行
            </div>
        );
    }

    render() {

        return (
            <div >
                <WindowScroller
                    scrollElement={null}
                >
                    {({ height, isScrolling, onChildScroll, scrollTop }) => {
                        return (
                            <AutoSizer disableHeight>
                                {({ _height, width }) => (
                                    <List
                                        autoHeight
                                        width={width}
                                        height={height}
                                        rowCount={this.props.dataList.length}
                                        rowHeight={160}
                                        isScrolling={isScrolling}
                                        onScroll={onChildScroll}
                                        scrollTop={scrollTop}
                                        noRowsRenderer={this.noRowsRenderer.bind(this)}
                                        overscanRowCount={2}
                                        rowRenderer={this.renderRow.bind(this)}
                                    />
                                )}
                            </AutoSizer>
                        );
                    }}
                </WindowScroller>
            </div>
        );
    }
}

rowHeightに固定のサイズを与えているが、行ごとに動的に当ててもイケる。

rowHeight={(item , index)=> /* itemに応じたheight設定をreturn */ }

リストだけでなく、Masonryレイアウトも対応しているっぽい。

SPA時の位置復元例(高階関数で)

各URLのエンドポイントとなるコンテナに刺すHoC。
EndpointHoc.tsx


const instanceHistory : Array<string>= []; const savedInstanceMemory : {[key:string] : InstanceState<any>} = {} const EndpointHoc : any = (WrappedComponent : any )=> { return class extends React.PureComponent<RouteComponentProps<{}> , any >{ childRef; action : string; savedInstanceState : InstanceState<any> = { instanceID : "VIEW_HISTORY_INSTANCE_" + Math.random(), pathname : this.props.location.pathname, documentHeight : 0, scrollY : 0, extra : {}, action : "REPLACE" } pathname : string; isPOP(){ if (this.props.history.action != "POP" ){ return false; } if (instanceHistory.length < 2){ return false; } if (savedInstanceMemory[instanceHistory[1]] == null){ return false; } if (savedInstanceMemory[instanceHistory[1]].pathname != this.props.location.pathname){ return false; } return true; } constructor(props) { super(props); this.action = this.props.history.action; this.pathname = this.props.location.pathname; if (lastpath != this.props.location.pathname){ if (this.isPOP()){ console.log("@POP!!!!!") const prevInstanceID = instanceHistory.shift(); delete savedInstanceMemory[prevInstanceID]; const instanceID = instanceHistory[0]; this.savedInstanceState = savedInstanceMemory[instanceID]; this.savedInstanceState.action = "POP"; setTimeout(()=>{ window.scrollTo(0,this.savedInstanceState.scrollY ); } , 16) } else { console.log("@PUSH!") window.scrollTo(0,0); this.savedInstanceState.action = "PUSH"; instanceHistory.unshift(this.savedInstanceState.instanceID); savedInstanceMemory[this.savedInstanceState.instanceID] = this.savedInstanceState; } } lastpath = this.props.location.pathname; } documentHeight() { return Math.max( document.documentElement.clientHeight, document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight ); } componentWillUnmount(){ this.savedInstanceState = {...this.savedInstanceState , scrollY : window.scrollY , documentHeight : this.documentHeight(), pathname : this.props.location.pathname, extra : {} }; savedInstanceMemory[this.savedInstanceState.instanceID] = this.savedInstanceState; } onSaveExtra(lastState : object){ this.savedInstanceState = {...this.savedInstanceState, extra : lastState} savedInstanceMemory[this.savedInstanceState.instanceID] = this.savedInstanceState; } render() { const addProp = { style : { minHeight : this.savedInstanceState.documentHeight } } return ( <div {...addProp} > <WrappedComponent {...this.props} ref={ref => this.childRef = ref} savedInstanceState={this.savedInstanceState} onSaveExtra={this.onSaveExtra.bind(this)} /> </div> ); } } } export interface EndpointHocProps <T> extends RouteComponentProps<{}> { savedInstanceState : InstanceState<T> onSaveExtra : (savedState : T)=>void } export default EndpointHoc;

ついでにwindow.historyのPUSH/POP時にインスタンスのEndpointにスクロール位置復元や任意のstateの保存をし、復元時にpropsとして渡す例。(react-router-v4の場合)
URLのback時にbodyの高さと位置とを復元する。
この辺はブラウザの機能の車輪再発明感がパないので何かいい実装ないかな。。。

Read More

TypeScriptのテストをパッと書く / 特に何も入れずにスクレイプのテストをパッと書く

TypeScriptのテストをパッと書く

typescriptのユニットテストしたいんじゃー的な時。余計なビルド結果を出力したくないのでプリプロセッサ機能必須。
ts-jest で書いていたが なんかコレジャナイ感あったので 一周回って mocha + power-assert + espower-typescript の王道構成に戻ってきたらメンテされて使いやすくなってた。

 

モジュールインストール

yarn add mocha power-assert espower-typescript --dev

型定義インストール

yarn add @types/power-assert @types/mocha --dev

実行

./node_modules/.bin/mocha --compilers ts:espower-typescript/guess test/spec/**/*-test.(ts|tsx)

とかでプロジェクトのtsconfig.jsonの設定を読んでいい感じにやってくれる。

package.jsonにシェル書いとけば npm test とか yarn test で動く

"scripts": {
    "test": "./node_modules/.bin/mocha --compilers ts:espower-typescript/guess test/spec/ --timeout 30000",
}

test.ts

tsconfigの設定によってはES7 async や デコレータも使えて調子いいっすね

import "mocha";
import assert = require("power-assert");

const sleep = (time : number)=> new Promise(r => setTimeout(r , time))

describe("section", ()=>{

    it("非同期テスト", async ()=>{
        await sleep(2000)
        assert.equal(3 ,3)
    });

    it("高階関数テスト" , ()=>{

        function decorate(clazz) {
            return class extends clazz {
                constructor() {
                    super();
                }
                prop : number = 100;
            }
        }

        @decorate
        class BaseClass {
            prop : number = 0;
        }

        const base = new BaseClass()
            assert.equal(base.prop , 100)
        });
    });
});

特に何も入れずにスクレイプのテストをパッと書く

スクレイプ / WebサイトE2Eテストの構成がほしい時。

TypeScript勢は上記の構成に好きにヘッドレスブラウザなりを入れて自由にしたらいいと思うが、それ以外の方。

結論から言うとこちらのボイラープレートを参考にするのが一番早い
nightmare-ava-example

 

ava はmochaみたいなテストフレームワークで、デフォルトでasyncをサポートしていたりとシンプル。
Nightmareはヘッドブラウザphantomjsのラッパー。(casperjsみたいなもん)…だったが、最近はElectron(chromium)が裏で立ち上がるようになってる。

こんな感じ

import test from 'ava';
import Nightmare from 'nightmare';

const nightmare = Nightmare({ show: true });

test.serial('Async/Await!', async (t) => {
    const result = await nightmare
       .goto('http://yahoo.com')
       .screenshot('output/png/hoge.jpg')
       .type('form[action*="/search"] [name=p]', 'github nightmare');

    const result2 = await nightmare.click('form[action*="/search"] [type=submit]')
       .wait('#main')
       .evaluate(function () {
           return document.querySelector('#main .searchCenterMiddle li a').href;
       });
    t.true(result2.includes('images.search.yahoo.com'));
});

実行

./node_modules/.bin/ava --verbose spec/

すぐに書き始められていいかと思う。

Read More