Reactコンポーネントをnpmパッケージとして開発する

こんにちは!ブログチームの id:amagitakayosi です。
今回は、業務で書いた小さなReactコンポーネントをnpmパッケージとして分離した話をします。

今回公開するパッケージ

www.npmjs.com

今回は「無限スクロール」のためのReactコンポーネントを作りました。
英語だと auto paging やら infinite scroll と呼ばれてる処理です。

npm search react-infinite-scrollの結果

npmには既に無限スクロール用コンポーネントは無数に公開されていますが、要素の高さをpropsで指定したり、要素をrenderする関数をpropsに渡す必要があったりと、なかなかクセのある物が多いです。
弊チームの要件には合わないため、今回は自分でコンポーネントを書いてしまうことにしました。

GitHub repoはこちら:

https://github.com/fand/react-infinite-scroll-container/tree/abf60f25a8c29c2f3948b79de0a3eeb17fd12afb

実装方針

今回開発するコンポーネントでは、スクロールイベントの監視のみを行うことにしました。
リストのrenderやスクロールしたときの具体的な処理は、親コンポーネントに任せます。

利用イメージとしてはこんな感じ。
リストとなる要素をラップし、下までスクロールした時にイベントハンドラを呼ぶだけ、という割と単純なコンポーネントになっています。

2016-04-05 16 11 50

利用する側のコードはこうなります。

import InfiniteScrollContainer from '@fand/react-infinite-scroll-container';

class App extends React.Component {

  render () {
    return (
      <div className="App">
        <InfiniteScrollContainer
          padding={100}
          interval={300}
          onScroll={() => this.loadNextItems()}>

          {this.state.items.map((i) => <Item key={i.id} item={i} />)}

        </InfiniteScrollContainer>
      </div>
    );
  }

}

ユーザーがリストをスクロールし、残りの高さが padding の値より小さくなった時、 onScroll が呼ばれます。
intervalonScroll が呼ばれ過ぎないようにするためのパラメータです。

なぜnpmパッケージにするのか

Reactコンポーネントをnpmパッケージとして切り出す理由としては、以下のメリットが挙げられます。

他のコンポーネントの影響で壊れることを防げる

長い間プロジェクトをメンテしていると、修正したコードが意外な副作用を生み、予想していなかった場所が壊れる、ということがあります。
とくにCSS周りでは、親要素から継承したスタイルや共通のクラスのスタイルのせいで見た目が崩れてしまうといった事が頻繁に起こります。
あなたのプロジェクトがこのような現象に苦しんでいるならば、CSS in JS系ライブラリを利用し、全てのスタイルをstyle属性に書くことを検討してもいいかもしれません。

アドホックな修正の積み重ねで複雑になることを防げる

仕事でコードを書いていると、新たに機能を追加したくなった時、既存コンポーネントに直接手を入れてしまいがちです。
その結果、どんどん複雑で巨大なコンポーネントになり、バグが発生しやすく見つけにくいコードになってしまいます。
適切な粒度でnpmパッケージとして切り出してしまえば、少なくともプロジェクトの都合で書き直されることは無くなるでしょう。

本体プロジェクトのコードを削減できる

機能をコンポーネント単位で開発していき、npmで公開してしまえば、プロジェクト本体のコードを削減出来ます。
実際は外部に公開できるコンポーネントばかりでは無いと思うので、まあ副次的なメリットって感じですね。

デメリット

デメリットとしては、修正コストが増える可能性が挙げられます。
切り出したコンポーネントに問題が見つかった場合、コンポーネントを修正してpublishした後、本体プロジェクトで最新版をインストールする、といった手順を踏むことになります。
本体プロジェクトのコードを直接修正するのに比べると、多少時間がかかるかもしれません。

ある程度単純で、単体で完結しているか、という基準で判断すると良いと思います。

Babelなパッケージのディレクトリ構成

今回のパッケージは次のようなディレクトリ構成にしました。

- react-infinite-scroll-container/
  - src/
    - index.js
    - InfiniteScrollContainer.js
  - lib/
  - test/
  - example/
  - index.js
  - package.json

今回のパッケージではBabelを利用します。
src/ にJSを書き、ディレクトリまるごと lib/ に変換しています。
JSファイルの依存関係は index.js -> lib/index.js -> lib/InfiniteScrollContainer.js となります。

index.js 及び src/index.js はCommonJS形式でエクスポートするようにしました。
これらをES2015 modulesの export default 形式でエクスポートすると、CommonJSなコードからうまく require() できなくなるためです。

// src/index.js
const InfiniteScrollContainer = require('./InfiniteScrollContainer');
exports.default = InfiniteScrollContainer;

// index.js
module.exports = require('./lib');

npmパッケージを手元で開発する場合、 npm link を利用すると便利です。
あるnpmパッケージのディレクトリで npm link すると、そのnpmパッケージが擬似的にglobalにインストールされた状態になります。
その後ほかのディレクトリで npm link *** とすると、 実際に npm publish することなく手元のnpmパッケージの動作を確認することが出来ます。
(参考: https://docs.npmjs.com/cli/link)

今回は example/ に動作確認用プロジェクトを作りました。
ブラウザ用JSの確認のため、npm scriptsで簡単にビルドしたりサーバーを立ち上げたりできるようになってます。
また、example/npm run link すると、上記の npm link の手順を自動で実行します。

同様の方法でnpmパッケージを開発されている方も多いのではないでしょうか。

実装

いきなりですが、実装は以下のようになります。

import { throttle } from 'lodash';
import React        from 'react';

const PADDING  = 100;
const INTERVAL = 300;

const STYLE = {
  outer : {
    position  : 'absolute',
    top       : 0,
    left      : 0,
    width     : '100%',
    height    : '100%',
    margin    : 0,
    padding   : 0,
    overflowY : 'auto',
  },
  inner : {
    width : '100%',
  },
};

/**
 * 無限リスト用のラッパー
 * スクロールイベントだけを管理する
 */
class InfiniteScrollContainer extends React.Component {

  componentDidMount () {
    this._interval = Number.isFinite(this.props.interval) ? +this.props.interval : INTERVAL;
    this._padding  = Number.isFinite(this.props.padding) ? +this.props.padding   : PADDING;

    this._onScroll = throttle((e) => this.onScroll(e), this._interval);
    this.refs.outer.addEventListener('scroll', this._onScroll);
  }

  componentWillUnmount () {
    this.refs.outer.removeEventListener('scroll', this._onScroll);
    this._onScroll = null;
  }

  onScroll (e) {
    if (this.props.disabled) { return; }

    const target    = e.target;
    const remaining = target.scrollHeight - (target.clientHeight + target.scrollTop);

    if (remaining < this._padding) {
      this.props.onScroll();
    }
  }

  render () {
    return (
      <div className="InfiniteScrollContainer" ref="outer"
        style={STYLE.outer}
        onScroll={this._onScroll}>
        <div className="InfiniteScrollContainer__Inner" style={STYLE.inner}>
          {this.props.children}
        </div>
      </div>
    );
  }

}

InfiniteScrollContainer.propTypes = {
  children : React.PropTypes.node,
  disabled : React.PropTypes.bool,
  padding  : React.PropTypes.number,
  interval : React.PropTypes.number,
  onScroll : React.PropTypes.func.isRequired,
};

export default InfiniteScrollContainer;

要素を2つに分けたのは、それぞれの役割をシンプルに保つためです。
.InfiniteScrollContainer の役割は、「親要素ピッタリの大きさになること」「子要素のスクロールを監視すること」の2つ。
.InfiniteScrollContainer__Inner の役割は、「子要素と同じ大きさになること」「スクロールすること」の2つです。
スクロール周りのように、スタイルに大きく左右されるコンポーネントの場合、期待した通りのスタイルを実現することも重要な役割と考えています。

最終ページに到達した時は disabled={true} とすれば、 onScroll が無駄に呼ばれるのを防げます。

早速 example/ で動作確認します。

f:id:amagitakayosi:20160410123732g:plain

スクロールバーが下端に近づくと、更新処理が呼ばれ、リストに要素が追加されています。
無事動いているようです。

npmの注意点

……とすんなり行けば良いのですが、実際は普通に動作するまでに何度もハマりました……😇
以下では、今回僕がハマったポイントを紹介します。

なお、以下ではnpm3を利用していると仮定します。
(npm2ではpeerDependencies等の扱いが異なる)

reactはpeerDependenciesに入れる

package.json | npm Documentation

npmでは、gruntプラグインやReactコンポーネントのように、依存する側で別途あるパッケージをインストールする必要がある場合、そのパッケージをpeerDependenciesに指定します。
gruntプラグインでは grunt が、Reactコンポーネントでは react がpeerDependenciesとなります。

devDependenciesとpeerDependenciesの両方に指定していましたが、 example/ をブラウザで確認すると Uncaught Invaliant Violation: addComponentAsRefTo(...) というエラーが表示されました。
どうやら、 example/ のビルド結果に2種類の react が含まれてしまっていたようです。 (参考: https://gist.github.com/jimfb/4faa6cbfb1ef476bd105)

実は、 npm link でリンクを張ったパッケージは、peerDependenciesを探すときに依存元パッケージの node_modules/ を参照できないという仕様になっているようです。

今回 react が重複してロードされてしまったのは、おそらく以下のような理由だと思われます。

  • example/package.json のdependenciesから example/node_modules/react をロード
  • example/package.json のdependenciesから、 npm link されている react-infinite-scroll-container ディレクトリを読みに行く
  • package.json から react を探すが、 example/node_modules を参照できないので node_modules/react をロード

npmパッケージのインストール先を NODE_PATH などで明示的に指定すれば、browserifyはそのパスを認識するようになります(参考: https://github.com/npm/npm/issues/5875)
ただし、 node_modules/react がインストールされている場合はそちらが優先されてしまうようです。
つまり、 npm link を利用する場合、dependenciesまたはdevDependenciesに react を指定してはいけない、という事になります。

しかし、node_modules/react がインストールされていなければ、パッケージをテストすることが出来ません。
「テスト時は react のインストールが必要だが、ビルド時には react がインストールされてはいけない」という状況です。

今回は、 pretest prebuild 時に react を付け外しすることで事無きを得ました。

// package.json
{
  "scripts": {
    "pretest": "npm ls react || npm i react",
    "test": "karma start karma.conf.js",
    "prebuild": "npm ls react && npm rm react",
    "build": "babel src --out-dir lib"
  }
}

さて、react をpeerDependenciesに指定することで、 node_modules/ には react がインストールされなくなりました。
しかし、この状態で example/ ディレクトリのプロジェクトをビルドしようとすると Error: Cannot find module 'react' ... といったエラーが出ます。

これには前述した NODE_PATH を利用できます。
今回の場合、 example/package.jsonNODE_PATH を明示的に指定することで、無事ビルドが通るようになりました。

// example/package.json
{
  "scripts": {
    "build": "NODE_PATH=node_modules browserify index.js -o bundle.js -t babelify",
    "watch": "NODE_PATH=node_modules watchify index.js -o bundle.js -t babelify",
  }
}

(参考: https://github.com/substack/node-browserify#browserifyfiles--opts)

browserify-shimを利用する場合

browserify-shimを利用しているプロジェクトからnpm上のReactコンポーネントを利用する場合、さらにもう一つ問題があります。

github.com

はてなブログではbrowserifyを用いてJSファイルをバンドルしていますが、jQueryやReact等の大きめのライブラリはCDNに載せて <script> タグで別途読み込んでいます。
browserify-shimというパッケージを利用すると、こうしたスクリプトをbrowserifyの管理下のファイルからrequire出来るようになります。

この時、ブログ本体のdependenciesには react は含まれません。
そのため、npmからインストールしたパッケージのpeerDependenciesにある react が解決できず、browserifyのエラーとなります。

Browserify Error { [Error: Cannot find module 'react' from '/Users/amagitakayosi/Epic/node_modules/@fand/react-infinite-scroll-container-test/lib']

この場合は、設定ファイルに transform : ['babelify', ['browserify-shim', { global: true }]], と書くとよいです。
transformerの設定に global: true を渡すと、browserifyは node_modules/ 下のファイルも変換してくれます。

(参考: https://github.com/substack/node-browserify#btransformtr-opts)

結果、fand/react-infinite-scroll-containerが依存している react もbrowserify-shimが解決してくれ、ビルドが成功するようになりました。
gulp等でnode-browserifyを利用している場合は b.transform({ global: true }, 'browserify-shim'); とすると良いでしょう。

npm publish

今回は、個人用のアカウントでscoped packageとして公開します。
(参考: https://docs.npmjs.com/misc/scope)

npm publish --access=public

すると、npmjs.comに登録されるのが確認出来るはずです。

@fand/react-infinite-scroll-container

publishした後は、今回公開したパッケージを npm unlink し、example/ で実際にインストールして試してみると良いでしょう。

次回予告

いかがだったでしょうか?
Reactコンポーネントを作って公開している方なら、似たようなハマり方をしているのではないでしょうか。 (npm link のハマりポイントばかりだった気もしますが……)

ところで、最近react-storybookというReactコンポーネント開発用ライブラリが話題になっていました。
早速インストールして試してみたところ、今回紹介したパッケージの実装に欠陥があることが判明しました……。
example/ ディレクトリで動作確認しながら作ったのですが、実装が素朴すぎるので見落としてしまっていたようです。

というわけで、次回の記事では、react-storybookでリアルタイムに動作確認を行いながらReactコンポーネントを開発する方法を紹介します!

それではまた!


はてなでは、何度ハマっても乗り越えていける不屈の闘志をもったエンジニアを募集しています!!!!!(๑•̀ㅂ•́)و✧

追記