React/dekuコンポーネントとthisのパターン
最近、ReactやdekuといったViewのコンポーネントの仕組みを持ったライブラリを使って、NW.jsやElectronで動くようなアプリを書いたりしています。
thisの問題
ReactのコンポーネントをES6 Classesで書いている場合にthis
をbind
する必要が多くなる気がします。
(そういう意味では、無理してES6 Classesを使うよりは、React.createClass
を使ったほうが楽になるようにも思えます)
特にFlux的なStoreの変更した時にハンドラを登録したいときにthis
でbind
した関数を登録すると、ハンドラを解除するときに面倒なことが起こります。
x.bind(this) !== x.bind(this)
となるため、
componentWillMount() {
store.on("change", this.onChange.bind(this));
}
componentWillUnmount() {
store.off("change", this.onChange.bind(this));
}
だとイベントが解除できないという問題が起こります。
そのため、事前にbind
済みのハンドラを持っておく必要が出てきたりします。
サンプル
この記事では上記のような、ボタンでカウントアップするだけのものを
- React -
this
を普通にbindする手法 - React - azu/idempotent-bindを使ってbindする手法
- deku を使って書く
の3つ書いてみます。
ソースコードはこちら
React - this
を普通にbindする手法
これは単純で毎回bindすると===で異なる関数が返すため、constructor(){}
などで一度だけbindして、それを使いまわすという形で書くことができます。
import React from "react"
export default class IBComponent extends React.Component {
constructor(...args) {
super(...args);
this.counterStore = this.props.context.counterStore;
this.couterActions = this.props.context.counterActions;
this.state = {
count: this.counterStore.getCount()
};
// bind
this.onChange = this._onChange.bind(this);
}
_onChange() {
this.setState({
count: this.counterStore.getCount()
});
}
componentWillMount() {
this.counterStore.onChange(this.onChange);
}
componentWillUnmount() {
this.counterStore.removeChangeListener(this.onChange);
}
onClick() {
this.couterActions.countUp();
}
render() {
return <div>
<button onClick={this.onClick.bind(this)}>{this.state.count}</button>
</div>
}
}
何度でもbindできる関数
先ほどのbind
した結果は新しい関数を毎回作るため、ハンドラの登録と解除がそのままできないという話だったので、azu/idempotent-bindというものを書いてみました。
azu/idempotent-bindは以下のように、同じ関数とthis
の組み合わせだとunbind
するまで同じ結果を返すbind関数です。
import { bind, unbind } from "idempotent-bind"
bind(bind(x, this), this) === bind(x, this);
これを使うと先ほどのコードは、以下のように書けるようになるのでちょっとだけ短くなります。
import React from "react"
import { bind, unbind } from "idempotent-bind"
export default class IBComponent extends React.Component {
constructor(...args) {
super(...args);
this.counterStore = this.props.context.counterStore;
this.couterActions = this.props.context.counterActions;
this.state = {
count: this.counterStore.getCount()
};
}
_onChange() {
this.setState({
count: this.counterStore.getCount()
});
}
componentWillMount() {
this.counterStore.onChange(bind(this._onChange, this));
}
componentWillUnmount() {
// The unbind method takes two arguments, target, thisArg, and returns a bound function.
this.counterStore.removeChangeListener(unbind(this._onChange, this));
}
onClick() {
this.couterActions.countUp();
}
render() {
return <div>
<button onClick={this.onClick.bind(this)}>{this.state.count}</button>
</div>
}
}
dekuの場合
ReactはコンポーネントをES6 ClassesまたはReact.createClass
を使って書くので、いわゆるクラスっぽいもので書く感じになっています。クラスで書くとクラス内のメソッドを参照するのにthis
使うため、必然的にthis
が多くなることがわかると思います。
Reactのようなコンポーネントを扱うdekuというライブラリでは、このthisの問題をクラスではなく関数でコンポーネントを書くことで解決しようとしています。
dekuではコンポーネントはあるメソッドを持つただのオブジェクトであって、そのメソッドに対してproos
やstate
といったものが渡されるので、this
を参照する必要がない仕組みになっています。
以下のようなオブジェクトが一つのコンポーネントで、Reactではthis.props
という感じで参照する部分が引数として渡ってきていることがわかると思います。
var dekuComponent = {
initialState: function (props) {
return {count: 0};
},
afterMount (component, el, setState) {
let { props, state } = component;
},
beforeUnmount(component, el) {
let {props} = component;
},
render(component) {
let {props, state} = component;
return <div>
<button>{state.count}</button>
</div>
}
};
dekuを使って先ほどのカウントアップボタンを書いてみると以下のように書くことができます。
import {element} from 'deku'
// state event mapping
var events = {};
// ライフサイクルイベントはReactとほぼ同じ
function initialState(props) {
return {
count: props.context.counterStore.getCount()
};
}
function afterMount(component, el, setState) {
let { props, state, id } = component;
setState({
count: props.context.counterStore.getCount()
});
var onChange = ()=> {
setState({
count: props.context.counterStore.getCount()
});
};
// save onChange for unmount
events[id] = events[id] || {};
events[id].onChange = onChange; // remove listenerするために保持する
props.context.counterStore.onChange(onChange);
}
function beforeUnmount(component, el) {
let {props, state, id} = component;
var onChange = events[id].onChange;
props.context.counterStore.removeChangeListener(onChange);
}
function render(component) {
let {props, state} = component;
function onClick() {
props.context.counterActions.countUp();
}
return <div>
<button onClick={onClick}>{state.count}</button>
</div>
}
// コンポーネントを構成するオブジェクトを返してる
export default {
initialState,
afterMount,
beforeUnmount,
render
}
dekuもBabelの設定を一行加えるとJSXで書くことができます。
引数に--jsxPragma element
を渡すか、babelrcに以下のような設定を追加することでJSXがdekuの要素を作成する関数に変換されます。
{
"jsxPragma": "element"
}
話を戻して、dekuによるコンポーネントを見てみると、this
が一切出てきてないことがわかると思います。dekuのコンポーネントはライフサイクルメソッドを持ったオブジェクトであればいいので、普通に関数として定義してやって、最後にオブジェクトとしてまとめれば良いことになります。
コンポーネントを構成する関数に引数に必要なもの渡せばテストできるので、テストがしやすいというのがひとつの利点です。
dekuもReactのようにライフサイクルで間違った使い方などをするとコンソールに警告と解決方法を書いたURLを出してくれたり、Reactとライフサイクルは殆ど同じだったり、JSXも使えるのでReactを書いたことある人はすぐに分かる感じのライブラリだと思います。
min.jsが10kb程度でReactに比べて大分小さいことなど特徴はいくつかありますが、詳しくは以下を見てみてください。
おわりに
今回作ったサンプルはReactとDekuのコンポーネントをそれぞれ作って、StoreとActionは同じものを共有して使っています。
書いてて思ったのが、コンポーネントを使う側(<DekuComponent context={context}></DekuComponent>
のような部分)では、そのコンポーネントがReactなのかdekuなのかは区別しなくても良くなるのが面白いなと思いました(実際にjsxPragmaの違いがあるけど)
その辺はCustom ElementでやろうとしていることがJSX+Any Componentでやってることが大分近い感じな気がします。
React 0.14でStateless Componentsを入れる予定もあるらしいので、この辺のコンポーネントの作りはまだまだ議論の余地がありそうですね。
Fluxアーキテクチャの方もStoreやActionはPureなオブジェクトとしたいという意見にも同じ流れを感じるので、JavaScriptにおけるFunctional Programmingへの取り組みは模索中という感じです。
また、bindの問題を構文的にサポートするES.nextのプロポーサルもありますが(Babelではオプショナルサポート)、今回の話は構文で解決する問題ではないので単純には行かない感じですね。
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。