Reactで動的に出現する要素をサイズを抑えながら実装する
Reactで動的に出現する要素をDOM量抑えながら実装する。たとえばドロップダウン。
画面内に一個だけしか出現しないユニークな要素であれば、stateとcssでなんとかなると思う。
handleDropdownToggle = () => { this.setState({ dropdownVisible: !this.state.dropdownVisible }); }; render = () => ( <div> <button onClick={this.handleDropdownToggle}> 俺をクリックすると </button> <div className={this.state.dropdownVisible ? 'visible' : ''}> 俺がみえるぞ! </div> </div> );
ただ下記のような要件が出てくると、この実装だとマズそうな気配が強くなる。
- 複数のモジュールから使いたい
- 同じモジュール内で違う種類のドロップダウンを実装したい
- 最初に100個くらいレンダリングされる要素の中で使用したい
こういう需要が出た時にどうするか、という話。
切り出し方は「機能で切り出す場合」と「ビューで切り出す場合」の二つのパターンに大別できる、それぞれまとめる。
パターン1: 機能で切り出す場合
機能単位で切り出されて、内包するコンテンツが実装箇所それぞれで全く異なるタイプのもの。
平たく言えば「パラメーター渡しでレンダリングする仕組みの設計が困難」なもの、例えばドロップダウンとか。
- ある場所では
ul,li
なメニューが不定数あるドロップダウン - ある場所ではスクローラブルな(たとえば通知一覧とか)ドロップダウン
これだけの要件でも、実装しようとしたらパラメーター渡しでは設計が無理そうな気配が強く感じられる。
なので、利用先の各モジュールで内部ビューの実装ができるよう設計する。
動作イメージ
利用イメージ
下記三つのモジュールを実装して使う。
- 見た目に何も影響を及ぼさないラッパーとなる
<Dropdown>
- 実装する機能のトリガーとなるビューを実装する
<DropdownTrigger>
- トリガーされた結果表示されるビューを実装する
<DropdownContent>
render = () => ( <Dropdown> <DropdownTrigger> 俺をクリックすると </DropdownTrigger> <DropdownContent> 俺がみえるぞ! </DropdownContent> </Dropdown> );
ビューに関することは利用先で実装することができ、「動的に出現する」という機能だけを<Dropdown>
で担当することができる。
パラメータ渡しでDOMを構築させるよりもカスタマイズしやすく、見通しもよい。
ドロップダウンのようにコンテンツの表示非表示を切り替えるという『機能』単位で切り出されるモジュールは、ビューがその時々によって異なるため上記のように記述したい需要が特に高いと思う。
実装イメージ
React.cloneElementというメソッドがある。propsやchildrenを拡張する目的で、特定のReactElementをクローンすることができる。
これを利用して、<Dropdown>
に渡されたchildrenの中から<DropdownTrigger>
と<DropdownContent>
を探してきて、<Dropdown>
が管理するpropsを知っている状態のTrigger,Contentへ加工する。
加工した結果はstateに保持しておき、このstateをレンダリングするようにする。
import DropdownTrigger from './DropdownTrigger'; import DropdownContent from './DropdownContent'; class Dropdown extends PureComponent { constructor(props, context) { super(props, context); this.state = { active: false }; this.state.children = this.getChildren(); } componentDidUpdate = (prevProps, prevState) => { if (this.state.active && prevState.active !== this.state.active) { this.setState({ children: this.getChildren() }); } if (!isEqual(prevProps.children, this.props.children)) { this.setState({ children: this.getChildren() }); } }; // ここでprops.childrenを加工している getChildren = () => React.Children.map(this.props.children, (child) => { if (child.type === DropdownTrigger) { return React.cloneElement(child, { onClick: this.handleToggle }); } if (child.type === DropdownContent) { return React.cloneElement(child, { active: this.state.active }); } return child; }); handleToggle = () => { this.setState({ active: !this.state.active }) }; // state.childrenをレンダリングしている render = () => ( <div className={classNames('Dropdown', { active: this.state.active })}> {this.state.children} </div> ); } export { Dropdown, DropdownTrigger, DropdownContent };
<DropdownTrigger>
と<DropdownContent>
は、props.onClick
をDOMにアサインしたり、props.active
にしたがって表示非表示を切り替えたりするような実装になっていればよい。
export default class DropdownTrigger extends PureComponent { render = () => ( <div className='DropdownTrigger' onClick={this.props.onClick}> {this.props.children} </div> ); }
export default class DropdownContent extends PureComponent { render = () => ( <div className='DropdownContent'> {this.props.active && this.props.children} </div> ); }
<DropdownContent>
にactive状態を渡してprops.children
の表示非表示を切り替えているのは、アクティブじゃない状態の時にDOMをレンダリングさせないため。
アクティブじゃない時のDOMを最初からレンダリングしてしまうと、例えばイテレートされるモジュールの中で使用した時に初回のレンダリングコストや、サーバーサイドレンダリングをした時の配信サイズが大きくなってしまう。
DOMを最初からレンダリングしないようにしておけば、心置きなく巨大なドロップダウンを実装することができる(メモリのことを無視すればだけど..)。
パターン2: ビューで切り出す場合
ある見た目を実現するために切り出されて、内包するビューが画一的でよいタイプのもの。
たとえばアラートモーダル。見た目は統一されていてほしいので、パラメーター渡しでいい感じにコンテンツを構築してくれるものがほしい。
動作イメージ
利用イメージ
下記二つのモジュールとインターフェースを実装して使う。
- ビューを構築する
<Dialog>
- 実行すると
<Dialog>
をレンダリングするdialog
handleDialog = async () => { const ok = await dialog('これでよいですか?'); // これ console.log(ok ? 'おk' : 'だめ..'); }; render = () => ( <button onClick={this.handleDialog}> 削除するぞ! </button> );
メソッドの実行前に画面上にDOMを保持しないため、ある程度ファットな実装になっても安心してイテレータの中で利用することができる。また、シングルトン制約もないので使い方には応用が効きやすい。
実装イメージ
dialog
を呼び出すとdocument.createElement
で空divを作ってbodyにappendし、ReactDOM.render
で<Dialog>
の実装をそのdivにマウントするようになっている。
<Dialog>
の中ではhandleFinalize
という終了メソッドを実装していて、その中で自分自身をunmountしつつparentNode
を削除する。そうしてしまえば、このモジュールに関するものはDOM上から一掃することができる。
export const dialog = (message) => new Promise(async (done) => { ReactDOM.render(<Dialog message={message} onDone={done} />, document.body.appendChild(document.createElement('div'))); }); class Dialog extends PureComponent { constructor(props) { super(props); } handleFinalize = () => { const wrapper = this.node.parentNode; ReactDOM.unmountComponentAtNode(wrapper); setTimeout(() => wrapper.parentNode.removeChild(wrapper), 0); }; handleAccept = () => { this.props.onDone(true); this.handleFinalize(); } handleCancel = () => { this.props.onDone(false); this.handleFinalize(); } render = () => ( <div className='Dialog'> {this.props.message} <button onClick={this.handleAccept}>OK</button> <button onClick={this.handleCancel}>Cancel</button> </div> ); }
webpack(v1)とbabelでES6コードをさくっと書く
最低限のコストで最近よく聞くいい感じのjsを書きたい時の構成をずらーっと書いてみる
準備するもの
- node/npm (最近はrbenvクローンのnodenvがいい感じ、操作は同じ)
- webpack
- babel
.babelrc
.babelrc
を設置しとくとbabelのデフォルト設定がこいつの中身で書き換わる
- Reactを使わないなら、presetの
react
はいらない export default
されたパッケージをimport
した時に.default
で引くのを許せるなら、add-module-exports
はいらない(後述)
Reactいる
{ "presets": [ "es2015", "stage-0", "react" ], "plugins": [ "add-module-exports" ] }
いらない
{ "presets": [ "es2015", "stage-0" ], "plugins": [ "add-module-exports" ] }
webpack.config.babel.js
webpack.config.js
という名前でファイルを設置しとくと、webpackコマンドが勝手に中身を読みに行ってくれる
さらにwebpack.config.babel.js
という名前にしておくと、babel
なコードとして読んでくれる(webpackはinterpretをロードしているため。このbabel
にも.babelrc
の内容は適用される)
babel-polyfill
を食わせておくと、Array.prototype.includes
みたいなprototypeメソッドが拡張されるbabel-runtime
はトップオブジェクトだけ
- DefinePluginを使っておくとUglifyのCodeElimination(後述)でいい感じにデバッグコードを除去できる
...
オペレータを使うと、条件で中身が切り替わるオブジェクトがいい感じに作れる.js
以外にもビルドしたいassetがあるならmodule.loaders
に追加する(後述)
import 'babel-polyfill'; import path from 'path'; import webpack from 'webpack'; const DEBUG = !process.argv.includes('--release'); const VERBOSE = process.argv.includes('--verbose'); export default { cache: DEBUG, debug: DEBUG, stats: { colors: true, reasons: DEBUG, hash: VERBOSE, version: VERBOSE, timings: true, chunks: VERBOSE, chunkModules: VERBOSE, cached: VERBOSE, cachedAssets: VERBOSE, }, entry: './src/app.js', output: { publicPath: '/', sourcePrefix: ' ', path: path.join(__dirname, 'public'), filename: 'app.js', }, target: 'web', devtool: DEBUG ? 'cheap-module-eval-source-map' : false, plugins: [ new webpack.optimize.OccurenceOrderPlugin(), new webpack.DefinePlugin({ 'process.env.NODE_ENV': `"${process.env.NODE_ENV || (DEBUG ? 'development' : 'production')}"` }), ...(DEBUG ? [] : [ new webpack.optimize.DedupePlugin(), new webpack.optimize.UglifyJsPlugin({ compress: { screw_ie8: true, warnings: VERBOSE } }), new webpack.optimize.AggressiveMergingPlugin(), ]), ], resolve: { extensions: ['', '.js', '.jsx'], }, module: { loaders: [ { test: /\.jsx?$/, include: [path.resolve(__dirname, 'src')], loader: 'babel' }, ], }, };
Reactがいらない場合、resolve
とmodule.loaders
を下記のように削る
{ resolve: { extensions: ['', '.js'], }, module: { loaders: [ { test: /\.js$/, include: [path.resolve(__dirname, 'src')], loader: 'babel' }, ], }, }
これでwebpackとbabelの設定はおしまい
もろもろインストールする
Reactがいらない場合、最後の一行はいらない
npm init npm i --save babel-polyfill npm i --save-dev webpack babel-core babel-loader babel-preset-es2015 babel-preset-stage-0 babel-plugin-add-module-exports npm i --save-dev babel-preset-react
ファイルを設置してビルドする
src/app.js
を設置する
わかりやすい例としてasync/await
を使ったsleepコードでも書いてみる
import 'babel-polyfill'; const sleep = (msec) => new Promise((resolve) => { setTimeout(resolve, msec); }); (async () => { console.log('start'); await sleep(2000); console.log('end'); })();
ビルドする
$(npm bin)/webpack
publicフォルダの中にappとかができてると思うので、nodeする
node public/app.js
以上となります
追記
webpackは基本的にすべての依存パッケージを同一ファイルにまとめてくれるので、このpublic/app.js
はどこへ持っていっても動く
webpackには画像などの重いファイルをoutput.publicPath
を元にパス化してファイル分割する機能があるため、assetを一緒にbundleした場合はその限りでない
jsだけ書いてたら気にしなくていいです
追記
で、実際ここからどう開発するの?という話
たとえばdom-loaded待ってfastclickでも入れたいとする
npm i --save domready fastclick
で、app.jsでimport
する
import 'babel-polyfill'; import domready from 'domready'; import fastclick from 'fastclick'; const sleep = (msec) => new Promise((resolve) => { setTimeout(resolve, msec); }); (async () => { console.log('start'); await sleep(2000); console.log('end'); })(); domready(() => { fastclick.attach(document.body); });
サーバー側でロードされることを期待しないのであれば、client側のライブラリもガンガン呼んでいい
余談: export default
add-module-exportsがある
// a.js export default 'hoge'; // b.js import a from './a.js'; console.log(a); // 'hoge'
ない
// a.js export default 'hoge'; // b.js import a from './a.js'; console.log(a); // { default: 'hoge' }
余談: CodeElimination
const debugInfo = 'hoge'; if (process.env.NODE_ENV === 'development') { console.log(debugInfo); }
こいつをwebpackに通すと、DefinePluginによってprocess.env.NODE_ENV
が置換される
const debugInfo = 'hoge'; if ('production' === 'development') { console.log(debugInfo); }
'production' === 'development'
は常にfalseなので、Uglifyによってこのconditionはまるごと削除される
const debugInfo = 'hoge';
余談: 例えばStylusをビルドする場合
こんな感じにする
{ module: { loaders: { { test: /\.styl$/, loaders: ['style?useable', `css?${DEBUG ? 'sourceMap' : 'minimize'}`, 'stylus'] }, }, }, };
リモートでシュッとShellを走らせたい時のShellScript
nodenvとかrbenvとかなんかしら食わせたいやつがある時
コマンドを打つ度にsshしてinitして、ってやってるとダルいのでheredocでまとめて実行する
ssh HOSTNAME -t "$(cat <<'CMD' export PATH="$HOME/.nodenv/bin:$PATH" eval "$(nodenv init -)" node -v node -e 'console.log("hoge")' CMD )"
gulpをstreamとか関係なくただのタスクランナーとして使う
gulpはstream志向でデザインされていて、streamしか受け入れない・streamじゃないとon the railじゃない、というようなイメージが強いと思う。
ところがどっこい、gulpのタスクが受け入れるのはstreamだけじゃないし、必ずしもgulp-*
とかvinyl
とかを使わなければならない理由も特に無い。それらを使わなくてもタスクは実行できる。
「stream使わなくてもいいじゃん」と割り切ると、gulpの使い途が広がる。
一応挙げておくと、例えば下記のコードは正しいタスク。
gulp.task('synctask', () => { console.log('sync task executed.'); });
非同期であれば下記のように書ける。
gulp.task('asynctask', done => { setTimeout(() => { console.log('async task executed.'); done(); }, 1000); });
orchestrator
ところで、gulpのdependenciesを見るとorchestratorというものがある。
orchestratorはgulpのようなタスクランナーツールのベースを提供するパッケージで、コールバックを渡せばコールバックが呼ばれるまで待つし、Promiseを返せばPromiesがresolveするまで待ってくれるAPIを提供している。
単純に言えば、gulpはこのorchestratorにstreamインターフェースを乗せたり、CLIツールを提供したり、interpretやrun-sequenceといった便利なツールが出揃ったりして、色々と便利になったものに過ぎない。
ここで重要なのが、orchestratorがPromiseインターフェースを持っている点(タスク内でPromiseをreturnすると、そのPromiseがresolve/rejectされるまで待ってくれる)。
ということは、gulpもPromiseのインターフェースを持っている。Promiseを受け入れられるということは、async/awaitが使える。
async/awaitを使うと、streamを使わないgulpタスクがめっちゃスッキリ書ける。
del
例えばこんなタスクがある。
gulp.task('clean', done => { del(['build/*', 'tmp/*'], { dot: false }, () => { console.log('clean upped!'); done(); }); });
delはPromiseを返すので、下記のように書くこともできる。
gulp.task('clean', async () => { await del(['build/*', 'tmp/*'], { dot: false }); console.log('clean upped!'); });
今までdelでファイルを削除した後にさらに何かしたい時は、自分で定義したコールバックの中でgulpからやってきたコールバック(done)を呼ぶ必要があった。だるい。
async/awaitを使うことでかなりシンプルになったと思う。
run-sequence
run-sequenceでも書き直してみる。
import run from 'run-sequence'; gulp.task('watch', done => { run(['clean', 'build', 'serve'], () => { gulp.watch('src/**/*.{js,jsx}', () => run('build', 'serve')); done(); }); });
これをasync/awaitで書き直すとこうなる。
gulp.task('watch', async () => { await new Promise(resolve => run(['clean', 'build', 'serve'], resolve)); gulp.watch('src/**/*.{js,jsx}', () => run('build', 'serve')); });
run-sequenceはundefined
を返すが、コールバックを受け入れてくれるのでPromiseでwrapしてawaitすればよい。
あるいは、頻繁に使うなら最初からwrapしたfunctionを定義すればよい(これより下の例ではそうしている)。
copy
copyとかもgulp.src
を使わず、一般的なnodejsの資産(例えばncp)を使って実装する方法もある。
import ncp from 'ncp'; const copy = (source, destination) => new Promise((resolve, reject) => { ncp(source, destination, err => err ? reject(err) : resolve()); }); gulp.task('copy', async () => { await Promise.all([ copy('src/index.html', 'build/index.html'), copy('package.json', 'build/package.json'), ]); });
watch
gulp.watch
を使わず、基盤パッケージであるgazeを使って書くこともできる。
import gaze from 'gaze'; const WATCH = process.argv.includes('--watch'); const watch = pattern => new Promise((resolve, reject) => { gaze(pattern, (err, watcher) => err ? reject(err) : resolve(watcher)); }); gulp.task('copy', async () => { await Promise.all([ copy('src/index.html', 'build/index.html'), copy('package.json', 'build/package.json'), ]); if (WATCH) { const watcher = await watch('src/**/*.html'); watcher.on('changed', async file => { await copy(file, `build/${path.basename(file)}`); }); } });
webpack
webapckの監視サーバーを立ち上げて差分ビルドするタスクをgulp-*
のstreamインターフェースで実現しているパッケージは(たぶん)無いが、自前で書けば普通にできる。
import webpack from 'webpack'; import util from 'gulp-util'; gulp.task('bundle', async () => { let config = require('./webpack.config.babel'); config = Array.isArray(config) ? config : [config]; await new Promise((resolve, reject) => { let count = 0; const bundler = webpack(config); const bundle = (error, stats) => { if (error) { reject(new util.PluginError('bundle', error.message)); } else { util.log(stats.toString(config[0].stats)); if (++count === (WATCH ? config.length : 1)) { resolve(); } } }; WATCH ? bundler.watch(200, bundle) : bundler.run(bundle); }); });
まとめ
nodejsの資産を使えばgulp・gulp周辺ライブラリへの依存度を下げることができる(じゃあgulp使うなよとか言われそう)。
streamに拘泥してマイナーなgulpライブラリ使ったり、慣れない実装でバグ産んだりするくらいだったら、自分が慣れてる方法で実装したほうが早いし確実。