getalog

console.log geta6

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がいらない場合、resolvemodule.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ツールを提供したり、interpretrun-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ライブラリ使ったり、慣れない実装でバグ産んだりするくらいだったら、自分が慣れてる方法で実装したほうが早いし確実。