最近、ReactやdekuといったViewのコンポーネントの仕組みを持ったライブラリを使って、NW.jsやElectronで動くようなアプリ書いたりしています。

thisの問題

ReactのコンポーネントをES6 Classesで書いている場合にthisbindする必要が多くなる気がします。 (そういう意味では、無理してES6 Classesを使うよりは、React.createClassを使ったほうが楽になるようにも思えます)

特にFlux的なStoreの変更した時にハンドラを登録したいときにthisbindした関数を登録すると、ハンドラを解除するときに面倒なことが起こります。

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ではコンポーネントはあるメソッドを持つただのオブジェクトであって、そのメソッドに対してproosstateといったものが渡されるので、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に比べて大分小さいことなど特徴はいくつかありますが、詳しくは以下を見てみてください。

おわりに

example

今回作ったサンプルはReactとDekuのコンポーネントをそれぞれ作って、StoreとActionは同じものを共有して使っています。

書いてて思ったのが、コンポーネントを使う側(<DekuComponent context={context}></DekuComponent>のような部分)では、そのコンポーネントがReactなのかdekuなのかは区別しなくても良くなるのが面白いなと思いました(実際にjsxPragmaの違いがあるけど)

その辺はCustom ElementでやろうとしていることがJSX+Any Componentでやってることが大分近い感じな気がします。

React 0.14Stateless Componentsを入れる予定もあるらしいので、この辺のコンポーネントの作りはまだまだ議論の余地がありそうですね。

Fluxアーキテクチャの方もStoreやActionはPureなオブジェクトとしたいという意見にも同じ流れを感じるので、JavaScriptにおけるFunctional Programmingへの取り組みは模索中という感じです。

また、bindの問題を構文的にサポートするES.nextのプロポーサルもありますが(Babelではオプショナルサポート)、今回の話は構文で解決する問題ではないので単純には行かない感じですね。