こんにちは!ブログチームの id:amagitakayosi です。
今回は、業務で書いた小さなReactコンポーネントをnpmパッケージとして分離した話をします。
今回公開するパッケージ
今回は「無限スクロール」のためのReactコンポーネントを作りました。
英語だと auto paging
やら infinite scroll
と呼ばれてる処理です。
npmには既に無限スクロール用コンポーネントは無数に公開されていますが、要素の高さをpropsで指定したり、要素をrenderする関数をpropsに渡す必要があったりと、なかなかクセのある物が多いです。
弊チームの要件には合わないため、今回は自分でコンポーネントを書いてしまうことにしました。
GitHub repoはこちら:
実装方針
今回開発するコンポーネントでは、スクロールイベントの監視のみを行うことにしました。
リストのrenderやスクロールしたときの具体的な処理は、親コンポーネントに任せます。
利用イメージとしてはこんな感じ。
リストとなる要素をラップし、下までスクロールした時にイベントハンドラを呼ぶだけ、という割と単純なコンポーネントになっています。
利用する側のコードはこうなります。
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
が呼ばれます。
interval
は onScroll
が呼ばれ過ぎないようにするためのパラメータです。
なぜ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/
で動作確認します。
スクロールバーが下端に近づくと、更新処理が呼ばれ、リストに要素が追加されています。
無事動いているようです。
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" } }
npm link を用いてローカル開発する場合、npmはpeerDependenciesを解決できない
さて、react
をpeerDependenciesに指定することで、 node_modules/
には react
がインストールされなくなりました。
しかし、この状態で example/
ディレクトリのプロジェクトをビルドしようとすると Error: Cannot find module 'react' ...
といったエラーが出ます。
これには前述した NODE_PATH
を利用できます。
今回の場合、 example/package.json
で NODE_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コンポーネントを利用する場合、さらにもう一つ問題があります。
はてなブログでは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コンポーネントを開発する方法を紹介します!
それではまた!
はてなでは、何度ハマっても乗り越えていける不屈の闘志をもったエンジニアを募集しています!!!!!(๑•̀ㅂ•́)و✧
追記
- 2016-04-10 14時05分 peerDependenciesの利用例として挙げるパッケージをgulpからgruntに修正
- 経緯: https://twitter.com/teppeis/status/719015210858582016
- ご指摘ありがとうございます! > id:teppeis
- 2016-04-10 15時04分
react
をdevDependenciesに指定するだけでは不十分である旨を追記- 経緯: https://twitter.com/bokuweb17/status/718990196461539329
- ご指摘ありがとうございます! > id:bokuweb